8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"index": 309,
|
||
"slug": "editorial-2019-06-practice-rest-api",
|
||
"title": "REST API без угадываний: как зафиксировать ответ операции",
|
||
"excerpt": "Список ломается не из-за URL, а из-за неявных правил: 200 скрывает ошибку, курсор исчезает на последней странице, а необязательное поле приходит то пустым, то отсутствующим. Разбираем контракт одной REST-операции и проверяем его по статусу, Content-Type и форме JSON.",
|
||
"contentHtml": "<p>Список заказов загружается без ошибки сети, но кнопка «Ещё» исчезает после первой страницы. На другой ветке карточка падает при чтении <code>customer.name</code>. Форма после отправки получает HTTP 200 и показывает общий сбой, потому что в JSON лежит <code>error</code>. Адрес <code>/api/v1/orders</code> не менялся. Изменился договор между клиентом и сервером, но его никто не записал.</p>\n<p>Цена ошибки — не только красный экран. Клиент может повторить выполненное действие. Пользователь не понимает, сохранился ли заказ. Поддержка получает неполное объяснение. Разработчики видят валидный JSON и спорят о смысле его полей. Чем больше клиентов у операции, тем дороже угадывание.</p>\n<p>REST-контракт описывает не URL, а наблюдаемый результат операции. Для каждого входа нужно зафиксировать статус, <code>Content-Type</code>, форму тела и действие клиента. В учебном примере возьмём <code>GET /api/v1/orders</code>. Он возвращает страницу заказов, принимает <code>limit</code> и непрозрачный <code>cursor</code>. Пример не обращается к реальному серверу и не доказывает поведение production.</p>\n<h2>Симптом показывает незаписанную границу</h2>\n<p>Фраза «метод возвращает JSON» слишком общая. Она не говорит, что означает пустой список, как обозначается конец пагинации и где искать ошибку параметра. Она также не отвечает, может ли ключ отсутствовать или должен иметь значение <code>null</code>. Без этих решений разные клиенты создают разные правила.</p>\n<p>HTTP различает успешный результат, ошибку запроса и ошибку сервера. Код 400 сообщает о проблеме в запросе, а 5xx — о сбое на стороне сервера. Поле <code>{ "ok": false }</code> внутри ответа 200 не заменяет HTTP-статус. Оно заставляет каждый клиент самостоятельно решать, когда успешный ответ нужно считать ошибкой.</p>\n<table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>«Ещё» исчезает рано</td><td>Конец списка выводят из длины массива</td><td>Проверить <code>page.nextCursor</code></td><td>Возвращать cursor или явный <code>null</code></td></tr><tr><td>Карточка падает на поле</td><td>Неясно, отсутствует ли <code>customer</code> или равен null</td><td>Сверить <code>required</code> и JSON</td><td>Выбрать одно правило</td></tr><tr><td>400 парсят как список</td><td>Клиент смотрит только на тело</td><td>Сравнить статус, media type и схему</td><td>Ветвить обработку по коду</td></tr><tr><td>Ошибка даёт общий баннер</td><td>Нет стабильного кода причины</td><td>Проверить <code>type</code> и <code>errors</code></td><td>Привязать действие к машинному коду</td></tr></tbody></table>\n<h2>Минимальный контракт списка</h2>\n<p>У <code>limit</code> должен быть диапазон, например от 1 до 100. <code>cursor</code> может отсутствовать на первом запросе и остаётся непрозрачной строкой. Клиент передаёт его обратно, но не извлекает из него дату, идентификатор или номер страницы.</p>\n<p>Успешный ответ всегда содержит <code>items</code> и <code>page</code>. В <code>page.nextCursor</code> строка означает, что следующая страница доступна. <code>null</code> означает конец текущего снимка. Отсутствие ключа не используем как третий сигнал. Это решение проекта, а не универсальное требование REST. Вместо него можно выбрать offset или заголовок <code>Link</code>, но смешивать способы не стоит.</p>\n<pre><code>GET /api/v1/orders?limit=2 HTTP/1.1 Accept: application/json HTTP/1.1 200 OK Content-Type: application/json { "items": [{ "id": "ord_1042", "status": "paid", "total": { "amount": 9900, "currency": "RUB" }, "customer": { "id": "cus_17", "name": "Ирина" } }], "page": { "limit": 2, "nextCursor": "ord_1042" } }</code></pre>\n<p>В ответе <code>id</code>, <code>status</code>, <code>total</code> и <code>page</code> образуют обязательное ядро. Если заказ может прийти без клиента, ключ <code>customer</code> не входит в <code>required</code>. При наличии он должен быть объектом с согласованными полями. Нельзя одновременно обещать «ключ отсутствует», «ключ равен null» и «ключ всегда объект». Для клиента это три разных состояния.</p>\n<h2>Статус и тело ошибки читаем вместе</h2>\n<p>Пусть cursor принадлежит другой выборке или имеет неверный формат. Сервер не может построить корректную страницу, поэтому учебный контракт возвращает 400. Тело использует Problem Details. Стандарт задаёт поля <code>type</code>, <code>title</code>, <code>status</code>, <code>detail</code> и <code>instance</code>. Проект может добавить расширение <code>errors</code>.</p>\n<pre><code>HTTP/1.1 400 Bad Request Content-Type: application/problem+json { "type": "https://api.example.test/problems/invalid-cursor", "title": "Недействительный cursor", "status": 400, "detail": "Курсор не принадлежит этому списку", "instance": "/api/v1/orders?limit=2&cursor=broken", "errors": [{ "path": "query.cursor", "code": "invalid_cursor" }] }</code></pre>\n<p>Клиент не должен принимать решение по <code>title</code> или изменчивому <code>detail</code>. Для ветвления подходит <code>type</code> или <code>errors[].code</code>. При <code>invalid_cursor</code> интерфейс может удалить сохранённый cursor и загрузить первую страницу. Нераспознанная проблема должна вести на общий путь ошибки, а не превращаться в пустой список.</p>\n<figure><img src=\"/assets/editorial/2019/rest-api-contract-map-2019.svg\" alt=\"Карта контракта GET списка заказов: параметры limit и cursor ведут к ответам 200 и 400, у каждого ответа указаны Content-Type и обязательные поля\"><figcaption>Контракт начинается на входе запроса и заканчивается проверяемой формой ответа. Один URL не описывает эти границы.</figcaption></figure>\n<h2>Записываем правила в OpenAPI</h2>\n<p>Комментарий в контроллере быстро расходится с реальным ответом. OpenAPI связывает операцию, параметры и responses в одном документе. У 200 указываем <code>application/json</code> и схему страницы. У 400 — <code>application/problem+json</code> и схему проблемы. Документ не доказывает, что живой сервер соблюдает YAML, но делает расхождение видимым.</p>\n<pre><code>/api/v1/orders: get: parameters: - in: query name: limit schema: { type: integer, minimum: 1, maximum: 100 } - in: query name: cursor schema: { type: string } responses: "200": description: Страница заказов content: application/json: schema: { $ref: "#/components/schemas/OrdersPage" } "400": description: Неверный limit или cursor content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" }</code></pre>\n<p>В схеме страницы <code>items</code> и <code>page</code> входят в <code>required</code>. Внутри <code>page</code> обязательны <code>limit</code> и <code>nextCursor</code>. Если конец списка обозначает <code>null</code>, это нужно выразить в используемой версии схемы и проверить выбранным инструментом. Если команда предпочитает отсутствие ключа, это тоже надо записать. Сериализатор не должен выбирать смысл молча.</p>\n<h2>Проверяем договор на наблюдаемом примере</h2>\n<p>Проверка должна ловить не только невалидный JSON. Для 200 сравните статус, media type, массив <code>items</code>, объект <code>page</code> и наличие <code>nextCursor</code>. Для ошибки сравните 400, <code>application/problem+json</code>, поле <code>type</code>, совпадение <code>status</code> с HTTP-кодом и стабильный код причины. Отдельно отправьте последнюю страницу и убедитесь, что она возвращает <code>nextCursor: null</code>, а не пропускает ключ.</p>\n<p>Учебные JSON проверяют форму, но не подтверждают авторизацию, маршрутизацию, таймауты, кеши или поведение базы. Для живой проверки нужен запрос к тестовому серверу с теми же ожиданиями. Результат запроса нельзя заменять обещанием из документации.</p>\n<h2>Порядок действий</h2>\n<ol><li>Выберите одну операцию и опишите, какое действие она выполняет.</li><li>Назовите параметры, типы, диапазоны и правило передачи cursor или offset.</li><li>Для каждого результата зафиксируйте HTTP-статус, Content-Type и тело.</li><li>Разведите обязательное поле, отсутствующий ключ и null; оставьте выбранные варианты.</li><li>Добавьте пример успеха, ошибки и последней страницы.</li><li>Опишите операцию в OpenAPI и сверьте схему с примерами.</li><li>Выполните те же случаи на тестовом сервере и сравните статус, заголовок и тело.</li><li>Проверьте старых клиентов перед изменением required-поля, типа, статуса или формата ошибки.</li></ol>\n<h2>Ограничения</h2>\n<p>Контракт ответа не решает авторизацию, идемпотентность команд, лимиты нагрузки и версионирование всего API. Cursor не становится безопасным токеном только потому, что клиент не разбирает его содержимое. У 400, 401, 409 и 5xx могут быть разные причины и действия. OpenAPI также не делает изменение схемы обратно совместимым автоматически.</p>\n<p>Добавление необязательного поля обычно требует меньше миграции, чем удаление обязательного. Замена строки объектом, перенос ошибки из 400 в 200 и замена <code>null</code> на отсутствие ключа меняют наблюдаемое поведение. Для таких изменений нужны проверка потребителей и явный переход. Иначе синтаксически корректный JSON снова скроет смысловую поломку.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Операция готова, когда независимый разработчик по контракту может предсказать ответ для первого запроса, неполного параметра и последней страницы, а затем подтвердить прогноз запросом к тестовому серверу. Для каждого случая совпадают HTTP-статус, Content-Type и обязательная форма тела. Клиент обрабатывает неизвестную ошибку как ошибку, не теряет последнюю страницу и не обращается к отсутствующему полю без явного правила.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener\">RFC 9110: HTTP Semantics</a> — статусы, методы и семантика HTTP-ответов.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9457.html\" target=\"_blank\" rel=\"noopener\">RFC 9457: Problem Details for HTTP APIs</a> — форма сообщения об ошибке и поля расширения.</li><li><a href=\"https://spec.openapis.org/oas/v3.0.3.html\" target=\"_blank\" rel=\"noopener\">OpenAPI Specification 3.0.3</a> — параметры операции, responses, content и Schema Object.</li></ul>"
|
||
}
|