{ "index": 12, "slug": "editorial-2027-09-practice-mentor-series", "title": "Совместимый API-ответ: как поймать breaking change до релиза", "excerpt": "Разбираем ответ HTTP-метода на границе сервиса: какие изменения ломают старого клиента, как отделить форму JSON от состояния системы и чем доказать совместимость.", "contentHtml": "
Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах появляется ошибка чтения поля, неизвестное значение перечисления или попытка вызвать метод у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки растёт быстро: приходится искать все версии клиента, откатывать серверный код и решать, не потеряны ли уже записи, созданные по новой схеме.
\\nПричина обычно не в синтаксической ошибке JSON. Команда меняет форму ответа как внутреннюю модель и не замечает потребителей. Она удаляет поле, делает новое поле обязательным, меняет тип или добавляет значение в enum. Каждый такой diff имеет собственный риск. Тезис простой: API-ответ нужно проверять как контракт двух сторон — по форме, по поведению старого клиента и по условиям, которые схема не описывает.
\\nКонтракт начинается с конкретной границы: метод, путь, статус, media type и тело. Для GET /customers/{id} можно зафиксировать объект с обязательными полями id, revision и state. У id строковый тип. У revision положительное целое число. У state закрытый набор значений active и blocked.
Эта форма отвечает на вопрос «можно ли разобрать JSON». Она не отвечает на вопросы «имеет ли пользователь право видеть клиента» и «не устарела ли ревизия записи». Эти проверки относятся к авторизации и состоянию. Если смешать их со схемой, ответ об ошибке станет неточным: клиент не поймёт, нужно ли исправить запрос, обновить данные или прекратить повторные попытки.
\\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}\\nУспешный ответ не становится совместимым только потому, что его принимает парсер JSON. Клиент может ветвить логику по state, строить URL из id и сравнивать revision с локальной версией. Сохранить синтаксис недостаточно: нужно сохранить значения и смысл, на которые опирается старый код.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый клиент получает ошибку при чтении поля | Поле удалили или изменили его тип | Сравнить старую и новую схему и найти чтения поля | Сохранить поле или выпустить новую версию |
| Клиент попадает в ветку «неизвестное состояние» | Enum расширили без обработки нового значения | Прогнать старый switch на каждом значении | Добавить обработку либо не включать значение в старый контракт |
| Запросы начинают отклоняться после обновления | Новое поле объявили обязательным | Отправить старую форму без поля | Сделать поле необязательным или изменить версию |
| Клиент принимает ответ, но действует по неверной ветке | Сохранили тип, но изменили смысл значения | Проверить примеры поведения, а не только JSON Schema | Сохранить семантику или переименовать поле |
| Ответ формально верен, но операция получает отказ | Нарушено право или текущее состояние ресурса | Проверить авторизацию и условие версии отдельно | Вернуть точный 403/409 и не маскировать его под 400 |
Добавление необязательного поля чаще всего совместимо: старый клиент его игнорирует. Но это правило действует только для потребителя, который действительно игнорирует неизвестные свойства. У строгого декодера или схемы с запретом дополнительных полей появится отказ. Поэтому решение принимают по реальным правилам клиента, а не по названию изменения.
\\nСледующая функция показывает минимальную проверку ответа. Она не ходит в сеть и не читает базу. Входом служит уже разобранный JavaScript-объект. Функция принимает только известную форму и возвращает нормализованное значение. Это учебный пример: он показывает границу контракта, но не заменяет OpenAPI, JSON Schema, интеграционный тест или авторизацию.
\\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.length === 0) {\\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 rejected = validateCustomerResponse({\\n id: 'customer-17', revision: 4, state: 'deleted',\\n});\\n\\nconsole.log(accepted.ok, accepted.value.state);\\nconsole.log(rejected.ok, rejected.reason);\\n// true active\\n// false state-is-outside-enum\\nОтдельный отрицательный пример важнее ещё одного успешного fixture. Если сервер начнёт отправлять state: deleted, валидатор обнаружит изменение до того, как клиент выполнит неверную ветку. Если сервер отправит revision: "4", отказ произойдёт по типу. Если поле исчезнет, причина должна назвать поле, а не скрыться за общим сообщением invalid response.
Схема хорошо описывает типы, обязательность и ограничения документа. Она может запретить лишние поля или определить ветвление по значению. Но она не видит пользователя, базу и время. Ответ state: active может быть синтаксически правильным, хотя запись уже заблокирована. Значение revision: 4 не доказывает, что обновление с ревизией 3 ещё допустимо.
Состояние требует отдельного протокола. Для конкурентного обновления подойдут версия ресурса и условный запрос с If-Match; для права — проверка роли до изменения; для отсутствующего ресурса — договорённый статус 404. Не превращайте 409 в 400: клиенту нужен сигнал, что запрос сформирован правильно, но состояние изменилось. Не повторяйте 403 автоматически: повтор не добавит прав.
Есть и отрицательный путь для самой проверки. Схема может пройти, а реальный handler — вернуть другой ответ в исключении. Поэтому contract test должен вызвать маршрут, проверить статус, заголовок и тело. Runtime-проверка должна работать на фактическом ответе, а не только на вручную собранном объекте. Это снижает конкретный риск, но не доказывает, что список потребителей полон.
\\nУчебная функция не проверяет OpenAPI-документ, сериализацию фреймворка, авторизацию, транзакции, сетевые сбои и содержимое базы. Она также не решает вопрос обратной совместимости для неизвестного клиента. Эти границы нужно оставить видимыми, иначе локальный зелёный тест создаст ложное чувство безопасности.
\\nИзменение готово к выпуску, если команда может показать четыре доказательства: новая форма проходит schema- и runtime-проверку; старый клиент проходит consumer contract test; отрицательные случаи возвращают согласованные статусы и причины; для breaking change указаны версия, период совместимости и проверяемое условие удаления. Если хотя бы одного доказательства нет, результат следует считать непроверенным, даже когда сборка завершилась успешно.
\\n