Files
progcode/editorial/agent-rewrites/227.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": 227,
"slug": "editorial-2021-09-mechanism-api-versioning",
"title": "Совместимость API: проверяйте request и response отдельно",
"excerpt": "Одинаковая JSON-схема не гарантирует совместимость: значение может сменить смысл, а новое поле — потеряться в старом сервере. Разбираем направления проверки, безопасное additive-изменение и остановку несовместимого retirement.",
"contentHtml": "<p>Сбой часто выглядит безобидно: сервер отвечает HTTP 200, JSON успешно разбирается, но старый клиент выбирает не ту ветку. Например, v1 ожидал <code>status: 'confirmed'</code>, а получил <code>status: 'accepted'</code>. Тип и имя поля не изменились. Изменился смысл. Пользователь может увидеть неверный статус заказа, а мониторинг отметит запрос как успешный.</p>\n<p>Есть и обратный случай. Новый клиент отправляет <code>deliveryPreference</code>, а старый сервер молча выбрасывает неизвестный ключ. Опечатка <code>deliveryPrefrence</code> выглядит для человека почти так же, но намерение теряется без ошибки. Цена такой ошибки — не только один неправильный ответ. Команда теряет границу между старым и новым контрактом, а затем пытается лечить её новым URL, повтором запроса или срочным откатом.</p>\n<p>Тезис статьи простой: совместимость API — это проверяемый договор между конкретным writer и reader. Response нужно проверять от нового сервера к старому клиенту. Request — от нового клиента к текущему серверу. Одна проверка схемы не заменяет эти два теста и не знает прикладной смысл строковых значений.</p>\n<h2>Механизм: кто кого читает</h2>\n<p>Рассмотрим учебный endpoint <code>POST /api/orders/{orderId}/confirm</code>. Ответ v1 содержит обязательные поля <code>id</code>, <code>status</code> и <code>totalMinor</code>. Клиент v1 принимает только значение <code>confirmed</code>. Ответ v2 может дополнительно содержать <code>deliveryWindow</code>. Это additive-изменение безопасно только для reader, который игнорирует неизвестное поле после проверки обязательных полей.</p>\n<p>У запроса другие правила. <code>confirmationCode</code> обязателен. <code>deliveryPreference</code> optional. Если ключ отсутствует, сервер сохраняет прежнее предпочтение. Если ключ равен <code>null</code>, сервер очищает его. Неизвестный ключ сервер отклоняет. Так опечатка становится наблюдаемым отказом, а не тихой потерей намерения.</p>\n<table><caption>Два направления совместимости</caption><thead><tr><th scope='col'>Поток</th><th scope='col'>Допустимое изменение</th><th scope='col'>Что проверяем</th><th scope='col'>Стоп-сигнал</th></tr></thead><tbody><tr><td>v1 client &larr; current response</td><td>Добавлен optional <code>deliveryWindow</code></td><td>v1 сохраняет прежний view и принимает <code>totalMinor</code></td><td>Исчезло <code>totalMinor</code> или изменился смысл <code>status</code></td></tr><tr><td>v2 client &larr; current response</td><td><code>deliveryWindow</code> absent, <code>null</code> или object</td><td>Три состояния различаются</td><td>Строка или неполный object</td></tr><tr><td>v1 client &rarr; current service</td><td>Нет нового optional ключа</td><td>Запрос принят, preference не меняется</td><td>Старый клиент обязан прислать новое поле</td></tr><tr><td>v2 client &rarr; current service</td><td>Передан допустимый <code>deliveryPreference</code></td><td>Значение проходит словарь</td><td>Unknown key или опечатка</td></tr></tbody></table>\n<p>В этом договоре отсутствие и <code>null</code> не равны. В response отсутствие означает, что representation не предлагает окно. <code>null</code> означает, что сервис проверил условие и сообщает: окна нет. В request отсутствие сохраняет прежнее значение, а <code>null</code> очищает его. Такая семантика должна быть написана рядом со схемой и покрыта проверкой. Сам JSON не объясняет намерение.</p>\n<h2>Конкретный пример</h2>\n<p>Reader должен проверять обязательные поля и смысл значения, а не только наличие ключей. Ниже — учебный JavaScript-фрагмент. Он не обращается к HTTP и не доказывает поведение production-сервиса. Его задача — показать границу, которую следует перенести в контрактный тест конкретного reader.</p>\n<pre><code>function readV1Response(body) {\n for (const key of ['id', 'status', 'totalMinor']) {\n if (!(key in body)) return { ok: false, reason: 'missing:' + key };\n }\n if (body.status !== 'confirmed') {\n return { ok: false, reason: 'unsupported-status-semantics' };\n }\n if (!Number.isInteger(body.totalMinor)) {\n return { ok: false, reason: 'invalid-totalMinor' };\n }\n return {\n ok: true,\n view: { id: body.id, status: body.status, totalMinor: body.totalMinor },\n };\n}\n\nconst additive = {\n id: 'order-417', status: 'confirmed', totalMinor: 1500,\n deliveryWindow: { from: '2021-09-14T10:00:00Z', to: '2021-09-14T12:00:00Z' },\n};\n\nconsole.log(readV1Response(additive).ok); // true: лишнее поле не попало в view\nconsole.log(readV1Response({\n id: 'order-417', status: 'confirmed', amountMinor: 1500,\n}).ok); // false: totalMinor нельзя переименовать молча\nconsole.log(readV1Response({\n id: 'order-417', status: 'accepted', totalMinor: 1500,\n}).ok); // false: одинаковый type не сохраняет смысл\n</code></pre>\n<p>Положительный результат относится только к этому reader: он выбирает известные поля и игнорирует новое response-поле. Нельзя объявлять additive-изменение универсально безопасным. Клиент, который хранит весь объект, использует строгую схему или делает exhaustive match, может сломаться от добавленного ключа. Сначала нужно проверить фактическое поведение потребителя.</p>\n<p>Для request проверяется другая функция. Она принимает старый body без optional поля, принимает новый body с допустимым значением и отклоняет неизвестный key. Это защищает от опечаток. Но строгий request validator не даёт права требовать новое поле от всех старых клиентов: обязательность определяется контрактом конкретной операции и периодом поддержки.</p>\n<figure><img src='/assets/editorial/2021/api-versioning-compatibility-2021.svg' alt='Матрица совместимости v1 и v2 клиентов: additive deliveryWindow проходит, удаление totalMinor и semantic change status отклоняются, request проверяется отдельным направлением.' loading='lazy' /><figcaption>Рисунок 1. Совместимость проверяется на пересечении reader и writer. Добавление окна проходит только при сохранении старого view; удаление обязательного поля и смена смысла статуса останавливают переход.</figcaption></figure>\n<h2>Симптом &rarr; причина &rarr; проверка &rarr; действие</h2>\n<table><caption>Диагностическая карта изменения API</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>HTTP 200, но клиент показывает другую ветку</td><td><code>status</code> сохранил type, но сменил смысл</td><td>Прогнать старый reader на candidate response и проверить допустимые значения</td><td>Вернуть прежнюю семантику или подготовить явный переход; не считать schema diff достаточным</td></tr><tr><td>Старый клиент не видит сумму</td><td><code>totalMinor</code> удалён или переименован в <code>amountMinor</code></td><td>Сравнить обязательные поля v1 с новым body</td><td>Оставить legacy-поле; retirement остановить до миграции всех readers</td></tr><tr><td>Новое предпочтение не применилось</td><td>Сервер молча проигнорировал unknown request key или опечатку</td><td>Проверить список ключей и различить отсутствие, <code>null</code> и значение</td><td>Отклонять неизвестные ключи и исправить request contract</td></tr><tr><td>v2 не показывает окно</td><td>Absent и <code>null</code> склеились или object неполный</td><td>Прогнать reader на трёх representation: absent, <code>null</code>, object</td><td>Зафиксировать семантику состояний и сохранить старый ответ до её проверки</td></tr></tbody></table>\n<h2>Почему одного /v2 недостаточно</h2>\n<p>Новый URL отделяет документацию или deployment, но сам не переводит клиента. Старый endpoint можно сломать внутри прежнего адреса. Новый endpoint можно сохранить совместимым. Поэтому номер в URL — инструмент маршрутизации, а не доказательство договора.</p>\n<p>Schema diff полезен для структурных изменений. Он может заметить исчезновение required key. Он не знает, что <code>confirmed</code> и <code>accepted</code> означают разные переходы, что unknown request key должен быть ошибкой или что <code>null</code> получил отдельное бизнес-значение. Эти правила принадлежат reader, writer и операции.</p>\n<p>OpenAPI помогает записать input и output, но описание не запускает проверку старого клиента. В OpenAPI 3.1 Schema Object описывает форму данных. Контрактный тест должен дополнить его реальными samples и отрицательными случаями: удалённым обязательным полем, изменённым значением, неизвестным request key, <code>null</code> и неправильной формой object.</p>\n<h2>Порядок действий</h2>\n<ol><li>Назовите endpoint, method, поддерживаемые readers и writers. Не начинайте с выбора <code>/v2</code>.</li><li>Зафиксируйте базовые request и response samples без секретов. Отдельно запишите обязательные поля, optional поля, допустимые значения и смысл отсутствия.</li><li>Разделите тесты на два направления: старый client читает новый response; текущий service принимает старый и новый request.</li><li>Добавьте положительный additive-case. Проверьте, что v1 сохраняет прежний view, а v2 различает absent, <code>null</code> и object.</li><li>Добавьте отрицательные cases: удаление <code>totalMinor</code>, смена смысла <code>status</code>, неизвестный ключ и неправильная форма <code>deliveryWindow</code>.</li><li>Проверьте фактические parser rules. Не переносите политику «игнорировать unknown response field» на клиентов, для которых она не доказана.</li><li>Перед retirement прогоните candidate на всех поддерживаемых readers. Если обязательное поле исчезло или семантика изменилась, остановите retirement без изменения legacy-контракта.</li><li>После успешной проверки объявите границу поддержки: representation, request, срок, owner и условие повторной проверки. Сигнал <code>Sunset</code> может дополнить коммуникацию, но не заменяет миграцию.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Учебный пример не проверяет framework serialization, gateway, SDK, авторизацию, cache, rate limit, retries или фактическое распространение мобильного клиента. Он не даёт production-результатов и не доказывает SLA. Для state-changing endpoint отдельно проверяйте idempotency и повторную отправку: совместимость body не делает повтор безопасным.</p>\n<p>Если candidate удаляет <code>totalMinor</code>, не добавляйте немедленно новый URL и не подменяйте поле на лету без владельца. Оставьте legacy response, сохраните факт отказа и выясните, какой reader ещё зависит от поля. Если request validator нашёл <code>deliveryPrefrence</code>, не повторяйте запрос вслепую: сначала исправьте имя и определите, применилось ли состояние. Отрицательный путь должен прекращать изменение, а не маскировать нарушение.</p>\n<p>Готовность доказана, когда для каждого поддерживаемого направления есть базовый sample, additive-case и отрицательный case. Старый reader принимает новый response только при сохранении обязательных полей и смысла значений. Текущий service принимает старый request без нового optional key. Unknown request key, removal required field и semantic change завершаются понятным отказом до retirement. Команда может повторить эти проверки на фиксированных входах и назвать owner каждого оставшегося потребителя.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://spec.openapis.org/oas/v3.1.0.html' target='_blank' rel='noopener noreferrer'>OpenAPI Specification 3.1.0</a> — официальное описание структуры HTTP API и Schema Object; спецификация не запускает контрактный тест и не выводит прикладной смысл значений автоматически.</li><li><a href='https://www.rfc-editor.org/rfc/rfc8594.html' target='_blank' rel='noopener noreferrer'>RFC 8594: The Sunset HTTP Header Field</a> — первичный документ IETF о сигнале вероятной будущей недоступности ресурса; заголовок не гарантирует дату вывода и не заменяет список потребителей и проверку миграции.</li></ul>"
}