{ "index": 227, "slug": "editorial-2021-09-mechanism-api-versioning", "title": "Совместимость API: проверяйте request и response отдельно", "excerpt": "Одинаковая JSON-схема не гарантирует совместимость: значение может сменить смысл, а новое поле — потеряться в старом сервере. Разбираем направления проверки, безопасное additive-изменение и остановку несовместимого retirement.", "contentHtml": "
Сбой часто выглядит безобидно: сервер отвечает HTTP 200, JSON успешно разбирается, но старый клиент выбирает не ту ветку. Например, v1 ожидал status: 'confirmed', а получил status: 'accepted'. Тип и имя поля не изменились. Изменился смысл. Пользователь может увидеть неверный статус заказа, а мониторинг отметит запрос как успешный.
Есть и обратный случай. Новый клиент отправляет deliveryPreference, а текущий сервер молча выбрасывает неизвестный ключ. Опечатка deliveryPrefrence выглядит для человека почти так же, но намерение теряется без ошибки. Цена такой ошибки — не только один неправильный ответ. Команда теряет границу между старым и новым контрактом, а затем пытается лечить её новым URL, повтором запроса или срочным откатом.
Тезис статьи простой: совместимость API — это проверяемый договор между конкретными writer и reader. Response нужно проверять от нового сервера к старому клиенту. Request — от нового клиента к текущему серверу. Одна проверка схемы не заменяет эти два теста и не знает прикладной смысл строковых значений.
\nРассмотрим учебный endpoint POST /api/orders/{orderId}/confirm. Ответ v1 содержит обязательные поля id, status и totalMinor. Клиент v1 принимает только значение confirmed. Ответ v2 может дополнительно содержать deliveryWindow. Это additive-изменение безопасно только для reader, который игнорирует неизвестное поле после проверки обязательных полей.
У запроса другие правила. confirmationCode обязателен. deliveryPreference — optional-поле с учебным словарём courier и pickup. Если ключ отсутствует, сервер сохраняет прежнее предпочтение. Если ключ равен null, сервер очищает его. Неизвестный ключ сервер отклоняет. Так опечатка становится наблюдаемым отказом, а не тихой потерей намерения.
| Поток | Допустимое изменение | Что проверяем | Стоп-сигнал |
|---|---|---|---|
| v1 client ← current response | Добавлен optional deliveryWindow | v1 сохраняет прежний view и принимает totalMinor | Исчезло totalMinor или изменился смысл status |
| v2 client ← current response | deliveryWindow absent, null или object | Три состояния различаются | Строка или неполный object |
| v1 client → current service | Нет нового optional ключа | Запрос принят, preference не меняется | Старый клиент обязан прислать новое поле |
| v2 client → current service | Передан допустимый deliveryPreference | Значение проходит словарь | Unknown key или опечатка |
В этом договоре отсутствие и null не равны. В response отсутствие означает, что representation не предлагает окно. null означает, что сервис проверил условие и сообщает: окна нет. В request отсутствие сохраняет прежнее значение, а null очищает его. Такая семантика должна быть написана рядом со схемой и покрыта проверкой. Сам JSON не объясняет намерение.
Ниже — учебный JavaScript-фрагмент без HTTP, базы и настоящего сервиса. Он фиксирует две разные границы: readV1Response проверяет response нового сервера для старого клиента, а validateConfirmRequest проверяет request нового клиента на входе текущего сервиса. Это детерминированная модель для переноса в контрактный тест конкретного reader и writer, а не результат production-запуска.
const preferences = new Set(['courier', 'pickup']);\n\nfunction 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\nfunction validateConfirmRequest(body) {\n const allowed = new Set(['confirmationCode', 'deliveryPreference']);\n const unknown = Object.keys(body).filter((key) => !allowed.has(key));\n if (unknown.length) return { ok: false, reason: 'unknown:' + unknown[0] };\n if (typeof body.confirmationCode !== 'string' || body.confirmationCode === '') {\n return { ok: false, reason: 'invalid:confirmationCode' };\n }\n if ('deliveryPreference' in body) {\n const value = body.deliveryPreference;\n if (value !== null && !preferences.has(value)) {\n return { ok: false, reason: 'invalid:deliveryPreference' };\n }\n }\n const preference = !('deliveryPreference' in body)\n ? 'keep'\n : body.deliveryPreference === null ? 'clear' : 'set';\n return { ok: true, preference };\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.assert(readV1Response(additive).ok);\nconsole.assert(!readV1Response({\n id: 'order-417', status: 'accepted', totalMinor: 1500,\n}).ok);\nconsole.assert(validateConfirmRequest({ confirmationCode: '417' }).preference === 'keep');\nconsole.assert(validateConfirmRequest({\n confirmationCode: '417', deliveryPreference: null,\n}).preference === 'clear');\nconsole.assert(!validateConfirmRequest({\n confirmationCode: '417', deliveryPrefrence: 'pickup',\n}).ok);\nПоложительный response-результат относится только к этому reader: он выбирает известные поля и игнорирует новое поле. Request-результаты относятся только к зафиксированной политике учебного сервиса: отсутствие означает keep, null — clear, а unknown key — отказ. Клиент, который хранит весь response-объект, использует строгую схему или делает exhaustive match, может сломаться от добавленного ключа. Клиент с другой политикой полей потребует другого теста.
Здесь намеренно проверяются и положительные, и отрицательные случаи. Переименование totalMinor в amountMinor должно завершиться отказом старого reader. Значение accepted должно быть отказом даже при правильном JSON-типе. Для request новый optional-ключ разрешён только после проверки словаря; строгий validator не даёт права требовать его от всех старых клиентов.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| HTTP 200, но клиент показывает другую ветку | status сохранил type, но сменил смысл | Прогнать старый reader на candidate response и проверить допустимые значения | Вернуть прежнюю семантику или подготовить явный переход; не считать schema diff достаточным |
| Старый клиент не видит сумму | totalMinor удалён или переименован в amountMinor | Сравнить обязательные поля v1 с новым body | Оставить legacy-поле; retirement остановить до миграции всех readers |
| Новое предпочтение не применилось | Сервер молча проигнорировал unknown request key или опечатку | Проверить список ключей и различить отсутствие, null и значение | Отклонять неизвестные ключи и исправить request contract |
| v2 не показывает окно | Absent и null склеились или object неполный | Прогнать reader на трёх representation: absent, null, object | Зафиксировать семантику состояний и сохранить старый ответ до её проверки |
Новый URL отделяет документацию или deployment, но сам не переводит клиента. Старый endpoint можно сломать внутри прежнего адреса. Новый endpoint можно сохранить совместимым. Поэтому номер в URL — инструмент маршрутизации, а не доказательство договора.
\nSchema diff полезен для структурных изменений. Он может заметить исчезновение required key. Он не знает, что confirmed и accepted означают разные переходы, что unknown request key должен быть ошибкой или что null получил отдельное бизнес-значение. Эти правила принадлежат reader, writer и операции.
OpenAPI помогает описать paths, operations и Schema Object для input/output, но описание не запускает проверку старого клиента. В OpenAPI 3.1 Schema Object описывает форму данных. Контрактный тест должен дополнить её фиксированными samples и отрицательными случаями: удалённым обязательным полем, изменённым значением, неизвестным request key, null и неправильной формой object.
Не смешивайте версию спецификации OpenAPI с версией самого API. В сентябре 2021 года существовали зафиксированные редакции OpenAPI 3.0.3 и 3.1.0, но выбор редакции спецификации не определял допустимые значения status в нашем учебном endpoint. Это прикладное правило должно жить в договоре операции и тестах потребителей.
/v2.null и object.totalMinor, смена смысла status, неизвестный ключ и неправильная форма deliveryWindow.Sunset может дополнить коммуникацию, но не заменяет миграцию.Учебный пример не проверяет framework serialization, gateway, SDK, авторизацию, cache, rate limit, retries или фактическое распространение мобильного клиента. Он не даёт production-результатов и не доказывает SLA. Для state-changing endpoint отдельно проверяйте idempotency и повторную отправку: совместимость body не делает повтор безопасным.
\nЕсли candidate удаляет totalMinor, не добавляйте немедленно новый URL и не подменяйте поле на лету без владельца. Оставьте legacy response, сохраните факт отказа и выясните, какой reader ещё зависит от поля. Если request validator нашёл deliveryPrefrence, не повторяйте запрос вслепую: сначала исправьте имя и определите, применилось ли состояние. Отрицательный путь должен прекращать изменение, а не маскировать нарушение.
Готовность доказана, когда для каждого поддерживаемого направления есть базовый sample, additive-case и отрицательный case. Старый reader принимает новый response только при сохранении обязательных полей и смысла значений. Текущий service принимает старый request без нового optional key. Unknown request key, removal required field и semantic change завершаются понятным отказом до retirement. Команда может повторить эти проверки на фиксированных входах и назвать owner каждого оставшегося потребителя.
\nSunset не гарантирует дату вывода и не заменяет список потребителей и проверку миграции.