8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"index": 307,
|
||
"slug": "editorial-2019-06-field-rest-api",
|
||
"title": "Контракт REST API: как не принять сломанный ответ за пустые данные",
|
||
"excerpt": "Если API возвращает 200 без nextCursor или 400 в формате HTML, клиент теряет границу между нормальным результатом и ошибкой. Разбираем контракт ответа, локальные проверки и честный переход к тестовому стенду.",
|
||
"contentHtml": "<p>Симптом часто выглядит безобидно: список заказов открывается, но кнопка «Ещё» исчезает раньше времени. В другом случае форма показывает общий баннер вместо сообщения о неверном cursor. В логах при этом есть успешный JSON и HTTP 200. Цена ошибки — потерянные записи, повторные запросы и неверное состояние интерфейса. Пользователь видит пустой экран там, где данные просто не прошли границу контракта.</p>\n<p>Причина обычно не в JSON как таковом. Клиент проверяет только то, что тело удалось распарсить. Он не проверяет статус, media type, обязательные поля и смысл специальных значений. Поэтому «ответ пришёл» ошибочно превращается в «ответ пригоден».</p>\n<h2>Тезис: контракт начинается с HTTP-ответа</h2>\n<p>Для каждой операции нужно описать не только набор полей, но и сочетание статуса, заголовков и тела. Возьмём учебный endpoint <code>GET /api/v1/orders</code>. Успешный ответ возвращает массив <code>items</code> и объект <code>page</code>. Поле <code>page.nextCursor</code> обязательно присутствует: строка означает, что следующую страницу можно запросить, а <code>null</code> означает конец списка.</p>\n<p>Некорректный cursor не должен превращаться в пустой список. Для него нужен отдельный ответ, например <code>400</code> с media type <code>application/problem+json</code>. Клиент сначала выбирает ветку по HTTP-статусу и типу содержимого, а затем проверяет форму тела этой ветки.</p>\n<h2>Механизм проверки</h2>\n<p>Разделите проверку на два шага. Сначала проверьте транспортную оболочку: статус и media type. Затем проверьте тело, которое разрешено для этого статуса. Такой порядок не даёт обработчику разобрать problem document как страницу списка и породить вторичную ошибку вроде <code>items.map is not a function</code>.</p>\n<p>Media type сравнивайте без случайных параметров. Заголовок <code>application/json; charset=utf-8</code> имеет тот же основной тип, что и <code>application/json</code>, если контракт разрешает параметры. Полную строку стоит сравнивать только тогда, когда это отдельное требование протокола.</p>\n<pre><code>function assertOrdersPage(response) {\n assert(response.status === 200, 'ожидался HTTP 200');\n assert(\n mediaType(response.headers['content-type']) === 'application/json',\n 'ожидался application/json',\n );\n assert(Array.isArray(response.body.items), 'items должен быть массивом');\n assert(response.body.page, 'page обязателен');\n assert(\n Object.prototype.hasOwnProperty.call(response.body.page, 'nextCursor'),\n 'page.nextCursor должен присутствовать',\n );\n assert(\n response.body.page.nextCursor === null ||\n typeof response.body.page.nextCursor === 'string',\n 'nextCursor должен быть строкой или null',\n );\n}</code></pre>\n<p>Функция принимает обычный объект <code>response</code>. Она не выполняет сеть и не подтверждает работу endpoint. Это локальная проверка формы ответа. Её задача — принять корректную последнюю страницу и обязательно отклонить страницу без <code>nextCursor</code>. Отрицательный пример нужен не для полноты отчёта, а для проверки силы самой защиты.</p>\n<h2>Граница между отсутствием и null</h2>\n<p>У optional-поля есть как минимум два разных состояния. Если <code>customer</code> отсутствует, сервер сообщает: для этого заказа поле не входит в представление. Если он возвращает <code>null</code>, сервер сообщает другое: поле известно, но значения нет. Интерфейс может обрабатывать эти состояния одинаково, но контракт не должен разрешать оба варианта случайно.</p>\n<p>В учебном договоре <code>customer</code> необязателен. При наличии он должен быть объектом с строковыми <code>id</code> и <code>name</code>. Значение <code>null</code> считается ошибкой. Это не универсальное правило. Если бизнес-смысл требует nullable-поля, его нужно явно описать в схеме и проверить отдельным случаем.</p>\n<pre><code>function assertCustomer(order) {\n const present = Object.prototype.hasOwnProperty.call(order, 'customer');\n if (!present) return;\n\n assert(order.customer !== null, 'customer не должен быть null');\n assert(typeof order.customer === 'object', 'customer должен быть объектом');\n assert(typeof order.customer.id === 'string', 'customer.id обязателен');\n assert(typeof order.customer.name === 'string', 'customer.name обязателен');\n}</code></pre>\n<p>Не добавляйте в клиент скрытую третью трактовку. Иначе backend может изменить сериализацию, а frontend начнёт угадывать намерение сервера. В результате один экран покажет запасной текст, другой упадёт на вложенном свойстве, а тесты останутся зелёными.</p>\n<figure><img src=\"/assets/editorial/2019/rest-api-contract-fixture-2019.svg\" alt=\"Схема проверки REST-ответа: статус и Content-Type, затем ветка успешного ответа или problem document; ответ 200 без nextCursor отклоняется\"><figcaption>Локальная fixture проверяет форму заранее заданного ответа. Запрос к тестовому серверу остаётся отдельным этапом.</figcaption></figure>\n<h2>Минимальная матрица случаев</h2>\n<table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Кнопка «Ещё» пропала</td><td>В ответе 200 нет <code>page.nextCursor</code></td><td>Проверить наличие ключа, а затем тип string или null</td><td>Отклонить ответ и исправить сериализацию или схему</td></tr><tr><td>Ошибка стала общим баннером</td><td>400 пришёл не как problem document</td><td>Сверить статус и media type <code>application/problem+json</code></td><td>Развести обработчики success и error</td></tr><tr><td>Карточка падает на customer.id</td><td>Поле стало null или имеет другой тип</td><td>Проверить optional-правило и обязательные поля объекта</td><td>Согласовать nullable-семантику и обновить контракт</td></tr><tr><td>Тест пропускает плохой ответ</td><td>Проверка смотрит только на валидный JSON</td><td>Запустить намеренно испорченный case без nextCursor</td><td>Добавить assertion до сетевой проверки</td></tr></tbody></table>\n<h2>Локальная fixture не заменяет HTTP-запрос</h2>\n<p>Локальные response objects дают быстрый и повторяемый тест. Они показывают, что код клиента распознаёт согласованные формы и не принимает известную регрессию. Но fixture не проверяет gateway, авторизацию, права, сериализатор, задержку или фактический Content-Type сервера.</p>\n<p>После локальной проверки выполните тот же запрос на разрешённом тестовом URL. Не подставляйте в пример production-адрес, токен или cookies. Сохраните заголовки и тело без секретов. Ниже показан учебный шаблон; его результат нельзя считать заранее известным.</p>\n<pre><code># Только разрешённый тестовый URL и безопасная авторизация.\ncurl -sS -D /tmp/orders.headers -o /tmp/orders.json \\\n -H 'Accept: application/json, application/problem+json' \\\n 'https://api.example.test/api/v1/orders?limit=2'\n\ngrep -Ei '^(HTTP/|content-type:)' /tmp/orders.headers\nnode -e \"const fs=require('fs'); const body=JSON.parse(fs.readFileSync('/tmp/orders.json')); console.log(body.page || body.type)\"</code></pre>\n<p>Сначала зафиксируйте фактический status. Затем проверьте media type. Только после этого выбирайте schema success или problem. Если сервер вернул другой status, не называйте ответ пустым результатом. Если status совпал, но тело расходится со схемой, сравните required-поля, nullable-правила и версию описания API.</p>\n<h2>Порядок действий</h2>\n<ol><li>Опишите один наблюдаемый сбой: какой запрос выполнен, какой status пришёл и что сделал клиент.</li><li>Сформулируйте допустимый success-ответ: status, media type, обязательные поля и допустимые типы.</li><li>Опишите отдельную ветку управляемой ошибки, включая problem media type и стабильный код причины.</li><li>Добавьте три локальных случая: валидную последнюю страницу, валидную ошибку и ответ, который обязан быть отклонён.</li><li>Проверьте status и media type до чтения полей тела.</li><li>Зафиксируйте правило для каждого optional-поля: отсутствие, null или оба варианта.</li><li>Сверьте fixture с OpenAPI-описанием и устраните расхождение в одном согласованном источнике.</li><li>Выполните запрос на тестовом стенде, сохраните проверяемые факты и не переносите его результат на production.</li><li>Если данные расходятся, изменяйте схему, сервер или mapper осознанно, а не ослабляйте assertion до зелёного результата.</li></ol>\n<h2>Ограничения</h2>\n<p>Такой контрактный тест не измеряет latency и не доказывает корректность пагинации при изменении данных между запросами. Он не проверяет безопасность cursor, права доступа и работу браузерного сценария. Он также не заменяет интеграционный тест с реальным gateway. Его область уже: обнаружить, что известная операция возвращает статус, тип или структуру, которую клиент не умеет безопасно трактовать.</p>\n<p>Не стоит описывать каждое неизвестное поле как ошибку. Клиент может игнорировать расширения, если контракт это разрешает. Но обязательные поля, status и media type должны иметь точное правило. Нельзя одновременно говорить «поле optional» и строить код так, будто оно всегда существует.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Сценарий готов, если локальная проверка принимает согласованные success и problem cases, отклоняет известный плохой ответ с понятной причиной, а сетевой запрос на разрешённом тестовом стенде даёт сохранённые status, media type и тело для сравнения. В отчёте отдельно указано, что именно проверила fixture и что подтвердил сервер. После этого изменение контракта становится видимым событием, а не случайным падением интерфейса.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener\">RFC 9110: HTTP Semantics</a> — статусы, заголовки и интерпретация содержимого HTTP-ответа.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9457.html\" target=\"_blank\" rel=\"noopener\">RFC 9457: Problem Details for HTTP APIs</a> — формат <code>application/problem+json</code> и машинно-читаемые детали ошибки.</li><li><a href=\"https://spec.openapis.org/oas/3.1.0.html\" target=\"_blank\" rel=\"noopener\">OpenAPI Specification 3.1.0</a> — описание responses и схемы данных API.</li></ul>"
|
||
}
|