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