diff --git a/editorial/agent-rewrites/307.json b/editorial/agent-rewrites/307.json index c7cc5cc..2be5479 100644 --- a/editorial/agent-rewrites/307.json +++ b/editorial/agent-rewrites/307.json @@ -1,7 +1,7 @@ { "index": 307, "slug": "editorial-2019-06-field-rest-api", - "title": "Контракт REST API: как не принять сломанный ответ за пустые данные", - "excerpt": "Если API возвращает 200 без nextCursor или 400 в формате HTML, клиент теряет границу между нормальным результатом и ошибкой. Разбираем контракт ответа, локальные проверки и честный переход к тестовому стенду.", - "contentHtml": "
Симптом часто выглядит безобидно: список заказов открывается, но кнопка «Ещё» исчезает раньше времени. В другом случае форма показывает общий баннер вместо сообщения о неверном cursor. В логах при этом есть успешный JSON и HTTP 200. Цена ошибки — потерянные записи, повторные запросы и неверное состояние интерфейса. Пользователь видит пустой экран там, где данные просто не прошли границу контракта.
\nПричина обычно не в JSON как таковом. Клиент проверяет только то, что тело удалось распарсить. Он не проверяет статус, media type, обязательные поля и смысл специальных значений. Поэтому «ответ пришёл» ошибочно превращается в «ответ пригоден».
\nДля каждой операции нужно описать не только набор полей, но и сочетание статуса, заголовков и тела. Возьмём учебный endpoint GET /api/v1/orders. Успешный ответ возвращает массив items и объект page. Поле page.nextCursor обязательно присутствует: строка означает, что следующую страницу можно запросить, а null означает конец списка.
Некорректный cursor не должен превращаться в пустой список. Для него нужен отдельный ответ, например 400 с media type application/problem+json. Клиент сначала выбирает ветку по HTTP-статусу и типу содержимого, а затем проверяет форму тела этой ветки.
Разделите проверку на два шага. Сначала проверьте транспортную оболочку: статус и media type. Затем проверьте тело, которое разрешено для этого статуса. Такой порядок не даёт обработчику разобрать problem document как страницу списка и породить вторичную ошибку вроде items.map is not a function.
Media type сравнивайте без случайных параметров. Заголовок application/json; charset=utf-8 имеет тот же основной тип, что и application/json, если контракт разрешает параметры. Полную строку стоит сравнивать только тогда, когда это отдельное требование протокола.
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}\nФункция принимает обычный объект response. Она не выполняет сеть и не подтверждает работу endpoint. Это локальная проверка формы ответа. Её задача — принять корректную последнюю страницу и обязательно отклонить страницу без nextCursor. Отрицательный пример нужен не для полноты отчёта, а для проверки силы самой защиты.
У optional-поля есть как минимум два разных состояния. Если customer отсутствует, сервер сообщает: для этого заказа поле не входит в представление. Если он возвращает null, сервер сообщает другое: поле известно, но значения нет. Интерфейс может обрабатывать эти состояния одинаково, но контракт не должен разрешать оба варианта случайно.
В учебном договоре customer необязателен. При наличии он должен быть объектом с строковыми id и name. Значение null считается ошибкой. Это не универсальное правило. Если бизнес-смысл требует nullable-поля, его нужно явно описать в схеме и проверить отдельным случаем.
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}\nНе добавляйте в клиент скрытую третью трактовку. Иначе backend может изменить сериализацию, а frontend начнёт угадывать намерение сервера. В результате один экран покажет запасной текст, другой упадёт на вложенном свойстве, а тесты останутся зелёными.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Кнопка «Ещё» пропала | В ответе 200 нет page.nextCursor | Проверить наличие ключа, а затем тип string или null | Отклонить ответ и исправить сериализацию или схему |
| Ошибка стала общим баннером | 400 пришёл не как problem document | Сверить статус и media type application/problem+json | Развести обработчики success и error |
| Карточка падает на customer.id | Поле стало null или имеет другой тип | Проверить optional-правило и обязательные поля объекта | Согласовать nullable-семантику и обновить контракт |
| Тест пропускает плохой ответ | Проверка смотрит только на валидный JSON | Запустить намеренно испорченный case без nextCursor | Добавить assertion до сетевой проверки |
Локальные response objects дают быстрый и повторяемый тест. Они показывают, что код клиента распознаёт согласованные формы и не принимает известную регрессию. Но fixture не проверяет gateway, авторизацию, права, сериализатор, задержку или фактический Content-Type сервера.
\nПосле локальной проверки выполните тот же запрос на разрешённом тестовом URL. Не подставляйте в пример production-адрес, токен или cookies. Сохраните заголовки и тело без секретов. Ниже показан учебный шаблон; его результат нельзя считать заранее известным.
\n# Только разрешённый тестовый 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)\"\nСначала зафиксируйте фактический status. Затем проверьте media type. Только после этого выбирайте schema success или problem. Если сервер вернул другой status, не называйте ответ пустым результатом. Если status совпал, но тело расходится со схемой, сравните required-поля, nullable-правила и версию описания API.
\nТакой контрактный тест не измеряет latency и не доказывает корректность пагинации при изменении данных между запросами. Он не проверяет безопасность cursor, права доступа и работу браузерного сценария. Он также не заменяет интеграционный тест с реальным gateway. Его область уже: обнаружить, что известная операция возвращает статус, тип или структуру, которую клиент не умеет безопасно трактовать.
\nНе стоит описывать каждое неизвестное поле как ошибку. Клиент может игнорировать расширения, если контракт это разрешает. Но обязательные поля, status и media type должны иметь точное правило. Нельзя одновременно говорить «поле optional» и строить код так, будто оно всегда существует.
\nСценарий готов, если локальная проверка принимает согласованные success и problem cases, отклоняет известный плохой ответ с понятной причиной, а сетевой запрос на разрешённом тестовом стенде даёт сохранённые status, media type и тело для сравнения. В отчёте отдельно указано, что именно проверила fixture и что подтвердил сервер. После этого изменение контракта становится видимым событием, а не случайным падением интерфейса.
\napplication/problem+json и машинно-читаемые детали ошибки.Симптом у контракта REST API часто выглядит безобидно: список заказов открывается, но кнопка «Ещё» исчезает раньше времени. В другом сценарии форма получает текст ошибки вместо объекта, а в третьем — пустой массив. JSON при этом может успешно распарситься. Проблема находится не в синтаксисе, а в том, что клиент принял одну форму ответа за другую.
\nЦена ошибки — потерянные записи и неверное состояние интерфейса. Пустой массив пользователь примет за конец списка, а чужой формат ошибки приведёт к общему баннеру или вторичному падению компонента. В исходном полевом симптоме одна ошибка приходила разными типами. Поэтому сначала нужно зафиксировать границу ответа, а уже потом выбирать исправление.
\nДля операции недостаточно записать URL и пример JSON. Контракт должен связывать HTTP-статус, заголовок Content-Type и форму тела. Возьмём учебный endpoint GET /api/v1/orders. Успешный ответ возвращает массив items и объект page. Внутри page ключ nextCursor обязателен: строка означает, что доступна следующая страница, а null — что текущая выборка закончилась.
Неверный cursor не должен превращаться в пустой список. Для него в нашем примере предусмотрен 400 с типом application/problem+json. Клиент сначала выбирает ветку по статусу и media type (основному типу содержимого), а затем проверяет тело этой ветки. Так ответ «сервер ответил» не подменяет ответ «клиент может его безопасно трактовать».
Это проектный договор, а не универсальное свойство REST. Другой API может выбрать иной статус, схему ошибки или пагинацию. В статье важно не название endpoint, а явное правило для каждого значения, которое влияет на интерфейс.
\nФраза «пагинация сломалась» слишком расплывчата. Её можно разложить на проверяемые условия: у ответа 200 application/json есть items, page и ключ nextCursor; у ответа с плохим cursor — 400 application/problem+json и стабильный машинный код причины. Отдельно нужен отрицательный случай: ответ 200 без nextCursor должен быть отклонён, даже если его JSON синтаксически корректен.
| Случай | Статус и тип | Ожидание | Действие |
|---|---|---|---|
| Последняя страница | 200 / application/json | page.nextCursor присутствует и равен null | Показать конец списка |
| Неверный cursor | 400 / application/problem+json | status равен 400, код — invalid_cursor | Показать управляемую ошибку |
| Регрессия пагинации | 200 / application/json | Ключ nextCursor отсутствует | Отклонить ответ и поднять причину |
| Optional customer | 200 / application/json | Ключ отсутствует или содержит объект, но не null | Применить одно правило схемы |
Матрица полезна тем, что отделяет решение от случайного текущего ответа. Если сервер однажды вернул пустой массив, это ещё не доказывает, что список закончился. Сначала нужно понять, присутствует ли обязательный ключ и какой статус сопровождал тело.
\nЛокальная fixture — это обычные объекты ответа, на которых выполняется валидатор. Она не обращается к сети, зато быстро показывает, что правило действительно защищает границу. В примере ниже определены нормализатор заголовка, обе положительные ветки и отрицательная страница. Код можно сохранить в файл и запустить обычным node; он не требует внешнего API.
function assert(condition, message) {\n if (!condition) throw new Error(message);\n}\n\nfunction header(headers, name) {\n const key = Object.keys(headers || {}).find(\n (candidate) => candidate.toLowerCase() === name.toLowerCase(),\n );\n return key ? headers[key] : undefined;\n}\n\nfunction mediaType(value) {\n return String(value || '').split(';', 1)[0].trim().toLowerCase();\n}\n\nfunction assertOrdersPage(response) {\n assert(response.status === 200, 'ожидался HTTP 200');\n assert(\n mediaType(header(response.headers, 'Content-Type')) === 'application/json',\n 'ожидался application/json',\n );\n assert(Array.isArray(response.body.items), 'items должен быть массивом');\n assert(response.body.page && typeof response.body.page === 'object', '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}\n\nfunction assertProblem(response, code) {\n assert(response.status === 400, 'ожидался HTTP 400');\n assert(\n mediaType(header(response.headers, 'Content-Type')) === 'application/problem+json',\n 'ожидался problem+json',\n );\n assert(response.body.status === 400, 'status в problem detail должен совпадать');\n assert(response.body.errors[0].code === code, 'неизвестный код причины');\n}\n\nconst finalPage = {\n status: 200,\n headers: { 'Content-Type': 'application/json; charset=utf-8' },\n body: { items: [], page: { limit: 2, nextCursor: null } },\n};\nconst invalidCursor = {\n status: 400,\n headers: { 'content-type': 'application/problem+json' },\n body: { status: 400, errors: [{ code: 'invalid_cursor' }] },\n};\nconst brokenPage = {\n status: 200,\n headers: { 'content-type': 'application/json' },\n body: { items: [], page: { limit: 2 } },\n};\n\nassertOrdersPage(finalPage);\nassertProblem(invalidCursor, 'invalid_cursor');\nlet rejected = false;\ntry {\n assertOrdersPage(brokenPage);\n} catch (error) {\n rejected = true;\n console.log(error.message);\n}\nassert(rejected, 'отрицательный случай должен завершиться ошибкой');\nВ этом коде сначала нормализуется имя заголовка, потому что имена HTTP-полей не должны зависеть от регистра. Затем из Content-Type отделяется media type от параметров вроде charset=utf-8. Только после транспортной проверки код читает поля тела. Если поменять порядок и сразу вызвать body.items, problem document легко будет принят за страницу или породит ошибку вроде items.map is not a function.
Отрицательный case — обязательная часть fixture. Он показывает силу проверки: валидный JSON без обязательного ключа должен стать красным. Положительный case с nextCursor: null нужен не меньше, иначе разработчик может «починить» тест, разрешив отсутствие ключа и снова смешав конец списка с дефектом сериализации.
Optional-поле имеет как минимум два различных состояния. Если customer отсутствует, это может означать, что поле не входит в представление заказа. Если ключ есть и равен null, сервер сообщает, что поле известно, но значения нет. UI может показать одинаковый текст, но схема должна выбрать одно правило, иначе разные клиенты начнут угадывать.
В учебном договоре customer необязателен. При наличии это объект со строковыми id и name; null считается ошибкой. Это не универсальная рекомендация. Если продукту нужна nullable-семантика, её нужно записать в OpenAPI и принять отдельным тестом, а не разрешать молчаливую третью ветку.
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(\n typeof order.customer === 'object' && !Array.isArray(order.customer),\n 'customer должен быть объектом',\n );\n assert(typeof order.customer.id === 'string', 'customer.id обязателен');\n assert(typeof order.customer.name === 'string', 'customer.name обязателен');\n}\nПроверка не обязана начинаться с большой библиотеки валидации. Для небольшого endpoint понятная функция рядом с тремя fixtures часто быстрее показывает договор. Позже её можно заменить инструментом на основе OpenAPI, но при этом нужно сохранить тот же набор случаев и убедиться, что выбранная версия Schema Object понимает nullable.
У теста не должно быть тайного второго контракта. В OpenAPI 3.0.x у операции описываются параметры, responses и media type каждого тела. Для нашего примера у ответа 200 схема содержит items и page в required, а внутри page — обязательный nextCursor. В OAS 3.0 nullable-строка задаётся через type: string и nullable: true, а не через синтаксис более поздней версии.
/api/v1/orders:\n get:\n parameters:\n - in: query\n name: limit\n schema: { type: integer, minimum: 1, maximum: 100 }\n - in: query\n name: cursor\n schema: { type: string }\n responses:\n '200':\n description: Страница заказов\n content:\n application/json:\n schema: { $ref: '#/components/schemas/OrdersPage' }\n '400':\n description: Неверный limit или cursor\n content:\n application/problem+json:\n schema: { $ref: '#/components/schemas/Problem' }\nСпецификация делает ожидаемую форму видимой, но сама не доказывает поведение сервера. Если fixture и YAML расходятся, нельзя подгонять тест под случайный ответ и оставлять схему прежней. Сначала нужно решить, какой договор принят, затем изменить один источник и повторить локальные случаи.
\nCursor и объект page — тоже выбор проекта. Для навигации можно использовать заголовок Link или offset, но нельзя одновременно трактовать часть ответов как cursor-пагинацию, а часть — как конец по длине массива. Способ передачи следующей страницы должен быть описан в операции и проверен на первой и последней странице.
После локальной проверки выполняется тот же запрос на разрешённом тестовом URL. Команда ниже — шаблон: адрес api.example.test не является работающим сервисом и не даёт заранее известного результата. В закрытом API вместо демонстрационного адреса нужен безопасный способ аутентификации; токены, cookies и реальные ответы не следует помещать в статью или CI-лог.
# Только разрешённый тестовый URL и безопасная авторизация.\ncurl -sS -D /tmp/orders.headers -o /tmp/orders.body \\\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'); console.log(fs.readFileSync('/tmp/orders.body', 'utf8'))\"\nЗапрос нужно читать по шагам. Сначала сохраняем фактический HTTP-статус и заголовок. Затем выбираем success или problem branch и сравниваем тело с соответствующей схемой. Если сервер вернул HTML с кодом 400, это не пустой результат и не problem document: нужно зафиксировать расхождение между gateway, приложением и контрактом.
\nЛокальный объект ответа отвечает только на вопрос «распознаёт ли клиент известные формы». Запрос к стенду дополнительно проверяет маршрутизацию, авторизацию, сериализатор и фактический заголовок. Эти проверки нельзя объединять в одно слово «тест прошёл»: у них разные владельцы и разные причины падения.
\nТакой контрактный тест не измеряет latency и не доказывает безопасность cursor, права доступа или корректность пагинации при изменении данных между запросами. Он также не заменяет интеграционный сценарий браузера и не проверяет поведение реального gateway, если запускается только на локальных объектах.
\nНе каждое неизвестное поле является ошибкой. Клиент может игнорировать расширения, если договор это разрешает. Но изменение статуса, media type, обязательности ключа или смысла null меняет наблюдаемое поведение. Для него нужны проверка потребителей и явный переход, иначе синтаксически корректный JSON снова скроет поломку.
Сценарий готов, если независимый разработчик может по схеме предсказать ответ для обычного запроса, последней страницы и плохого cursor, а затем проверить прогноз на тестовом сервере. Локальная fixture принимает финальную страницу и problem detail, отклоняет 200 без nextCursor, а клиент не разбирает HTML или неизвестную ошибку как пустой список.
Для статьи 2019 года это достаточная граница: от исходного симптома мы пришли к статусу, заголовку, required-полям, отдельной ошибке и воспроизводимому следующему шагу. Дальнейшие вопросы — права, нагрузка, совместимость клиентов и откат схемы — проверяются отдельными сценариями.
\napplication/problem+json.