8 lines
23 KiB
JSON
8 lines
23 KiB
JSON
{
|
||
"index": 307,
|
||
"slug": "editorial-2019-06-field-rest-api",
|
||
"title": "Контракт REST API: как не принять ошибку за пустые данные",
|
||
"excerpt": "Один и тот же сбой API не должен превращаться то в строку, то в пустой массив. Разбираем контракт ответа, воспроизводимую fixture и переход от локальной проверки к тестовому стенду.",
|
||
"contentHtml": "<p>Симптом у контракта REST API часто выглядит безобидно: список заказов открывается, но кнопка «Ещё» исчезает раньше времени. В другом сценарии форма получает текст ошибки вместо объекта, а в третьем — пустой массив. JSON при этом может успешно распарситься. Проблема находится не в синтаксисе, а в том, что клиент принял одну форму ответа за другую.</p>\n<p>Цена ошибки — потерянные записи и неверное состояние интерфейса. Пустой массив пользователь примет за конец списка, а чужой формат ошибки приведёт к общему баннеру или вторичному падению компонента. В исходном полевом симптоме одна ошибка приходила разными типами. Поэтому сначала нужно зафиксировать границу ответа, а уже потом выбирать исправление.</p>\n<h2>Контракт начинается с HTTP-ответа</h2>\n<p>Для операции недостаточно записать URL и пример JSON. Контракт должен связывать HTTP-статус, заголовок <code>Content-Type</code> и форму тела. Возьмём учебный endpoint <code>GET /api/v1/orders</code>. Успешный ответ возвращает массив <code>items</code> и объект <code>page</code>. Внутри <code>page</code> ключ <code>nextCursor</code> обязателен: строка означает, что доступна следующая страница, а <code>null</code> — что текущая выборка закончилась.</p>\n<p>Неверный cursor не должен превращаться в пустой список. Для него в нашем примере предусмотрен <code>400</code> с типом <code>application/problem+json</code>. Клиент сначала выбирает ветку по статусу и media type (основному типу содержимого), а затем проверяет тело этой ветки. Так ответ «сервер ответил» не подменяет ответ «клиент может его безопасно трактовать».</p>\n<p>Это проектный договор, а не универсальное свойство REST. Другой API может выбрать иной статус, схему ошибки или пагинацию. В статье важно не название endpoint, а явное правило для каждого значения, которое влияет на интерфейс.</p>\n<h2>Переводим симптом в проверяемые случаи</h2>\n<p>Фраза «пагинация сломалась» слишком расплывчата. Её можно разложить на проверяемые условия: у ответа <code>200 application/json</code> есть <code>items</code>, <code>page</code> и ключ <code>nextCursor</code>; у ответа с плохим cursor — <code>400 application/problem+json</code> и стабильный машинный код причины. Отдельно нужен отрицательный случай: ответ 200 без <code>nextCursor</code> должен быть отклонён, даже если его JSON синтаксически корректен.</p>\n<table><caption>Минимальная матрица контрактной проверки</caption><thead><tr><th scope=\"col\">Случай</th><th scope=\"col\">Статус и тип</th><th scope=\"col\">Ожидание</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Последняя страница</td><td><code>200 / application/json</code></td><td><code>page.nextCursor</code> присутствует и равен <code>null</code></td><td>Показать конец списка</td></tr><tr><td>Неверный cursor</td><td><code>400 / application/problem+json</code></td><td><code>status</code> равен 400, код — <code>invalid_cursor</code></td><td>Показать управляемую ошибку</td></tr><tr><td>Регрессия пагинации</td><td><code>200 / application/json</code></td><td>Ключ <code>nextCursor</code> отсутствует</td><td>Отклонить ответ и поднять причину</td></tr><tr><td>Optional customer</td><td><code>200 / application/json</code></td><td>Ключ отсутствует или содержит объект, но не <code>null</code></td><td>Применить одно правило схемы</td></tr></tbody></table>\n<p>Матрица полезна тем, что отделяет решение от случайного текущего ответа. Если сервер однажды вернул пустой массив, это ещё не доказывает, что список закончился. Сначала нужно понять, присутствует ли обязательный ключ и какой статус сопровождал тело.</p>\n<h2>Пишем локальную fixture</h2>\n<p>Локальная fixture — это обычные объекты ответа, на которых выполняется валидатор. Она не обращается к сети, зато быстро показывает, что правило действительно защищает границу. В примере ниже определены нормализатор заголовка, обе положительные ветки и отрицательная страница. Код можно сохранить в файл и запустить обычным <code>node</code>; он не требует внешнего API.</p>\n<pre><code>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, 'отрицательный случай должен завершиться ошибкой');</code></pre>\n<p>В этом коде сначала нормализуется имя заголовка, потому что имена HTTP-полей не должны зависеть от регистра. Затем из <code>Content-Type</code> отделяется media type от параметров вроде <code>charset=utf-8</code>. Только после транспортной проверки код читает поля тела. Если поменять порядок и сразу вызвать <code>body.items</code>, problem document легко будет принят за страницу или породит ошибку вроде <code>items.map is not a function</code>.</p>\n<p>Отрицательный case — обязательная часть fixture. Он показывает силу проверки: валидный JSON без обязательного ключа должен стать красным. Положительный case с <code>nextCursor: null</code> нужен не меньше, иначе разработчик может «починить» тест, разрешив отсутствие ключа и снова смешав конец списка с дефектом сериализации.</p>\n<h2>Не смешиваем отсутствие поля и null</h2>\n<p>Optional-поле имеет как минимум два различных состояния. Если <code>customer</code> отсутствует, это может означать, что поле не входит в представление заказа. Если ключ есть и равен <code>null</code>, сервер сообщает, что поле известно, но значения нет. UI может показать одинаковый текст, но схема должна выбрать одно правило, иначе разные клиенты начнут угадывать.</p>\n<p>В учебном договоре <code>customer</code> необязателен. При наличии это объект со строковыми <code>id</code> и <code>name</code>; <code>null</code> считается ошибкой. Это не универсальная рекомендация. Если продукту нужна nullable-семантика, её нужно записать в OpenAPI и принять отдельным тестом, а не разрешать молчаливую третью ветку.</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(\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}</code></pre>\n<p>Проверка не обязана начинаться с большой библиотеки валидации. Для небольшого endpoint понятная функция рядом с тремя fixtures часто быстрее показывает договор. Позже её можно заменить инструментом на основе OpenAPI, но при этом нужно сохранить тот же набор случаев и убедиться, что выбранная версия Schema Object понимает <code>nullable</code>.</p>\n<figure><img src=\"/assets/editorial/2019/rest-api-contract-fixture-2019.svg\" alt=\"Схема проверки REST-ответа: локальные fixtures проходят через статус и Content-Type, затем через success или problem schema; 200 без nextCursor отклоняется, а запрос к стенду выполняется отдельно\"><figcaption>Локальная fixture проверяет форму заранее заданного ответа. Сетевой запрос к тестовому стенду остаётся отдельным этапом и не выдаётся за проверку сервера.</figcaption></figure>\n<h2>Связываем fixture с OpenAPI</h2>\n<p>У теста не должно быть тайного второго контракта. В OpenAPI 3.0.x у операции описываются параметры, responses и media type каждого тела. Для нашего примера у ответа 200 схема содержит <code>items</code> и <code>page</code> в <code>required</code>, а внутри <code>page</code> — обязательный <code>nextCursor</code>. В OAS 3.0 nullable-строка задаётся через <code>type: string</code> и <code>nullable: true</code>, а не через синтаксис более поздней версии.</p>\n<pre><code>/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' }</code></pre>\n<p>Спецификация делает ожидаемую форму видимой, но сама не доказывает поведение сервера. Если fixture и YAML расходятся, нельзя подгонять тест под случайный ответ и оставлять схему прежней. Сначала нужно решить, какой договор принят, затем изменить один источник и повторить локальные случаи.</p>\n<p>Cursor и объект <code>page</code> — тоже выбор проекта. Для навигации можно использовать заголовок <code>Link</code> или offset, но нельзя одновременно трактовать часть ответов как cursor-пагинацию, а часть — как конец по длине массива. Способ передачи следующей страницы должен быть описан в операции и проверен на первой и последней странице.</p>\n<h2>Переходим от fixture к тестовому стенду</h2>\n<p>После локальной проверки выполняется тот же запрос на разрешённом тестовом URL. Команда ниже — шаблон: адрес <code>api.example.test</code> не является работающим сервисом и не даёт заранее известного результата. В закрытом API вместо демонстрационного адреса нужен безопасный способ аутентификации; токены, cookies и реальные ответы не следует помещать в статью или CI-лог.</p>\n<pre><code># Только разрешённый тестовый 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'))\"</code></pre>\n<p>Запрос нужно читать по шагам. Сначала сохраняем фактический HTTP-статус и заголовок. Затем выбираем success или problem branch и сравниваем тело с соответствующей схемой. Если сервер вернул HTML с кодом 400, это не пустой результат и не problem document: нужно зафиксировать расхождение между gateway, приложением и контрактом.</p>\n<p>Локальный объект ответа отвечает только на вопрос «распознаёт ли клиент известные формы». Запрос к стенду дополнительно проверяет маршрутизацию, авторизацию, сериализатор и фактический заголовок. Эти проверки нельзя объединять в одно слово «тест прошёл»: у них разные владельцы и разные причины падения.</p>\n<h2>Порядок диагностики</h2>\n<ol><li>Выберите одну операцию и опишите наблюдаемый сбой: запрос, статус, тип ответа и действие клиента.</li><li>Запишите допустимый success-ответ: статус, media type, обязательные поля и типы значений.</li><li>Опишите каждую управляемую ошибку отдельной веткой, включая стабильный машинный код причины.</li><li>Добавьте fixture для первой страницы, последней страницы, ошибки и известной регрессии.</li><li>Проверяйте статус и media type до чтения полей тела; имена заголовков нормализуйте.</li><li>Зафиксируйте правило для каждого optional-поля: отсутствие, null или оба варианта.</li><li>Сверьте fixtures с OpenAPI и исправьте источник договора, а не только assertion.</li><li>Выполните идентичный запрос на разрешённом тестовом сервере, сохранив headers и body без секретов.</li><li>При расхождении назовите конкретную изменившуюся границу: статус, заголовок, required-поле, nullable или схема ошибки.</li></ol>\n<h2>Ограничения</h2>\n<p>Такой контрактный тест не измеряет latency и не доказывает безопасность cursor, права доступа или корректность пагинации при изменении данных между запросами. Он также не заменяет интеграционный сценарий браузера и не проверяет поведение реального gateway, если запускается только на локальных объектах.</p>\n<p>Не каждое неизвестное поле является ошибкой. Клиент может игнорировать расширения, если договор это разрешает. Но изменение статуса, media type, обязательности ключа или смысла <code>null</code> меняет наблюдаемое поведение. Для него нужны проверка потребителей и явный переход, иначе синтаксически корректный JSON снова скроет поломку.</p>\n<h2>Проверяемый результат</h2>\n<p>Сценарий готов, если независимый разработчик может по схеме предсказать ответ для обычного запроса, последней страницы и плохого cursor, а затем проверить прогноз на тестовом сервере. Локальная fixture принимает финальную страницу и problem detail, отклоняет 200 без <code>nextCursor</code>, а клиент не разбирает HTML или неизвестную ошибку как пустой список.</p>\n<p>Для статьи 2019 года это достаточная граница: от исходного симптома мы пришли к статусу, заголовку, required-полям, отдельной ошибке и воспроизводимому следующему шагу. Дальнейшие вопросы — права, нагрузка, совместимость клиентов и откат схемы — проверяются отдельными сценариями.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc7231.html#section-6\" target=\"_blank\" rel=\"noopener\">RFC 7231, раздел 6: Status Code Definitions</a> — исторически уместное для 2019 года описание классов и семантики HTTP-статусов.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc7807.html\" target=\"_blank\" rel=\"noopener\">RFC 7807: Problem Details for HTTP APIs</a> — формат problem detail и media type <code>application/problem+json</code>.</li><li><a href=\"https://spec.openapis.org/oas/v3.0.2.html\" target=\"_blank\" rel=\"noopener\">OpenAPI Specification 3.0.2</a> — operation, parameters, responses, content и Schema Object; версия опубликована до даты исходной статьи.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc8288.html\" target=\"_blank\" rel=\"noopener\">RFC 8288: Web Linking</a> — Link header как один из вариантов навигации между страницами.</li></ul>"
|
||
}
|