Files
progcode/editorial/agent-rewrites/228.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
18 KiB
JSON
Raw 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 — это управление договором между writer и reader. Номер в 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> может направить запрос на другой обработчик, но не мигрирует сохранённые приложения. Поэтому сначала фиксируют фактический contract surface: кто отправляет 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>Строгий parser может отвергнуть новый ключ</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>. Это ограниченный пример, а не описание 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 (!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}</code></pre>\n<p>Функция показывает направление проверки. Новый сервер читает старый request. Он принимает обязательный код без нового поля, но не принимает опечатку <code>deliveryPrefrence</code>. Он различает отсутствие, <code>null</code> и строку. В реальном сервисе эти правила должны жить в его валидаторе и тестах. Здесь код служит учебной моделью.</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: '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</code></pre>\n<p>Последняя строка не универсальна. Данный reader обращается только к нужным полям, поэтому в этой модели новый ключ ему не мешает. Другой клиент может десериализовать JSON строгой схемой и отклонить тот же ответ. Проверять нужно поведение своего reader-а.</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>Parser строгий, хотя изменение считали 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 описывает документ и его контракт, но поле <code>openapi</code> — это версия спецификации, а <code>info.version</code> — версия описываемого API. Ни одно поле не заменяет тест 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> — различает версию спецификации и <code>info.version</code> API.</li><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html' target='_blank' rel='noopener'>RFC 9110: HTTP Semantics</a> — описывает request, response, representations и 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> — задаёт сигнал о будущем sunset ресурса.</li></ul>"
}