{"index":70,"slug":"editorial-2026-01-field-platform-api","title":"Совместимость платформенного API: проверка до изменения контракта","excerpt":"Как проверить изменение API на паре «контракт — потребитель»: отрезать несопоставимые операции, найти скрытые ожидания и передать результат с явными причинами и ограничениями.","contentHtml":"
Платформенная команда добавила в ответ новое поле и подняла версию с 1.2.0 до 1.3.0. Ручной запрос по-прежнему возвращает 200 OK, поэтому изменение называют обратно совместимым. Но один клиент читает ответ строгим декодером, второй ждёт старый набор значений, а третий обращается к endpoint команды, хотя сравнивает его с read API. Через час после rollout один экран показывает пустое состояние, другой повторяет запрос, а участники review спорят о том, что означает слово «совместимо».
Цена ошибки — не только сломанный экран. Клиент может записать неверное состояние, повторить необратимую операцию или превратить отказ в «объект не найден». Платформа затем получает временный флаг, скрытый обход и ещё одного потребителя, которого никто не внес в список. Поэтому вопрос перед изменением звучит точнее: совместим ли конкретный контракт с конкретным потребителем, для конкретного набора входов, ответов и ошибок?
\nСовместимость появляется не у версии самой по себе. Её проверяют на паре: именованный контракт и именованный потребитель. Контракт задаёт операцию, семейство, версию, формат запроса, формат ответа, ошибки и гарантии. Потребитель задаёт поддерживаемые версии, обязательные поля, допустимые значения и правила обработки отказов.
\nВ этой статье используется синтетическая пара fixed-catalog-read-v1 и fixed-tolerant-reader-v1. Она нужна для воспроизводимого рассуждения, а не для заявления о реальном сервисе. Реальный review дополнительно потребует инвентарь потребителей, контрактные тесты, права на проверку окружения и доказательства поведения конкретной версии клиента.
Пример ответа показывает одну representation — передаваемое представление ресурса. Он не обещает порядок ключей, время ответа, кэширование, сохранность записи или поведение при неизвестном поле. Такие свойства становятся контрактом только тогда, когда их явно описали и связали с потребителем.
\nGET /records/r-17 HTTP/1.1\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n \"id\": \"r-17\",\n \"state\": \"ready\",\n \"label\": \"Демо-запись\"\n}\nДля учебного read-контракта минимальная гарантия такова: id и state обязательны, label необязателен, а state принимает только ready или blocked. Отсутствие записи обозначается ошибкой fixed-not-found. Ответ с 401, 403, 422 или сетевым таймаутом не является этой ошибкой. Такая граница нужна, чтобы fallback не стирал различие между отсутствием данных и проблемой доступа.
OpenAPI помогает записать HTTP-поверхность в форме, пригодной для людей и инструментов: operation, параметры, responses и schemas. Но схема не знает закрытую ветку кода клиента. Строгий декодер, зависимость от порядка массива и особый query-параметр могут существовать вне OpenAPI. Документ снижает неопределённость формы, но не заменяет проверку потребителя.
\nСначала сравните contractFamily, затем версию и поверхность. Read API fixed-catalog-read-v1 и командный адаптер fixed-catalog-command-v1 могут иметь одинаковые поля id и state, но отвечают на разные действия. Один читает состояние, второй запускает изменение. Называть их несовместимыми — значит уже предположить, что сравнение допустимо. Правильный результат здесь — «несопоставимо», а не отрицательный вердикт по полям.
Это короткая, но важная остановка. Если её пропустить, команда начнёт чинить не тот контракт: добавит в read API поля для команды или объявит общую версию, которая скрывает разные риски повторения и идемпотентности. Семейство должно быть именованным, а не выводиться по похожему URL или совпавшим ключам.
\nconst api = {\n family: 'fixed-catalog-read-v1',\n version: '1.3.0',\n requiredResponse: ['id', 'state'],\n errors: ['fixed-not-found'],\n};\n\nconst consumer = {\n id: 'fixed-command-adapter-v1',\n family: 'fixed-catalog-command-v1',\n requiredResponse: ['id', 'state'],\n};\n\nfunction compareFamily(contract, client) {\n if (contract.family !== client.family) {\n return {\n status: 'stop-incomparable',\n reason: 'contract-family-mismatch',\n nextAction: 'open-separate-command-review',\n };\n }\n\n return { status: 'family-matches' };\n}\n\nconsole.log(compareFamily(api, consumer));\n// stop-incomparable: сравнение полей ещё не началось\nКод использует только объекты в памяти. Он не обращается к сети, не проверяет реального клиента и не выдаёт разрешение на выпуск. Его полезность в том, что отрицательный путь нельзя случайно превратить в «почти совместимо»: при другой family функция останавливается до анализа полей.
\nФраза «у нас есть несколько клиентов» слишком расплывчата для решения. Карточка должна отвечать на пять вопросов: кто потребитель, к какому семейству относится, какую версию поддерживает, что ему обязательно прочитать и какие ошибки он различает. Если клиент зависит от неизвестных полей, порядка элементов, заголовка или задержки, зависимость нужно записать отдельно, даже если она пока не считается допустимой.
\n| Поле | Учебное значение | Что доказывает | Чего не доказывает |
|---|---|---|---|
| consumer id | fixed-tolerant-reader-v1 | какую пару проверяем | что неизвестных клиентов нет |
| family | fixed-catalog-read-v1 | операции сопоставимы | что поля совпадают |
| supported version | 1.3.0 | какую поверхность клиент заявляет | что реализация действительно её принимает |
| required fields | id, state | минимум чтения | что новое значение enum обработано |
| known errors | fixed-not-found | какой fallback разрешён | что timeout можно считать отсутствием |
| hidden dependency | не зафиксирована | остаётся вопрос для проверки | что можно объявить compatible |
Пустая ячейка — это не нулевой риск. Если версия или required fields неизвестны, потребитель нельзя включать в успешный список. Это отдельный статус stop-unknown-consumer с действием «получить карточку». Инвентарь не должен награждать отсутствие сведений положительным verdict.
Добавление необязательного поля часто безопаснее удаления обязательного, но слово «часто» не является результатом проверки. Нужно знать, как клиент обращается с неизвестными ключами. У строгого валидатора расширение может стать ошибкой. У tolerant reader оно может быть проигнорировано. У третьего клиента отсутствие нового поля может включить устаревший режим.
\nОсобенно опасно изменение перечисления. Старый клиент принимает ready и blocked. Если сервер добавляет paused, JSON остаётся корректным, а смысл для клиента — нет. Поэтому compatibility check должен сравнить допустимые значения и переходы, которые видит потребитель. Схема с типом string не доказывает, что любое строковое значение безопасно.
То же относится к массивам. Если контракт не обещает порядок, клиент не вправе выбирать первый элемент как «главный». Если порядок нужен, его надо назвать гарантией, протестировать и связать с версией. Текущий порядок, который виден в одном ответе, — наблюдение, а не обещание.
\nИногда потребителю действительно нужна расширенная форма. Это не повод открыть внутренний объект под флагом ?debug=1. Скрытый маршрут быстро становится вторым API: его начинают вызывать из скриптов, а затем требуют сохранить навсегда. У исключения должны быть имя, версия, получатель, входные условия, гарантии и отрицательная граница.
const escapeHatch = {\n name: 'raw-envelope-v1',\n consumer: 'fixed-exporter-v1',\n guarantees: ['id', 'state'],\n doesNotGuarantee: [\n 'field-order',\n 'future-fields',\n 'availability',\n 'filtering',\n ],\n};\n\nif (!escapeHatch.name || !escapeHatch.consumer) {\n throw new Error('stop-undocumented-exception');\n}\nТакая запись не делает escape hatch безопасным автоматически. Она лишь делает обещание видимым и ограниченным. После неё всё равно нужны тесты клиента, политика доступа, наблюдение и решение о сроке жизни. Если команда не может назвать владельца и отрицательные гарантии, временный обход не должен проходить compatibility review.
\nSemVer 2.0.0 различает patch, minor и major изменения публичного API. Это полезная дисциплина, когда public API уже определён. Но строка 1.3.0 не создаёт список потребителей и не отвечает, принимает ли клиент новый enum. Номер версии — адрес набора правил, а не доказательство того, что все участники прочитали этот набор одинаково.
HTTP Semantics задаёт значения методов, статус-кодов, сообщений и representations. Из этого не следует application-level совместимость: HTTP 200 не гарантирует, что клиент понял бизнес-состояние. 404 тоже не означает автоматически «можно показать пустой список» — это решение конкретного контракта и его потребителя.
Наконец, OpenAPI описывает поверхность HTTP API и позволяет генерировать документацию, клиентов и тесты. Однако specification не исследует исходный код каждого consumer. Поэтому три слоя дополняют друг друга: стандарт описывает vocabulary, контракт фиксирует обещания, а карточка потребителя показывает, что именно нужно проверить.
\nНиже — полностью локальная проверка на Node.js 18 или новее. Она не требует пакетов, сети и доступа к production. Сохраните фрагмент в любой временный файл или вставьте в Node REPL: он проверяет family, обязательные поля и допустимые ошибки на фиксированных данных. В рабочем репозитории тот же порядок следует перенести в contract test, где fixtures принадлежат контракту и потребителю.
\nconst contract = {\n family: 'fixed-catalog-read-v1',\n version: '1.3.0',\n response: { id: 'r-17', state: 'ready' },\n errors: ['fixed-not-found'],\n};\n\nconst consumer = {\n id: 'fixed-tolerant-reader-v1',\n family: 'fixed-catalog-read-v1',\n supportedVersions: ['1.3.0'],\n requiredFields: ['id', 'state'],\n handledErrors: ['fixed-not-found'],\n};\n\nfunction assertCompatible(api, client) {\n if (api.family !== client.family) return 'stop-incomparable';\n if (!client.supportedVersions.includes(api.version)) return 'stop-version';\n\n const missing = client.requiredFields.filter(\n (field) => !(field in api.response),\n );\n if (missing.length) return `stop-missing:${missing.join(',')}`;\n\n const unknownErrors = api.errors.filter(\n (error) => !client.handledErrors.includes(error),\n );\n if (unknownErrors.length) return `stop-error:${unknownErrors.join(',')}`;\n\n return 'compatible-for-this-fixture';\n}\n\nconsole.log(assertCompatible(contract, consumer));\n// compatible-for-this-fixture\nЗапуск возвращает только compatible-for-this-fixture. Это намеренно узкий результат: фикстура не знает о TLS, авторизации, нагрузке, реальном сериализаторе, rollout, миграции данных и неизвестных клиентах. Для отрицательного теста удалите state из contract.response: результат станет stop-missing:state. Замените family у consumer: получите stop-incomparable. Так проверяется не красивый happy path, а причина остановки.
Одно слово compatible плохо переносится между командами. Минимальный hand-off должен содержать идентификатор контракта, версию, consumer, проверенные гарантии, ограничения и следующий шаг. Для отрицательного результата причина обязательна: missing field, неизвестное значение, неподдерживаемая версия, mismatch family или undocumented exception.
| Статус | Когда выдавать | Следующее действие |
|---|---|---|
compatible-for-this-fixture | одна family, версия, поля и ошибки прошли указанную фикстуру | запустить реальные contract tests и review rollout |
stop-incomparable | семейства контрактов различаются | открыть отдельный review для другой операции |
stop-missing | нет обязательного поля или значения | сохранить поле, адаптировать consumer или выпустить новую поверхность |
stop-version | consumer не заявляет версию API | получить supported versions и тест на переход |
stop-unknown-consumer | нет карточки потребителя | установить владельца, family и минимальное чтение |
stop-undocumented-exception | обход не имеет имени или отрицательной границы | оформить versioned exception либо удалить обход |
Важно не смешивать эти исходы в один процент «готовности». Процент скрывает, что часть клиентов нельзя сопоставить, часть требует поля, а часть неизвестна. Список причин дольше, зато он подсказывает конкретную работу и не превращает неизвестность в разрешение на rollout.
\nЭтот алгоритм не обнаруживает неизвестные интеграции сам. Неполный инвентарь остаётся риском. Он также не заменяет security review, тестирование прав, нагрузочную проверку, SLO, миграцию данных, проверку идемпотентности команд и план отката. Для асинхронного API нужно дополнительно проверять порядок событий, повторную доставку и версию схемы сообщения. Для публичного API понадобятся правила deprecation и коммуникация с внешними клиентами.
\nФикстура из статьи не доказывает поведение реального сервиса. Она проверяет только заранее введённые literals и может пропустить ошибку сериализатора, конфигурации или среды. Node.js 18+ нужен лишь для воспроизведения примера; production-совместимость не зависит от одной версии Node и должна проверяться в поддерживаемых окружениях.
\nReview можно считать завершённым только для явно ограниченной пары, если в записи есть family, версия, consumer, required fields, допустимые ошибки, проверенные гарантии, список отрицательных тестов и итоговый status. Положительный status должен содержать слово «для этой фикстуры» или эквивалентную границу. Если нет карточки потребителя или неизвестно поведение обязательного поля, честный результат — stop, а не «вероятно совместимо».
\n