From 690e918a18d59d97b2a2c8ebfad1512906885ca6 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 23:33:34 +0300 Subject: [PATCH] Rewrite editorial article 309 to 10x10 standard --- editorial/agent-rewrites/309.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/editorial/agent-rewrites/309.json b/editorial/agent-rewrites/309.json index c5abdac..1c29e51 100644 --- a/editorial/agent-rewrites/309.json +++ b/editorial/agent-rewrites/309.json @@ -1,7 +1 @@ -{ - "index": 309, - "slug": "editorial-2019-06-practice-rest-api", - "title": "REST API без угадываний: как зафиксировать ответ операции", - "excerpt": "Список ломается не из-за URL, а из-за неявных правил: 200 скрывает ошибку, курсор исчезает на последней странице, а необязательное поле приходит то пустым, то отсутствующим. Разбираем контракт одной REST-операции и проверяем его по статусу, Content-Type и форме JSON.", - "contentHtml": "

Список заказов загружается без ошибки сети, но кнопка «Ещё» исчезает после первой страницы. На другой ветке карточка падает при чтении customer.name. Форма после отправки получает HTTP 200 и показывает общий сбой, потому что в JSON лежит error. Адрес /api/v1/orders не менялся. Изменился договор между клиентом и сервером, но его никто не записал.

\n

Цена ошибки — не только красный экран. Клиент может повторить выполненное действие. Пользователь не понимает, сохранился ли заказ. Поддержка получает неполное объяснение. Разработчики видят валидный JSON и спорят о смысле его полей. Чем больше клиентов у операции, тем дороже угадывание.

\n

REST-контракт описывает не URL, а наблюдаемый результат операции. Для каждого входа нужно зафиксировать статус, Content-Type, форму тела и действие клиента. В учебном примере возьмём GET /api/v1/orders. Он возвращает страницу заказов, принимает limit и непрозрачный cursor. Пример не обращается к реальному серверу и не доказывает поведение production.

\n

Симптом показывает незаписанную границу

\n

Фраза «метод возвращает JSON» слишком общая. Она не говорит, что означает пустой список, как обозначается конец пагинации и где искать ошибку параметра. Она также не отвечает, может ли ключ отсутствовать или должен иметь значение null. Без этих решений разные клиенты создают разные правила.

\n

HTTP различает успешный результат, ошибку запроса и ошибку сервера. Код 400 сообщает о проблеме в запросе, а 5xx — о сбое на стороне сервера. Поле { "ok": false } внутри ответа 200 не заменяет HTTP-статус. Оно заставляет каждый клиент самостоятельно решать, когда успешный ответ нужно считать ошибкой.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
«Ещё» исчезает раноКонец списка выводят из длины массиваПроверить page.nextCursorВозвращать cursor или явный null
Карточка падает на полеНеясно, отсутствует ли customer или равен nullСверить required и JSONВыбрать одно правило
400 парсят как списокКлиент смотрит только на телоСравнить статус, media type и схемуВетвить обработку по коду
Ошибка даёт общий баннерНет стабильного кода причиныПроверить type и errorsПривязать действие к машинному коду
\n

Минимальный контракт списка

\n

У limit должен быть диапазон, например от 1 до 100. cursor может отсутствовать на первом запросе и остаётся непрозрачной строкой. Клиент передаёт его обратно, но не извлекает из него дату, идентификатор или номер страницы.

\n

Успешный ответ всегда содержит items и page. В page.nextCursor строка означает, что следующая страница доступна. null означает конец текущего снимка. Отсутствие ключа не используем как третий сигнал. Это решение проекта, а не универсальное требование REST. Вместо него можно выбрать offset или заголовок Link, но смешивать способы не стоит.

\n
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" }
}
\n

В ответе id, status, total и page образуют обязательное ядро. Если заказ может прийти без клиента, ключ customer не входит в required. При наличии он должен быть объектом с согласованными полями. Нельзя одновременно обещать «ключ отсутствует», «ключ равен null» и «ключ всегда объект». Для клиента это три разных состояния.

\n

Статус и тело ошибки читаем вместе

\n

Пусть cursor принадлежит другой выборке или имеет неверный формат. Сервер не может построить корректную страницу, поэтому учебный контракт возвращает 400. Тело использует Problem Details. Стандарт задаёт поля type, title, status, detail и instance. Проект может добавить расширение errors.

\n
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" }]
}
\n

Клиент не должен принимать решение по title или изменчивому detail. Для ветвления подходит type или errors[].code. При invalid_cursor интерфейс может удалить сохранённый cursor и загрузить первую страницу. Нераспознанная проблема должна вести на общий путь ошибки, а не превращаться в пустой список.

\n
\"Карта
Контракт начинается на входе запроса и заканчивается проверяемой формой ответа. Один URL не описывает эти границы.
\n

Записываем правила в OpenAPI

\n

Комментарий в контроллере быстро расходится с реальным ответом. OpenAPI связывает операцию, параметры и responses в одном документе. У 200 указываем application/json и схему страницы. У 400 — application/problem+json и схему проблемы. Документ не доказывает, что живой сервер соблюдает YAML, но делает расхождение видимым.

\n
/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" }
\n

В схеме страницы items и page входят в required. Внутри page обязательны limit и nextCursor. Если конец списка обозначает null, это нужно выразить в используемой версии схемы и проверить выбранным инструментом. Если команда предпочитает отсутствие ключа, это тоже надо записать. Сериализатор не должен выбирать смысл молча.

\n

Проверяем договор на наблюдаемом примере

\n

Проверка должна ловить не только невалидный JSON. Для 200 сравните статус, media type, массив items, объект page и наличие nextCursor. Для ошибки сравните 400, application/problem+json, поле type, совпадение status с HTTP-кодом и стабильный код причины. Отдельно отправьте последнюю страницу и убедитесь, что она возвращает nextCursor: null, а не пропускает ключ.

\n

Учебные JSON проверяют форму, но не подтверждают авторизацию, маршрутизацию, таймауты, кеши или поведение базы. Для живой проверки нужен запрос к тестовому серверу с теми же ожиданиями. Результат запроса нельзя заменять обещанием из документации.

\n

Порядок действий

\n
  1. Выберите одну операцию и опишите, какое действие она выполняет.
  2. Назовите параметры, типы, диапазоны и правило передачи cursor или offset.
  3. Для каждого результата зафиксируйте HTTP-статус, Content-Type и тело.
  4. Разведите обязательное поле, отсутствующий ключ и null; оставьте выбранные варианты.
  5. Добавьте пример успеха, ошибки и последней страницы.
  6. Опишите операцию в OpenAPI и сверьте схему с примерами.
  7. Выполните те же случаи на тестовом сервере и сравните статус, заголовок и тело.
  8. Проверьте старых клиентов перед изменением required-поля, типа, статуса или формата ошибки.
\n

Ограничения

\n

Контракт ответа не решает авторизацию, идемпотентность команд, лимиты нагрузки и версионирование всего API. Cursor не становится безопасным токеном только потому, что клиент не разбирает его содержимое. У 400, 401, 409 и 5xx могут быть разные причины и действия. OpenAPI также не делает изменение схемы обратно совместимым автоматически.

\n

Добавление необязательного поля обычно требует меньше миграции, чем удаление обязательного. Замена строки объектом, перенос ошибки из 400 в 200 и замена null на отсутствие ключа меняют наблюдаемое поведение. Для таких изменений нужны проверка потребителей и явный переход. Иначе синтаксически корректный JSON снова скроет смысловую поломку.

\n

Проверяемый критерий готовности

\n

Операция готова, когда независимый разработчик по контракту может предсказать ответ для первого запроса, неполного параметра и последней страницы, а затем подтвердить прогноз запросом к тестовому серверу. Для каждого случая совпадают HTTP-статус, Content-Type и обязательная форма тела. Клиент обрабатывает неизвестную ошибку как ошибку, не теряет последнюю страницу и не обращается к отсутствующему полю без явного правила.

\n

Проверяемые источники

\n" -} +{"index":309,"slug":"editorial-2019-06-practice-rest-api","title":"REST API без угадываний: как зафиксировать ответ операции","excerpt":"Список ломается не из-за URL, а из-за неявных правил: 200 скрывает ошибку, курсор исчезает на последней странице, а необязательное поле приходит то пустым, то отсутствующим. Разбираем контракт одной REST-операции и проверяем его по статусу, Content-Type и форме JSON.","contentHtml":"

Список заказов загружается без ошибки сети, но кнопка «Ещё» исчезает после первой страницы. На другой ветке карточка падает при чтении customer.name. Форма после отправки получает HTTP 200 и показывает общий сбой, потому что в JSON лежит error. Адрес /api/v1/orders не менялся. Изменился договор между клиентом и сервером, но его никто не записал.

\n

Цена ошибки — не только красный экран. Клиент может повторить выполненное действие. Пользователь не понимает, сохранился ли заказ. Поддержка получает неполное объяснение. Разработчики видят валидный JSON и спорят о смысле его полей. Чем больше клиентов у операции, тем дороже угадывание.

\n

REST-контракт описывает не URL, а наблюдаемый результат операции. Для каждого входа нужно зафиксировать статус, Content-Type, форму тела и действие клиента. В учебном примере возьмём GET /api/v1/orders. Он возвращает страницу заказов, принимает limit и непрозрачный cursor. Пример не обращается к реальному серверу и не доказывает поведение production.

\n

Симптом показывает незаписанную границу

\n

Фраза «метод возвращает JSON» слишком общая. Она не говорит, что означает пустой список, как обозначается конец пагинации и где искать ошибку параметра. Она также не отвечает, может ли ключ отсутствовать или должен иметь значение null. Без этих решений разные клиенты создают разные правила.

\n

HTTP различает успешный результат, ошибку запроса и ошибку сервера. Код 400 сообщает о проблеме в запросе, а 5xx — о сбое на стороне сервера. Поле { "ok": false } внутри ответа 200 не заменяет HTTP-статус. Оно заставляет каждый клиент самостоятельно решать, когда успешный ответ нужно считать ошибкой.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
«Ещё» исчезает раноКонец списка выводят из длины массиваПроверить page.nextCursorВозвращать cursor или явный null
Карточка падает на полеНеясно, отсутствует ли customer или равен nullСверить required и JSONВыбрать одно правило
400 парсят как списокКлиент смотрит только на телоСравнить статус, media type и схемуВетвить обработку по коду
Ошибка даёт общий баннерНет стабильного кода причиныПроверить type и errorsПривязать действие к машинному коду
\n

Минимальный контракт списка

\n

У limit должен быть диапазон, например от 1 до 100. cursor может отсутствовать на первом запросе и остаётся непрозрачной строкой. Клиент передаёт его обратно, но не извлекает из него дату, идентификатор или номер страницы.

\n

Успешный ответ всегда содержит items и page. В page.nextCursor строка означает, что следующая страница доступна. null означает конец текущего снимка. Отсутствие ключа не используем как третий сигнал. Это решение проекта, а не универсальное требование REST. Вместо него можно выбрать offset или заголовок Link, но смешивать способы не стоит.

\n
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": "cursor_v2_a91f" }
}
\n

В ответе id, status, total и page образуют обязательное ядро. Если заказ может прийти без клиента, ключ customer не входит в required. При наличии он должен быть объектом с согласованными полями. Нельзя одновременно обещать «ключ отсутствует», «ключ равен null» и «ключ всегда объект». Для клиента это три разных состояния.

\n

Статус и тело ошибки читаем вместе

\n

Пусть cursor принадлежит другой выборке или имеет неверный формат. Сервер не может построить корректную страницу, поэтому учебный контракт возвращает 400. Тело использует Problem Details. RFC 7807 описывает стандартные поля type, title, status, detail и instance; проект может добавить расширение errors. Поле status в RFC носит информационный характер, но генератор должен послать тот же код HTTP, поэтому проверяем оба значения.

\n
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" }]
}
\n

Клиент не должен принимать решение по title или изменчивому detail. Для ветвления подходит type или errors[].code. При invalid_cursor интерфейс может один раз удалить сохранённый cursor и загрузить первую страницу. Если ошибка повторилась, её нужно показать как ошибку, а не запускать бесконечный retry. Нераспознанная проблема должна вести на общий путь ошибки, а не превращаться в пустой список.

\n
\"Карта
Контракт начинается на входе запроса и заканчивается проверяемой формой ответа. Один URL не описывает эти границы.
\n

Записываем правила в OpenAPI

\n

Комментарий в контроллере быстро расходится с реальным ответом. OpenAPI связывает операцию, параметры и responses в одном документе. У 200 указываем application/json и схему страницы. У 400 — application/problem+json и схему проблемы. Документ не доказывает, что живой сервер соблюдает YAML, но делает расхождение видимым.

\n
/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" }
\n

Фрагмент написан для OpenAPI 3.0.2 — версии, опубликованной в 2018 году. В этой версии nullable-строку описывают как type: string и nullable: true; в OpenAPI 3.1 можно использовать схему JSON Schema, например type: [string, "null"]. Сначала зафиксируйте версию документа и поддерживаемое ею правило, затем проверьте его инструментом.

\n

В схеме страницы items и page входят в required. Внутри page обязательны limit и nextCursor. Если конец списка обозначает null, это нужно выразить в используемой версии схемы и проверить выбранным инструментом. Если команда предпочитает отсутствие ключа, это тоже надо записать. Сериализатор не должен выбирать смысл молча.

\n

Проверяем договор на наблюдаемом примере

\n

Проверка должна ловить не только невалидный JSON. Для 200 сравните статус, media type, массив items, объект page и наличие nextCursor. Для ошибки сравните 400, application/problem+json, поле type, совпадение status с HTTP-кодом и стабильный код причины. Отдельно отправьте последнюю страницу и убедитесь, что она возвращает nextCursor: null, а не пропускает ключ.

\n

Учебные JSON проверяют форму, но не подтверждают авторизацию, маршрутизацию, таймауты, кеши или поведение базы. Для живой проверки нужен запрос к тестовому серверу с теми же ожиданиями. Результат запроса нельзя заменять обещанием из документации.

\n

Порядок действий

\n
  1. Выберите одну операцию и опишите, какое действие она выполняет.
  2. Назовите параметры, типы, диапазоны и правило передачи cursor или offset.
  3. Для каждого результата зафиксируйте HTTP-статус, Content-Type и тело.
  4. Разведите обязательное поле, отсутствующий ключ и null; оставьте выбранные варианты.
  5. Добавьте отдельные примеры успеха, ошибки валидации и последней страницы.
  6. Опишите операцию в OpenAPI и сверьте схему с примерами.
  7. Выполните те же случаи на тестовом сервере и сравните статус, заголовок и тело.
  8. Проверьте старых клиентов перед изменением required-поля, типа, статуса или формата ошибки.
\n

Ограничения

\n

Контракт ответа не решает авторизацию, идемпотентность команд, лимиты нагрузки и версионирование всего API. Cursor не становится безопасным токеном только потому, что клиент не разбирает его содержимое. У 400, 401, 409 и 5xx могут быть разные причины и действия. OpenAPI также не делает изменение схемы обратно совместимым автоматически.

\n

Добавление необязательного поля обычно требует меньше миграции, чем удаление обязательного. Замена строки объектом, перенос ошибки из 400 в 200 и замена null на отсутствие ключа меняют наблюдаемое поведение. Для таких изменений нужны проверка потребителей и явный переход. Иначе синтаксически корректный JSON снова скроет смысловую поломку.

\n

Проверяемый критерий готовности

\n

Операция готова, когда независимый разработчик по контракту может предсказать ответ для первого запроса, ошибки валидации limit или cursor и последней страницы, а затем подтвердить прогноз запросом к тестовому серверу. Для каждого случая совпадают HTTP-статус, Content-Type и обязательная форма тела. Клиент обрабатывает неизвестную ошибку как ошибку, не теряет последнюю страницу и не обращается к отсутствующему полю без явного правила.

\n

Проверяемые источники

\n"}