Б.3 Проектирование контракта API: ресурсы, OpenAPI, ошибки, совместимость
Зачем это аналитику
Middle-аналитик обычно умеет описать метод API. Следующий шаг — спроектировать API целиком: так, чтобы им было удобно пользоваться, чтобы ошибки были понятны, списки не падали на больших объёмах, а изменения не ломали существующих клиентов. Контракт живёт дольше кода, и ошибки в нём самые дорогие.
Готовность к модулю
Модуль опирается на темы ниже. Если какая-то из них незнакома или её вопросы для самопроверки даются с трудом, начните с неё.
Куда ведёт
Тема подробнее раскрывается в модулях:
Что понять
- Ресурсный подход: существительные в путях, вложенность, действия, которые плохо ложатся на CRUD.
- OpenAPI 3: пути, операции, параметры, тела запроса и ответа, компоненты и переиспользование схем, примеры.
- JSON Schema в OpenAPI: типы, обязательность, форматы, перечисления, ограничения.
- Формат ошибок: единая структура, машинно-читаемый код, Problem Details (RFC 9457) как стандарт.
- Пагинация: смещение и курсор, почему смещение ломается на больших и меняющихся данных. Фильтрация и сортировка.
- Идемпотентность операций создания: ключ идемпотентности и что делать при повторе.
- Совместимость изменений: что можно добавлять без новой версии, что ломает клиентов; способы версионирования.
- Подход «сначала контракт» (contract-first): контракт как артефакт требований, генерация кода и моков.
Урок
Описать один метод — значит перечислить поля запроса и ответа. Спроектировать API — значит принять набор решений, которые действуют для всех методов сразу: как называются ресурсы, как выглядят ошибки, как читаются большие списки, что происходит при повторе запроса и как контракт будет меняться, не ломая тех, кто им уже пользуется. Эти решения дёшево принять до реализации и очень дорого менять после, когда у API появились внешние потребители.
Ресурсный подход
REST-стиль строит API вокруг ресурсов — сущностей предметной области, у которых есть адрес. Действие выражается методом HTTP, а не словом в пути.
- Путь — существительное, коллекции во множественном числе:
/orders,/orders/{orderId},/domains/{domainName}. - Метод задаёт действие:
GET /orders— список,POST /orders— создать,GET /orders/{orderId}— получить,PATCH /orders/{orderId}— изменить часть полей,DELETE— удалить. - Глагол в пути (
/getOrders,/createOrder) — признак того, что API спроектирован как набор удалённых процедур, а HTTP использован только как транспорт.
Вложенность отражает принадлежность: /orders/{orderId}/items — позиции конкретного заказа, которые без заказа не существуют. Вкладывать глубже двух уровней обычно не стоит: /customers/{id}/orders/{id}/items/{id}/discounts трудно читать, и клиенту приходится знать всю цепочку идентификаторов. Если у сущности самостоятельный жизненный цикл, она живёт на верхнем уровне, а связь выражается фильтром: /shipments?orderId=..., а не /orders/{orderId}/shipments.
Действия, которые плохо ложатся на CRUD
Отменить заказ, продлить домен, подписать платёжное поручение — это не «создать» и не «изменить поле», а бизнес-операция со своими правилами. Есть три распространённых способа её выразить.
| Способ | Пример | Когда подходит | Слабые места |
|---|---|---|---|
| Изменение поля состояния | PATCH /orders/{orderId} с телом {"status": "cancelled"} |
Переход действительно сводится к смене статуса без параметров | Правила перехода прячутся за общим методом изменения; трудно передать причину отмены и выдать понятную ошибку |
| Действие как ресурс-существительное | POST /orders/{orderId}/cancellation с причиной в теле |
У операции есть свои данные и история: кто отменил, когда, почему | Нужно придумать удачное существительное |
| Действие-подресурс с глаголом | POST /orders/{orderId}/cancel или POST /orders/{orderId}:cancel |
Операция — чистая команда без собственной сущности | Отход от чистого ресурсного стиля, нужно единое правило для всего API |
Общее для всех вариантов: операция, меняющая состояние, идёт через POST или PATCH, но не через GET. Если отмена невозможна, потому что заказ уже отгружен, это конфликт с текущим состоянием, и ответ — 409 с понятным кодом ошибки. Какой из способов выбрать, менее важно, чем применять выбранный одинаково во всём API.
OpenAPI 3: из чего состоит описание
OpenAPI — стандартный машиночитаемый формат описания HTTP API в YAML или JSON. Из одного файла получают документацию, клиентские библиотеки, серверные заготовки, заглушки для тестов и правила проверки запросов на шлюзе. На практике распространены 3.0 и 3.1 (в 2025 году вышла 3.2, развивающая 3.1); между 3.0 и 3.1 есть отличия в описании схем, например способ указать, что поле может быть null.
| Раздел | Что содержит |
|---|---|
openapi, info |
Версия формата, название и версия самого API, контакты |
servers |
Базовые адреса: промышленный, тестовый |
paths |
Пути и операции на них: get, post, patch и так далее |
| Операция | operationId, описание, parameters, requestBody, responses, security, tags |
parameters |
Параметры с указанием места: path, query, header, cookie |
requestBody |
Тело запроса по типам содержимого (application/json и другие) со схемой и примерами |
responses |
Ответы по кодам HTTP, у каждого — заголовки и тело по типам содержимого |
components |
Переиспользуемые части: schemas, parameters, responses, headers, examples, securitySchemes |
components — главный инструмент против расхождений. Схема ошибки, параметр ключа идемпотентности, параметры пагинации описываются один раз и подключаются ссылкой $ref. Если ошибка описана в каждом методе заново, через год в API окажется пять немного разных форматов ошибок. Переиспользование — это способ закрепить в контракте общие решения, а не только сократить текст.
Примеры (example, examples) — не украшение. По ним разработчик клиента понимает смысл полей быстрее, чем по схеме, а заглушки для тестов отдают именно их. Пример с реалистичными значениями часто вскрывает ошибку в схеме: сумма, записанная числом с плавающей точкой, или дата без часового пояса.
JSON Schema: как описываются данные
Схемы тел и параметров в OpenAPI пишутся на JSON Schema (начиная с версии 3.1 — на диалекте, совместимом с JSON Schema 2020-12, в 3.0 — на расширенном подмножестве более ранней черновой версии JSON Schema). Основные конструкции:
- Типы:
string,number,integer,boolean,array,object,null. - Обязательность задаётся на уровне объекта списком
required, а не у отдельного поля. - Форматы:
date,date-time,uuid,email,uri. Формат уточняет смысл строки, но не каждый инструмент его проверяет, поэтому критичные ограничения лучше дублировать явно. - Перечисления:
enum— фиксированный список допустимых значений. - Ограничения:
minLength,maxLength,patternдля строк;minimum,maximumдля чисел;minItems,maxItemsдля массивов;additionalProperties: falseзапрещает неописанные поля. - Направление:
readOnly— поле только в ответе (идентификатор, дата создания),writeOnly— только в запросе (пароль).
Две тонкости, на которых чаще всего ошибаются.
Первая — «необязательное» и «может быть null» — разные вещи. Поле не в required означает, что его может не быть в документе. Тип, допускающий null, означает, что поле есть, но значения нет. Клиенту важно различать: отсутствие поля в запросе на частичное изменение часто означает «не трогать», а null — «очистить».
Вторая — деньги. Число с плавающей точкой не представляет точно большинство десятичных дробей, и суммы начинают расходиться на копейки. Сумму передают либо целым числом в минимальных единицах (копейках, центах), либо десятичной строкой с шаблоном, всегда вместе с кодом валюты.
JSON Schema используется не только в OpenAPI. Схемами описывают сообщения в брокерах и, что стало важно недавно, инструменты, которые вызывает языковая модель: приложение передаёт модели имя инструмента, описание и схему аргументов. Навык писать точные схемы переносится туда напрямую.
Единый формат ошибок
Ошибка — такая же часть контракта, как успешный ответ, и клиент строит на ней логику: повторить, исправить запрос, показать сообщение пользователю, позвать оператора. Для этого ему нужны три вещи: код HTTP, который определяет класс ситуации; машинно-читаемый код конкретной ошибки, по которому ветвится логика; и текст для человека, который может меняться и переводиться.
Стандарт для такой структуры — Problem Details for HTTP APIs (RFC 9457, заменил RFC 7807). Тип содержимого — application/problem+json, стандартные поля: type (URI вида проблемы), title, status, detail, instance. Свои поля добавлять можно. Для ошибок валидации удобно вернуть список нарушений с указанием поля, чтобы клиент подсветил их все сразу, а не по одному за попытку:
{
"type": "https://api.shop.example/problems/validation-error",
"title": "Запрос не прошёл проверку",
"status": 422,
"errors": [
{ "field": "deliveryDate", "code": "date_in_past" },
{ "field": "items[2].quantity", "code": "must_be_positive" }
]
}
Правило для контракта: во всех методах один формат ошибок, описанный в components, и перечень кодов ошибок для каждого метода. Клиент не должен разбирать текст detail, чтобы понять, что случилось. Выбор между кодами HTTP (400 против 422, 404 против 403) и их значение для клиента разбираются в модуле 1.3.
Пагинация, фильтрация и сортировка
Список без ограничения размера — дефект, который проявится не сразу: на тестовых данных ответ маленький, а через год у крупного клиента метод отдаёт сотни тысяч записей и падает по таймауту. Поэтому у каждого списка есть ограничение на размер страницы со значением по умолчанию и максимумом.
Смещение (offset): клиент просит ?limit=50&offset=1000. Просто, можно перейти на любую страницу. Но на больших объёмах база всё равно перебирает и отбрасывает все пропущенные строки, и глубокие страницы работают всё медленнее. А если между запросами страниц появились или исчезли записи, окно сдвигается: клиент получает дубли или теряет записи. Для выгрузки остатков или выписки потеря строки — дефект данных.
Курсор (cursor): сервер возвращает вместе со страницей непрозрачную строку nextCursor, клиент передаёт её в следующий запрос. Внутри курсор кодирует позицию последней отданной записи по ключу сортировки, и следующая страница начинается строго после неё. Вставки перед курсором не сдвигают окно, скорость не зависит от глубины. Цена: нельзя сразу перейти на страницу 37, общее количество записей считается отдельно. Непрозрачность курсора — требование к контракту: клиент не должен разбирать и собирать курсор сам, иначе сервер не сможет поменять его устройство.
Фильтрация и сортировка задаются параметрами запроса: ?status=paid&createdFrom=2026-09-01, ?sort=-createdAt. В контракте стоит явно перечислить поля, по которым можно фильтровать и сортировать, — каждое из них потребует индекса в базе. Сортировка должна быть однозначной: если две записи имеют одинаковое время создания, их порядок между запросами может меняться, поэтому к ключу сортировки добавляют уникальный идентификатор.
Идемпотентность создания
Клиент отправил запрос на создание заказа, заказ создан, а ответ потерялся по дороге. Клиент не знает, что произошло, и повторяет запрос. Без защиты появляется второй заказ, а в платёжном API — второе списание.
Решение на уровне контракта — ключ идемпотентности. Клиент генерирует уникальный ключ для каждой логической операции (обычно UUID) и передаёт его в заголовке Idempotency-Key. Сервер при первой обработке сохраняет ключ вместе с результатом, а на повтор с тем же ключом возвращает сохранённый результат, не выполняя операцию снова. Новая попытка той же операции — тот же ключ; новая операция — новый ключ. Тот же ключ с другим телом запроса — ошибка клиента, а не повод вернуть старый результат или создать новый объект.
Для аналитика важно, что ключ — не деталь реализации, а часть контракта: заголовок описывается в components как обязательный параметр операций создания, а в описании метода фиксируется поведение при повторе. Полный набор сценариев (срок хранения ключа, повтор во время обработки, атомарность) и коды ответа для них разбираются в модуле 1.3.
Совместимость изменений
Контракт меняется всё время, а клиенты обновляются по своему графику. Базовое правило: добавлять можно, удалять и менять смысл нельзя. Изменение безопасно, если корректно написанный существующий клиент продолжает работать.
Полезно смотреть на изменение с двух сторон — что сервер принимает и что отдаёт:
- В запросе ослаблять можно: новый необязательный параметр, более широкий диапазон значений, новое допустимое значение перечисления. Ужесточать нельзя: сделать поле обязательным, сократить максимальную длину, убрать значение из перечисления — старые клиенты начнут получать ошибки.
- В ответе наоборот: сужать можно, расширять опасно. Добавить поле безопасно, только если клиенты игнорируют незнакомые поля. Сделать всегда присутствующее поле необязательным — ломающее изменение: клиент, который на него рассчитывал, упадёт.
Отсюда опасность нового значения перечисления в ответе. Схема формально только расширилась, но клиент, написанный с полным перебором вариантов, получит статус, которого не знает, и либо упадёт, либо обработает его как ошибку. Защита — заранее записать в контракте, что перечисления расширяемы и неизвестное значение клиент обязан обрабатывать как «прочее». Это частный случай принципа терпимого читателя (tolerant reader): клиент берёт только нужные поля, игнорирует незнакомые и готов к новым значениям.
Самое коварное изменение — смена смысла без изменения схемы: поле period было в годах и стало в месяцах. Никакая проверка схемы этого не поймает, поэтому в описании полей фиксируют единицы, часовые пояса и смысл.
Когда без ломающего изменения не обойтись, выпускают новую версию. Версию указывают в пути (/v2/orders), в заголовке или в типе содержимого. Путь нагляднее и проще в маршрутизации, поэтому встречается чаще всего. Важнее места версии процесс вокруг неё: срок поддержки старой версии, уведомление клиентов, наблюдение за тем, кто ещё её вызывает. Эта тема — модуль 8.3.
Сначала контракт
Подход «сначала контракт» (contract-first, или design-first) означает, что спецификация OpenAPI пишется и согласуется до реализации и становится исходным артефактом, из которого получают всё остальное. Противоположность — code-first: разработчик пишет код, а спецификация генерируется из него.
flowchart LR
Draft["Черновик контракта<br/>аналитик"] --> Review["Ревью с потребителями<br/>и разработкой"]
Review --> Lint["Автопроверки<br/>стиль и совместимость"]
Lint --> Mock["Заглушка по контракту"]
Lint --> Gen["Генерация кода<br/>сервера и клиентов"]
Mock --> Client["Разработка клиента"]
Gen --> Server["Реализация сервера"]
Server --> Tests["Проверка реализации<br/>на соответствие контракту"]
Что это даёт:
- Контракт обсуждается, когда изменение стоит правки в файле, а не переделки сервиса. Потребители видят API до того, как он написан.
- Клиентская и серверная команды работают параллельно: клиент разрабатывается против заглушки, которая отвечает примерами из контракта.
- Контракт — единый источник правды для кода, документации, тестов и правил шлюза. Документация не отстаёт от кода, потому что оба получены из одного файла.
- Контракт проверяется автоматически: линтер следит за единым стилем, сравнение версий ловит ломающие изменения до релиза.
Для аналитика contract-first — способ сделать спецификацию API частью требований, а не документацией задним числом. Постановка ссылается на операции контракта, а бизнес-правила, которые схема не выражает (когда можно отменить заказ, какие ошибки в каком случае), описываются в тексте операции и в перечне кодов ошибок.
Ограничения тоже есть: на ранних прототипах, когда модель данных меняется каждый день, поддерживать спецификацию дороже, чем она окупается, а генераторы кода поддерживают не все конструкции схем одинаково хорошо. Важно не то, что написано первым, а то, считается ли контракт артефактом, который проектируют и проверяют.
Разбор: ставки на аукционе доменов
Регистратор доменов проводит аукционы на освободившиеся имена и открывает API для участников: сделать ставку и посмотреть ставки по аукциону. Решения, принятые при проектировании:
- Ставка — ресурс внутри аукциона: без аукциона она не существует. Путь
/auctions/{auctionId}/bids. - Создание ставки требует ключа идемпотентности: повтор после обрыва не должен создать вторую ставку.
- Сумма — десятичная строка с двумя знаками и отдельный код валюты.
- Ошибки: ставка ниже минимального шага — 422 (запрос корректен, но нарушает правило), аукцион уже закрыт — 409 (конфликт с состоянием). Обе в едином формате с машинным кодом.
- Список ставок — с курсорной пагинацией: ставки поступают постоянно, смещение давало бы дубли.
- Статус ставки — перечисление, которое может расшириться, и это записано в описании.
paths:
/auctions/{auctionId}/bids:
parameters:
- name: auctionId
in: path
required: true
schema:
type: string
post:
operationId: placeBid
summary: Сделать ставку
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BidCreate'
example:
amount: "150.00"
currency: USD
responses:
'201':
description: Ставка принята
headers:
Location:
schema:
type: string
content:
application/json:
schema:
$ref: '#/components/schemas/Bid'
'409':
$ref: '#/components/responses/Problem'
'422':
$ref: '#/components/responses/Problem'
get:
operationId: listBids
summary: Список ставок аукциона
parameters:
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
- name: cursor
in: query
schema:
type: string
responses:
'200':
description: Страница ставок
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: '#/components/schemas/Bid'
nextCursor:
type: [string, 'null']
components:
parameters:
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema:
type: string
format: uuid
schemas:
BidCreate:
type: object
required: [amount, currency]
additionalProperties: false
properties:
amount:
type: string
pattern: '^[0-9]+\.[0-9]{2}$'
description: Сумма ставки, десятичная строка
currency:
type: string
enum: [USD, EUR]
Bid:
type: object
required: [id, amount, currency, status, createdAt]
properties:
id:
type: string
readOnly: true
amount:
type: string
currency:
type: string
status:
type: string
description: Список значений может расширяться
enum: [leading, outbid, won, lost]
createdAt:
type: string
format: date-time
Problem:
type: object
required: [type, title, status]
properties:
type:
type: string
format: uri
title:
type: string
status:
type: integer
detail:
type: string
code:
type: string
description: Машинно-читаемый код ошибки
responses:
Problem:
description: Ошибка в формате Problem Details
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Обратите внимание, что additionalProperties: false стоит только на теле запроса: сервер строго отвергает неизвестные поля, но в ответе такое ограничение сделало бы любое добавление поля ломающим для клиентов, которые валидируют ответ по схеме. Схема проверяется валидатором OpenAPI, но бизнес-правила — минимальный шаг ставки, закрытие аукциона — живут в тексте описания операции.
Что дальше
Семантика методов, выбор кодов ответа, полный набор сценариев ключа идемпотентности, кэширование и оптимистичная блокировка через ETag разбираются в модуле 1.3. Процесс жизни контракта — линтинг, контрактное тестирование, версионирование и вывод старых версий из эксплуатации — в модуле 8.3. Контракты на Protobuf и GraphQL со своими правилами совместимости — в модуле 8.2. Описание инструментов для языковых моделей через JSON Schema — в модуле 5.3.
Типичные ошибки
- Глаголы в путях и действия через GET:
GET /orders/42/cancelвызовет любой робот, который прошёл по ссылке. - Формат ошибок, придуманный заново в каждом методе, и ошибки без машинного кода, из-за чего клиенты разбирают текст сообщения.
- Списки без ограничения размера страницы или с пагинацией по смещению для выгрузок, которые клиенты читают целиком.
- Суммы в числах с плавающей точкой и даты без часового пояса.
- Смешение «необязательного» и «допускающего
null» поля, из-за чего частичное изменение не может отличить «не трогать» от «очистить». - Новое значение перечисления в ответе без договорённости о терпимом чтении, а также смена смысла поля при неизменной схеме.
- Спецификация, которая пишется после реализации и расходится с ней уже через месяц.
Как говорить об этом на собеседовании
От middle/senior-аналитика ждут, что он говорит о контракте как о наборе решений с последствиями. Хороший ответ на «как бы вы спроектировали API заказов» начинается не с полей, а с общих правил: ресурсы и способ выражать действия, единый формат ошибок в Problem Details с машинными кодами, курсорная пагинация с ограничением страницы, ключ идемпотентности на создании, расширяемые перечисления и терпимое чтение. Затем уже конкретные операции.
Сильный пример из опыта — ломающее изменение, которое не было очевидным: новое значение статуса, из-за которого упал клиент, или поле, поменявшее смысл. Расскажите, как такую ошибку ловить заранее: контракт проходит ревью до реализации, изменения сравниваются с предыдущей версией, семантика полей записана в описании. Слова contract-first подкрепите процессом: кто пишет контракт, кто ревьюит, как по нему поднимается заглушка для клиентской команды.
Коротко
- Ресурсы — существительные в путях, действие выражается методом; операции вне CRUD оформляются подресурсом или командой по единому правилу, но не через GET.
- OpenAPI описывает пути, операции, параметры, тела и ответы;
componentsи$refзакрепляют общие решения и не дают им расходиться. - В JSON Schema обязательность задаётся списком
requiredу объекта; «необязательное» и «допускаетnull» — разные вещи; деньги — не число с плавающей точкой. - Ошибки — в одном формате для всех методов, с машинным кодом; стандарт — Problem Details по RFC 9457.
- У каждого списка есть лимит страницы; курсорная пагинация устойчива к изменениям данных и быстра на глубине, смещение — нет.
- Создание защищается ключом идемпотентности, и это часть контракта, а не реализации.
- В запросе можно ослаблять, в ответе расширять осторожно; новое значение перечисления в ответе опасно без правила терпимого чтения.
- Contract-first делает контракт артефактом требований: ревью до реализации, заглушки, генерация кода, автоматические проверки.
Читать дальше
- Спецификация OpenAPI (spec.openapis.org) и учебник на learn.openapis.org.
- Арно Лоре, «Проектирование веб-API».
- RFC 9457 «Problem Details for HTTP APIs».
На system-design.space:
- Web API Design: The Missing Link (short summary)
- Customer-friendly API: удобное API для клиентов
- API Design Patterns (short summary)
Практика
- Спроектировать в OpenAPI API заказов обезличенного интернет-магазина: создание заказа с ключом идемпотентности, получение, список с курсорной пагинацией и фильтрами, отмена. Единый формат ошибок.
- Проверить файл в Swagger Editor или другом валидаторе и посмотреть сгенерированную документацию.
- Составить список из 10 изменений контракта (добавить поле, сделать поле обязательным, сменить тип, добавить значение в перечисление и т. д.) и для каждого решить: совместимо или нет.
Вопросы для самопроверки
- Как спроектировать в REST действие, которое не является CRUD-операцией, например отмену заказа?
- Какие части есть в описании OpenAPI и зачем нужны components?
- Чем курсорная пагинация лучше пагинации по смещению?
- Как должен выглядеть ответ с ошибкой и почему важен машинно-читаемый код?
- Какие изменения контракта ломают клиентов? Почему добавление значения в перечисление может быть опасным?
- Что такое contract-first и какие у него плюсы?