Б.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:

Практика

  1. Спроектировать в OpenAPI API заказов обезличенного интернет-магазина: создание заказа с ключом идемпотентности, получение, список с курсорной пагинацией и фильтрами, отмена. Единый формат ошибок.
  2. Проверить файл в Swagger Editor или другом валидаторе и посмотреть сгенерированную документацию.
  3. Составить список из 10 изменений контракта (добавить поле, сделать поле обязательным, сменить тип, добавить значение в перечисление и т. д.) и для каждого решить: совместимо или нет.

Вопросы для самопроверки

  1. Как спроектировать в REST действие, которое не является CRUD-операцией, например отмену заказа?
  2. Какие части есть в описании OpenAPI и зачем нужны components?
  3. Чем курсорная пагинация лучше пагинации по смещению?
  4. Как должен выглядеть ответ с ошибкой и почему важен машинно-читаемый код?
  5. Какие изменения контракта ломают клиентов? Почему добавление значения в перечисление может быть опасным?
  6. Что такое contract-first и какие у него плюсы?

Заметки