Files

2 lines
18 KiB
JSON
Raw Permalink 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":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>{ &quot;ok&quot;: 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&#10;Accept: application/json&#10;&#10;HTTP/1.1 200 OK&#10;Content-Type: application/json&#10;&#10;{&#10; &quot;items&quot;: [{ &quot;id&quot;: &quot;ord_1042&quot;, &quot;status&quot;: &quot;paid&quot;, &quot;total&quot;: { &quot;amount&quot;: 9900, &quot;currency&quot;: &quot;RUB&quot; }, &quot;customer&quot;: { &quot;id&quot;: &quot;cus_17&quot;, &quot;name&quot;: &quot;Ирина&quot; } }],&#10; &quot;page&quot;: { &quot;limit&quot;: 2, &quot;nextCursor&quot;: &quot;cursor_v2_a91f&quot; }&#10;}</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. RFC 7807 описывает стандартные поля <code>type</code>, <code>title</code>, <code>status</code>, <code>detail</code> и <code>instance</code>; проект может добавить расширение <code>errors</code>. Поле <code>status</code> в RFC носит информационный характер, но генератор должен послать тот же код HTTP, поэтому проверяем оба значения.</p>\n<pre><code>HTTP/1.1 400 Bad Request&#10;Content-Type: application/problem+json&#10;&#10;{&#10; &quot;type&quot;: &quot;https://api.example.test/problems/invalid-cursor&quot;,&#10; &quot;title&quot;: &quot;Недействительный cursor&quot;,&#10; &quot;status&quot;: 400,&#10; &quot;detail&quot;: &quot;Курсор не принадлежит этому списку&quot;,&#10; &quot;instance&quot;: &quot;/api/v1/orders?limit=2&amp;cursor=broken&quot;,&#10; &quot;errors&quot;: [{ &quot;path&quot;: &quot;query.cursor&quot;, &quot;code&quot;: &quot;invalid_cursor&quot; }]&#10;}</code></pre>\n<p>Клиент не должен принимать решение по <code>title</code> или изменчивому <code>detail</code>. Для ветвления подходит <code>type</code> или <code>errors[].code</code>. При <code>invalid_cursor</code> интерфейс может один раз удалить сохранённый cursor и загрузить первую страницу. Если ошибка повторилась, её нужно показать как ошибку, а не запускать бесконечный retry. Нераспознанная проблема должна вести на общий путь ошибки, а не превращаться в пустой список.</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:&#10; get:&#10; parameters:&#10; - in: query&#10; name: limit&#10; schema: { type: integer, minimum: 1, maximum: 100 }&#10; - in: query&#10; name: cursor&#10; schema: { type: string }&#10; responses:&#10; &quot;200&quot;:&#10; description: Страница заказов&#10; content:&#10; application/json:&#10; schema: { $ref: &quot;#/components/schemas/OrdersPage&quot; }&#10; &quot;400&quot;:&#10; description: Неверный limit или cursor&#10; content:&#10; application/problem+json:&#10; schema: { $ref: &quot;#/components/schemas/Problem&quot; }</code></pre>\n<p>Фрагмент написан для OpenAPI 3.0.2 — версии, опубликованной в 2018 году. В этой версии nullable-строку описывают как <code>type: string</code> и <code>nullable: true</code>; в OpenAPI 3.1 можно использовать схему JSON Schema, например <code>type: [string, &quot;null&quot;]</code>. Сначала зафиксируйте версию документа и поддерживаемое ею правило, затем проверьте его инструментом.</p>\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>Операция готова, когда независимый разработчик по контракту может предсказать ответ для первого запроса, ошибки валидации limit или cursor и последней страницы, а затем подтвердить прогноз запросом к тестовому серверу. Для каждого случая совпадают HTTP-статус, Content-Type и обязательная форма тела. Клиент обрабатывает неизвестную ошибку как ошибку, не теряет последнюю страницу и не обращается к отсутствующему полю без явного правила.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc7231.html\" target=\"_blank\" rel=\"noopener\">RFC 7231: HTTP/1.1 Semantics and Content</a> — статусы, методы и семантика HTTP, применимая к статье 2019 года.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc7807.html\" target=\"_blank\" rel=\"noopener\">RFC 7807: Problem Details for HTTP APIs</a> — формат ошибки и поля расширения, опубликованный до даты статьи.</li><li><a href=\"https://spec.openapis.org/oas/v3.0.2.html\" target=\"_blank\" rel=\"noopener\">OpenAPI Specification 3.0.2</a> — версия от 8 октября 2018 года: параметры, responses, content и Schema Object.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener\">RFC 9110: HTTP Semantics</a> и <a href=\"https://www.rfc-editor.org/rfc/rfc9457.html\" target=\"_blank\" rel=\"noopener\">RFC 9457: Problem Details for HTTP APIs</a> — современные преемники RFC 7231 и RFC 7807; сверяйте их отдельно, если проект не ограничен историческим контрактом.</li></ul>"}