{ "index": 228, "slug": "editorial-2021-09-practice-api-versioning", "title": "Версионирование API: как изменить контракт и не сломать клиентов", "excerpt": "Пошаговая схема для изменения request и response: найти реальный разрыв, проверить старого и нового потребителя, а опасное удаление поставить на паузу.", "contentHtml": "
Рассмотрим учебный сценарий: после выпуска новой версии API старое мобильное приложение перестало показывать сумму заказа. HTTP-ответ оставался успешным, сервер не сообщал об ошибке, а в JSON вместо обязательного totalMinor появилось новое поле amountMinor. Пользователь видел пустой экран или не мог подтвердить заказ. Цена ошибки — не только сломанный экран: это срочный релиз, ручная сверка данных и риск повторить тот же разрыв в другом клиенте.
Тезис простой: версионирование API — это управление договором между отправителем и получателем данных. Номер в URL помогает разделить маршруты, но не доказывает совместимость. Перед изменением нужно отдельно проверить request и response, обязательные поля, допустимые значения и смысл ответа. Если проверка не проходит, старый договор остаётся действующим, а удаление откладывается.
\nВ одной фразе «мы обновили API» часто смешивают четыре слоя. Первый — версия документа OpenAPI. Второй — публичный маршрут, например /api/orders или /api/v2/orders. Третий — форма сообщения: ключи, типы, обязательность и значения. Четвёртый — поведение клиента: какую ветку он выбирает после статуса confirmed, что делает при отсутствии поля и как обрабатывает неизвестный ключ.
Эти слои связаны, но не заменяют друг друга. OpenAPI может описать поле, но не знает, что клиент использует status как разрешение показать кнопку. Сегмент /v2 может направить запрос на другой обработчик, но не мигрирует сохранённые приложения. Поэтому сначала фиксируют фактическую границу договора: кто отправляет request, кто читает response, какие значения обязательны и какое отсутствие считается нормальным.
| Слой | Пример | Риск | Проверка |
|---|---|---|---|
| Маршрут | /api/orders → /api/v2/orders | Старый клиент продолжает ходить в прежний маршрут | Составить список потребителей каждого маршрута |
| Response | Добавить deliveryWindow | Валидатор со строгой схемой может отвергнуть новый ключ | Прогнать реальный reader на additive response |
| Request | Добавить deliveryPreference | Старый сервер не знает поле или молча его теряет | Проверить v1 request и каждое новое значение |
| Смысл | confirmed → accepted | Тип остался string, но ветка клиента изменилась | Проверить переходы состояния и пользовательское действие |
Возьмём учебный endpoint POST /api/orders/{orderId}/confirm. Это ограниченная in-memory модель, а не описание production-сервиса. Версия v1 отправляет только строковый confirmationCode. В этом договоре новый сервер принимает такой request. Версия v2 может добавить deliveryPreference. Отсутствие поля означает «не менять настройку», null — «очистить настройку», а строки weekday и weekend задают значение.
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.
Response проверяют в обратную сторону. Старый клиент должен прочитать текущий ответ, пока команда обещает его поддержку. В базовом ответе обязательны id, status и totalMinor. Новое поле deliveryWindow можно добавить только после проверки конкретного v1 reader-а. Если reader строго валидирует набор ключей, additive change тоже ломает договор. Если он игнорирует неизвестные поля, это свойство нужно зафиксировать тестом, а не считать общим правилом.
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 синтаксически разобрался.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Экран не показывает сумму | Удалено или переименовано обязательное поле | Сравнить 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-а |
Безопасный rollout не начинается с удаления старого ключа. Сначала фиксируют базовый response и v1 request. Затем добавляют новое поле в response и проверяют старого reader-а. После этого проверяют v2 request на новом сервере. Только потом обсуждают retirement. Удаление totalMinor проходит через тот же v1 compatibility test. Если тест отклоняет candidate response, legacy field остаётся, а выпуск останавливается до изменения состояния.
Это отрицательный путь, а не исключение. Отказ теста сообщает: команда пока не доказала совместимость. Нельзя превращать его в разрешение на выпуск с флагом «проверим позже». Сохраняют прежний response, записывают вход и результат проверки, затем уточняют владельца клиента или договор миграции. Если нужный клиент неизвестен, это причина расширить инвентаризацию, а не причина считать его отсутствующим.
\nДля вывода из эксплуатации можно сообщить клиентам о будущем сроке. Заголовок Sunset из RFC 8594 помогает передать намерение для ресурса, но сам по себе не доказывает миграцию и не отключает endpoint. Нужны также список потребителей, срок поддержки, новая форма ответа и проверяемое условие удаления.
null, допустимые значения и опечатку неизвестного ключа.Учебный endpoint не проверяет настоящий HTTP-трафик, gateway, кеш, авторизацию, SDK, базу и несколько одновременных обновлений. Он не отвечает на вопрос о retry для операции, которая меняет состояние. Для такого endpoint отдельно фиксируют idempotency key, допустимый повтор и способ сверить результат. Модель также не говорит, нужно ли использовать URL-версию, media type или заголовок. Выбор зависит от числа потребителей, размера breaking change, маршрутизации и способа поддержки клиентов.
\nOpenAPI описывает интерфейс HTTP API, но поле openapi — это версия спецификации OpenAPI, а info.version — версия самого OpenAPI-документа; это не идентификатор версии реализации. Ни одно поле не заменяет тест reader-а. HTTP задаёт семантику request, response и status code, но смысл confirmed или deliveryWindow принадлежит вашему договору.
Изменение готово к выпуску, когда команда может воспроизвести его на сохранённых примерах и получить четыре проверяемых результата: v1 request принимается новым сервером; v1 reader принимает обещанный response; v2 request валидирует отсутствие, null, допустимые значения и неизвестный ключ; candidate removal или semantic change автоматически отклоняется. Для каждого результата есть вход, ожидаемый ответ и владелец исправления. Пока хотя бы один пункт не доказан, старый contract не удаляют.
openapi и info.version и описывает интерфейс HTTP API.