Files

8 lines
19 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 228,
"slug": "editorial-2021-09-practice-api-versioning",
"title": "Версионирование API: как изменить контракт и не сломать клиентов",
"excerpt": "Пошаговая схема для изменения request и response: найти реальный разрыв, проверить старого и нового потребителя, а опасное удаление поставить на паузу.",
"contentHtml": "<p>Рассмотрим учебный сценарий: после выпуска новой версии API старое мобильное приложение перестало показывать сумму заказа. HTTP-ответ оставался успешным, сервер не сообщал об ошибке, а в JSON вместо обязательного <code>totalMinor</code> появилось новое поле <code>amountMinor</code>. Пользователь видел пустой экран или не мог подтвердить заказ. Цена ошибки — не только сломанный экран: это срочный релиз, ручная сверка данных и риск повторить тот же разрыв в другом клиенте.</p>\n<p>Тезис простой: версионирование API — это управление договором между отправителем и получателем данных. Номер в URL помогает разделить маршруты, но не доказывает совместимость. Перед изменением нужно отдельно проверить request и response, обязательные поля, допустимые значения и смысл ответа. Если проверка не проходит, старый договор остаётся действующим, а удаление откладывается.</p>\n<h2>Что именно считается версией</h2>\n<p>В одной фразе «мы обновили API» часто смешивают четыре слоя. Первый — версия документа OpenAPI. Второй — публичный маршрут, например <code>/api/orders</code> или <code>/api/v2/orders</code>. Третий — форма сообщения: ключи, типы, обязательность и значения. Четвёртый — поведение клиента: какую ветку он выбирает после статуса <code>confirmed</code>, что делает при отсутствии поля и как обрабатывает неизвестный ключ.</p>\n<p>Эти слои связаны, но не заменяют друг друга. OpenAPI может описать поле, но не знает, что клиент использует <code>status</code> как разрешение показать кнопку. Сегмент <code>/v2</code> может направить запрос на другой обработчик, но не мигрирует сохранённые приложения. Поэтому сначала фиксируют фактическую границу договора: кто отправляет request, кто читает response, какие значения обязательны и какое отсутствие считается нормальным.</p>\n<table><caption>Слои изменения и проверка перед выпуском</caption><thead><tr><th>Слой</th><th>Пример</th><th>Риск</th><th>Проверка</th></tr></thead><tbody><tr><td>Маршрут</td><td><code>/api/orders</code> → <code>/api/v2/orders</code></td><td>Старый клиент продолжает ходить в прежний маршрут</td><td>Составить список потребителей каждого маршрута</td></tr><tr><td>Response</td><td>Добавить <code>deliveryWindow</code></td><td>Валидатор со строгой схемой может отвергнуть новый ключ</td><td>Прогнать реальный reader на additive response</td></tr><tr><td>Request</td><td>Добавить <code>deliveryPreference</code></td><td>Старый сервер не знает поле или молча его теряет</td><td>Проверить v1 request и каждое новое значение</td></tr><tr><td>Смысл</td><td><code>confirmed</code> → <code>accepted</code></td><td>Тип остался string, но ветка клиента изменилась</td><td>Проверить переходы состояния и пользовательское действие</td></tr></tbody></table>\n<h2>Модель на одном endpoint</h2>\n<p>Возьмём учебный endpoint <code>POST /api/orders/{orderId}/confirm</code>. Это ограниченная in-memory модель, а не описание production-сервиса. Версия v1 отправляет только строковый <code>confirmationCode</code>. В этом договоре новый сервер принимает такой request. Версия v2 может добавить <code>deliveryPreference</code>. Отсутствие поля означает «не менять настройку», <code>null</code> — «очистить настройку», а строки <code>weekday</code> и <code>weekend</code> задают значение.</p>\n<pre><code>function acceptRequest(request) {\n const known = new Set(['confirmationCode', 'deliveryPreference']);\n const unknown = Object.keys(request).filter((key) =&gt; !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');</code></pre>\n<p>Функция показывает направление проверки. Новый сервер принимает обязательный непустой код без нового поля, но не принимает опечатку <code>deliveryPrefrence</code>. Он различает отсутствие, <code>null</code> и строку. В реальном сервисе эти правила должны жить в его валидаторе и тестах. Здесь код служит учебной моделью: три <code>console.assert</code> дают минимальную проверку положительного, очистившего и ошибочного request.</p>\n<p>Response проверяют в обратную сторону. Старый клиент должен прочитать текущий ответ, пока команда обещает его поддержку. В базовом ответе обязательны <code>id</code>, <code>status</code> и <code>totalMinor</code>. Новое поле <code>deliveryWindow</code> можно добавить только после проверки конкретного v1 reader-а. Если reader строго валидирует набор ключей, additive change тоже ломает договор. Если он игнорирует неизвестные поля, это свойство нужно зафиксировать тестом, а не считать общим правилом.</p>\n<pre><code>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 обязателен</code></pre>\n<p>В этой модели последний вызов действительно отклонит ответ без <code>totalMinor</code>, а additive response пройдёт, потому что reader обращается только к нужным полям. Другой клиент может применять схему с запретом неизвестных полей и отклонить тот же additive response. Проверять нужно поведение своего reader-а на полном candidate response, а не делать вывод по тому, что JSON синтаксически разобрался.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Экран не показывает сумму</td><td>Удалено или переименовано обязательное поле</td><td>Сравнить base и candidate response на v1 reader-е</td><td>Вернуть legacy field и остановить retirement</td></tr><tr><td>HTTP 200, но неверная ветка клиента</td><td>Изменён смысл допустимого значения <code>status</code></td><td>Проверить переходы по значениям, а не только JSON type</td><td>Сохранить старый смысл или выпустить явный новый contract</td></tr><tr><td>Новое предпочтение не применилось</td><td>Старый сервер отбросил неизвестное request-поле</td><td>Проверить ответ валидатора и итоговое состояние</td><td>Дождаться поддержки writer-а или использовать отдельный маршрут</td></tr><tr><td>Сервис принимает опечатку</td><td>Unknown request fields разрешены молча</td><td>Отправить <code>deliveryPrefrence</code> и проверить отказ</td><td>Отклонять неизвестные поля с понятной причиной</td></tr><tr><td>Старый клиент падает после добавления поля</td><td>Валидатор строгий, хотя изменение считали additive</td><td>Прогнать реальный parser на полном candidate response</td><td>Сохранить форму ответа или расширить поддержку reader-а</td></tr></tbody></table>\n<h2>Rollout и отрицательный путь</h2>\n<p>Безопасный rollout не начинается с удаления старого ключа. Сначала фиксируют базовый response и v1 request. Затем добавляют новое поле в response и проверяют старого reader-а. После этого проверяют v2 request на новом сервере. Только потом обсуждают retirement. Удаление <code>totalMinor</code> проходит через тот же v1 compatibility test. Если тест отклоняет candidate response, legacy field остаётся, а выпуск останавливается до изменения состояния.</p>\n<figure><img src='/assets/editorial/2021/api-versioning-rollout-2021.svg' alt='Маршрут эволюции API: базовый контракт, additive response, v2 request и безопасная пауза перед удалением totalMinor'><figcaption>Рисунок 1. Сначала проверяют additive-изменение и новый request. Удаление обязательного поля останавливается на v1 gate.</figcaption></figure>\n<p>Это отрицательный путь, а не исключение. Отказ теста сообщает: команда пока не доказала совместимость. Нельзя превращать его в разрешение на выпуск с флагом «проверим позже». Сохраняют прежний response, записывают вход и результат проверки, затем уточняют владельца клиента или договор миграции. Если нужный клиент неизвестен, это причина расширить инвентаризацию, а не причина считать его отсутствующим.</p>\n<p>Для вывода из эксплуатации можно сообщить клиентам о будущем сроке. Заголовок <code>Sunset</code> из RFC 8594 помогает передать намерение для ресурса, но сам по себе не доказывает миграцию и не отключает endpoint. Нужны также список потребителей, срок поддержки, новая форма ответа и проверяемое условие удаления.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксируйте request и response, которые реально использует старый клиент. Запишите обязательные поля, типы, значения и поведение при отсутствии.</li><li>Разделите проверку на два направления: новый сервер читает старый request; старый клиент читает новый response.</li><li>Классифицируйте изменение. Добавление ключа, удаление ключа, переименование и смена смысла требуют разных проверок.</li><li>Проверьте additive response конкретным v1 reader-ом. Не делайте вывод по одной схеме OpenAPI.</li><li>Проверьте новый request: отсутствие optional-поля, <code>null</code>, допустимые значения и опечатку неизвестного ключа.</li><li>Добавьте наблюдаемый guard: contract test, проверку на границе сервиса или другой автоматический сигнал, который блокирует опасное изменение.</li><li>Только после зелёных проверок объявите поддержку новой формы. Retirement запускайте последним и оставьте обратимый шаг.</li></ol>\n<h2>Ограничения модели</h2>\n<p>Учебный endpoint не проверяет настоящий HTTP-трафик, gateway, кеш, авторизацию, SDK, базу и несколько одновременных обновлений. Он не отвечает на вопрос о retry для операции, которая меняет состояние. Для такого endpoint отдельно фиксируют idempotency key, допустимый повтор и способ сверить результат. Модель также не говорит, нужно ли использовать URL-версию, media type или заголовок. Выбор зависит от числа потребителей, размера breaking change, маршрутизации и способа поддержки клиентов.</p>\n<p>OpenAPI описывает интерфейс HTTP API, но поле <code>openapi</code> — это версия спецификации OpenAPI, а <code>info.version</code> — версия самого OpenAPI-документа; это не идентификатор версии реализации. Ни одно поле не заменяет тест reader-а. HTTP задаёт семантику request, response и status code, но смысл <code>confirmed</code> или <code>deliveryWindow</code> принадлежит вашему договору.</p>\n<h2>Критерий готовности</h2>\n<p>Изменение готово к выпуску, когда команда может воспроизвести его на сохранённых примерах и получить четыре проверяемых результата: v1 request принимается новым сервером; v1 reader принимает обещанный response; v2 request валидирует отсутствие, <code>null</code>, допустимые значения и неизвестный ключ; candidate removal или semantic change автоматически отклоняется. Для каждого результата есть вход, ожидаемый ответ и владелец исправления. Пока хотя бы один пункт не доказан, старый contract не удаляют.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://spec.openapis.org/oas/v3.1.0.html' target='_blank' rel='noopener'>OpenAPI Specification 3.1.0</a> (15 февраля 2021 года) — различает поле <code>openapi</code> и <code>info.version</code> и описывает интерфейс HTTP API.</li><li><a href='https://www.rfc-editor.org/rfc/rfc7231.html' target='_blank' rel='noopener'>RFC 7231: HTTP/1.1 Semantics and Content</a> (июнь 2014 года) — фиксирует семантику request, response и status codes.</li><li><a href='https://www.rfc-editor.org/rfc/rfc8594.html' target='_blank' rel='noopener'>RFC 8594: The Sunset HTTP Header Field</a> (май 2019 года) — задаёт сигнал о вероятной будущей недоступности ресурса и прямо называет его подсказкой, а не гарантией.</li></ul>"
}