{ "index": 12, "slug": "editorial-2027-09-practice-mentor-series", "title": "Совместимый API-ответ: как поймать breaking change до релиза", "excerpt": "Разбираем ответ HTTP-метода на границе сервиса: какие изменения ломают старого клиента, как отделить форму JSON от состояния системы и чем доказать совместимость.", "contentHtml": "
Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах появляется ошибка чтения поля, неизвестное значение перечисления или попытка вызвать метод у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки быстро становится операционной: приходится искать версии клиента, решать, можно ли откатить сервер, и проверять, не появились ли уже записи по новой схеме.
\nПричина часто не в синтаксисе JSON. Команда меняет ответ как внутреннюю модель и не замечает внешних потребителей: удаляет свойство, меняет тип, сужает перечисление или объявляет новое свойство обязательным. Поэтому проверять нужно не только «валиден ли JSON», но и может ли прежний клиент разобрать ответ и выбрать ту же ветку поведения. Ниже — небольшой контракт для GET /customers/{id}, воспроизводимая проверка и критерий выпуска.
До обсуждения полей зафиксируйте операцию: метод, путь, допустимый статус, Content-Type и тело ответа. OpenAPI описывает HTTP-интерфейс так, чтобы его могли читать люди и инструменты; это удобный источник договорённости, но не телеметрия реального сервера. Если handler иногда отвечает HTML-страницей ошибки или другой схемой при том же статусе, один файл OpenAPI этого не обнаружит.
В примере успешное представление клиента имеет три обязательных поля. id — непустая строка, revision — положительное целое, state — одно из двух значений. Это именно внешний формат, а не копия таблицы в базе данных. Внутреннее поле updatedAt можно не публиковать; наоборот, публичное revision может быть вычисляемым. Такое разделение помогает не вынести внутреннюю миграцию наружу случайным изменением DTO.
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}\nHTTP 200 говорит о результате операции на уровне протокола, но не обещает, что конкретная библиотека десериализации примет все значения. Клиент может строить URL из id, сравнивать ревизии или выбирать экран по state. Совместимость — это сохранение тех свойств и значений, на которые реально опирается старый потребитель.
Термин breaking change нельзя применять к любому diff. Риск зависит от направления обмена и поведения клиента. Добавление необязательного свойства в ответ обычно переживает tolerant-клиент, но строгий декодер может отклонить неизвестное поле. Добавление нового значения enum не меняет JSON-тип, однако ломает закрытый switch, если клиент не имеет безопасной ветки по умолчанию. Поэтому таблица ниже — матрица для проверки, а не автоматический вердикт для всех библиотек.
| Изменение | Обычный риск | Что проверить | Решение |
|---|---|---|---|
| Удалено свойство | Breaking | Чтение поля, мапперы и fixtures старых клиентов | Сохранить поле на период совместимости или выпустить версию |
| Тип изменён: строка стала числом | Breaking | Десериализация и сравнения в старом клиенте | Добавить новое свойство с новым типом |
| Добавлено обязательное свойство в ответ | Breaking для строгого декодера | Обработка отсутствия поля и правила схемы клиента | Согласовать режим декодера; при запрете неизвестных полей — сменить версию |
| Добавлено новое значение enum | Условный breaking | Ветки старого клиента на каждом значении | Расширить обработчик либо не отправлять значение старой версии |
| Добавлено необязательное свойство | Обычно совместимо | Запрет неизвестных полей и влияние на размер ответа | Оставить расширение и добавить consumer-тест |
| Изменён смысл прежнего значения | Скрытый breaking | Поведение, а не только JSON Schema | Сохранить смысл или переименовать поле |
Отдельно проверяйте статус и заголовки. Ответ 404, который превратился в 200 с объектом ошибки, может сломать клиент раньше, чем тот доберётся до тела. И наоборот, формально одинаковый JSON при смене семантики статуса изменит ветку повторов и отображение ошибки. Для каждого исхода задайте точную пару «статус — форма тела».
\nJSON Schema описывает документ: типы, обязательность, перечисления и ограничения, когда выбранный диалект и режим валидатора это поддерживают. Она не знает, имеет ли пользователь право видеть клиента, существует ли запись в базе и актуальна ли ревизия в момент обновления. Эти вопросы не нужно прятать в проверку формы: у них другие входы, причины отказа и тесты.
\nНиже — намеренно маленький валидатор на уже разобранном объекте. Он не исправляет ответ молча и не подставляет отсутствующую ревизию. Для production-кода понадобятся проверка фактического HTTP-ответа, единый формат ошибок и согласованный с командой способ обработки лишних полей.
\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 wrongType = validateCustomerResponse({\n id: 'customer-17', revision: '4', state: 'active',\n});\nconst unknownState = validateCustomerResponse({\n id: 'customer-17', revision: 4, state: 'deleted',\n});\n\nconsole.assert(accepted.ok === true);\nconsole.assert(wrongType.reason === 'revision-must-be-positive-integer');\nconsole.assert(unknownState.reason === 'state-is-outside-enum');\nconsole.log('contract checks passed');\nСкопируйте блок в файл contract-check.mjs и выполните node contract-check.mjs. Нулевой код завершения доказывает только три перечисленных свойства функции. Он не доказывает, что handler действительно вызывает эту функцию или что сериализатор не меняет данные после проверки. Именно поэтому проверку объекта дополняют тестом маршрута с настоящим статусом, заголовком и телом.
Тест потребителя должен запускать старый клиент против нового ответа. Успешная десериализация недостаточна: нужно пройти ветку, которая использует поле. Для state это означает проверить active, blocked и поведение на неизвестном значении, если сервер имеет право его прислать. Для удаляемого свойства — убедиться, что старый клиент не строит на его отсутствии неверное значение по умолчанию.
Полезный fixture хранит не только payload, но и ожидаемый результат: название экрана, решение о повторе, сформированный запрос или доменную ошибку. Тогда тест ловит смену смысла, которую структурная схема не видит. Версию потребителя выбирайте явно: тест «текущий клиент против текущего сервера» может оставаться зелёным после изменения, потому что оба обновились одновременно.
\nУчитывайте настройки декодера. В одном клиенте неизвестные свойства игнорируются, в другом запрещены; одни библиотеки превращают число в строку, другие требуют точного типа. Не называйте изменение совместимым по опыту одной реализации. Зафиксируйте режим парсера и повторите тест на минимальной поддерживаемой версии клиента.
\nКаждый шаг должен оставлять артефакт: diff схемы, fixture, результат теста или метрику. Фраза «клиенты не жаловались» не является доказательством: она не показывает покрытые версии, редкие ветки и неактивных потребителей.
\nСхема ответа не заменяет протокол ошибки. Если запрос сформирован правильно, но ресурс изменился между чтением и записью, клиенту нужен сигнал конфликта состояния, а не сообщение о неверном JSON. RFC 9110 описывает условные запросы и заголовок If-Match; его можно использовать как часть отдельного контракта конкурентного обновления, если сервер проверяет условие до изменения.
Авторизация, наличие ресурса и конкурентная версия имеют разные причины и обычно разные статусы. Нельзя выводить право доступа из того, что тело прошло схему. Нельзя считать revision: 4 доказательством, что обновление с ревизией 4 разрешено сейчас: это значение становится полезным только в договорённом протоколе проверки версии.
Ограничение применимости здесь принципиальное: показанный валидатор не проверяет OpenAPI-документ, правила JSON Schema, права, базу, транзакцию, сетевой таймаут или полноту списка потребителей. Он предотвращает конкретные ошибки формы в учебной границе. Для выпуска нужны интеграционный маршрут, старый клиент и наблюдаемое правило удаления.
\nИзменение ответа можно считать проверенным, когда команда показывает четыре независимых результата. Новая форма проходит схему и runtime-проверку на фактическом HTTP-ответе. Старый поддерживаемый клиент разбирает ответ и выполняет ожидаемую ветку. Отрицательные случаи имеют согласованные статусы и тела. Если diff несовместим, описаны версия или период совместимости и измеримое условие удаления.
\nЕсли зелёный результат есть только у unit-теста валидатора, это ещё не совместимость API. Если схема не описывает смысл поля, добавьте поведенческий consumer-тест. Если неизвестны потребители, не обещайте безопасное удаление: сначала соберите сигнал использования или выберите версионирование. Такой критерий делает решение проверяемым и оставляет видимой цену неизвестности.
\n