Команда может увидеть одинаковую схему JSON и решить, что API совместим. Затем v1-клиент получает status: "accepted" вместо привычного confirmed, выбирает другую ветку или останавливается. Цена — не обязательно ошибка парсинга: тихая смена смысла способна показать пользователю неверное действие и оставить в логах «успешный» HTTP-ответ.
Обратная сторона не менее опасна. Новый клиент отправляет optional deliveryPreference, а старый сервер либо молча выбрасывает поле, либо принимает опечатку deliveryPrefrence как неизвестные данные. Цена молчания — отсутствие объяснения, почему предпочтение не применилось. Поэтому request и response нельзя проверять одной фразой «всё валидно по схеме». У них разные читатели и разные направления эволюции.
Граница механизма: кто кого читает
Пусть есть POST /api/orders/{orderId}/confirm. Для request совместимость смотрит вперёд: текущий сервис обязан принимать старый v1 request без нового optional field. Для response она смотрит назад: существующий v1-клиент обязан читать current representation, пока обещана его поддержка. v2 добавляет знание о deliveryWindow, но не получает права переименовать v1 totalMinor или подменить смысл status.
| Поток | Изменение | Допустимый результат в модели | Что test обязан отклонить |
|---|---|---|---|
| v1 client ← current response | добавлен optional deliveryWindow | v1 собирает прежний view и игнорирует неизвестный response key | отсутствие totalMinor |
| v2 client ← current response | deliveryWindow absent / null / object | три состояния читаются различно и явно | строка или неполный object вместо оговорённой формы |
| v1 client → current service | не прислан deliveryPreference | request принят, preference не меняется | требование нового optional field задним числом |
| v2 client → current service | прислано deliveryPreference | значение проходит ограниченный словарь | неизвестный request key и опечатка |
OpenAPI описывает interface HTTP API и его input/output. Но сам по себе OpenAPI document не выполняет за команду контрактный тест. В редакции 3.1.0 Schema Object может описать типы, а прикладная семантика остаётся у приложения. Значит, наличие ключа status и его type string не доказывают, что confirmed и accepted взаимозаменяемы. Это отдельное правило reader-а.
Форма ответа: additive не равно автоматически compatible
В fixture v1 reader требует три поля и считает confirmed единственным ожидаемым значением. Остальные response keys он не переносит в свой view. Поэтому добавленный deliveryWindow не ломает именно этого v1. В то же время тот же reader отклоняет candidate с amountMinor вместо totalMinor. Он также отклоняет сохранённый ключ status, если значение получило новый смысл. Так один test показывает два вида break: structural и semantic.
function readV1(response) {
for (const field of ['id', 'status', 'totalMinor']) {
if (!Object.hasOwn(response, field)) throw new Error('missing ' + field);
}
if (response.status !== 'confirmed') throw new Error('status semantics changed');
if (!Number.isInteger(response.totalMinor)) throw new Error('invalid totalMinor');
return {
id: response.id,
status: response.status,
totalMinor: response.totalMinor,
// Неизвестные response-поля сознательно не попадают в view.
};
}
Здесь unknown response field игнорируется намеренно и локально. Это не совет «всегда игнорируйте всё». Если клиент хранит весь объект, применяет строгую схему или делает exhaustive match, добавление ключа может стать incompatibility. Сначала надо подтвердить поведение конкретного reader-а. Даже когда v1 может проигнорировать новое поле, v2 должен проверить его форму: object содержит from и to, null не подменяется отсутствием, а absence не превращается в пустое окно.
Форма запроса: unknown key лучше сделать наблюдаемым
С request выбираю другой policy. Current service знает confirmationCode и optional deliveryPreference. Unknown key отвергается, потому что deliveryPrefrence похож на полезное поле, но не является им. Если сервис проглотит опечатку, клиент получит формально успешный result без обещанного поведения. Это не универсальная строгость: публичный API может иметь иной договор. Важно записать policy и покрыть её test-ом.
function acceptRequest(request) {
const known = new Set(['confirmationCode', 'deliveryPreference']);
const unknown = Object.keys(request).filter((key) => !known.has(key));
if (unknown.length) return { ok: false, reason: 'unknown-request-field', unknown };
if (!request.confirmationCode) return { ok: false, reason: 'confirmationCode-required' };
if (!Object.hasOwn(request, 'deliveryPreference')) return { ok: true, preference: 'unchanged' };
if (request.deliveryPreference === null) return { ok: true, preference: 'clear' };
return { ok: true, preference: 'set' };
}
acceptRequest({ confirmationCode: 'A7K9' }); // unchanged
acceptRequest({ confirmationCode: 'A7K9', deliveryPreference: null }); // clear
acceptRequest({ confirmationCode: 'A7K9', deliveryPrefrence: 'weekday' }); // reject typo
Здесь absence и null имеют разные side effect намерения, хотя fixture не меняет реальный заказ. Absence означает «сохранить прежнее предпочтение», null — «очистить». В response разница другая: absence означает, что representation не предлагает поле, null — сервис явно сообщает отсутствие окна. Одинаковый JSON token null не имеет магического смысла; его нельзя вводить без текста рядом с контрактом и проверкой reader-а.
Contract test как узкий факт, а не имитация всей системы
Вместо теста «все поля на месте» fixture строит набор фиксированных объектов и прогоняет функции reader/validator. Нет сети, базы и code generator. Это ограничение полезно: мы видим только контрактную ветку и не приписываем результату свойства transport. В timeline остаются этапы базового контракта, additive response, v2 request, отклонённого retirement и rollback-safe паузы. Любая assertion падает до решения о change.
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
| Assertion | Положительный случай | Отрицательный случай | Вывод |
|---|---|---|---|
| v1 response | deliveryWindow добавлен | нет totalMinor | не путать additive и removal |
| v2 response | object / absent / null различаются | неверная форма окна | новая семантика требует собственного reader-а |
| request | v1 без optional и v2 с valid optional приняты | опечатка unknown key | request policy должна быть явной |
| retirement | candidate проверен до change | v1 reader его отверг | остановить migration без изменения учебных данных |
Почему /v2 и schema diff не закрывают задачу
Новый маршрут может разделить документы или deployments, но сам не переводит клиента и не объясняет, что происходит с прежним endpoint. Можно сломать v1 внутри старого URL, а можно сохранить v1 semantics на новом URL. Аналогично, schema diff замечает required key, но не знает, что строка стала означать другое, что unknown field необходимо отклонить именно в request или что null теперь имеет отдельный бизнес-смысл. Поэтому этот материал использует API versioning как управление договором, а не как выбор одной нотации URL.
Документирование нужно, но недостаточно: v1/v2 примеры и negative examples входят в test suite. В OpenAPI 3.0.3 request body имеет собственную отметку required, которая по умолчанию false. Это свойство описания request body, а не автоматическое разрешение заставить старого клиента посылать новое поле. Решение о required/new optional принимается на границе конкретного endpoint и подтверждается старым request sample.
Маршрут: симптом → причина → проверка → действие
- Симптом. Schema diff «чистый», но reader v1 ломается либо выполняет другой переход после response.
- Причина. Определить направление: исчезло обязательное response-поле, появилось неизвестное request-поле или изменилась semantics знакомого значения.
- Проверка. Составить fixtures: base, additive, absent, null, removed field, semantic change, v1 request, v2 request и request с опечаткой. Не заменять их общим валидным JSON.
- Действие. Добавить только change, который прошёл нужные reader/validator tests. При break оставить legacy representation, отклонить candidate и записать условие повторной попытки migration.
Ограничения
Проверка не доказывает, как реальный framework сериализует undefined, что делает прокси с body или как SDK обрабатывает unknown fields. Она не охватывает authorization, rate limit, cache, idempotency и фактическое распространение клиента. OpenAPI 3.0.3 и 3.1.0 используются как исторические первичные источники терминов и границ спецификации, а не как подтверждение этого учебного endpoint. Перед применением нужны фактические parser tests и договор с владельцами consumers.
Проверяемые источники
- 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 сигнализирует вероятную недоступность ресурса в будущем и является подсказкой, а не гарантией или заменой договорённости с потребителем