From 47f24464354d6e042ed4834059a51b32a472e07e Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 23:35:31 +0300 Subject: [PATCH] Polish editorial rewrite 307 --- editorial/agent-rewrites/307.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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

Тезис: контракт начинается с HTTP-ответа

\n

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

\n

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

\n

Механизм проверки

\n

Разделите проверку на два шага. Сначала проверьте транспортную оболочку: статус и media type. Затем проверьте тело, которое разрешено для этого статуса. Такой порядок не даёт обработчику разобрать problem document как страницу списка и породить вторичную ошибку вроде items.map is not a function.

\n

Media type сравнивайте без случайных параметров. Заголовок application/json; charset=utf-8 имеет тот же основной тип, что и application/json, если контракт разрешает параметры. Полную строку стоит сравнивать только тогда, когда это отдельное требование протокола.

\n
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. Отрицательный пример нужен не для полноты отчёта, а для проверки силы самой защиты.

\n

Граница между отсутствием и null

\n

У optional-поля есть как минимум два разных состояния. Если customer отсутствует, сервер сообщает: для этого заказа поле не входит в представление. Если он возвращает null, сервер сообщает другое: поле известно, но значения нет. Интерфейс может обрабатывать эти состояния одинаково, но контракт не должен разрешать оба варианта случайно.

\n

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

\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(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
\"Схема
Локальная fixture проверяет форму заранее заданного ответа. Запрос к тестовому серверу остаётся отдельным этапом.
\n

Минимальная матрица случаев

\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 до сетевой проверки
\n

Локальная fixture не заменяет HTTP-запрос

\n

Локальные 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

Порядок действий

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

Ограничения

\n

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

\n

Не стоит описывать каждое неизвестное поле как ошибку. Клиент может игнорировать расширения, если контракт это разрешает. Но обязательные поля, status и media type должны иметь точное правило. Нельзя одновременно говорить «поле optional» и строить код так, будто оно всегда существует.

\n

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

\n

Сценарий готов, если локальная проверка принимает согласованные success и problem cases, отклоняет известный плохой ответ с понятной причиной, а сетевой запрос на разрешённом тестовом стенде даёт сохранённые status, media type и тело для сравнения. В отчёте отдельно указано, что именно проверила fixture и что подтвердил сервер. После этого изменение контракта становится видимым событием, а не случайным падением интерфейса.

\n

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

\n" + "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" }