Симптом более опасный, чем опечатка в URL: backend выкатывает «небольшое» изменение, и старый клиент получает ответ, который синтаксически остаётся JSON, но семантически стал другим. status превратился из строки в объект, пустая страница потеряла nextCursor, а ошибка валидации стала 200. Цена — тихая поломка: мониторинг видит успешные HTTP-запросы, а пользователь видит пустой экран или повторную отправку формы. Причина — у операции нет границы совместимости, есть только маршрут и пример из happy path.
Разберём механизм на той же коллекции заказов, но не как рецепт одного контроллера. Нам нужно понять, что именно фиксирует спецификация и что остаётся проектным решением. В 2019 году для этого подходит OpenAPI 3.0.2: она описывает Path Item, Operation, параметры, Responses, content и Schema Object. HTTP остаётся транспортным контрактом, а OpenAPI собирает выбранный договор операции в один документ. Ни одна YAML-схема сама не проверит живой сервер, если команда не подключит её к тесту или ревью.
Операция — это не только path и метод
Когда в описании есть только GET /orders, человек всё ещё не знает, какие параметры разрешены и что приходит при каждом результате. Operation Object в OpenAPI объединяет эти части. У cursor фиксируется расположение in: query, тип и условная необязательность; у limit — числовые границы. В responses фиксируется не один пример, а карта статусов. Клиент тогда строит ветвление не по догадке «если в JSON есть error», а по документированному результату HTTP.
Практический минимум: 200 для страницы, 400 для недопустимого запроса, 401 для отсутствующего или недействительного контекста доступа, 500 для непредвиденного сбоя. Не нужно объявлять все коды, которые может вернуть любой proxy в мире. Но каждый код, который сознательно производит приложение, должен иметь форму ответа или честную пометку, что тела нет. Иначе мобильный клиент может ждать JSON от 401, а gateway отправит HTML, который парсер примет за сетевую ошибку.
| HTTP-статус | Content-Type | Обязательная форма | Действие клиента | Чего не делать |
|---|---|---|---|---|
| 200 | application/json | items, page.limit, page.nextCursor | Показать элементы; передать строковый cursor дальше только при наличии | Не считать пустой items концом без проверки page |
| 400 | application/problem+json | type, title, status и project errors | Сбросить только проблемный параметр или показать объяснение | Не парсить ошибку как страницу |
| 401 | Описывается отдельно | Статус и согласованный ответ/заголовок | Запустить известный поток авторизации | Не повторять запрос бесконечно |
| 500 | application/problem+json либо общий ответ | Без внутренних деталей и стека | Показать общий сбой и сохранить trace identifier | Не выдавать пользователю SQL или stack trace |
Responses Object связывает код и представление
В OpenAPI ответ задан ключом HTTP-кода или default. У него есть description, headers, links и content. Самая полезная часть для клиента — content: она связывает media type с schema. Если 200 объявлен как application/json, а 400 как application/problem+json, граница становится наблюдаемой даже до чтения каждого поля. Серверу не стоит отдавать HTML-страницу ошибки под тем же публичным API-путём молча: это нарушает ожидание парсера и скрывает источник проблемы.
responses:
"200":
description: Страница заказов
content:
application/json:
schema:
$ref: "#/components/schemas/OrdersPage"
"400":
description: Параметры списка не проходят проверку
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"500":
description: Непредвиденная ошибка обработки
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
Файл не обязан делать все ошибки одинаковыми. Например, ошибка авторизации может прийти с заголовком, который понятен используемому механизму доступа, а у асинхронной команды может быть другой ожидаемый успешный статус. Важно не прятать различие. Если две операции возвращают разные формы ошибки, это надо назвать в их responses. Если команда сознательно выбирает один Problem schema для нескольких операций, то её расширения — errors, traceId, code полей — тоже становятся частью совместимого контракта.
Schema Object: required и nullable решают разные вопросы
Самая частая ловушка — назвать поле «необязательным», не указав, что это означает на проводе. В OpenAPI 3.0.2 массив required принадлежит объекту: в нём перечислены имена свойств, которые должны присутствовать. nullable: true отвечает на другой вопрос: можно ли передать значение null, если у schema явно указан type. Отсутствующий ключ и ключ со значением null — разные состояния; клиенту нельзя считать их одинаковыми, если это не записано в договоре.
Для страницы заказов выберем строгую форму. items и page обязательны, потому что клиент всегда должен отличить ответ коллекции от произвольного объекта. В page обязательны limit и nextCursor; последний имеет тип string и nullable, поэтому конец списка выражается null, а не пропущенным ключом. customer у заказа не входит в required: его может не быть, но если он есть, он — object, не null. Такое решение можно поменять, но менять его надо как изменение контракта, а не как побочный эффект ORM.
OrdersPage:
type: object
required: [items, page]
properties:
items:
type: array
items: { $ref: "#/components/schemas/Order" }
page:
type: object
required: [limit, nextCursor]
properties:
limit: { type: integer, minimum: 1 }
nextCursor: { type: string, nullable: true }
Order:
type: object
required: [id, status, total]
properties:
id: { type: string }
customer: { $ref: "#/components/schemas/Customer" }
Эта схема не говорит, что JSON Schema валидатор в проекте обязан полностью понимать любую возможность JSON Schema. OpenAPI 3.0.2 определяет собственный Schema Object с расширенным подмножеством. Поэтому до выбора генератора или validator надо сверить, какую версию и какую часть спецификации он реально поддерживает. В противном случае на бумаге появится nullable, а в рантайме проверка пропустит другой вариант или, наоборот, отвергнет законный ответ.
Problem Details не отменяет проектную ошибку
RFC 7807 полезен тем, что проблема перестаёт быть бесформенным { "error": "..." }. Поле type является URI reference, title — кратким названием, status отражает HTTP-код, detail поясняет конкретный случай, instance помогает различать экземпляры. Но RFC не выдаёт команде готовые коды полей. Если в UI важно выделить query.cursor, это безопаснее сделать явным расширением errors с документированными path и code, чем извлекать смысл из локализованного текста.
Не называйте каждый бизнес-конфликт «400» только потому, что клиент передал JSON. Нужный статус зависит от семантики операции; его стоит сверять с HTTP и договором продукта. В этой статье мы ограничили пример недопустимым cursor, поэтому 400 понятен: сообщение запроса нельзя обработать как корректную страницу. Для конфликта версии ресурса команда может выбрать другой документированный путь. Главное — не менять статус между релизами без проверки клиентов и не посылать известную ошибку как успешный JSON.
Пагинация — часть представления, а не свойство базы
Наличие LIMIT 20 в SQL ещё не создаёт API-пагинацию. Клиенту нужно знать порядок, размер страницы, признак конца и поведение cursor после изменения данных. В нашем компактном договоре сервер возвращает текущий limit и opaque nextCursor. Мы не обещаем стабильный total и не выводим его из длины items: короткая страница может быть последней, но это решение подтверждает именно nextCursor: null. Если продукту нужен total, он становится отдельным полем с отдельной стоимостью и условиями точности.
RFC 8288 описывает Web Linking, и команда может выбрать Link header для relation next. Это допустимый, но другой контракт: тогда нужно зафиксировать relation, относительность URL, порядок параметров и способ, которым клиент читает header. Не смешивайте Link и page.nextCursor наполовину. Один доступный путь быстрее тестируется и не заставляет frontend искать несколько несогласованных признаков конца списка.
| Изменение | Почему риск есть | Что проверить до выпуска | Безопасный переход |
|---|---|---|---|
| Добавить необязательное поле | Старый клиент может игнорировать его, новый — ошибочно ожидать | Парсер не требует поле до согласованного релиза | Сначала добавить и наблюдать, затем использовать |
| Удалить required-поле | Старый клиент делает прямой доступ | Все поддерживаемые клиенты и контрактные фикстуры | Новая версия или период двух полей |
| Изменить string на object | JSON парсится, но логика ломается позже | Потребители, schema и примеры | Новое поле или новая операция |
| Заменить null отсутствием | Это два разных состояния schema | Проверки terminal page и UI ветвления | Сохранить один вариант до миграции клиентов |
Делаем изменение проверяемым
Техническая ценность OpenAPI начинается, когда на её основе появляется проверка. В минимальном варианте это ревью diff: изменились ли status, media type, required или nullable? Затем — пример каждого ответа и локальная fixture, которая намеренно отвергает потерянный nextCursor или problem document с 200. После подключения тестового сервера те же случаи становятся запросами к живому endpoint. Так документ не обещает автоматически совместимость, а даёт список точек, где она может быть нарушена.
- Опишите Operation Object вместе с параметрами, а не добавляйте responses после реализации контроллера.
- Для каждого сознательно возвращаемого статуса укажите description, Content-Type и schema тела или явное отсутствие тела.
- Разведите отсутствующее свойство и null через required и nullable; зафиксируйте выбор примером.
- Опишите окончание пагинации как часть 200, не выводите его из случайной длины массива.
- Добавьте локальные positive и negative fixtures, затем перенесите те же ожидания на тестовый сервер.
- При изменении schema оцените поддержку старых клиентов до слияния, а не после первых ошибок пользователя.
Границы механизма и короткий вывод
OpenAPI не заменяет авторизацию, миграцию данных или проверку таймаутов. Она также не делает любое изменение YAML обратно совместимым. Но спецификация даёт инженерный язык для спора: не «у нас же JSON», а «операция больше не возвращает required page.nextCursor на 200». В 2019 это уже достаточный шаг от договорённостей в чате к T-shaped работе на границе frontend, backend и HTTP.
- Операция состоит из параметров, HTTP-кодов, media types и schemas; URL — только вход в этот договор.
- Responses Object связывает код с представлением, а Schema Object делает форму тела проверяемой.
- required и nullable не взаимозаменяемы; отсутствие ключа и null надо выбирать осознанно.
- Пагинация и extension-поля problem detail принадлежат проектному контракту и требуют теста.
Проверяемые источники
- 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