Проблема изменения API часто выглядит безобидно: переименовать totalMinor в amountMinor или вернуть более «понятный» статус. Но старый клиент может читать поле как обязательное и перестать строить экран после одного ответа. Цена здесь не в названии версии: пользователь видит пустую сумму, а команда получает повод срочно менять сервер без доказательства, какой договор был нарушен.
Добавить deliveryWindow в ответ обычно безопаснее, но только если конкретный v1-клиент действительно игнорирует незнакомые поля. У другого клиента строгий декодер может отклонить тот же JSON. Поэтому первым артефактом должен быть не маршрут /v2, а маленький контракт: кто читает request, кто читает response, что означает отсутствие поля и какой тест остановит removal до изменения.
Сначала отделяем четыре версии
В разговоре «версия API» смешивают как минимум четыре вещи: версию документа OpenAPI, номер маршрута, форму JSON и поведение клиента. OpenAPI 3.0.3 прямо различает версию спецификации в поле openapi и версию самого API в info.version. Ни одно из этих полей не доказывает, что потребитель переживёт переименование или иной смысл значения.
| Слой | Пример изменения | Риск | Проверяем до продолжения |
|---|---|---|---|
| Маршрут | /api/orders остаётся прежним | URL не говорит, какие ключи читает клиент | Зафиксировать request и response для двух клиентов |
| Форма response | Добавить optional deliveryWindow | старый parser может быть строгим | v1 читает обязательные поля и явно игнорирует добавленное |
| Форма request | Добавить deliveryPreference | старый сервер может принять опечатку или отклонить новое поле | новый сервер принимает v1 request, а unknown key отклоняет |
| Семантика | confirmed заменить на accepted | ключ сохранён, но ветка клиента меняет смысл | проверить допустимые значения, а не только список ключей |
Я не считаю сегмент URL ни единственным, ни автоматическим средством совместимости. Он может быть удобен для разведения крупных договоров, но не делает совместимым ни тело запроса, ни body ответа, ни ожидание статуса. Схема тоже описывает только часть формы. Если клиент использует confirmed как разрешение показать следующий шаг, замена на accepted является разрывом даже при одинаковом JSON type.
Один endpoint и два потребителя
Для учебной модели беру один endpoint: POST /api/orders/{orderId}/confirm. v1 отправляет только confirmationCode. Текущий сервис принимает этот request без optional-поля. v2 может передать deliveryPreference; отсутствие означает «ничего не менять», null — «явно очистить», строка weekday или weekend — «задать». Эти значения не являются правилом HTTP: это письменный договор данного endpoint.
// Учебный контракт одного endpoint. Не сетевой вызов.
const endpoint = 'POST /api/orders/{orderId}/confirm';
const v1Response = {
id: 'order-417',
status: 'confirmed',
totalMinor: 1500,
};
// В v2 поле добавлено и optional. Старый parser его не читает.
const v2Response = {
...v1Response,
deliveryWindow: { from: '2021-09-14T10:00:00Z', to: '2021-09-14T14:00:00Z' },
};
// absent: поле не предложено; null: окно проверено, но его нет.
В response v1 требуются id, status и totalMinor. v2 понимает optional deliveryWindow. Здесь зафиксирована важная тройка: field отсутствует — эта representation его не предлагает; field равен null — вычисление произошло и окна нет; object — окно известно. Если продукту не нужна эта разница, лучше не вводить null «на всякий случай»: неясность позже станет семантическим разрывом.
| Поле | v1 | v2 | Правило изменения |
|---|---|---|---|
id | обязательное | обязательное | не удалять без переходного договора |
status | confirmed имеет известный смысл | тот же смысл | новое значение проверяется отдельным compatibility test |
totalMinor | обязательное число | обязательное число | переименование в amountMinor — breaking change |
deliveryWindow | неизвестное поле игнорируется в этой модели | optional: absent / null / object различаются | добавлять только после проверки v1 parser |
Rollout — это последовательность ворот, а не календарная дата
Сначала записываю базовый contract и примеры для v1/v2. Затем добавляю optional response-поле, но ещё не полагаюсь на него в request. Только после зелёных проверок разрешаю v2 optional request. Retirement обязательного totalMinor ставлю последним: кандидатный response проходит тот же v1 test. Если test отклонил response, retirement не начинается, legacy field остаётся, а v2-only режим не включается ради «проверки вживую». Это и есть rollback-safe шаг: остановка происходит до мутации учебного состояния.
RFC 8594 уже к 2021 году описывал Sunset как сигнал будущей вероятной недоступности ресурса. Это может быть полезной частью retirement-коммуникации, но не заменяет список потребителей, срок поддержки и проверку. Сам документ отделяет «больше не предпочтительный вариант» от фактического вывода из эксплуатации; timestamp остаётся подсказкой, а не обещанием доступности до секунды.
Минимальная проверка до большого решения
Ниже не сервер и не генератор SDK. Это детерминированная in-memory fixture из этого пакета. Она проверяет, что v1 переживает additive response, v2 различает absent/null/object, текущий обработчик принимает v1 и v2 request, опечатка request-поля отклоняется, а removal/semantic change не проходят. Сценарий занимает один Node-процесс и оставляет evidence в timeline.
import { runApiVersioningFixture } from './upgrade-2021-09.mjs';
const fixture = runApiVersioningFixture();
for (const [name, passed] of Object.entries(fixture.assertions)) {
if (!passed) throw new Error('failed: ' + name);
}
console.log(fixture.timeline.map(({ stage }) => stage));
// retirement-candidate-rejected → rollback-safe-pause
Смысл fixture не в количестве assertions. Она заставляет назвать направление совместимости. «Новый сервер читает старый request» и «старый клиент читает новый response» — два разных условия. Третье условие — понимание нового поля новым клиентом. Четвёртое — запретить change, который оставляет shape похожей, но меняет допустимое значение status. Пока эти условия не разделены, общий «schema passed» может скрыть нужный разрыв.
Маршрут: симптом → причина → проверка → действие
- Симптом. После candidate response v1 не показывает сумму или не завершает сценарий. Не добавлять новую URL-версию как рефлекторную реакцию.
- Причина. Сравнить не только JSON keys: обязательное
totalMinorмогло исчезнуть, а значениеstatus— изменить смысл при том же type. - Проверка. Прогнать v1 reader на base, additive и candidate response. Отдельно прогнать request validator на v1 request, v2 request, отсутствие поля,
nullи опечатку. - Действие. Если removal не проходит v1 gate, сохранить legacy response, зафиксировать отказ в evidence и поставить retirement на паузу. Сначала согласовать миграцию, затем повторить те же tests.
Как объявить поддержку без ложной точности
В документе контракта нужны owner, перечисление поддерживаемых representation, условие перехода и способ проверить каждый пункт. Формулировка «поддерживаем v1 до даты» неполна, пока не названы endpoint, request и response, которые относятся к v1, и проверяемый шаг после даты. Лучше написать: «обязательный totalMinor остаётся в response, пока v1 compatibility test является входным условием; кандидат removal отклонён fixture». Так договор можно проверить в review без доступа к чужому клиенту.
Не стоит объявлять, что optional field всегда безопасен, что все consumers терпимы к unknown keys или что OpenAPI сам подскажет режим migration. Эти свойства принадлежат конкретным parsers, правилам валидации и ожиданиям пользователя. В этой модели unknown response field v1 игнорирует, а unknown request field сервис отклоняет; другая система вправе выбрать иной договор, но тогда ей нужен другой test.
Ограничения
Fixture не запускает HTTP, базу, SDK, gateway или настоящий deployment. В ней нет реальных клиентов, SLA, метрик и инцидента. Она не проверяет code generation, media type, авторизацию, retries и конкуренцию изменения заказа. Если endpoint изменяет состояние, проект отдельно решает вопрос повторного запроса и идентификатора операции. Этот материал даёт форму разговора и минимальный guard, а не универсальную политику версионирования.
Проверяемые источники
- OpenAPI Specification 3.0.3 — 20 февраля 2020 года — официальная фиксированная редакция: отличает версию самой спецификации от версии API, описывает request body, parameter и response; это описание контракта, а не готовая проверка конкретного клиента
- OpenAPI Specification 3.1.0 — 15 февраля 2021 года — официальная редакция, доступная к сентябрю 2021 года: Schema Object задаёт форму input/output, а прикладная семантика не выводится автоматически из формы
- RFC 8594: The Sunset HTTP Header Field — май 2019 года — первичный документ IETF: Sunset сигнализирует вероятную недоступность ресурса в будущем и является подсказкой, а не гарантией или заменой договорённости с потребителем