{ "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 не менялся. Изменился договор между клиентом и сервером, но его никто не записал.
Цена ошибки — не только красный экран. Клиент может повторить выполненное действие. Пользователь не понимает, сохранился ли заказ. Поддержка получает неполное объяснение. Разработчики видят валидный JSON и спорят о смысле его полей. Чем больше клиентов у операции, тем дороже угадывание.
\nREST-контракт описывает не URL, а наблюдаемый результат операции. Для каждого входа нужно зафиксировать статус, Content-Type, форму тела и действие клиента. В учебном примере возьмём GET /api/v1/orders. Он возвращает страницу заказов, принимает limit и непрозрачный cursor. Пример не обращается к реальному серверу и не доказывает поведение production.
Фраза «метод возвращает JSON» слишком общая. Она не говорит, что означает пустой список, как обозначается конец пагинации и где искать ошибку параметра. Она также не отвечает, может ли ключ отсутствовать или должен иметь значение null. Без этих решений разные клиенты создают разные правила.
HTTP различает успешный результат, ошибку запроса и ошибку сервера. Код 400 сообщает о проблеме в запросе, а 5xx — о сбое на стороне сервера. Поле { "ok": false } внутри ответа 200 не заменяет HTTP-статус. Оно заставляет каждый клиент самостоятельно решать, когда успешный ответ нужно считать ошибкой.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| «Ещё» исчезает рано | Конец списка выводят из длины массива | Проверить page.nextCursor | Возвращать cursor или явный null |
| Карточка падает на поле | Неясно, отсутствует ли customer или равен null | Сверить required и JSON | Выбрать одно правило |
| 400 парсят как список | Клиент смотрит только на тело | Сравнить статус, media type и схему | Ветвить обработку по коду |
| Ошибка даёт общий баннер | Нет стабильного кода причины | Проверить type и errors | Привязать действие к машинному коду |
У limit должен быть диапазон, например от 1 до 100. cursor может отсутствовать на первом запросе и остаётся непрозрачной строкой. Клиент передаёт его обратно, но не извлекает из него дату, идентификатор или номер страницы.
Успешный ответ всегда содержит items и page. В page.nextCursor строка означает, что следующая страница доступна. null означает конец текущего снимка. Отсутствие ключа не используем как третий сигнал. Это решение проекта, а не универсальное требование REST. Вместо него можно выбрать offset или заголовок Link, но смешивать способы не стоит.
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» и «ключ всегда объект». Для клиента это три разных состояния.
Пусть cursor принадлежит другой выборке или имеет неверный формат. Сервер не может построить корректную страницу, поэтому учебный контракт возвращает 400. Тело использует Problem Details. Стандарт задаёт поля type, title, status, detail и instance. Проект может добавить расширение errors.
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 и загрузить первую страницу. Нераспознанная проблема должна вести на общий путь ошибки, а не превращаться в пустой список.
Комментарий в контроллере быстро расходится с реальным ответом. OpenAPI связывает операцию, параметры и responses в одном документе. У 200 указываем application/json и схему страницы. У 400 — application/problem+json и схему проблемы. Документ не доказывает, что живой сервер соблюдает YAML, но делает расхождение видимым.
/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, это нужно выразить в используемой версии схемы и проверить выбранным инструментом. Если команда предпочитает отсутствие ключа, это тоже надо записать. Сериализатор не должен выбирать смысл молча.
Проверка должна ловить не только невалидный JSON. Для 200 сравните статус, media type, массив items, объект page и наличие nextCursor. Для ошибки сравните 400, application/problem+json, поле type, совпадение status с HTTP-кодом и стабильный код причины. Отдельно отправьте последнюю страницу и убедитесь, что она возвращает nextCursor: null, а не пропускает ключ.
Учебные JSON проверяют форму, но не подтверждают авторизацию, маршрутизацию, таймауты, кеши или поведение базы. Для живой проверки нужен запрос к тестовому серверу с теми же ожиданиями. Результат запроса нельзя заменять обещанием из документации.
\nКонтракт ответа не решает авторизацию, идемпотентность команд, лимиты нагрузки и версионирование всего API. Cursor не становится безопасным токеном только потому, что клиент не разбирает его содержимое. У 400, 401, 409 и 5xx могут быть разные причины и действия. OpenAPI также не делает изменение схемы обратно совместимым автоматически.
\nДобавление необязательного поля обычно требует меньше миграции, чем удаление обязательного. Замена строки объектом, перенос ошибки из 400 в 200 и замена null на отсутствие ключа меняют наблюдаемое поведение. Для таких изменений нужны проверка потребителей и явный переход. Иначе синтаксически корректный JSON снова скроет смысловую поломку.
Операция готова, когда независимый разработчик по контракту может предсказать ответ для первого запроса, неполного параметра и последней страницы, а затем подтвердить прогноз запросом к тестовому серверу. Для каждого случая совпадают HTTP-статус, Content-Type и обязательная форма тела. Клиент обрабатывает неизвестную ошибку как ошибку, не теряет последнюю страницу и не обращается к отсутствующему полю без явного правила.
\n