{ "index": 12, "slug": "editorial-2027-09-practice-mentor-series", "title": "Совместимый API-ответ: как поймать breaking change до релиза", "excerpt": "Разбираем ответ HTTP-метода на границе сервиса: какие изменения ломают старого клиента, как отделить форму JSON от состояния системы и чем доказать совместимость.", "contentHtml": "
Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах видны неизвестное значение enum, отсутствие поля или вызов метода у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки — поиск версии клиента, срочный откат и проверка кэшей или данных, если изменение затронуло их формат.
\nПроблема возникает, когда форму ответа считают внутренней деталью. Удаление свойства, изменение типа, добавление обязательного поля и новое значение enum меняют контракт по-разному. Ниже — способ проверить один HTTP-ответ до релиза: сначала зафиксировать границу, затем прогнать старого потребителя и только после этого выбирать совместимое расширение или новую версию.
\nВ учебном примере граница — GET /customers/{id}, статус 200, media type application/json и тело ответа. Направление тоже входит в контракт: request отправляет клиент, response читает клиент. Поэтому обязательное поле в запросе и обязательное поле в ответе нельзя оценивать одним правилом.
OpenAPI описывает HTTP-операцию, её ответы и доступную потребителю форму интерфейса. JSON Schema проверяет экземпляр JSON по типам, обязательным полям и ограничениям. Эти инструменты отвечают на разные части вопроса. Ни один из них сам по себе не доказывает, что фактический handler отдаёт описанное тело, что у пользователя есть право на ресурс или что ревизия записи ещё актуальна.
\nGET /customers/{id}\nAccept: application/json\n\n200 OK\nContent-Type: application/json\n\n{\n "id": "customer-17",\n "revision": 4,\n "state": "active"\n}\nHTTP 200 подтверждает успешную обработку запроса на уровне протокола, но не совместимость представления со старым кодом. Потребитель может ветвить логику по state, строить URL из id и сравнивать revision с локальной версией. Синтаксически правильный JSON всё равно ломает клиент, если изменились тип, допустимые значения или смысл поля.
| Изменение | Что ломается | Проверка | Действие |
|---|---|---|---|
response: поле удалили или переименовали | Старый клиент обращается к property | Найти чтения поля и старые fixtures | Сохранить поле на deprecated-период или выпустить версию |
response: изменили тип или смысл | Десериализатор или бизнес-ветка принимает неверное значение | Проверить тип и поведение старого клиента | Добавить новое поле с новым именем или сохранить семантику |
response: добавили значение enum | Строгий decoder или ветка по умолчанию не знает значение | Прогнать старый код на каждом допустимом значении | Не включать значение в старый контракт или подготовить новую версию |
request: добавили required-поле | Старый отправитель получает отказ | Отправить новую форму без поля | Сделать поле optional, дать default или изменить версию |
response: добавили optional-поле | Обычно ничего, но strict decoder может отклонить неизвестный ключ | Проверить реальную политику неизвестных полей | Зафиксировать поведение decoder и добавить contract-test |
Слово breaking относится не к строке diff, а к конкретному потребителю и направлению обмена. Новое поле в response обычно расширяет контракт, если старый decoder игнорирует неизвестные ключи. Но схема с additionalProperties: false или строгая библиотека могут сделать такое расширение несовместимым. Решение принимают по исполняемому правилу клиента, а не по названию изменения.
Функция ниже получает уже разобранный JavaScript-объект. Она не ходит в сеть, не читает базу и не проверяет право доступа. Валидатор извлекает известные поля и возвращает ясную причину отказа. Дополнительные ключи он не использует и не объявляет допустимыми: политику strict или permissive нужно задать отдельной схемой и тестом.
\nfunction validateCustomerResponse(payload) {\n if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {\n return { ok: false, reason: 'body-must-be-object' };\n }\n\n if (typeof payload.id !== 'string' || payload.id.trim() === '') {\n return { ok: false, reason: 'id-must-be-non-empty-string' };\n }\n\n if (!Number.isInteger(payload.revision) || payload.revision < 1) {\n return { ok: false, reason: 'revision-must-be-positive-integer' };\n }\n\n if (!['active', 'blocked'].includes(payload.state)) {\n return { ok: false, reason: 'state-is-outside-enum' };\n }\n\n return {\n ok: true,\n value: { id: payload.id, revision: payload.revision, state: payload.state },\n };\n}\n\nconst accepted = validateCustomerResponse({\n id: 'customer-17', revision: 4, state: 'active',\n});\nconst unknownState = validateCustomerResponse({\n id: 'customer-17', revision: 4, state: 'deleted',\n});\nconst wrongRevision = validateCustomerResponse({\n id: 'customer-17', revision: '4', state: 'active',\n});\n\nconsole.log(accepted.ok, accepted.value.state);\n// true active\nconsole.log(unknownState.ok, unknownState.reason);\n// false state-is-outside-enum\nconsole.log(wrongRevision.ok, wrongRevision.reason);\n// false revision-must-be-positive-integer\nОтрицательные случаи показывают, где остановился контракт. state: deleted не должен тихо попасть в ветку для активного клиента, а строка "4" не должна превратиться в число без явного правила. Если обязательное поле исчезло, причина должна назвать его. Такой адаптер полезен на границе, но его зелёный результат не заменяет вызов реального HTTP-маршрута.
Content-Type и тело.400 для неверной формы, 403 для отказа в праве, 404 для отсутствующего ресурса, 409 для конфликта состояния и 412 для невыполненного условия If-Match.JSON Schema хорошо описывает документ: типы, обязательность, enum и дополнительные свойства. Она не видит пользователя, базу и время. Ответ с state: active может соответствовать схеме, хотя запись уже заблокирована. revision: 4 не доказывает, что обновление поверх ревизии 3 ещё разрешено.
Состояние и право требуют отдельного протокола. Для авторизации сервис проверяет роль и возвращает согласованный отказ. Для конкурентного обновления он может использовать версию ресурса и условный запрос с If-Match; если условие не выполнено, клиенту нужен отдельный сигнал 412 Precondition Failed. Конфликт доменного состояния может быть 409 Conflict. Не маскируйте эти случаи под 400: форма запроса может быть правильной, а причина отказа — в праве или текущем состоянии.
Есть и отрицательный путь для самой проверки. Схема может пройти, а реальный handler — вернуть другой ответ в исключении или на редком кодовом пути. Поэтому contract-test должен вызвать маршрут и проверить фактические статус, заголовок и тело. Runtime-проверка снижает риск несовместимого payload, но не доказывает, что список потребителей полон.
\nУчебный валидатор не проверяет OpenAPI-документ, сериализацию фреймворка, авторизацию, транзакции, сетевые сбои, кэш и содержимое базы. Он также не решает вопрос обратной совместимости для неизвестного клиента. Эти границы нужно оставить видимыми, иначе локальный зелёный тест создаст ложное чувство безопасности.
\nИзменение готово к выпуску, когда команда может показать четыре доказательства:
\nЕсли одного доказательства нет, результат следует считать непроверенным, даже когда сборка завершилась успешно. Возьмите один настоящий endpoint, заведите для него положительный и отрицательные fixtures, а затем повторите проверку после изменения схемы. Такой маленький контур быстрее полного аудита и оставляет след, который можно повторить в CI.
\n