{ "index": 307, "slug": "editorial-2019-06-field-rest-api", "title": "Контракт REST API: как не принять ошибку за пустые данные", "excerpt": "Один и тот же сбой API не должен превращаться то в строку, то в пустой массив. Разбираем контракт ответа, воспроизводимую fixture и переход от локальной проверки к тестовому стенду.", "contentHtml": "

Симптом у контракта REST API часто выглядит безобидно: список заказов открывается, но кнопка «Ещё» исчезает раньше времени. В другом сценарии форма получает текст ошибки вместо объекта, а в третьем — пустой массив. JSON при этом может успешно распарситься. Проблема находится не в синтаксисе, а в том, что клиент принял одну форму ответа за другую.

\n

Цена ошибки — потерянные записи и неверное состояние интерфейса. Пустой массив пользователь примет за конец списка, а чужой формат ошибки приведёт к общему баннеру или вторичному падению компонента. В исходном полевом симптоме одна ошибка приходила разными типами. Поэтому сначала нужно зафиксировать границу ответа, а уже потом выбирать исправление.

\n

Контракт начинается с HTTP-ответа

\n

Для операции недостаточно записать URL и пример JSON. Контракт должен связывать HTTP-статус, заголовок Content-Type и форму тела. Возьмём учебный endpoint GET /api/v1/orders. Успешный ответ возвращает массив items и объект page. Внутри page ключ nextCursor обязателен: строка означает, что доступна следующая страница, а null — что текущая выборка закончилась.

\n

Неверный cursor не должен превращаться в пустой список. Для него в нашем примере предусмотрен 400 с типом application/problem+json. Клиент сначала выбирает ветку по статусу и media type (основному типу содержимого), а затем проверяет тело этой ветки. Так ответ «сервер ответил» не подменяет ответ «клиент может его безопасно трактовать».

\n

Это проектный договор, а не универсальное свойство REST. Другой API может выбрать иной статус, схему ошибки или пагинацию. В статье важно не название endpoint, а явное правило для каждого значения, которое влияет на интерфейс.

\n

Переводим симптом в проверяемые случаи

\n

Фраза «пагинация сломалась» слишком расплывчата. Её можно разложить на проверяемые условия: у ответа 200 application/json есть items, page и ключ nextCursor; у ответа с плохим cursor — 400 application/problem+json и стабильный машинный код причины. Отдельно нужен отрицательный случай: ответ 200 без nextCursor должен быть отклонён, даже если его JSON синтаксически корректен.

\n
Минимальная матрица контрактной проверки
СлучайСтатус и типОжиданиеДействие
Последняя страница200 / application/jsonpage.nextCursor присутствует и равен nullПоказать конец списка
Неверный cursor400 / application/problem+jsonstatus равен 400, код — invalid_cursorПоказать управляемую ошибку
Регрессия пагинации200 / application/jsonКлюч nextCursor отсутствуетОтклонить ответ и поднять причину
Optional customer200 / application/jsonКлюч отсутствует или содержит объект, но не nullПрименить одно правило схемы
\n

Матрица полезна тем, что отделяет решение от случайного текущего ответа. Если сервер однажды вернул пустой массив, это ещё не доказывает, что список закончился. Сначала нужно понять, присутствует ли обязательный ключ и какой статус сопровождал тело.

\n

Пишем локальную fixture

\n

Локальная fixture — это обычные объекты ответа, на которых выполняется валидатор. Она не обращается к сети, зато быстро показывает, что правило действительно защищает границу. В примере ниже определены нормализатор заголовка, обе положительные ветки и отрицательная страница. Код можно сохранить в файл и запустить обычным node; он не требует внешнего API.

\n
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.

\n

Отрицательный case — обязательная часть fixture. Он показывает силу проверки: валидный JSON без обязательного ключа должен стать красным. Положительный case с nextCursor: null нужен не меньше, иначе разработчик может «починить» тест, разрешив отсутствие ключа и снова смешав конец списка с дефектом сериализации.

\n

Не смешиваем отсутствие поля и null

\n

Optional-поле имеет как минимум два различных состояния. Если customer отсутствует, это может означать, что поле не входит в представление заказа. Если ключ есть и равен null, сервер сообщает, что поле известно, но значения нет. UI может показать одинаковый текст, но схема должна выбрать одно правило, иначе разные клиенты начнут угадывать.

\n

В учебном договоре customer необязателен. При наличии это объект со строковыми id и name; null считается ошибкой. Это не универсальная рекомендация. Если продукту нужна nullable-семантика, её нужно записать в OpenAPI и принять отдельным тестом, а не разрешать молчаливую третью ветку.

\n
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.

\n
\"Схема
Локальная fixture проверяет форму заранее заданного ответа. Сетевой запрос к тестовому стенду остаётся отдельным этапом и не выдаётся за проверку сервера.
\n

Связываем fixture с OpenAPI

\n

У теста не должно быть тайного второго контракта. В OpenAPI 3.0.x у операции описываются параметры, responses и media type каждого тела. Для нашего примера у ответа 200 схема содержит items и page в required, а внутри page — обязательный nextCursor. В OAS 3.0 nullable-строка задаётся через type: string и nullable: true, а не через синтаксис более поздней версии.

\n
/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 расходятся, нельзя подгонять тест под случайный ответ и оставлять схему прежней. Сначала нужно решить, какой договор принят, затем изменить один источник и повторить локальные случаи.

\n

Cursor и объект page — тоже выбор проекта. Для навигации можно использовать заголовок Link или offset, но нельзя одновременно трактовать часть ответов как cursor-пагинацию, а часть — как конец по длине массива. Способ передачи следующей страницы должен быть описан в операции и проверен на первой и последней странице.

\n

Переходим от fixture к тестовому стенду

\n

После локальной проверки выполняется тот же запрос на разрешённом тестовом URL. Команда ниже — шаблон: адрес api.example.test не является работающим сервисом и не даёт заранее известного результата. В закрытом API вместо демонстрационного адреса нужен безопасный способ аутентификации; токены, cookies и реальные ответы не следует помещать в статью или CI-лог.

\n
# Только разрешённый тестовый 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

Порядок диагностики

\n
  1. Выберите одну операцию и опишите наблюдаемый сбой: запрос, статус, тип ответа и действие клиента.
  2. Запишите допустимый success-ответ: статус, media type, обязательные поля и типы значений.
  3. Опишите каждую управляемую ошибку отдельной веткой, включая стабильный машинный код причины.
  4. Добавьте fixture для первой страницы, последней страницы, ошибки и известной регрессии.
  5. Проверяйте статус и media type до чтения полей тела; имена заголовков нормализуйте.
  6. Зафиксируйте правило для каждого optional-поля: отсутствие, null или оба варианта.
  7. Сверьте fixtures с OpenAPI и исправьте источник договора, а не только assertion.
  8. Выполните идентичный запрос на разрешённом тестовом сервере, сохранив headers и body без секретов.
  9. При расхождении назовите конкретную изменившуюся границу: статус, заголовок, required-поле, nullable или схема ошибки.
\n

Ограничения

\n

Такой контрактный тест не измеряет latency и не доказывает безопасность cursor, права доступа или корректность пагинации при изменении данных между запросами. Он также не заменяет интеграционный сценарий браузера и не проверяет поведение реального gateway, если запускается только на локальных объектах.

\n

Не каждое неизвестное поле является ошибкой. Клиент может игнорировать расширения, если договор это разрешает. Но изменение статуса, media type, обязательности ключа или смысла null меняет наблюдаемое поведение. Для него нужны проверка потребителей и явный переход, иначе синтаксически корректный JSON снова скроет поломку.

\n

Проверяемый результат

\n

Сценарий готов, если независимый разработчик может по схеме предсказать ответ для обычного запроса, последней страницы и плохого cursor, а затем проверить прогноз на тестовом сервере. Локальная fixture принимает финальную страницу и problem detail, отклоняет 200 без nextCursor, а клиент не разбирает HTML или неизвестную ошибку как пустой список.

\n

Для статьи 2019 года это достаточная граница: от исходного симптома мы пришли к статусу, заголовку, required-полям, отдельной ошибке и воспроизводимому следующему шагу. Дальнейшие вопросы — права, нагрузка, совместимость клиентов и откат схемы — проверяются отдельными сценариями.

\n

Проверяемые источники

\n" }