Files
progcode/editorial/agent-rewrites/012.json
T

8 lines
22 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 12,
"slug": "editorial-2027-09-practice-mentor-series",
"title": "Совместимый API-ответ: как поймать breaking change до релиза",
"excerpt": "Разбираем ответ HTTP-метода на границе сервиса: какие изменения ломают старого клиента, как отделить форму JSON от состояния системы и чем доказать совместимость.",
"contentHtml": "<p>Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах появляется ошибка чтения поля, неизвестное значение перечисления или попытка вызвать метод у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки быстро становится операционной: приходится искать версии клиента, решать, можно ли откатить сервер, и проверять, не появились ли уже записи по новой схеме.</p>\n<p>Причина часто не в синтаксисе JSON. Команда меняет ответ как внутреннюю модель и не замечает внешних потребителей: удаляет свойство, меняет тип, сужает перечисление или объявляет новое свойство обязательным. Поэтому проверять нужно не только «валиден ли JSON», но и может ли прежний клиент разобрать ответ и выбрать ту же ветку поведения. Ниже — небольшой контракт для <code>GET /customers/{id}</code>, воспроизводимая проверка и критерий выпуска.</p>\n<h2>Контракт начинается с границы HTTP</h2>\n<p>До обсуждения полей зафиксируйте операцию: метод, путь, допустимый статус, <code>Content-Type</code> и тело ответа. OpenAPI описывает HTTP-интерфейс так, чтобы его могли читать люди и инструменты; это удобный источник договорённости, но не телеметрия реального сервера. Если handler иногда отвечает HTML-страницей ошибки или другой схемой при том же статусе, один файл OpenAPI этого не обнаружит.</p>\n<p>В примере успешное представление клиента имеет три обязательных поля. <code>id</code> — непустая строка, <code>revision</code> — положительное целое, <code>state</code> — одно из двух значений. Это именно внешний формат, а не копия таблицы в базе данных. Внутреннее поле <code>updatedAt</code> можно не публиковать; наоборот, публичное <code>revision</code> может быть вычисляемым. Такое разделение помогает не вынести внутреннюю миграцию наружу случайным изменением DTO.</p>\n<pre><code>GET /customers/{id}\nAccept: application/json\n\n200 OK\nContent-Type: application/json\n\n{\n &quot;id&quot;: &quot;customer-17&quot;,\n &quot;revision&quot;: 4,\n &quot;state&quot;: &quot;active&quot;\n}</code></pre>\n<p>HTTP 200 говорит о результате операции на уровне протокола, но не обещает, что конкретная библиотека десериализации примет все значения. Клиент может строить URL из <code>id</code>, сравнивать ревизии или выбирать экран по <code>state</code>. Совместимость — это сохранение тех свойств и значений, на которые реально опирается старый потребитель.</p>\n<h2>Какие изменения действительно опасны</h2>\n<p>Термин breaking change нельзя применять к любому diff. Риск зависит от направления обмена и поведения клиента. Добавление необязательного свойства в ответ обычно переживает tolerant-клиент, но строгий декодер может отклонить неизвестное поле. Добавление нового значения enum не меняет JSON-тип, однако ломает закрытый <code>switch</code>, если клиент не имеет безопасной ветки по умолчанию. Поэтому таблица ниже — матрица для проверки, а не автоматический вердикт для всех библиотек.</p>\n<div class=\"table-scroll\"><table><caption>Изменение ответа, риск и доказательство совместимости</caption><thead><tr><th scope=\"col\">Изменение</th><th scope=\"col\">Обычный риск</th><th scope=\"col\">Что проверить</th><th scope=\"col\">Решение</th></tr></thead><tbody><tr><td>Удалено свойство</td><td>Breaking</td><td>Чтение поля, мапперы и fixtures старых клиентов</td><td>Сохранить поле на период совместимости или выпустить версию</td></tr><tr><td>Тип изменён: строка стала числом</td><td>Breaking</td><td>Десериализация и сравнения в старом клиенте</td><td>Добавить новое свойство с новым типом</td></tr><tr><td>Добавлено обязательное свойство в ответ</td><td>Breaking для строгого декодера</td><td>Обработка отсутствия поля и правила схемы клиента</td><td>Согласовать режим декодера; при запрете неизвестных полей — сменить версию</td></tr><tr><td>Добавлено новое значение enum</td><td>Условный breaking</td><td>Ветки старого клиента на каждом значении</td><td>Расширить обработчик либо не отправлять значение старой версии</td></tr><tr><td>Добавлено необязательное свойство</td><td>Обычно совместимо</td><td>Запрет неизвестных полей и влияние на размер ответа</td><td>Оставить расширение и добавить consumer-тест</td></tr><tr><td>Изменён смысл прежнего значения</td><td>Скрытый breaking</td><td>Поведение, а не только JSON Schema</td><td>Сохранить смысл или переименовать поле</td></tr></tbody></table></div>\n<p>Отдельно проверяйте статус и заголовки. Ответ 404, который превратился в 200 с объектом ошибки, может сломать клиент раньше, чем тот доберётся до тела. И наоборот, формально одинаковый JSON при смене семантики статуса изменит ветку повторов и отображение ошибки. Для каждого исхода задайте точную пару «статус — форма тела».</p>\n<h2>Проверяем форму до бизнес-правила</h2>\n<p>JSON Schema описывает документ: типы, обязательность, перечисления и ограничения, когда выбранный диалект и режим валидатора это поддерживают. Она не знает, имеет ли пользователь право видеть клиента, существует ли запись в базе и актуальна ли ревизия в момент обновления. Эти вопросы не нужно прятать в проверку формы: у них другие входы, причины отказа и тесты.</p>\n<p>Ниже — намеренно маленький валидатор на уже разобранном объекте. Он не исправляет ответ молча и не подставляет отсутствующую ревизию. Для production-кода понадобятся проверка фактического HTTP-ответа, единый формат ошибок и согласованный с командой способ обработки лишних полей.</p>\n<pre><code>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.length === 0) {\n return { ok: false, reason: 'id-must-be-non-empty-string' };\n }\n\n if (!Number.isInteger(payload.revision) || payload.revision &lt; 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');</code></pre>\n<p>Скопируйте блок в файл <code>contract-check.mjs</code> и выполните <code>node contract-check.mjs</code>. Нулевой код завершения доказывает только три перечисленных свойства функции. Он не доказывает, что handler действительно вызывает эту функцию или что сериализатор не меняет данные после проверки. Именно поэтому проверку объекта дополняют тестом маршрута с настоящим статусом, заголовком и телом.</p>\n<figure><img src=\"/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg\" alt=\"Поток проверки совместимости API: HTTP-ответ проверяется по форме, затем старым клиентом и отдельно по условиям операции.\" loading=\"lazy\" /><figcaption>У совместимости три разные точки доказательства: форма ответа, поведение потребителя и контекст операции. Прохождение первой точки не подтверждает право доступа, актуальность данных или корректность бизнес-перехода.</figcaption></figure>\n<h2>Consumer-тест проверяет не только декодирование</h2>\n<p>Тест потребителя должен запускать старый клиент против нового ответа. Успешная десериализация недостаточна: нужно пройти ветку, которая использует поле. Для <code>state</code> это означает проверить <code>active</code>, <code>blocked</code> и поведение на неизвестном значении, если сервер имеет право его прислать. Для удаляемого свойства — убедиться, что старый клиент не строит на его отсутствии неверное значение по умолчанию.</p>\n<p>Полезный fixture хранит не только payload, но и ожидаемый результат: название экрана, решение о повторе, сформированный запрос или доменную ошибку. Тогда тест ловит смену смысла, которую структурная схема не видит. Версию потребителя выбирайте явно: тест «текущий клиент против текущего сервера» может оставаться зелёным после изменения, потому что оба обновились одновременно.</p>\n<p>Учитывайте настройки декодера. В одном клиенте неизвестные свойства игнорируются, в другом запрещены; одни библиотеки превращают число в строку, другие требуют точного типа. Не называйте изменение совместимым по опыту одной реализации. Зафиксируйте режим парсера и повторите тест на минимальной поддерживаемой версии клиента.</p>\n<h2>Воспроизводимый порядок проверки</h2>\n<ol><li>Опишите endpoint, метод, допустимые статусы, заголовки и media type. Отделите публичное представление от внутренней модели.</li><li>Сохраните текущий контракт как набор схем и примеров: успешный ответ, отсутствие записи, отказ в доступе и конфликт версии, если эти исходы предусмотрены.</li><li>Соберите список потребителей и версий. Ищите чтения полей, enum-ветвления, строгие декодеры, DTO-мапперы и генерацию клиентов.</li><li>Сравните старую и новую формы. Отдельно пометьте удаление, изменение типа, обязательность, enum, статус, заголовки и изменение смысла.</li><li>Запустите схему и runtime-проверку на реальном сериализованном ответе. Не подменяйте HTTP-ответ вручную собранным объектом без отдельного теста сериализации.</li><li>Прогоните consumer-тест на старой версии клиента. Проверяйте результат поведения и отрицательные случаи, а не только отсутствие исключения.</li><li>Для несовместимого diff выберите одно действие: сохранить старое свойство, добавить новое рядом, открыть период совместимости или выпустить отдельную версию. Назначьте измеримое условие удаления.</li><li>После выпуска наблюдайте использование старого поля и долю старых клиентов. Удаляйте deprecated-часть только после подтверждения, что условие выполнено.</li></ol>\n<p>Каждый шаг должен оставлять артефакт: diff схемы, fixture, результат теста или метрику. Фраза «клиенты не жаловались» не является доказательством: она не показывает покрытые версии, редкие ветки и неактивных потребителей.</p>\n<h2>Статус, версия и состояние — разные решения</h2>\n<p>Схема ответа не заменяет протокол ошибки. Если запрос сформирован правильно, но ресурс изменился между чтением и записью, клиенту нужен сигнал конфликта состояния, а не сообщение о неверном JSON. RFC 9110 описывает условные запросы и заголовок <code>If-Match</code>; его можно использовать как часть отдельного контракта конкурентного обновления, если сервер проверяет условие до изменения.</p>\n<p>Авторизация, наличие ресурса и конкурентная версия имеют разные причины и обычно разные статусы. Нельзя выводить право доступа из того, что тело прошло схему. Нельзя считать <code>revision: 4</code> доказательством, что обновление с ревизией 4 разрешено сейчас: это значение становится полезным только в договорённом протоколе проверки версии.</p>\n<p>Ограничение применимости здесь принципиальное: показанный валидатор не проверяет OpenAPI-документ, правила JSON Schema, права, базу, транзакцию, сетевой таймаут или полноту списка потребителей. Он предотвращает конкретные ошибки формы в учебной границе. Для выпуска нужны интеграционный маршрут, старый клиент и наблюдаемое правило удаления.</p>\n<h2>Критерий готовности к выпуску</h2>\n<p>Изменение ответа можно считать проверенным, когда команда показывает четыре независимых результата. Новая форма проходит схему и runtime-проверку на фактическом HTTP-ответе. Старый поддерживаемый клиент разбирает ответ и выполняет ожидаемую ветку. Отрицательные случаи имеют согласованные статусы и тела. Если diff несовместим, описаны версия или период совместимости и измеримое условие удаления.</p>\n<p>Если зелёный результат есть только у unit-теста валидатора, это ещё не совместимость API. Если схема не описывает смысл поля, добавьте поведенческий consumer-тест. Если неизвестны потребители, не обещайте безопасное удаление: сначала соберите сигнал использования или выберите версионирование. Такой критерий делает решение проверяемым и оставляет видимой цену неизвестности.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://spec.openapis.org/oas/v3.2.0.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification 3.2.0</a> — официальное описание языка интерфейсов HTTP API; применимо к структуре операций, ответов и схем, но не подтверждает фактическое поведение сервера.</li><li><a href=\"https://json-schema.org/draft/2020-12/json-schema-core.html\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema Core, draft 2020-12</a> — официальный документ о базовой модели и диалектах схем; сам по себе не описывает права, состояние системы и поведение потребителя.</li><li><a href=\"https://json-schema.org/draft/2020-12/json-schema-validation.html\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema Validation, draft 2020-12</a> — официальный vocabulary для структурных ограничений JSON; результат зависит от выбранного валидатора и его режима обработки аннотаций и неизвестных ключей.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — стандарт IETF для методов, статусов, условных запросов и заголовков; не определяет доменную модель и конкретное версионирование API.</li></ul>"
}