Files

8 lines
26 KiB
JSON
Raw Permalink 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": 71,
"slug": "editorial-2026-01-mechanism-platform-api",
"title": "Платформенный API без скрытых обещаний: как проверить контракт и потребителя",
"excerpt": "Успешный HTTP-ответ ещё не означает совместимость. Разбираем, как отделить схему от случайного поведения реализации, проверить пару «контракт — потребитель» и оформить особый режим так, чтобы он не стал вторым API.",
"contentHtml": "<p>Проблема платформенного API часто обнаруживается после успешного запроса. Владелец сервиса изменил внутренний сериализатор, а клиент уже зависел от порядка элементов, неописанного поля или значения, которое встречалось только в одном окружении. Логи показывают HTTP 200, но экран пуст, статус неверен, а fallback получает значение без определённого смысла.</p><p>Первое действие в таком случае — не возвращать старый код наугад, а зафиксировать пару: конкретная операция, её контракт и конкретный потребитель. API совместим не сам по себе. Совместимость означает, что данный потребитель использует только обещанную поверхность, понимает заявленные ответы и имеет явную ветку для отказа. Всё остальное — гипотеза, которую нужно проверить.</p><p>Ниже — учебный маршрут для HTTP API. Пример синтетический: он не описывает production-сервис и не доказывает его доступность. Его задача — показать, какие ожидания следует сделать видимыми до изменения endpoint.</p>\n<h2>Начните с наблюдаемого симптома</h2>\n<p>Запишите один воспроизводимый случай: запрос, версию сервиса, фактический статус, тело ответа и действие потребителя. Фраза «после релиза сломалась интеграция» слишком широка. Полезнее: «при <code>GET /catalog/r-17</code> клиент получил 200, но поле <code>state</code> стало <code>archived</code>; клиент знает только <code>ready</code> и <code>blocked</code> и показал общий fallback».</p><p>Затем разделите наблюдение и ожидание. Наблюдение — ответ действительно содержал <code>archived</code>. Ожидание — клиент рассчитывал на закрытый набор состояний. Если второе не записано в контракте или тесте, это скрытая зависимость клиента, даже если сервис годами возвращал только два значения.</p><p>Не смешивайте уровни. HTTP определяет общую семантику запроса, ответа и классов статус-кодов, но не знает, что для конкретного каталога означает <code>fixed-not-found</code> или <code>blocked</code>. Прикладное значение должно жить в схеме и документации вашей операции.</p>\n<h2>Разложите поверхность контракта</h2>\n<p>Для одной операции выпишите пять границ. <strong>Идентичность</strong> — имя операции, contract family и версию. <strong>Запрос</strong> — обязательные поля, типы и допустимые значения. <strong>Ответ</strong> — обязательные и необязательные поля, включая правило обработки неизвестных полей. <strong>Ошибки</strong> — статус, код и действие потребителя. <strong>Гарантии</strong> — только то, что команда действительно готова поддерживать: например, порядок элементов или идемпотентность.</p><p>Текущая реализация не становится гарантией автоматически. Если SQL сейчас возвращает строки по дате, это ещё не обещание сортировки. Если gateway отвечает за 40 миллисекунд в одном замере, это ещё не SLO. Если JSON-сериализатор добавил поле, это ещё не разрешение потребителю использовать его как обязательное.</p>\n<figure><img src=\"/assets/editorial/2026/platform-api-2026-contract-surface.svg\" alt=\"Пять границ контракта платформенного API: идентичность операции, запрос, ответ, ошибки и гарантии\" loading=\"lazy\" /><figcaption>Контракт отделяет названные обязательства от внутреннего устройства сервиса. Потребитель проверяется только относительно этой поверхности, а не относительно случайного поведения текущей реализации.</figcaption></figure>\n<h2>Сделайте форму ответа исполняемой</h2>\n<p>Схема нужна не вместо текста, а вместе с ним. В синтетическом OpenAPI-фрагменте ниже операция возвращает фиксированную запись. Поля <code>id</code> и <code>state</code> обязательны, <code>label</code> можно не прислать, а дополнительные ключи не превращаются в новые обещания без отдельного решения.</p><pre><code>openapi: 3.1.1\ninfo:\n title: Fixed Catalog API\n version: 1.3.0\npaths:\n /catalog/{id}:\n get:\n operationId: readFixedRecord\n parameters:\n - name: id\n in: path\n required: true\n schema: { type: string }\n responses:\n '200':\n description: A record visible to this consumer\n content:\n application/json:\n schema:\n type: object\n required: [id, state]\n properties:\n id: { type: string }\n state: { type: string, enum: [ready, blocked] }\n label: { type: string }\n '404':\n description: The fixed record is not available\n content:\n application/json:\n schema:\n type: object\n required: [code]\n properties:\n code: { const: fixed-not-found }</code></pre><p>Это не означает, что любой валидатор автоматически проверит смысл клиента. Схема может подтвердить наличие поля, но не ответит, можно ли показывать <code>blocked</code> как доступный, или должен ли повтор запроса быть безопасным. Эти правила нужно записать в описание операции и покрыть тестами потребителя.</p><p>Если API использует строгую проверку неизвестных ключей, это тоже часть контракта и её нужно назвать. Если клиент обязан игнорировать дополнительные поля, запишите это как правило расширения и проверьте на фикстуре. Не выдавайте выбранную настройку валидатора за универсальное свойство OpenAPI: поведение зависит от конкретного инструмента и его конфигурации.</p>\n<h2>Проверьте потребителя, а не только схему</h2>\n<p>Карточка потребителя должна отвечать на четыре вопроса: какую family и версию он принимает, какие поля реально читает, какие значения перечислений умеет обрабатывать и какие ошибки различает. Поиск по имени endpoint недостаточен: тот же URL может вызываться браузером, worker-ом и старым мобильным клиентом с разными ожиданиями.</p><p>Следующий небольшой пример воспроизводит проверку в памяти. Он намеренно не делает HTTP-запрос. Запустите его в Node.js 20+ как <code>node contract-check.mjs</code> после сохранения блока в файл. Результат с <code>compatible: true</code> означает только прохождение перечисленных проверок для этой фикстуры.</p><pre><code>const contract = {\n family: 'fixed-catalog-read-v1',\n version: '1.3.0',\n requiredResponseFields: ['id', 'state'],\n states: ['ready', 'blocked'],\n errors: ['fixed-not-found'],\n};\n\nconst consumer = {\n family: 'fixed-catalog-read-v1',\n supportedVersions: ['1.3.0'],\n requiredResponseFields: ['id', 'state'],\n states: ['ready', 'blocked'],\n errors: ['fixed-not-found'],\n};\n\nfunction checkCompatibility(api, client) {\n const sameFamily = api.family === client.family;\n const versionKnown = client.supportedVersions.includes(api.version);\n const fieldsKnown = client.requiredResponseFields.every((field) =&gt;\n api.requiredResponseFields.includes(field));\n const statesKnown = client.states.every((state) =&gt; api.states.includes(state));\n const errorsKnown = client.errors.every((error) =&gt; api.errors.includes(error));\n\n return { sameFamily, versionKnown, fieldsKnown, statesKnown, errorsKnown,\n compatible: sameFamily &amp;&amp; versionKnown &amp;&amp; fieldsKnown &amp;&amp; statesKnown &amp;&amp; errorsKnown };\n}\n\nconsole.log(checkCompatibility(contract, consumer));</code></pre><p>Поменяйте в фикстуре <code>consumer.states</code> на <code>['ready', 'blocked', 'archived']</code>. Проверка завершится с <code>statesKnown: false</code>: клиент заявляет значение, которого нет в текущем контракте. Поменяйте <code>family</code> на <code>fixed-catalog-command-v1</code> — результат должен остановиться на <code>sameFamily: false</code>. Это важнее похожего имени: read и command имеют разные семантики и не образуют пару для автоматического вывода.</p><p>В production такой тест должен получать контракт из вашей схемы или типизированного артефакта, а не из двух вручную синхронизированных объектов. Учебная копия полезна для объяснения алгоритма, но сама по себе не предотвращает расхождение.</p>\n<h2>Разберите ошибки и отрицательные пути</h2>\n<p>Самые дорогие несовместимости происходят там, где клиент превращает неизвестное состояние в правдоподобный успех. Для каждой ошибки задайте статус, прикладной код и действие. Если клиент видит неизвестный код, безопаснее остановить обработку и показать диагностируемый отказ, чем назвать его «не найдено».</p><div class=\"table-scroll\"><table><caption>Матрица проверки контракта платформенного API</caption><thead><tr><th scope=\"col\">Изменение или симптом</th><th scope=\"col\">Скрытое ожидание</th><th scope=\"col\">Воспроизводимая проверка</th><th scope=\"col\">Решение</th></tr></thead><tbody><tr><td>Удалено поле <code>state</code></td><td>Поле считалось обязательным только в коде клиента</td><td>Прогнать фикстуру ответа без поля и проверить отказ до рендера</td><td>Сохранить поле, мигрировать клиента или выпустить новую версию</td></tr><tr><td>Добавлено значение <code>archived</code></td><td>Перечисление считалось закрытым</td><td>Подать новое значение в consumer test и проверить явную ветку unknown</td><td>Добавить поддержку, объявить расширение или не отправлять значение старому клиенту</td></tr><tr><td>Порядок элементов изменился</td><td>Клиент использовал первый элемент как главный</td><td>Перемешать массив с теми же элементами и сравнить результат</td><td>Объявить сортировку или убрать зависимость от позиции</td></tr><tr><td>Пришёл новый error code</td><td>Неизвестная ошибка считалась <code>404</code></td><td>Подставить код <code>access-denied</code> и проверить ветку отказа</td><td>Сохранить смысл ошибки и добавить явную миграцию клиента</td></tr><tr><td>API отвечает 200, экран пуст</td><td>HTTP-успех приняли за прикладной успех</td><td>Сверить тело, schema validation и решение consumer-а</td><td>Разделить транспортный статус и прикладное состояние</td></tr></tbody></table></div>\n<p>Тесты должны включать не только валидный ответ. Минимальный набор — отсутствие каждого обязательного поля, неизвестное значение перечисления, перестановка массива, неизвестный error code и несовместимая family. Каждый тест должен фиксировать ожидаемое действие: отказ, безопасный fallback или обработку. Одного snapshot-а успешного JSON недостаточно.</p>\n<h2>Оформите особый режим как отдельный контракт</h2>\n<p>Платформенной команде иногда нужен временный обход: сырой envelope для миграции, расширенный ответ для одного worker-а или флаг, который открывает новую форму. Проблема не в самом исключении. Проблема начинается, когда его называют «внутренним» и не фиксируют имя, владельца, срок удаления, потребителей и отрицательные гарантии.</p><p>У особого режима должны быть отдельные operation name или media type, версия, разрешённые потребители и список того, чего он не обещает. Например: режим <code>raw-envelope-v1</code> возвращает поля <code>id</code> и <code>state</code> для одного миграционного worker-а; он не гарантирует сортировку, фильтрацию, будущие поля, latency или доступность. Отрицательная граница не украшение: она не даёт временной форме стать постоянным вторым API.</p><p>Если особый режим нельзя удалить без поиска по коду и конфигурации, его уже трудно контролировать. Добавьте метрику вызовов по имени режима, тест на разрешённый список потребителей и дату пересмотра. Метрика показывает использование, но не доказывает совместимость и не заменяет контрактный тест.</p>\n<h2>Классифицируйте изменение по последствиям</h2>\n<p>SemVer полезен, если команда действительно применяет его к названному публичному API: добавление обратно совместимой возможности обычно относится к minor, а несовместимое изменение — к major. Но номер не обнаруживает скрытых клиентов. Удаление поля может быть breaking change даже при «вежливом» тексте релиза, а добавление значения enum может сломать клиент, который исчерпывающе обрабатывает варианты.</p><p>Поэтому перед изменением соберите diff не только схемы, но и поведения. Для каждого пункта ответьте: меняется ли обязательность поля, множество значений, порядок, смысл ошибки, способ авторизации или время жизни особого режима? Затем найдите потребителей статическим поиском, реестром клиентов и runtime-метрикой. Ни один источник не гарантирует полный инвентарь в одиночку: dynamic import, конфигурация и старые версии требуют отдельной проверки.</p><p>OpenAPI описывает HTTP-поверхность в машиночитаемом виде. RFC 9110 задаёт общие semantics HTTP. SemVer помогает договориться о нумерации. Вместе они уменьшают догадки, но не отвечают за прикладной смысл и не подтверждают, что найден каждый потребитель.</p>\n<h2>Порядок действий перед выпуском</h2><ol><li><strong>Зафиксируйте симптом.</strong> Сохраните запрос, фактический ответ, версию сервиса и решение клиента; отделите наблюдение от предположения.</li><li><strong>Назовите контракт.</strong> Запишите operation, family, версию, обязательные поля, перечисления, ошибки и гарантии.</li><li><strong>Сопоставьте потребителей.</strong> Для каждого укажите поддерживаемые версии, реально читаемые поля и отрицательные ветви.</li><li><strong>Проверьте границы.</strong> Удалите обязательное поле, добавьте неизвестное значение, перемешайте список и подайте новый error code.</li><li><strong>Разберите особые режимы.</strong> Дайте им имя, разрешённый список потребителей, отрицательные гарантии и план удаления.</li><li><strong>Запустите проверку.</strong> Выполните schema validation, consumer contract tests и интеграционный smoke в названном окружении. Сохраните команды и результаты.</li><li><strong>Примите решение для пары.</strong> Запишите, совместимы ли конкретная версия API и конкретный потребитель. «Похожий endpoint работает» не является таким решением.</li></ol>\n<h2>Границы применимости</h2><p>Эта схема проверяет форму и заявленные ожидания. Она не доказывает доступность, latency, пропускную способность, безопасность, корректность данных в базе или работу всех клиентов. Успешный schema validation не подтверждает бизнес-правило. Успешный smoke подтверждает только названный маршрут, режим и окружение.</p><p>Пример с Node.js синтетический: он сравнивает заранее заданные массивы и не загружает OpenAPI-файл, не вызывает сеть и не проверяет права. Не переносите его как готовый production validator. В реальном проекте укажите источник схемы, генератор типов, версию артефакта и способ обнаружения потребителей.</p><p>Строгая остановка неизвестного состояния безопаснее молчаливой подмены, но может ухудшить доступность. Решение о fallback зависит от риска операции: для справочного текста допустим нейтральный fallback, для платежного статуса — явный отказ и расследование. Это прикладное решение, а не следствие одного HTTP-кода.</p>\n<h2>Критерий готовности</h2><p>Изменение можно передавать на выпуск, когда другой инженер без чтения реализации отвечает на пять вопросов: какую операцию меняем; какую family и версию принимает клиент; какие поля и значения обязательны; какие ошибки он различает; где ограничен особый режим. Для breaking-пути есть тест, который показывает явное действие, а не правдоподобный успех.</p><p>Если на один вопрос приходится отвечать «так было принято» или «этот флаг всегда работал», контракт ещё не найден. Назовите ожидание, решите, должно ли оно стать публичным обязательством, и либо добавьте его в версию, либо удалите зависимость. Только после этого номер версии и зелёный HTTP 200 становятся частью доказательства, а не заменой доказательства.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://spec.openapis.org/oas/v3.1.1.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification 3.1.1</a> — официальная спецификация машиночитаемого описания HTTP API. Она помогает зафиксировать paths, operations, responses и schemas, но не знает скрытых ожиданий конкретного клиента.</li><li><a href=\"https://semver.org/\" target=\"_blank\" rel=\"noopener noreferrer\">Semantic Versioning 2.0.0</a> — официальное правило связи публичного API и номеров major/minor/patch. Оно не инвентаризирует потребителей и не проверяет поведение перечислений.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — нормативное описание общей семантики HTTP-запросов, ответов и статус-кодов. RFC не определяет прикладной код ошибки или contract family конкретного сервиса.</li></ul>"
}