{ "index": 12, "slug": "editorial-2027-09-practice-mentor-series", "title": "Совместимый API-ответ: как поймать breaking change до релиза", "excerpt": "Разбираем ответ HTTP-метода на границе сервиса: какие изменения ломают старого клиента, как отделить форму JSON от состояния системы и чем доказать совместимость.", "contentHtml": "

Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах видны неизвестное значение enum, отсутствие поля или вызов метода у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки — поиск версии клиента, срочный откат и проверка кэшей или данных, если изменение затронуло их формат.

\n

Проблема возникает, когда форму ответа считают внутренней деталью. Удаление свойства, изменение типа, добавление обязательного поля и новое значение enum меняют контракт по-разному. Ниже — способ проверить один HTTP-ответ до релиза: сначала зафиксировать границу, затем прогнать старого потребителя и только после этого выбирать совместимое расширение или новую версию.

\n

Контракт начинается с границы

\n

В учебном примере граница — GET /customers/{id}, статус 200, media type application/json и тело ответа. Направление тоже входит в контракт: request отправляет клиент, response читает клиент. Поэтому обязательное поле в запросе и обязательное поле в ответе нельзя оценивать одним правилом.

\n

OpenAPI описывает HTTP-операцию, её ответы и доступную потребителю форму интерфейса. JSON Schema проверяет экземпляр JSON по типам, обязательным полям и ограничениям. Эти инструменты отвечают на разные части вопроса. Ни один из них сам по себе не доказывает, что фактический handler отдаёт описанное тело, что у пользователя есть право на ресурс или что ревизия записи ещё актуальна.

\n
GET /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}
\n

HTTP 200 подтверждает успешную обработку запроса на уровне протокола, но не совместимость представления со старым кодом. Потребитель может ветвить логику по state, строить URL из id и сравнивать revision с локальной версией. Синтаксически правильный JSON всё равно ломает клиент, если изменились тип, допустимые значения или смысл поля.

\n

Какие изменения ломают потребителя

\n
Направление изменения → риск → проверка → действие
ИзменениеЧто ломаетсяПроверкаДействие
response: поле удалили или переименовалиСтарый клиент обращается к propertyНайти чтения поля и старые fixturesСохранить поле на deprecated-период или выпустить версию
response: изменили тип или смыслДесериализатор или бизнес-ветка принимает неверное значениеПроверить тип и поведение старого клиентаДобавить новое поле с новым именем или сохранить семантику
response: добавили значение enumСтрогий decoder или ветка по умолчанию не знает значениеПрогнать старый код на каждом допустимом значенииНе включать значение в старый контракт или подготовить новую версию
request: добавили required-полеСтарый отправитель получает отказОтправить новую форму без поляСделать поле optional, дать default или изменить версию
response: добавили optional-полеОбычно ничего, но strict decoder может отклонить неизвестный ключПроверить реальную политику неизвестных полейЗафиксировать поведение decoder и добавить contract-test
\n

Слово breaking относится не к строке diff, а к конкретному потребителю и направлению обмена. Новое поле в response обычно расширяет контракт, если старый decoder игнорирует неизвестные ключи. Но схема с additionalProperties: false или строгая библиотека могут сделать такое расширение несовместимым. Решение принимают по исполняемому правилу клиента, а не по названию изменения.

\n

Учебный валидатор ответа

\n

Функция ниже получает уже разобранный JavaScript-объект. Она не ходит в сеть, не читает базу и не проверяет право доступа. Валидатор извлекает известные поля и возвращает ясную причину отказа. Дополнительные ключи он не использует и не объявляет допустимыми: политику strict или permissive нужно задать отдельной схемой и тестом.

\n
function 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-маршрута.

\n
\"Схема
Сначала проверяется форма ответа, затем клиент получает нормализованный объект. Красная ветка показывает отказ на неверном типе или значении; схема не заменяет проверку прав и состояния ресурса.
\n

Проверка до релиза

\n
  1. Зафиксируйте старый и новый контракт отдельно для request и response: метод, путь, статус, media type, обязательные поля, типы, nullable и enum.
  2. Соберите fixtures из фактического HTTP-ответа. Сохраните успешный случай, пропущенное поле, неверный тип и неизвестное enum-значение, а не только вручную созданный объект.
  3. Проверьте форму через OpenAPI или JSON Schema, затем прогоните runtime-проверку на ответе реального handler. Сверьте статус, заголовок Content-Type и тело.
  4. Найдите всех известных потребителей: чтения property, строгие decoders, DTO-мэпперы, сгенерированные SDK, кэши и события. Один найденный клиент не доказывает полноту списка.
  5. Запустите старую версию клиента против нового ответа. Для enum проверьте каждое значение, для optional-полей — поведение при неизвестном ключе, а для изменения типа — реальную десериализацию.
  6. Разведите ожидаемые ошибки. Зафиксируйте, что означает 400 для неверной формы, 403 для отказа в праве, 404 для отсутствующего ресурса, 409 для конфликта состояния и 412 для невыполненного условия If-Match.
  7. Выберите обратимый ход: сохранить старое поле, добавить новое рядом, открыть окно deprecated или выпустить новую версию. Для удаления запишите срок и наблюдаемый сигнал использования.
  8. После rollout сравните ошибки старого и нового клиентов, а затем удаляйте старую форму только после проверки этого сигнала. Не считайте зелёную сборку доказательством неизвестных внешних потребителей.
\n

Схема не видит состояние

\n

JSON Schema хорошо описывает документ: типы, обязательность, enum и дополнительные свойства. Она не видит пользователя, базу и время. Ответ с state: active может соответствовать схеме, хотя запись уже заблокирована. revision: 4 не доказывает, что обновление поверх ревизии 3 ещё разрешено.

\n

Состояние и право требуют отдельного протокола. Для авторизации сервис проверяет роль и возвращает согласованный отказ. Для конкурентного обновления он может использовать версию ресурса и условный запрос с If-Match; если условие не выполнено, клиенту нужен отдельный сигнал 412 Precondition Failed. Конфликт доменного состояния может быть 409 Conflict. Не маскируйте эти случаи под 400: форма запроса может быть правильной, а причина отказа — в праве или текущем состоянии.

\n

Есть и отрицательный путь для самой проверки. Схема может пройти, а реальный handler — вернуть другой ответ в исключении или на редком кодовом пути. Поэтому contract-test должен вызвать маршрут и проверить фактические статус, заголовок и тело. Runtime-проверка снижает риск несовместимого payload, но не доказывает, что список потребителей полон.

\n

Ограничения и критерий готовности

\n

Учебный валидатор не проверяет OpenAPI-документ, сериализацию фреймворка, авторизацию, транзакции, сетевые сбои, кэш и содержимое базы. Он также не решает вопрос обратной совместимости для неизвестного клиента. Эти границы нужно оставить видимыми, иначе локальный зелёный тест создаст ложное чувство безопасности.

\n

Изменение готово к выпуску, когда команда может показать четыре доказательства:

\n\n

Если одного доказательства нет, результат следует считать непроверенным, даже когда сборка завершилась успешно. Возьмите один настоящий endpoint, заведите для него положительный и отрицательные fixtures, а затем повторите проверку после изменения схемы. Такой маленький контур быстрее полного аудита и оставляет след, который можно повторить в CI.

\n

Проверяемые источники

" }