DarkRiDDeR14 мин

API-контракт: как остановить несовместимый ответ до релиза

BackendAPI

Проблема проявляется не в файле OpenAPI, а у потребителя: клиент получает 200, пытается прочитать поле и падает на обычном успешном ответе. Цена такой ошибки — не только один дефектный запрос. Нужно одновременно искать версию клиента, выяснять, какой ответ он ожидал, и решать, можно ли откатить сервер без потери данных.

Частая причина — считать добавление поля безопасным всегда или проверять только happy path. Несовместимыми бывают удаление свойства, добавление обязательного свойства, сужение enum и изменение типа. Ниже — узкий контракт для ответа клиента, чистая проверка входа и порядок, который позволяет увидеть риск до публикации изменения.

Контракт начинается с формы ответа

Контракт — это не комментарий к контроллеру. Он отвечает на четыре вопроса: какой ресурс возвращён, какие поля обязательны, какие значения допустимы и как клиент понимает отказ. Если поле state раньше имело значения active и blocked, добавление deleted может сломать клиентский switch, даже если JSON остаётся валидным. Если поле стало числом вместо строки, ломается уже десериализация.

OpenAPI удобно держать источником формы интерфейса, а JSON Schema — точным описанием JSON-части. Но схема не проверит право пользователя и не узнает, что ревизия записи уже устарела. Поэтому в статье разделены синтаксический контракт и бизнес-проверка: первый должен быть быстрым и детерминированным, вторая живёт рядом с доменным кодом и тестируется отдельно.

Изменение ответа и риск для клиента
ИзменениеТип рискаПроверка перед выпускомБезопасное действие
Добавлено необязательное полеОбычно совместимоСтарый клиент игнорирует полеДобавить contract-test на старую форму
Удалено полеBreakingПоиск чтения поля в клиентахСначала deprecated-окно, затем удаление
Добавлено обязательное полеBreaking для отправителяПроверить все request/response buildersСделать поле optional или выпустить версию
Сужен enumBreaking для ветвленийПрогнать все старые значенияСохранить значение либо объявить несовместимость
Изменён типBreakingСериализация и fixture ответаДобавить новое поле с новым именем

Маленький контракт лучше общего обещания

Возьмём ответ GET /customers/{id}. Клиенту нужны строковый идентификатор, положительная ревизия и закрытый набор состояний. Валидатор не обращается к сети и не угадывает отсутствующие данные. Он принимает JSON-представление, возвращает нормализованный набор полей или ясную причину отказа. Это полезно в unit-тесте, в consumer contract test и в адаптере на границе сервиса.

Важно не путать нормализацию с исправлением. Значение limit можно подставить по умолчанию только там, где это прямо разрешено контрактом фильтра. Для ответа клиента отсутствие обязательного revision — ошибка, а не повод поставить единицу. Молчаливое исправление скрывает несовместимость и переносит её на более дорогой этап.

Диаграмма API-контракта: JSON-ответ проходит через проверку обязательных полей, перечислений и типа, после чего клиент получает совместимое представление или ясный отказ.
Схема показывает границу между описанием ответа, проверкой формы и действием клиента. Она не обещает, что проверка заменяет бизнес-правила или интеграционные тесты.

Runnable-пример: проверяем вход и ожидаемый результат

Пример запускается в Node.js и вызывает экспортированную функцию с двумя объектами. Входом служит обычный JavaScript-объект, а результатом — ok: true с нормализованным значением или ok: false с конкретной причиной. В учебном примере нет HTTP-сервера: цель — показать поведение контракта на границе, а не изобразить готовый production-adapter.

import { validateCustomerResponse } from './upgrade-2027-09.mjs';

const accepted = validateCustomerResponse({
  id: 'customer-17',
  revision: 4,
  state: 'active',
});
const rejected = validateCustomerResponse({
  id: 'customer-17',
  revision: 4,
  state: 'deleted',
});

console.log(accepted.ok, accepted.value.state);
console.log(rejected.ok, rejected.reason);
// true active
// false state-is-outside-enum

Порядок проверки изменения

  1. Сначала назовите endpoint, метод, статус и media type. Без этого слово «контракт» смешивает запрос, ответ и внутреннюю модель.
  2. Снимите текущую форму ответа: обязательные поля, типы, enum, nullable и значения по умолчанию. Зафиксируйте один положительный и несколько отрицательных примеров.
  3. Сравните diff схемы с реальными местами чтения. Особенно ищите удаление поля, изменение типа и сужение перечисления.
  4. Запустите детерминированный валидатор на старой и новой форме. Ошибка должна содержать поле и причину, а не общий «invalid response».
  5. Прогоните consumer contract tests для двух соседних версий клиента. Если старый клиент не проходит, выберите новое поле, совместимое расширение или отдельную версию.
  6. После выпуска добавьте срок удаления deprecated-поля и проверяемый сигнал использования. Не удаляйте его по ощущению, если нет данных о потребителях.

Где заканчивается схема

Схема не отвечает на вопрос, можно ли изменить запись. Ответ state: active может быть формально правильным, но устаревшим относительно команды обновления. Для этого нужны версия ресурса, условный запрос вроде If-Match, правила авторизации и транзакционная проверка. Эти условия следует описывать рядом с endpoint, но не выдавать за свойства JSON Schema.

Схема также не гарантирует одинаковое поведение всех реализаций. Сервер может вернуть правильный JSON только для одного кода пути, а ошибка сериализации останется в редком исключении. Поэтому проверка формы должна быть дополнена интеграционным тестом, который вызывает реальный handler, и тестом совместимости, который запускает старый клиент против нового ответа. Наличие двух тестов не делает контракт вечным: оно снижает конкретный риск в известной границе.

Ограничения и следующий шаг

Учебный валидатор не проверяет OpenAPI-документ, авторизацию, базу данных, компрессию и сетевые ошибки. Он также не доказывает, что всех потребителей нашли. Его задача уже: не пропустить неверный тип, обязательное поле или новое значение enum на границе JSON.

Следующий шаг — собрать один реальный endpoint и добавить к нему пару contract-тестов: старый потребитель должен пройти на расширенном ответе, а breaking diff должен завершаться осознанным решением о версии. Если правило нельзя выразить в форме, status или условии запроса, вынесите его в отдельный раздел доменного контракта, не прячьте в описании поля.

Проверяемые источники

  • OpenAPI Specification 3.1.1 — OpenAPI Initiative, 24 октября 2024 года, версия 3.1.1. Применение: Фиксирует структуру HTTP-интерфейса, операции, ответы и семантику описания, чтобы контракт был машинно читаемым. Граница: Не доказывает, что сервер действительно отдаёт описанное тело: runtime-проверка и тесты остаются отдельной обязанностью.
  • JSON Schema Core 2020-12 — JSON Schema, draft 2020-12, спецификация Core. Применение: Задаёт язык типов, обязательных полей, ограничений и ветвления для JSON-документов. Граница: Схема не знает бизнес-состояние, права доступа, задержку или согласованность нескольких запросов.
  • RFC 9110 — HTTP Semantics — IETF, июнь 2022 года, RFC 9110, Standards Track. Применение: Разделяет метод, статус, представление ресурса и условия обмена, на которые опирается совместимость. Граница: Не описывает локальную реализацию сервиса, формат внутренней базы или конкретный клиент.