8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"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 ← 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 ← current response</td><td><code>deliveryWindow</code> absent, <code>null</code> или object</td><td>Три состояния различаются</td><td>Строка или неполный object</td></tr><tr><td>v1 client → current service</td><td>Нет нового optional ключа</td><td>Запрос принят, preference не меняется</td><td>Старый клиент обязан прислать новое поле</td></tr><tr><td>v2 client → 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>Симптом → причина → проверка → действие</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>"
|
||
}
|