{ "index": 227, "slug": "editorial-2021-09-mechanism-api-versioning", "title": "Совместимость API: проверяйте request и response отдельно", "excerpt": "Одинаковая JSON-схема не гарантирует совместимость: значение может сменить смысл, а новое поле — потеряться в старом сервере. Разбираем направления проверки, безопасное additive-изменение и остановку несовместимого retirement.", "contentHtml": "

Сбой часто выглядит безобидно: сервер отвечает HTTP 200, JSON успешно разбирается, но старый клиент выбирает не ту ветку. Например, v1 ожидал status: 'confirmed', а получил status: 'accepted'. Тип и имя поля не изменились. Изменился смысл. Пользователь может увидеть неверный статус заказа, а мониторинг отметит запрос как успешный.

\n

Есть и обратный случай. Новый клиент отправляет deliveryPreference, а старый сервер молча выбрасывает неизвестный ключ. Опечатка deliveryPrefrence выглядит для человека почти так же, но намерение теряется без ошибки. Цена такой ошибки — не только один неправильный ответ. Команда теряет границу между старым и новым контрактом, а затем пытается лечить её новым URL, повтором запроса или срочным откатом.

\n

Тезис статьи простой: совместимость API — это проверяемый договор между конкретным writer и reader. Response нужно проверять от нового сервера к старому клиенту. Request — от нового клиента к текущему серверу. Одна проверка схемы не заменяет эти два теста и не знает прикладной смысл строковых значений.

\n

Механизм: кто кого читает

\n

Рассмотрим учебный endpoint POST /api/orders/{orderId}/confirm. Ответ v1 содержит обязательные поля id, status и totalMinor. Клиент v1 принимает только значение confirmed. Ответ v2 может дополнительно содержать deliveryWindow. Это additive-изменение безопасно только для reader, который игнорирует неизвестное поле после проверки обязательных полей.

\n

У запроса другие правила. confirmationCode обязателен. deliveryPreference optional. Если ключ отсутствует, сервер сохраняет прежнее предпочтение. Если ключ равен null, сервер очищает его. Неизвестный ключ сервер отклоняет. Так опечатка становится наблюдаемым отказом, а не тихой потерей намерения.

\n
Два направления совместимости
ПотокДопустимое изменениеЧто проверяемСтоп-сигнал
v1 client ← current responseДобавлен optional deliveryWindowv1 сохраняет прежний view и принимает totalMinorИсчезло totalMinor или изменился смысл status
v2 client ← current responsedeliveryWindow absent, null или objectТри состояния различаютсяСтрока или неполный object
v1 client → current serviceНет нового optional ключаЗапрос принят, preference не меняетсяСтарый клиент обязан прислать новое поле
v2 client → current serviceПередан допустимый deliveryPreferenceЗначение проходит словарьUnknown key или опечатка
\n

В этом договоре отсутствие и null не равны. В response отсутствие означает, что representation не предлагает окно. null означает, что сервис проверил условие и сообщает: окна нет. В request отсутствие сохраняет прежнее значение, а null очищает его. Такая семантика должна быть написана рядом со схемой и покрыта проверкой. Сам JSON не объясняет намерение.

\n

Конкретный пример

\n

Reader должен проверять обязательные поля и смысл значения, а не только наличие ключей. Ниже — учебный JavaScript-фрагмент. Он не обращается к HTTP и не доказывает поведение production-сервиса. Его задача — показать границу, которую следует перенести в контрактный тест конкретного reader.

\n
function readV1Response(body) {\n  for (const key of ['id', 'status', 'totalMinor']) {\n    if (!(key in body)) return { ok: false, reason: 'missing:' + key };\n  }\n  if (body.status !== 'confirmed') {\n    return { ok: false, reason: 'unsupported-status-semantics' };\n  }\n  if (!Number.isInteger(body.totalMinor)) {\n    return { ok: false, reason: 'invalid-totalMinor' };\n  }\n  return {\n    ok: true,\n    view: { id: body.id, status: body.status, totalMinor: body.totalMinor },\n  };\n}\n\nconst additive = {\n  id: 'order-417', status: 'confirmed', totalMinor: 1500,\n  deliveryWindow: { from: '2021-09-14T10:00:00Z', to: '2021-09-14T12:00:00Z' },\n};\n\nconsole.log(readV1Response(additive).ok); // true: лишнее поле не попало в view\nconsole.log(readV1Response({\n  id: 'order-417', status: 'confirmed', amountMinor: 1500,\n}).ok); // false: totalMinor нельзя переименовать молча\nconsole.log(readV1Response({\n  id: 'order-417', status: 'accepted', totalMinor: 1500,\n}).ok); // false: одинаковый type не сохраняет смысл\n
\n

Положительный результат относится только к этому reader: он выбирает известные поля и игнорирует новое response-поле. Нельзя объявлять additive-изменение универсально безопасным. Клиент, который хранит весь объект, использует строгую схему или делает exhaustive match, может сломаться от добавленного ключа. Сначала нужно проверить фактическое поведение потребителя.

\n

Для request проверяется другая функция. Она принимает старый body без optional поля, принимает новый body с допустимым значением и отклоняет неизвестный key. Это защищает от опечаток. Но строгий request validator не даёт права требовать новое поле от всех старых клиентов: обязательность определяется контрактом конкретной операции и периодом поддержки.

\n
Матрица совместимости v1 и v2 клиентов: additive deliveryWindow проходит, удаление totalMinor и semantic change status отклоняются, request проверяется отдельным направлением.
Рисунок 1. Совместимость проверяется на пересечении reader и writer. Добавление окна проходит только при сохранении старого view; удаление обязательного поля и смена смысла статуса останавливают переход.
\n

Симптом → причина → проверка → действие

\n
Диагностическая карта изменения API
СимптомПричинаПроверкаДействие
HTTP 200, но клиент показывает другую веткуstatus сохранил type, но сменил смыслПрогнать старый reader на candidate response и проверить допустимые значенияВернуть прежнюю семантику или подготовить явный переход; не считать schema diff достаточным
Старый клиент не видит суммуtotalMinor удалён или переименован в amountMinorСравнить обязательные поля v1 с новым bodyОставить legacy-поле; retirement остановить до миграции всех readers
Новое предпочтение не применилосьСервер молча проигнорировал unknown request key или опечаткуПроверить список ключей и различить отсутствие, null и значениеОтклонять неизвестные ключи и исправить request contract
v2 не показывает окноAbsent и null склеились или object неполныйПрогнать reader на трёх representation: absent, null, objectЗафиксировать семантику состояний и сохранить старый ответ до её проверки
\n

Почему одного /v2 недостаточно

\n

Новый URL отделяет документацию или deployment, но сам не переводит клиента. Старый endpoint можно сломать внутри прежнего адреса. Новый endpoint можно сохранить совместимым. Поэтому номер в URL — инструмент маршрутизации, а не доказательство договора.

\n

Schema diff полезен для структурных изменений. Он может заметить исчезновение required key. Он не знает, что confirmed и accepted означают разные переходы, что unknown request key должен быть ошибкой или что null получил отдельное бизнес-значение. Эти правила принадлежат reader, writer и операции.

\n

OpenAPI помогает записать input и output, но описание не запускает проверку старого клиента. В OpenAPI 3.1 Schema Object описывает форму данных. Контрактный тест должен дополнить его реальными samples и отрицательными случаями: удалённым обязательным полем, изменённым значением, неизвестным request key, null и неправильной формой object.

\n

Порядок действий

\n
  1. Назовите endpoint, method, поддерживаемые readers и writers. Не начинайте с выбора /v2.
  2. Зафиксируйте базовые request и response samples без секретов. Отдельно запишите обязательные поля, optional поля, допустимые значения и смысл отсутствия.
  3. Разделите тесты на два направления: старый client читает новый response; текущий service принимает старый и новый request.
  4. Добавьте положительный additive-case. Проверьте, что v1 сохраняет прежний view, а v2 различает absent, null и object.
  5. Добавьте отрицательные cases: удаление totalMinor, смена смысла status, неизвестный ключ и неправильная форма deliveryWindow.
  6. Проверьте фактические parser rules. Не переносите политику «игнорировать unknown response field» на клиентов, для которых она не доказана.
  7. Перед retirement прогоните candidate на всех поддерживаемых readers. Если обязательное поле исчезло или семантика изменилась, остановите retirement без изменения legacy-контракта.
  8. После успешной проверки объявите границу поддержки: representation, request, срок, owner и условие повторной проверки. Сигнал Sunset может дополнить коммуникацию, но не заменяет миграцию.
\n

Ограничения и отрицательный путь

\n

Учебный пример не проверяет framework serialization, gateway, SDK, авторизацию, cache, rate limit, retries или фактическое распространение мобильного клиента. Он не даёт production-результатов и не доказывает SLA. Для state-changing endpoint отдельно проверяйте idempotency и повторную отправку: совместимость body не делает повтор безопасным.

\n

Если candidate удаляет totalMinor, не добавляйте немедленно новый URL и не подменяйте поле на лету без владельца. Оставьте legacy response, сохраните факт отказа и выясните, какой reader ещё зависит от поля. Если request validator нашёл deliveryPrefrence, не повторяйте запрос вслепую: сначала исправьте имя и определите, применилось ли состояние. Отрицательный путь должен прекращать изменение, а не маскировать нарушение.

\n

Готовность доказана, когда для каждого поддерживаемого направления есть базовый sample, additive-case и отрицательный case. Старый reader принимает новый response только при сохранении обязательных полей и смысла значений. Текущий service принимает старый request без нового optional key. Unknown request key, removal required field и semantic change завершаются понятным отказом до retirement. Команда может повторить эти проверки на фиксированных входах и назвать owner каждого оставшегося потребителя.

\n

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

" }