diff --git a/editorial/agent-rewrites/308.json b/editorial/agent-rewrites/308.json index 79efdad..7201db1 100644 --- a/editorial/agent-rewrites/308.json +++ b/editorial/agent-rewrites/308.json @@ -3,5 +3,5 @@ "slug": "editorial-2019-06-mechanism-rest-api", "title": "REST API без угадывания: как зафиксировать контракт операции", "excerpt": "Старый клиент получает валидный JSON, но показывает пустой экран, теряет последнюю страницу или повторяет ошибку. Разбираем контракт одной REST-операции: статусы, схемы, problem details, обязательные поля и cursor-пагинацию.", - "contentHtml": "
Список заказов перестал листаться после изменения backend. URL не менялся. Клиент по-прежнему получает JSON с HTTP-статусом 200, но кнопка «Ещё» исчезает, карточка падает на customer.name, а ошибка неверного курсора выглядит как пустой список. Цена ошибки — не только один дефект интерфейса. Пользователь не понимает, сохранилось ли действие. Клиент повторяет запрос. Команда тратит время на поиск «сломанного URL», хотя разошлись смысл статуса и структура ответа.
Тезис простой: REST-контракт описывает не маршрут, а наблюдаемый результат операции. В нём есть метод, параметры, допустимые статусы, Content-Type, форма тела, обязательность полей и правило для повторной попытки. Если записать только GET /api/v1/orders, клиент всё равно будет угадывать остальное. Угадывание превращает совместимость в случайность.
Разделите запрос на четыре границы. Первая — вход: какие параметры принимает сервер и что считается неверным значением. Вторая — транспортный результат: какой HTTP-статус сообщает успех, ошибку клиента или временный сбой. Третья — представление: какой тип содержимого и какие поля находятся в теле. Четвёртая — действие клиента: показать данные, исправить ввод, очистить курсор, повторить запрос или остановиться.
\nЭти границы связаны. Статус 200 сообщает, что операция завершилась успешно, но не говорит, где лежит следующий курсор. Поле nextCursor сообщает, что страница продолжается, но не объясняет, как выглядит ошибка. Строка status внутри JSON не должна отменять HTTP-статус: прокси, кеш и библиотека клиента читают прежде всего протокол.
Возьмём учебную операцию GET /api/v1/orders. Параметр limit задаёт размер страницы. Параметр cursor непрозрачен: сервер выдал строку, клиент передал её обратно и не разбирает её содержимое. Успешное тело всегда содержит items и page. Внутри page значение nextCursor равно строке, если есть следующая страница, и null, если текущая страница последняя. Отсутствие ключа не означает третий случай.
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}\nПример учебный. Он показывает форму договора и не доказывает задержку, доступность, права доступа или состав реальных заказов. Поле id, состояние заказа, сумма и объект page образуют ядро, которое клиент может читать без угадывания. Если backend добавляет необязательное поле, терпимый клиент может его проигнорировать. Если backend удаляет обязательное поле или меняет его тип, это уже несовместимое изменение для такого клиента.
Неверный курсор — ошибка запроса, а не пустой результат. Ответ 200 с items: [] смешивает два смысла: данных действительно нет или сервер не смог понять параметр. Клиент не может безопасно выбрать действие. Для ошибки параметра используем 400 Bad Request и отдельное представление application/problem+json.
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&cursor=broken\",\n \"errors\": [\n { \"path\": \"query.cursor\", \"code\": \"invalid_cursor\" }\n ]\n}\nВ RFC 7807 поля type, title, detail и instance образуют переносимую модель problem details. errors в примере — расширение конкретного API, а не универсальное поле. Клиент связывает действие с машинным ключом type или errors[].code, а не с текстом title. Текст можно локализовать и изменить без смены поведения.
Поле status в problem document дублирует HTTP-статус. Поэтому сервер и клиент должны считать HTTP-статус транспортной границей, а тело — детализацией. Если посредник изменил статус, эти значения могут расходиться. Клиент не должен превращать такой конфликт в успешный ответ или в пустой список. Он должен записать диагностический контекст и применить общий путь ошибки.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Кнопка «Ещё» исчезает до конца данных | Последняя страница смешана с пустым результатом или потерян nextCursor | Сравнить ответы первой и последней страниц; проверить наличие ключа и значение null | Зафиксировать page.nextCursor как обязательное поле с явным правилом конца |
| Клиент падает на вложенном поле | Неясно, обязательны ли customer и его свойства | Сверить схему, реальные тела ответа и место чтения поля | Сделать поле required либо обработать его отсутствие в одном месте |
| Ошибка параметра выглядит как пустой список | Сервер вернул 200 с ошибкой в JSON | Проверить HTTP-статус и Content-Type в сетевом журнале | Вернуть 400 и problem document; не повторять тот же запрос |
| Повторный запрос создаёт дубликат действия | Клиент не знает, безопасен ли повтор и был ли запрос принят | Проверить метод, статус, идемпотентность и поведение после таймаута | Описать правило повтора; для записи использовать ключ идемпотентности, если он нужен |
| Генератор принимает поле, которое сервер отвергает | Схема и исполнение разошлись | Сопоставить OpenAPI, валидатор и фактическое тело на каждом статусе | Синхронно изменить схему и обработчик; добавить проверку на границе |
Таблица не выбирает решение сама. Например, отсутствие customer может быть допустимым для списка и недопустимым для экрана деталей. Тогда это две разные операции или две явно описанные схемы. Нельзя объявлять объект «опциональным» только потому, что один старый ответ его не содержал. Нужно назвать условия, при которых поле присутствует, и поведение клиента при его отсутствии.
OpenAPI связывает путь и метод с параметрами и ответами. Каждый ответ получает описание, тип содержимого и схему тела. Благодаря этому в одном месте видно, что 200 — это страница заказов, 400 — problem document, а nextCursor имеет тип string или null. Спецификация не запускает сервер сама и не доказывает его соответствие. Она делает расхождение заметным и даёт вход для валидатора.
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\nВ OpenAPI 3.0.2 required относится к свойствам объекта. Это не означает, что значение каждого свойства не может быть null. Для cursor нужны два решения: ключ nextCursor приходит всегда, а его значение может быть строкой или null. Так клиент отличает конец списка от повреждённого тела, где сервер забыл ключ.
Схема должна описывать каждый допустимый статус операции, включая тело ошибки, если клиент использует его для решения. Если API иногда возвращает 204, этот вариант тоже нужно записать. Если сервер отдаёт 500 с HTML от прокси, это инфраструктурный путь, а не повод заявить, что ошибка имеет тот же контракт, что и 400. Клиенту нужен общий fallback для неизвестного содержимого.
Таймаут не сообщает, обработал ли сервер запрос. Для чтения GET повтор обычно не меняет ресурс, но клиент всё равно должен ограничить число попыток и различать сетевой сбой, 429 и 400. Неверный параметр повторять бессмысленно. Для записи нельзя переносить правило GET автоматически: повтор POST может создать две операции, если сервер принял первый запрос, а ответ потерялся.
Для записи нужен отдельный контракт идемпотентности. Один из вариантов — заголовок с ключом операции, который сервер связывает с результатом. Это учебная модель, а не обязательное требование HTTP. Если проект выбирает такой механизм, нужно описать срок хранения ключа, конфликт повторного ключа с другим телом и статус при повторе. Без этих условий слово «идемпотентный» не даёт клиенту действия.
\nОтрицательный путь важен не меньше happy path. Проверьте отсутствующий cursor, неверный тип limit, слишком большое значение, пустую страницу, последнюю страницу, неизвестный problem type, неожиданный Content-Type и сетевой таймаут. Если система не знает, что делать с ответом, она должна остановить автоматический повтор и оставить диагностический след. Притвориться успешным пустым ответом безопаснее не становится.
Content-Type, тело и действие клиента.null, отсутствие ключа и правило cursor-пагинации.object.OpenAPI не исправляет сервер и не гарантирует, что прокси не изменит ответ. Валидатор проверяет только то, что ему передали, и может поддерживать не все возможности выбранной версии схемы. Локальный пример не показывает права доступа, нагрузку, задержку, порядок данных или поведение кеша. Для этих свойств нужны отдельные проверки на подходящем контуре.
\nHTTP-статус не заменяет доменную ошибку, а problem document не заменяет правила интерфейса. Не стоит создавать отдельный тип ошибки для каждой фразы. Тип должен объяснять устойчивый класс проблемы и способ реакции. Не стоит объявлять cursor прозрачным идентификатором, если сервер оставляет за собой право менять его формат.
\nЕсли после проверки схема совпадает с сериализатором, но клиент всё равно показывает неверный экран, ищите ошибку в преобразовании данных или состоянии интерфейса. Не добавляйте новый статус и не меняйте форму JSON без доказательства, что граница находится в API. Контракт помогает локализовать проблему, но не делает каждый дефект проблемой контракта.
\nОперация готова к интеграции, когда для каждого заявленного статуса есть проверяемое тело или явно указанное отсутствие тела; клиент знает действие для успеха, неверного параметра, повторяемого сбоя и неизвестного ответа; схема совпадает с фактической сериализацией; последняя страница отличается от пустого результата явным nextCursor: null. В артефакте должны остаться запросы, ответы и результаты отрицательных проверок. Без этого «контракт есть» означает только наличие файла.
Список заказов перестал листаться после изменения backend. URL не менялся. Клиент по-прежнему получает JSON с HTTP-статусом 200, но кнопка «Ещё» исчезает, карточка падает на customer.name, а ошибка неверного курсора выглядит как пустой список. Цена ошибки — не только один дефект интерфейса. Пользователь не понимает, сохранилось ли действие. Клиент повторяет запрос. Команда тратит время на поиск «сломанного URL», хотя разошлись смысл статуса и структура ответа.
Тезис простой: REST-контракт описывает не маршрут, а наблюдаемый результат операции. В нём есть метод, параметры, допустимые статусы, Content-Type, форма тела, обязательность полей и правило для повторной попытки. Если записать только GET /api/v1/orders, клиент всё равно будет угадывать остальное. Угадывание превращает совместимость в случайность.
Разделите запрос на четыре границы. Первая — вход: какие параметры принимает сервер и что считается неверным значением. Вторая — транспортный результат: какой HTTP-статус сообщает успех, ошибку клиента или временный сбой. Третья — представление: какой тип содержимого и какие поля находятся в теле. Четвёртая — действие клиента: показать данные, исправить ввод, очистить курсор, повторить запрос или остановиться.
\nЭти границы связаны. Статус 200 сообщает, что операция завершилась успешно, но не говорит, где лежит следующий курсор. Поле nextCursor сообщает, что страница продолжается, но не объясняет, как выглядит ошибка. Строка status внутри JSON не должна отменять HTTP-статус: прокси, кеш и библиотека клиента читают прежде всего протокол.
Возьмём учебную операцию GET /api/v1/orders. Параметр limit задаёт размер страницы. Параметр cursor непрозрачен: сервер выдал строку, клиент передал её обратно и не разбирает её содержимое. Успешное тело всегда содержит items и page. Внутри page значение nextCursor равно строке, если есть следующая страница, и null, если текущая страница последняя. Отсутствие ключа не означает третий случай.
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}\nПример учебный. Он показывает форму договора и не доказывает задержку, доступность, права доступа или состав реальных заказов. Поле id, состояние заказа, сумма и объект page образуют ядро, которое клиент может читать без угадывания. Если backend добавляет необязательное поле, терпимый клиент может его проигнорировать. Если backend удаляет обязательное поле или меняет его тип, это уже несовместимое изменение для такого клиента.
Неверный курсор — ошибка запроса, а не пустой результат. Ответ 200 с items: [] смешивает два смысла: данных действительно нет или сервер не смог понять параметр. Клиент не может безопасно выбрать действие. Для ошибки параметра используем 400 Bad Request и отдельное представление application/problem+json.
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&cursor=broken\",\n \"errors\": [\n { \"path\": \"query.cursor\", \"code\": \"invalid_cursor\" }\n ]\n}\nRFC 9457 (он заменяет RFC 7807) описывает базовую модель problem details: поля type, title, detail и instance несут идентификатор типа, краткое описание, детали случая и ссылку на конкретное возникновение ошибки. errors в примере — расширение конкретного API, а не универсальное поле. Клиент связывает действие с машинным ключом type или errors[].code, а не с текстом title. Текст можно локализовать и изменить без смены поведения.
Поле status в problem document содержит код, который сгенерировал исходный сервер для этого случая; оно не создаёт второй транспортный статус. HTTP-статус определяет транспортный результат, а тело уточняет проблему. Если посредник изменил статус, значения могут расходиться. Клиент не должен превращать такой конфликт в успешный ответ или в пустой список: нужно сохранить диагностический контекст и применить общий путь ошибки.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Кнопка «Ещё» исчезает до конца данных | Последняя страница смешана с пустым результатом или потерян nextCursor | Сравнить ответы первой и последней страниц; проверить наличие ключа и значение null | Зафиксировать page.nextCursor как обязательное поле с явным правилом конца |
| Клиент падает на вложенном поле | Неясно, обязательны ли customer и его свойства | Сверить схему, реальные тела ответа и место чтения поля | Сделать поле required либо обработать его отсутствие в одном месте |
| Ошибка параметра выглядит как пустой список | Сервер вернул 200 с ошибкой в JSON | Проверить HTTP-статус и Content-Type в сетевом журнале | Вернуть 400 и problem document; не повторять тот же запрос |
| Повторный запрос создаёт дубликат действия | Клиент не знает, безопасен ли повтор и был ли запрос принят | Проверить метод, статус, идемпотентность и поведение после таймаута | Описать правило повтора; для записи использовать ключ идемпотентности, если он нужен |
| Генератор принимает поле, которое сервер отвергает | Схема и исполнение разошлись | Сопоставить OpenAPI, валидатор и фактическое тело на каждом статусе | Синхронно изменить схему и обработчик; добавить проверку на границе |
Таблица не выбирает решение сама. Например, отсутствие customer может быть допустимым для списка и недопустимым для экрана деталей. Тогда это две разные операции или две явно описанные схемы. Нельзя объявлять объект «опциональным» только потому, что один старый ответ его не содержал. Нужно назвать условия, при которых поле присутствует, и поведение клиента при его отсутствии.
OpenAPI связывает путь и метод с параметрами и ответами. Каждый ответ получает описание, тип содержимого и схему тела. Благодаря этому в одном месте видно, что 200 — это страница заказов, 400 — problem document, а nextCursor имеет тип string или null. Спецификация не запускает сервер сама и не доказывает его соответствие. Она делает расхождение заметным и даёт вход для валидатора.
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\nВ OpenAPI 3.0.2 ключ required перечисляет свойства, которые должны присутствовать в объекте; само наличие ключа не запрещает значение null. Для cursor нужны два решения: ключ nextCursor приходит всегда, а его значение может быть строкой или null. Так клиент отличает конец списка от повреждённого тела, где сервер забыл ключ.
Схема должна описывать каждый допустимый статус операции, включая тело ошибки, если клиент использует его для решения. Если API иногда возвращает 204, этот вариант тоже нужно записать. Если сервер отдаёт 500 с HTML от прокси, это инфраструктурный путь, а не повод заявить, что ошибка имеет тот же контракт, что и 400. Клиенту нужен общий fallback для неизвестного содержимого.
Таймаут не сообщает, обработал ли сервер запрос. Для чтения GET относится к безопасным и идемпотентным методам: повтор одинакового запроса не должен менять ожидаемый эффект над ресурсом. Это не обещает отсутствия побочных эффектов реализации, поэтому клиент всё равно должен ограничить число попыток и различать сетевой сбой, 429 и 400. Неверный параметр повторять бессмысленно. Для записи нельзя переносить правило GET автоматически: повтор POST может создать две операции, если сервер принял первый запрос, а ответ потерялся.
Для записи нужен отдельный контракт идемпотентности. Один из вариантов — заголовок с ключом операции, который сервер связывает с результатом. Это учебная модель, а не обязательное требование HTTP. Если проект выбирает такой механизм, нужно описать срок хранения ключа, конфликт повторного ключа с другим телом и статус при повторе. Без этих условий слово «идемпотентный» не даёт клиенту действия.
\nОтрицательный путь важен не меньше happy path. Проверьте отсутствующий cursor, неверный тип limit, слишком большое значение, пустую страницу, последнюю страницу, неизвестный problem type, неожиданный Content-Type и сетевой таймаут. Если система не знает, что делать с ответом, она должна остановить автоматический повтор и оставить диагностический след. Притвориться успешным пустым ответом безопаснее не становится.
Content-Type, тело и действие клиента.null, отсутствие ключа и правило cursor-пагинации.object.OpenAPI не исправляет сервер и не гарантирует, что прокси не изменит ответ. Валидатор проверяет только то, что ему передали, и может поддерживать не все возможности выбранной версии схемы. Локальный пример не показывает права доступа, нагрузку, задержку, порядок данных или поведение кеша. Для этих свойств нужны отдельные проверки на подходящем контуре.
\nHTTP-статус не заменяет доменную ошибку, а problem document не заменяет правила интерфейса. Не стоит создавать отдельный тип ошибки для каждой фразы. Тип должен объяснять устойчивый класс проблемы и способ реакции. Не стоит объявлять cursor прозрачным идентификатором, если сервер оставляет за собой право менять его формат.
\nЕсли после проверки схема совпадает с сериализатором, но клиент всё равно показывает неверный экран, ищите ошибку в преобразовании данных или состоянии интерфейса. Не добавляйте новый статус и не меняйте форму JSON без доказательства, что граница находится в API. Контракт помогает локализовать проблему, но не делает каждый дефект проблемой контракта.
\nОперация готова к интеграции, когда для каждого заявленного статуса есть проверяемое тело или явно указанное отсутствие тела; клиент знает действие для успеха, неверного параметра, повторяемого сбоя и неизвестного ответа; схема совпадает с фактической сериализацией; последняя страница отличается от пустого результата явным nextCursor: null. В артефакте должны остаться запросы, ответы и результаты отрицательных проверок. Без этого «контракт есть» означает только наличие файла.