Files
progcode/editorial/agent-rewrites/307.json
T
2026-09-03 23:36:42 +03:00

8 lines
23 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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) =&gt; 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 &amp;&amp; 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' &amp;&amp; !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>"
}