From 5d204128068000146ee2bef178be309b61ee0153 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 20:45:35 +0300 Subject: [PATCH] Rewrite article 228 on API versioning --- editorial/agent-rewrites/228.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/228.json b/editorial/agent-rewrites/228.json index 1d3acac..a1229ee 100644 --- a/editorial/agent-rewrites/228.json +++ b/editorial/agent-rewrites/228.json @@ -3,5 +3,5 @@ "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

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

" + "contentHtml": "

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

\n

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

\n

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

\n

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

\n

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

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

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

\n

Возьмём учебный endpoint POST /api/orders/{orderId}/confirm. Это ограниченная in-memory модель, а не описание 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 (typeof request.confirmationCode !== 'string' || !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\nconsole.assert(acceptRequest({ confirmationCode: 'c-17' }).ok);\nconsole.assert(acceptRequest({ confirmationCode: 'c-17', deliveryPreference: null }).preference === 'clear');\nconsole.assert(acceptRequest({ confirmationCode: 'c-17', deliveryPrefrence: 'weekday' }).reason === 'unknown-field');
\n

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

\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: '2021-08-03T10:00:00Z', to: '2021-08-03T12:00:00Z' },\n};\n\nconst removalResponse = { id: 'order-17', status: 'confirmed' };\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);   // проходит: reader не обращается к новому ключу\n// readV1(removalResponse);  // выбрасывает ошибку: totalMinor обязателен
\n

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

\n

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

\n
СимптомПричинаПроверкаДействие
Экран не показывает суммуУдалено или переименовано обязательное полеСравнить base и candidate response на v1 reader-еВернуть legacy field и остановить retirement
HTTP 200, но неверная ветка клиентаИзменён смысл допустимого значения statusПроверить переходы по значениям, а не только JSON typeСохранить старый смысл или выпустить явный новый contract
Новое предпочтение не применилосьСтарый сервер отбросил неизвестное request-полеПроверить ответ валидатора и итоговое состояниеДождаться поддержки writer-а или использовать отдельный маршрут
Сервис принимает опечаткуUnknown request fields разрешены молчаОтправить deliveryPrefrence и проверить отказОтклонять неизвестные поля с понятной причиной
Старый клиент падает после добавления поляВалидатор строгий, хотя изменение считали 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 описывает интерфейс HTTP API, но поле openapi — это версия спецификации OpenAPI, а info.version — версия самого OpenAPI-документа; это не идентификатор версии реализации. Ни одно поле не заменяет тест reader-а. HTTP задаёт семантику request, response и status code, но смысл confirmed или deliveryWindow принадлежит вашему договору.

\n

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

\n

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

\n

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

" }