8 lines
26 KiB
JSON
8 lines
26 KiB
JSON
{
|
||
"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) =>\n api.requiredResponseFields.includes(field));\n const statesKnown = client.states.every((state) => api.states.includes(state));\n const errorsKnown = client.errors.every((error) => api.errors.includes(error));\n\n return { sameFamily, versionKnown, fieldsKnown, statesKnown, errorsKnown,\n compatible: sameFamily && versionKnown && fieldsKnown && statesKnown && 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>"
|
||
}
|