Files
progcode/editorial/agent-rewrites/308.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
22 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": 308,
"slug": "editorial-2019-06-mechanism-rest-api",
"title": "REST API без угадывания: как зафиксировать контракт операции",
"excerpt": "Старый клиент получает валидный JSON, но показывает пустой экран, теряет последнюю страницу или повторяет ошибку. Разбираем контракт одной REST-операции: статусы, схемы, problem details, обязательные поля и cursor-пагинацию.",
"contentHtml": "<p>Список заказов перестал листаться после изменения backend. URL не менялся. Клиент по-прежнему получает JSON с HTTP-статусом <code>200</code>, но кнопка «Ещё» исчезает, карточка падает на <code>customer.name</code>, а ошибка неверного курсора выглядит как пустой список. Цена ошибки — не только один дефект интерфейса. Пользователь не понимает, сохранилось ли действие. Клиент повторяет запрос. Команда тратит время на поиск «сломанного URL», хотя разошлись смысл статуса и структура ответа.</p>\n<p>Тезис простой: REST-контракт описывает не маршрут, а наблюдаемый результат операции. В нём есть метод, параметры, допустимые статусы, <code>Content-Type</code>, форма тела, обязательность полей и правило для повторной попытки. Если записать только <code>GET /api/v1/orders</code>, клиент всё равно будет угадывать остальное. Угадывание превращает совместимость в случайность.</p>\n<h2>Механизм: операция состоит из границ</h2>\n<p>Разделите запрос на четыре границы. Первая — вход: какие параметры принимает сервер и что считается неверным значением. Вторая — транспортный результат: какой HTTP-статус сообщает успех, ошибку клиента или временный сбой. Третья — представление: какой тип содержимого и какие поля находятся в теле. Четвёртая — действие клиента: показать данные, исправить ввод, очистить курсор, повторить запрос или остановиться.</p>\n<p>Эти границы связаны. Статус <code>200</code> сообщает, что операция завершилась успешно, но не говорит, где лежит следующий курсор. Поле <code>nextCursor</code> сообщает, что страница продолжается, но не объясняет, как выглядит ошибка. Строка <code>status</code> внутри JSON не должна отменять HTTP-статус: прокси, кеш и библиотека клиента читают прежде всего протокол.</p>\n<p>Возьмём учебную операцию <code>GET /api/v1/orders</code>. Параметр <code>limit</code> задаёт размер страницы. Параметр <code>cursor</code> непрозрачен: сервер выдал строку, клиент передал её обратно и не разбирает её содержимое. Успешное тело всегда содержит <code>items</code> и <code>page</code>. Внутри <code>page</code> значение <code>nextCursor</code> равно строке, если есть следующая страница, и <code>null</code>, если текущая страница последняя. Отсутствие ключа не означает третий случай.</p>\n<pre><code>GET /api/v1/orders?limit=2 HTTP/1.1\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n \"items\": [\n {\n \"id\": \"ord_1042\",\n \"status\": \"paid\",\n \"total\": { \"amount\": 9900, \"currency\": \"RUB\" }\n }\n ],\n \"page\": { \"limit\": 2, \"nextCursor\": \"ord_1042\" }\n}</code></pre>\n<p>Пример учебный. Он показывает форму договора и не доказывает задержку, доступность, права доступа или состав реальных заказов. Поле <code>id</code>, состояние заказа, сумма и объект <code>page</code> образуют ядро, которое клиент может читать без угадывания. Если backend добавляет необязательное поле, терпимый клиент может его проигнорировать. Если backend удаляет обязательное поле или меняет его тип, это уже несовместимое изменение для такого клиента.</p>\n<h2>Статус и тело ошибки</h2>\n<p>Неверный курсор — ошибка запроса, а не пустой результат. Ответ <code>200</code> с <code>items: []</code> смешивает два смысла: данных действительно нет или сервер не смог понять параметр. Клиент не может безопасно выбрать действие. Для ошибки параметра используем <code>400 Bad Request</code> и отдельное представление <code>application/problem+json</code>.</p>\n<pre><code>HTTP/1.1 400 Bad Request\nContent-Type: application/problem+json\n\n{\n \"type\": \"https://api.example.test/problems/invalid-cursor\",\n \"title\": \"Параметр cursor недействителен\",\n \"status\": 400,\n \"detail\": \"Курсор не принадлежит этому списку заказов\",\n \"instance\": \"/api/v1/orders?limit=2&amp;cursor=broken\",\n \"errors\": [\n { \"path\": \"query.cursor\", \"code\": \"invalid_cursor\" }\n ]\n}</code></pre>\n<p>В RFC 7807 поля <code>type</code>, <code>title</code>, <code>detail</code> и <code>instance</code> образуют переносимую модель problem details. <code>errors</code> в примере — расширение конкретного API, а не универсальное поле. Клиент связывает действие с машинным ключом <code>type</code> или <code>errors[].code</code>, а не с текстом <code>title</code>. Текст можно локализовать и изменить без смены поведения.</p>\n<p>Поле <code>status</code> в problem document дублирует HTTP-статус. Поэтому сервер и клиент должны считать HTTP-статус транспортной границей, а тело — детализацией. Если посредник изменил статус, эти значения могут расходиться. Клиент не должен превращать такой конфликт в успешный ответ или в пустой список. Он должен записать диагностический контекст и применить общий путь ошибки.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Диагностика контракта одной операции</caption><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Кнопка «Ещё» исчезает до конца данных</td><td>Последняя страница смешана с пустым результатом или потерян <code>nextCursor</code></td><td>Сравнить ответы первой и последней страниц; проверить наличие ключа и значение <code>null</code></td><td>Зафиксировать <code>page.nextCursor</code> как обязательное поле с явным правилом конца</td></tr><tr><td>Клиент падает на вложенном поле</td><td>Неясно, обязательны ли <code>customer</code> и его свойства</td><td>Сверить схему, реальные тела ответа и место чтения поля</td><td>Сделать поле required либо обработать его отсутствие в одном месте</td></tr><tr><td>Ошибка параметра выглядит как пустой список</td><td>Сервер вернул <code>200</code> с ошибкой в JSON</td><td>Проверить HTTP-статус и <code>Content-Type</code> в сетевом журнале</td><td>Вернуть <code>400</code> и problem document; не повторять тот же запрос</td></tr><tr><td>Повторный запрос создаёт дубликат действия</td><td>Клиент не знает, безопасен ли повтор и был ли запрос принят</td><td>Проверить метод, статус, идемпотентность и поведение после таймаута</td><td>Описать правило повтора; для записи использовать ключ идемпотентности, если он нужен</td></tr><tr><td>Генератор принимает поле, которое сервер отвергает</td><td>Схема и исполнение разошлись</td><td>Сопоставить OpenAPI, валидатор и фактическое тело на каждом статусе</td><td>Синхронно изменить схему и обработчик; добавить проверку на границе</td></tr></tbody></table>\n<p>Таблица не выбирает решение сама. Например, отсутствие <code>customer</code> может быть допустимым для списка и недопустимым для экрана деталей. Тогда это две разные операции или две явно описанные схемы. Нельзя объявлять объект «опциональным» только потому, что один старый ответ его не содержал. Нужно назвать условия, при которых поле присутствует, и поведение клиента при его отсутствии.</p>\n<h2>Как записать договор в OpenAPI</h2>\n<p>OpenAPI связывает путь и метод с параметрами и ответами. Каждый ответ получает описание, тип содержимого и схему тела. Благодаря этому в одном месте видно, что <code>200</code> — это страница заказов, <code>400</code> — problem document, а <code>nextCursor</code> имеет тип <code>string</code> или <code>null</code>. Спецификация не запускает сервер сама и не доказывает его соответствие. Она делает расхождение заметным и даёт вход для валидатора.</p>\n<pre><code>paths:\n /api/v1/orders:\n get:\n parameters:\n - in: query\n name: limit\n required: false\n schema:\n type: integer\n minimum: 1\n maximum: 100\n - in: query\n name: cursor\n required: false\n schema:\n type: string\n responses:\n \"200\":\n description: Страница заказов\n content:\n application/json:\n schema:\n $ref: '#/components/schemas/OrdersPage'\n \"400\":\n description: Неверный параметр\n content:\n application/problem+json:\n schema:\n $ref: '#/components/schemas/Problem'\n\ncomponents:\n schemas:\n OrdersPage:\n type: object\n required: [items, page]\n properties:\n items:\n type: array\n items: { $ref: '#/components/schemas/Order' }\n page:\n type: object\n required: [limit, nextCursor]\n properties:\n limit: { type: integer }\n nextCursor:\n type: string\n nullable: true</code></pre>\n<p>В OpenAPI 3.0.2 <code>required</code> относится к свойствам объекта. Это не означает, что значение каждого свойства не может быть <code>null</code>. Для cursor нужны два решения: ключ <code>nextCursor</code> приходит всегда, а его значение может быть строкой или <code>null</code>. Так клиент отличает конец списка от повреждённого тела, где сервер забыл ключ.</p>\n<p>Схема должна описывать каждый допустимый статус операции, включая тело ошибки, если клиент использует его для решения. Если API иногда возвращает <code>204</code>, этот вариант тоже нужно записать. Если сервер отдаёт <code>500</code> с HTML от прокси, это инфраструктурный путь, а не повод заявить, что ошибка имеет тот же контракт, что и <code>400</code>. Клиенту нужен общий fallback для неизвестного содержимого.</p>\n<figure><img src=\"/assets/editorial/2019/rest-api-response-matrix-2019.svg\" alt=\"Матрица контракта REST-операции: параметры limit и cursor ведут к ответам 200 application/json и 400 application/problem+json, а схемы описывают обязательные поля\" loading=\"lazy\" /><figcaption>Существующая схема показывает связь операции, статуса, типа содержимого и полей тела. Маршрут — только начало договора.</figcaption></figure>\n<h2>Повтор, идемпотентность и отрицательный путь</h2>\n<p>Таймаут не сообщает, обработал ли сервер запрос. Для чтения <code>GET</code> повтор обычно не меняет ресурс, но клиент всё равно должен ограничить число попыток и различать сетевой сбой, <code>429</code> и <code>400</code>. Неверный параметр повторять бессмысленно. Для записи нельзя переносить правило GET автоматически: повтор POST может создать две операции, если сервер принял первый запрос, а ответ потерялся.</p>\n<p>Для записи нужен отдельный контракт идемпотентности. Один из вариантов — заголовок с ключом операции, который сервер связывает с результатом. Это учебная модель, а не обязательное требование HTTP. Если проект выбирает такой механизм, нужно описать срок хранения ключа, конфликт повторного ключа с другим телом и статус при повторе. Без этих условий слово «идемпотентный» не даёт клиенту действия.</p>\n<p>Отрицательный путь важен не меньше happy path. Проверьте отсутствующий cursor, неверный тип <code>limit</code>, слишком большое значение, пустую страницу, последнюю страницу, неизвестный problem type, неожиданный <code>Content-Type</code> и сетевой таймаут. Если система не знает, что делать с ответом, она должна остановить автоматический повтор и оставить диагностический след. Притвориться успешным пустым ответом безопаснее не становится.</p>\n<h2>Порядок действий</h2>\n<ol><li>Выберите одну операцию и сохраните точный метод, путь, query-параметры, заголовки и пример реального вызова.</li><li>Назовите наблюдаемый успех: какие данные и какая интеракция должны быть доступны пользователю.</li><li>Составьте карту статусов. Для каждого статуса укажите <code>Content-Type</code>, тело и действие клиента.</li><li>Зафиксируйте обязательные поля, допустимый <code>null</code>, отсутствие ключа и правило cursor-пагинации.</li><li>Запишите операцию в OpenAPI и добавьте схемы для успеха и ошибок. Не оставляйте тело как безымянный <code>object</code>.</li><li>Проверьте учебные примеры валидатором схемы и сравните их с кодом сериализации. Эта проверка не заменяет интеграционный запрос.</li><li>Прогоните отрицательные случаи: неверные параметры, последнюю страницу, сетевой сбой и неожиданный тип содержимого.</li><li>Согласуйте изменение как совместимое или несовместимое. При несовместимом изменении задайте версию, переход или адаптер до выкладки клиента.</li></ol>\n<h2>Ограничения</h2>\n<p>OpenAPI не исправляет сервер и не гарантирует, что прокси не изменит ответ. Валидатор проверяет только то, что ему передали, и может поддерживать не все возможности выбранной версии схемы. Локальный пример не показывает права доступа, нагрузку, задержку, порядок данных или поведение кеша. Для этих свойств нужны отдельные проверки на подходящем контуре.</p>\n<p>HTTP-статус не заменяет доменную ошибку, а problem document не заменяет правила интерфейса. Не стоит создавать отдельный тип ошибки для каждой фразы. Тип должен объяснять устойчивый класс проблемы и способ реакции. Не стоит объявлять cursor прозрачным идентификатором, если сервер оставляет за собой право менять его формат.</p>\n<p>Если после проверки схема совпадает с сериализатором, но клиент всё равно показывает неверный экран, ищите ошибку в преобразовании данных или состоянии интерфейса. Не добавляйте новый статус и не меняйте форму JSON без доказательства, что граница находится в API. Контракт помогает локализовать проблему, но не делает каждый дефект проблемой контракта.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Операция готова к интеграции, когда для каждого заявленного статуса есть проверяемое тело или явно указанное отсутствие тела; клиент знает действие для успеха, неверного параметра, повторяемого сбоя и неизвестного ответа; схема совпадает с фактической сериализацией; последняя страница отличается от пустого результата явным <code>nextCursor: null</code>. В артефакте должны остаться запросы, ответы и результаты отрицательных проверок. Без этого «контракт есть» означает только наличие файла.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://spec.openapis.org/oas/v3.0.2.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification 3.0.2</a></li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9457.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9457: Problem Details for HTTP APIs</a></li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a></li></ul>"
}