DarkRiDDeR12 мин

Под капотом: совместимость API идёт в две стороны

APIАрхитектура

Команда может увидеть одинаковую схему 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.

Направления contract test
ПотокИзменениеДопустимый результат в моделиЧто test обязан отклонить
v1 client ← current responseдобавлен optional deliveryWindowv1 собирает прежний view и игнорирует неизвестный response keyотсутствие totalMinor
v2 client ← current responsedeliveryWindow absent / null / objectтри состояния читаются различно и явнострока или неполный object вместо оговорённой формы
v1 client → current serviceне прислан deliveryPreferencerequest принят, 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 не превращается в пустое окно.

Матрица совместимости v1 и v2 клиентов: additive deliveryWindow проходит, удаление totalMinor и semantic change status отклоняются, request проверяется отдельным направлением.
Рисунок 1. Contract test смотрит не на одну «версию», а на четыре пересечения reader-а и writer-а. Зеленая ячейка — доказанный учебный case, красная — stop signal.

Форма запроса: 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
Что именно доказывает fixture
AssertionПоложительный случайОтрицательный случайВывод
v1 responsedeliveryWindow добавленнет totalMinorне путать additive и removal
v2 responseobject / absent / null различаютсяневерная форма окнановая семантика требует собственного reader-а
requestv1 без optional и v2 с valid optional принятыопечатка unknown keyrequest policy должна быть явной
retirementcandidate проверен до changev1 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.

Маршрут: симптом → причина → проверка → действие

  1. Симптом. Schema diff «чистый», но reader v1 ломается либо выполняет другой переход после response.
  2. Причина. Определить направление: исчезло обязательное response-поле, появилось неизвестное request-поле или изменилась semantics знакомого значения.
  3. Проверка. Составить fixtures: base, additive, absent, null, removed field, semantic change, v1 request, v2 request и request с опечаткой. Не заменять их общим валидным JSON.
  4. Действие. Добавить только 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 сигнализирует вероятную недоступность ресурса в будущем и является подсказкой, а не гарантией или заменой договорённости с потребителем