Симптом обычно выглядит как ошибка интерфейса: список заказов перестал листаться, карточка падает на customer.name, а форма показывает «неизвестную ошибку». URL /api/orders при этом не менялся. Цена такого сбоя выше одной красной строки в консоли: клиент повторяет запрос, пользователь не понимает, сохранено ли действие, а backend и frontend спорят о том, кто «сломал API». Причина почти всегда в незафиксированном ответе: статус говорит одно, тело другое, а пагинация или необязательное поле существуют только в чьей-то памяти.
В июне 2019 я бы начал не с генератора клиента и не с большой документации. Для одной операции нужен короткий договор, который можно прочитать за несколько минут и прогнать на данных. Возьмём GET /api/v1/orders: он возвращает страницу заказов, принимает limit и непрозрачный cursor, а при плохом курсоре отдаёт problem document. Пример учебный: он не выполняет запрос к серверу и не доказывает поведение production. Его задача — показать, какие признаки должны совпасть до интеграции.
Сначала описываем наблюдаемый сбой и стоимость
Плохой договор часто начинается с фразы «успешный ответ — JSON». Она не отвечает на четыре вопроса. Что считать успехом: только 200 или ещё 204? Как клиент узнаёт о неправильном параметре? Чем последняя страница отличается от временно пустого списка? И допустимо ли отсутствие поля customer, либо оно должно быть null? Если эти решения не записаны, каждая библиотека подставляет собственное: fetch не считает 400 исключением, сериализатор может опустить ключ, а UI делает доступ к вложенному свойству без проверки.
HTTP уже задаёт язык для результата операции. RFC 7231 описывает метод, целевой ресурс, представление и коды статуса; 200 означает успешный ответ, а 4xx и 5xx сообщают разные классы ошибки запроса и сервера. Наш проектный контракт не должен переопределять этот язык флагом ok: false внутри ответа с 200. Он должен уточнять его: для какого статуса какое представление приходит, какой Content-Type ожидается и какие поля клиент вправе читать.
| Наблюдение у клиента | Незакрытая граница | Что фиксируем в контракте | Цена, если не зафиксировать |
|---|---|---|---|
| Кнопка «ещё» исчезла раньше времени | Последняя страница смешана с пустым результатом | Обязательный объект page и явный nextCursor: null | Пользователь не видит часть заказов |
| Экран падает на вложенном свойстве | Неизвестно, обязательны ли customer и его поля | required для ядра заказа; правило для отсутствующего customer | Падение или ложная пустая карточка |
| Форма показывает общий баннер | Ошибка параметра не имеет стабильной формы | application/problem+json, type и расширение errors | Нельзя привязать действие к полю |
| Клиент продолжает парсить 200 | Статус и тело противоречат друг другу | Список допустимых статусов на операцию | Сбой маскируется как «пустые данные» |
Выбираем маленький, но полный контракт операции
Для начала достаточно одного пути, одного метода и нескольких ответов. Наша операция читает коллекцию, поэтому договор включает параметры, а не только тело 200. limit имеет диапазон; cursor либо отсутствует, либо является строкой, которую клиент не разбирает; ответ всегда содержит items и page. В page.nextCursor строка означает, что следующий запрос возможен, а null означает конец снимка. Мы не используем отсутствие ключа как отдельный сигнал.
Это проектное решение, а не требование REST или HTTP. Пагинация не задана RFC 7231: команда могла бы использовать offset, Link header или отдельный объект links. Важно выбрать одну форму и описать её до кода. Cursor здесь непрозрачен намеренно. Если UI начинает вырезать из него дату или ID, сервер уже не сможет изменить кодирование без поломки клиента. Клиент должен только передать полученную строку в следующий запрос.
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" }
}
В примере id, status, total и page — обязательное ядро. customer — необязательное поле: если его нет, это не ошибка транспорта и не строка null. Если поле присутствует, оно обязано быть объектом с теми свойствами, которые нужны текущему экрану. Это важнее, чем кажется: «может прийти всё что угодно» делает любой клиент вынужденным угадывать, а строгий контракт позволяет обработать отсутствие ровно в одном месте.
Статус и ошибка образуют один результат
Для неправильного cursor не нужно возвращать 200 с массивом errors. Запрос не выполнен как запрос списка, поэтому выбираем клиентскую ошибку 400 Bad Request. RFC 7807 задаёт переносимую оболочку problem detail: поля type, title, status, detail и instance; дополнительные поля разрешены как extension members. В договоре ниже 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" }
]
}
Клиенту не следует сопоставлять логику с русским title или английским detail. Текст пригодится человеку и журналу, но стабильным ключом решения становится type или наш errors[0].code. Например, invalid_cursor означает: очистить сохранённый курсор, загрузить первую страницу и не повторять тот же запрос в цикле. Нераспознанный type должен показать общий сбой и оставить диагностический след, а не притвориться пустым списком.
Записываем операцию в OpenAPI, а не в комментарий
OpenAPI 3.0.2 уже позволяет записать этот договор рядом с API. Operation Object связывает путь, метод, параметры и responses. Responses Object, в свою очередь, привязывает конкретный HTTP-код к content и Schema Object. Это не гарантирует, что сервер исполняет YAML автоматически. Зато файл становится единым местом, где видно: 200 — страница, 400 — problem document, а тело не описывается абстрактным словом object.
/api/v1/orders:
get:
parameters:
- in: query
name: cursor
schema: { type: string }
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 100 }
responses:
"200":
description: Страница заказов
content:
application/json:
schema: { $ref: "#/components/schemas/OrdersPage" }
"400":
description: Неподходящий cursor или limit
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
Не надо делать OpenAPI файлом «на потом». В ревью к изменению операции должны попасть одновременно: изменение схемы, пример ответа и правило для клиента. Если backend добавляет поле, которое может отсутствовать, это чаще всего обратно совместимо для терпимого клиента, но только после проверки его потребления. Если он удаляет required-поле, меняет тип или переносит ошибку из 400 в 200, это уже изменение поведения, для которого нужен согласованный переход.
Проверяем договор на локальных данных
До доступа к стенду можно поймать часть расхождений на фикстурах. В пакете есть небольшой запуск node web/scripts/upgrade-2019-06.mjs --run-fixture. Он не открывает сеть: берёт три заранее заданных response-объекта и проверяет обязательные поля страницы, явный конец пагинации, форму problem document и отрицательный случай с потерянным nextCursor. Такой тест не заменяет интеграционный: он не знает о роутинге, авторизации или сериализаторе сервера. Но он делает документированную границу исполнимой до подключения реального API.
- Выберите одну операцию и запишите её ожидаемое действие, а не общий список «API должно быть RESTful».
- Назовите допустимые HTTP-статусы, Content-Type и форму тела для каждого статуса.
- Для коллекции отдельно зафиксируйте первый запрос, окончание страницы и правило передачи cursor или offset.
- Отметьте required-поля и одно точное правило для необязательного поля: отсутствует, null или объект; не оставляйте все три варианта одновременно.
- Добавьте пример успеха, пример ошибки и минимальную фикстуру, которая ломается при изменении этих признаков.
- Перед выпуском выполните такой же запрос к тестовому серверу и сравните статус, заголовок и тело со спецификацией.
Границы решения и короткий вывод
Этот контракт не решает авторизацию, повторную доставку команд, лимиты нагрузки и версионирование всех ресурсов. Он также не утверждает, что cursor безопасен как токен доступа: его формат и срок жизни остаются отдельной задачей. Зато он убирает базовую неопределённость на границе frontend и backend. Когда 200, 400, items, nextCursor и optional-поля можно прочитать и проверить, ошибка перестаёт выглядеть как мистический «сломанный REST».
- URL и метод идентифицируют операцию, но не описывают все её успешные и ошибочные представления.
- Статус, Content-Type и схема тела проверяются вместе; флаг ошибки внутри 200 не заменяет HTTP-семантику.
- Пагинация и optional-поля — явные проектные решения, которым нужен один проверяемый вариант.
- Локальная фикстура полезна как ранняя проверка контракта, но не является отчётом о работе production-сервера.
Проверяемые источники
- IETF RFC 7231, HTTP/1.1 Semantics and Content — семантика методов, представлений, Content-Type и кодов ответа, действовавшая в 2019 году
- IETF RFC 7231, раздел 6: Response Status Codes — коды 2xx, 4xx и 5xx сообщают результат конкретного HTTP-запроса; их смысл нельзя заменять произвольным полем JSON
- IETF RFC 7807, Problem Details for HTTP APIs — стандартизированная форма problem detail с type, title, status, detail и instance; расширения остаются контрактом API
- IETF RFC 8288, Web Linking — модель ссылочных отношений HTTP; конкретная форма пагинации должна быть явно выбрана командой
- OpenAPI Specification 3.0.2 — версия спецификации, доступная в 2019 году; описывает пути, операции, ответы, content и Schema Object
- OpenAPI 3.0.2, Operation Object — каждая операция объявляет параметры и ожидаемые ответы, а не только URL и метод
- OpenAPI 3.0.2, Responses Object — ответы задаются по HTTP-коду или default; у каждого можно описать content и схему тела
- OpenAPI 3.0.2, Schema Object — required относится к свойствам объекта, а nullable разрешает null только при явно заданном type