{ "index": 228, "slug": "editorial-2021-09-practice-api-versioning", "title": "Версионирование API: как изменить контракт и не сломать клиентов", "excerpt": "Пошаговая схема для изменения request и response: найти реальный разрыв, проверить старого и нового потребителя, а опасное удаление поставить на паузу.", "contentHtml": "

После выпуска новой версии API старое мобильное приложение перестало показывать сумму заказа. HTTP-ответ оставался успешным, сервер не сообщал об ошибке, а в JSON вместо обязательного totalMinor появилось новое поле amountMinor. Пользователь видел пустой экран или не мог подтвердить заказ. Команда потеряла время на поиск в логах, потому что сервер считал запрос обработанным. Цена ошибки — не только один сломанный экран. Это срочный релиз, ручная сверка данных и риск повторить тот же разрыв в другом клиенте.

\n

Тезис простой: версионирование API — это управление договором между writer и reader. Номер в URL помогает разделить маршруты, но не доказывает совместимость. Перед изменением нужно отдельно проверить request и response, обязательные поля, допустимые значения и смысл ответа. Если проверка не проходит, старый договор остаётся действующим, а удаление откладывается.

\n

Что именно считается версией

\n

В одной фразе «мы обновили API» часто смешивают четыре слоя. Первый — версия документа OpenAPI. Второй — публичный маршрут, например /api/orders или /api/v2/orders. Третий — форма сообщения: ключи, типы, обязательность и значения. Четвёртый — поведение клиента: какую ветку он выбирает после статуса confirmed, что делает при отсутствии поля и как обрабатывает неизвестный ключ.

\n

Эти слои связаны, но не заменяют друг друга. OpenAPI может описать поле, но не знает, что клиент использует status как разрешение показать кнопку. Сегмент /v2 может направить запрос на другой обработчик, но не мигрирует сохранённые приложения. Поэтому сначала фиксируют фактический contract surface: кто отправляет request, кто читает response, какие значения обязательны и какое отсутствие считается нормальным.

\n
Слои изменения и проверка перед выпуском
СлойПримерРискПроверка
Маршрут/api/orders → /api/v2/ordersСтарый клиент продолжает ходить в прежний маршрутСоставить список потребителей каждого маршрута
ResponseДобавить deliveryWindowСтрогий parser может отвергнуть новый ключПрогнать реальный reader на additive response
RequestДобавить deliveryPreferenceСтарый сервер не знает поле или молча его теряетПроверить v1 request и каждое новое значение
Смыслconfirmed → acceptedТип остался string, но ветка клиента измениласьПроверить переходы состояния и пользовательское действие
\n

Модель на одном endpoint

\n

Возьмём учебный endpoint POST /api/orders/{orderId}/confirm. Это ограниченный пример, а не описание production-сервиса. Версия v1 отправляет только confirmationCode. Новый сервер обязан принять такой request. Версия v2 может добавить deliveryPreference. Отсутствие поля означает «не менять настройку», null — «очистить настройку», а строки weekday и weekend задают значение.

\n
function acceptRequest(request) {\n  const known = new Set(['confirmationCode', 'deliveryPreference']);\n  const unknown = Object.keys(request).filter((key) => !known.has(key));\n\n  if (unknown.length) return { ok: false, reason: 'unknown-field' };\n  if (!request.confirmationCode) {\n    return { ok: false, reason: 'confirmationCode-required' };\n  }\n  if (!Object.hasOwn(request, 'deliveryPreference')) {\n    return { ok: true, preference: 'unchanged' };\n  }\n  if (request.deliveryPreference === null) {\n    return { ok: true, preference: 'clear' };\n  }\n  if (!['weekday', 'weekend'].includes(request.deliveryPreference)) {\n    return { ok: false, reason: 'invalid-preference' };\n  }\n  return { ok: true, preference: 'set' };\n}
\n

Функция показывает направление проверки. Новый сервер читает старый request. Он принимает обязательный код без нового поля, но не принимает опечатку deliveryPrefrence. Он различает отсутствие, null и строку. В реальном сервисе эти правила должны жить в его валидаторе и тестах. Здесь код служит учебной моделью.

\n

Response проверяют в обратную сторону. Старый клиент должен прочитать текущий ответ, пока команда обещает его поддержку. В базовом ответе обязательны id, status и totalMinor. Новое поле deliveryWindow можно добавить только после проверки конкретного v1 reader-а. Если reader строго сравнивает набор ключей, additive change тоже ломает договор. Если он игнорирует неизвестные поля, это свойство нужно зафиксировать тестом, а не считать общим правилом.

\n
const baseResponse = {\n  id: 'order-17',\n  status: 'confirmed',\n  totalMinor: 129900,\n};\n\nconst additiveResponse = {\n  ...baseResponse,\n  deliveryWindow: { from: '2026-08-03T10:00:00Z', to: '2026-08-03T12:00:00Z' },\n};\n\nfunction readV1(response) {\n  if (typeof response.id !== 'string') throw new Error('id');\n  if (response.status !== 'confirmed') throw new Error('status');\n  if (!Number.isInteger(response.totalMinor)) throw new Error('totalMinor');\n  return response.totalMinor;\n}\n\nreadV1(baseResponse);       // учебный пример: проходит\nreadV1(additiveResponse);   // проходит только при tolerant reader
\n

Последняя строка не универсальна. Данный reader обращается только к нужным полям, поэтому в этой модели новый ключ ему не мешает. Другой клиент может десериализовать JSON строгой схемой и отклонить тот же ответ. Проверять нужно поведение своего reader-а.

\n

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

\n
СимптомПричинаПроверкаДействие
Экран не показывает суммуУдалено или переименовано обязательное полеСравнить base и candidate response на v1 reader-еВернуть legacy field и остановить retirement
HTTP 200, но неверная ветка клиентаИзменён смысл допустимого значения statusПроверить переходы по значениям, а не только JSON typeСохранить старый смысл или выпустить явный новый contract
Новое предпочтение не применилосьСтарый сервер отбросил неизвестное request-полеПроверить ответ валидатора и итоговое состояниеДождаться поддержки writer-а или использовать отдельный маршрут
Сервис принимает опечаткуUnknown request fields разрешены молчаОтправить deliveryPrefrence и проверить отказОтклонять неизвестные поля с понятной причиной
Старый клиент падает после добавления поляParser строгий, хотя изменение считали additiveПрогнать реальный parser на полном candidate responseСохранить форму ответа или расширить поддержку reader-а
\n

Rollout и отрицательный путь

\n

Безопасный rollout не начинается с удаления старого ключа. Сначала фиксируют базовый response и v1 request. Затем добавляют новое поле в response и проверяют старого reader-а. После этого проверяют v2 request на новом сервере. Только потом обсуждают retirement. Удаление totalMinor проходит через тот же v1 compatibility test. Если тест отклоняет candidate response, legacy field остаётся, а выпуск останавливается до изменения состояния.

\n
Маршрут эволюции API: базовый контракт, additive response, v2 request и безопасная пауза перед удалением totalMinor
Рисунок 1. Сначала проверяют additive-изменение и новый request. Удаление обязательного поля останавливается на v1 gate.
\n

Это отрицательный путь, а не исключение. Отказ теста сообщает: команда пока не доказала совместимость. Нельзя превращать его в разрешение на выпуск с флагом «проверим позже». Сохраняют прежний response, записывают вход и результат проверки, затем уточняют владельца клиента или договор миграции. Если нужный клиент неизвестен, это причина расширить инвентаризацию, а не причина считать его отсутствующим.

\n

Для вывода из эксплуатации можно сообщить клиентам о будущем сроке. Заголовок Sunset из RFC 8594 помогает передать намерение для ресурса, но сам по себе не доказывает миграцию и не отключает endpoint. Нужны также список потребителей, срок поддержки, новая форма ответа и проверяемое условие удаления.

\n

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

\n
  1. Зафиксируйте request и response, которые реально использует старый клиент. Запишите обязательные поля, типы, значения и поведение при отсутствии.
  2. Разделите проверку на два направления: новый сервер читает старый request; старый клиент читает новый response.
  3. Классифицируйте изменение. Добавление ключа, удаление ключа, переименование и смена смысла требуют разных проверок.
  4. Проверьте additive response конкретным v1 reader-ом. Не делайте вывод по одной схеме OpenAPI.
  5. Проверьте новый request: отсутствие optional-поля, null, допустимые значения и опечатку неизвестного ключа.
  6. Добавьте наблюдаемый guard: contract test, проверку на границе сервиса или другой автоматический сигнал, который блокирует опасное изменение.
  7. Только после зелёных проверок объявите поддержку новой формы. Retirement запускайте последним и оставьте обратимый шаг.
\n

Ограничения модели

\n

Учебный endpoint не проверяет настоящий HTTP-трафик, gateway, кеш, авторизацию, SDK, базу и несколько одновременных обновлений. Он не отвечает на вопрос о retry для операции, которая меняет состояние. Для такого endpoint отдельно фиксируют idempotency key, допустимый повтор и способ сверить результат. Модель также не говорит, нужно ли использовать URL-версию, media type или заголовок. Выбор зависит от числа потребителей, размера breaking change, маршрутизации и способа поддержки клиентов.

\n

OpenAPI описывает документ и его контракт, но поле openapi — это версия спецификации, а info.version — версия описываемого API. Ни одно поле не заменяет тест reader-а. HTTP задаёт семантику request, response и status code, но смысл confirmed или deliveryWindow принадлежит вашему договору.

\n

Критерий готовности

\n

Изменение готово к выпуску, когда команда может воспроизвести его на сохранённых примерах и получить четыре проверяемых результата: v1 request принимается новым сервером; v1 reader принимает обещанный response; v2 request валидирует отсутствие, null, допустимые значения и неизвестный ключ; candidate removal или semantic change автоматически отклоняется. Для каждого результата есть вход, ожидаемый ответ и владелец исправления. Пока хотя бы один пункт не доказан, старый contract не удаляют.

\n

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

" }