Files
progcode/editorial/agent-rewrites/071.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

2 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":71,"slug":"editorial-2026-01-mechanism-platform-api","title":"Платформенный API без скрытых обещаний: как проверить контракт и потребителя","excerpt":"Платформенный API ломается не в момент изменения endpoint, а раньше: потребитель начинает зависеть от неописанного поля, порядка ответа или особого флага. Разбираем поверхность контракта, именованные исключения и проверку совместимости на учебном примере.","contentHtml":"<p>Потребитель вызывает платформенный API и получает ответ, который формально не нарушает документацию. Но код уже читает поле, которого нет в контракте, ждёт определённый порядок элементов или включает внутренний режим особым флагом. Через месяц владелец меняет реализацию. Запрос остаётся успешным, а потребитель начинает показывать пустой экран, неверный статус или устаревшие данные.</p><p>Симптомы обычно появляются не в одном месте. В логах нет ошибки схемы. В трассировке виден HTTP 200. Падение происходит позже: парсер не находит поле, сортировка меняет порядок, а fallback получает значение, для которого не определено поведение. Цена ошибки — не только один сломанный экран. Команда откладывает релиз, возвращает совместимость наугад и навсегда добавляет в платформу исключение, о котором знают только два человека.</p><p>Тезис статьи простой: платформенный API нужно проверять как набор объявленных ожиданий. У операции есть имя и версия. У запроса есть обязательные поля. У ответа есть обязательные и необязательные поля. У ошибок есть закрытый или явно расширяемый набор. Любой особый маршрут получает имя, границу и отдельное решение о совместимости. Если потребитель зависит от детали, которой нет в этом списке, система уже имеет скрытый контракт.</p>\n<h2>Механизм: контракт ограничивает ожидания</h2>\n<p>API — это не только URL и тип ответа. Контракт отвечает на четыре вопроса: что отправляет потребитель, что возвращает сервис, какие ошибки он различает и что именно сервис гарантирует. Реализация может быть сложнее. Потребитель должен зависеть только от объявленной части.</p><p>Для платформенного сервиса полезно разделить поверхность на пять блоков. Первый блок — идентичность: имя операции и версия контракта. Второй — запрос: обязательные поля и допустимые значения. Третий — ответ: обязательные поля, необязательные поля и правило расширения. Четвёртый — ошибки: коды, которые потребитель действительно умеет обработать. Пятый — гарантии: например, фиксированный набор состояний. Кэширование, задержка, сортировка и доступность не становятся гарантией только потому, что текущая реализация их даёт.</p><p>Эта граница защищает обе стороны. Владелец может менять внутренний код, если не меняет публичную поверхность. Потребитель не получает права читать новые поля «на всякий случай». Если новая потребность возникает, команда принимает её как изменение контракта, а не как случайный доступ к внутреннему представлению.</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>Ниже — синтетический TypeScript-пример. Он хранит данные в памяти, не обращается к сети и не показывает результат работы реального сервиса. Имена, версия и значения нужны, чтобы проверить логику границ.</p><pre><code>type CatalogResponse = {\n id: string;\n state: 'ready' | 'blocked';\n label?: string;\n};\n\ntype Contract = {\n family: 'fixed-catalog-read-v1';\n version: '1.3.0';\n operation: 'readFixedRecord';\n errors: ['fixed-not-found'];\n response: CatalogResponse;\n};\n\nconst contract: Contract = {\n family: 'fixed-catalog-read-v1',\n version: '1.3.0',\n operation: 'readFixedRecord',\n errors: ['fixed-not-found'],\n response: { id: 'r-17', state: 'ready', label: 'Demo' },\n};\n\nfunction consume(value: CatalogResponse) {\n if (value.state === 'ready') return value.label ?? value.id;\n return 'blocked';\n}\n\n// Учебная проверка: это не сетевой вызов и не измерение сервиса.\nconsole.log(consume(contract.response)); // Demo</code></pre><p>Потребитель использует только <code>id</code>, <code>state</code> и явно допустимое поле <code>label</code>. Он не читает внутреннюю сортировку, не предполагает наличие дополнительных ключей и не превращает неизвестную ошибку в успешный ответ. Обязательное поле <code>state</code> задаёт конечный набор значений. Если сервис отправит новое состояние, потребитель должен получить явный сигнал несовместимости или заранее иметь правило расширения.</p><p>Теперь рассмотрим особый случай. Потребителю нужна сырая форма записи. Это не повод открыть внутренний объект без условий. У исключения должны быть собственное имя, версия и отрицательная граница: например, «возвращает одну фиксированную форму; не обещает сортировку, фильтрацию, будущие поля, задержку или сохранность». Граница важнее самого доступа. Она не даёт временной лазейке стать вторым API.</p><pre><code>type EscapeHatch = {\n name: 'raw-envelope-v1';\n returns: 'one-fixed-representation';\n guarantees: ['id', 'state'];\n doesNotGuarantee: [\n 'ordering',\n 'filtering',\n 'future-fields',\n 'availability',\n ];\n};\n\nconst escapeHatch: EscapeHatch = {\n name: 'raw-envelope-v1',\n returns: 'one-fixed-representation',\n guarantees: ['id', 'state'],\n doesNotGuarantee: [\n 'ordering',\n 'filtering',\n 'future-fields',\n 'availability',\n ],\n};</code></pre><p>Если исключение нельзя назвать или его граница звучит как «пока работает», его нельзя считать контрактом. Отрицательный путь здесь обязательный. Сервис либо возвращает объявленную форму, либо останавливает вызов с известной ошибкой. Он не подменяет отсутствующее поле, не угадывает режим по тексту ошибки и не молча переключается на внутренний endpoint.</p>\n<h2>Совместимость: сравнивать нужно пару</h2>\n<p>Совместимость не принадлежит одному API. Она возникает между конкретным контрактом и конкретным потребителем. Одинаковое имя операции ничего не доказывает. Сначала нужно убедиться, что обе стороны относятся к одной семейству контракта. Затем сравнить версии, обязательные поля и ошибки.</p><p>Потребитель совместим с учебным контрактом, если он поддерживает версию <code>1.3.0</code>, требует только <code>id</code> и <code>state</code>, а также обрабатывает единственную объявленную ошибку <code>fixed-not-found</code>. Потребитель, который требует <code>legacyMode</code>, несовместим: поле отсутствует в ответе. Потребитель с семейством <code>fixed-catalog-command-v1</code> нельзя назвать несовместимым или совместимым с read-контрактом. Это другой тип операции. Его нужно разбирать отдельно.</p><p>Неизвестный потребитель тоже не равен совместимому. Если команда не знает его версию, обязательные поля и обработку ошибок, у неё нет данных для вывода. Правильное действие — запросить минимальную карточку потребителя, а не принять его по умолчанию и не расширить API наугад.</p>\n<h2>Симптом → причина → проверка → действие</h2><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>Он читает неописанное поле или значение</td><td>Сопоставьте чтение полей с опубликованной схемой</td><td>Удалите зависимость или добавьте поле в отдельную версию контракта</td></tr><tr><td>Ответ считается неверным при перестановке элементов</td><td>Потребитель зависит от неоговорённого порядка</td><td>Сравните контракт и код сортировки</td><td>Объявите порядок или запретите на него опираться</td></tr><tr><td>Особый флаг стал обязательным</td><td>Временный обход не получил имени и границы</td><td>Найдите флаг, его потребителей и обещания</td><td>Оформите versioned escape hatch либо удалите обход</td></tr><tr><td>Ошибка превращается в пустой успешный ответ</td><td>Клиент принимает неизвестные ошибки за известные</td><td>Сверьте список кодов и ветки обработки</td><td>Остановите неизвестный исход и добавьте явную миграцию</td></tr><tr><td>Команды спорят о совместимости</td><td>Сравнивают похожие имена, а не contract family</td><td>Проверьте family, version, required fields и errors</td><td>Разделите несопоставимые операции и повторите сравнение</td></tr></tbody></table></div>\n<h2>Что проверять в изменении</h2>\n<p>Добавление необязательного поля обычно безопаснее удаления обязательного, но безопасность зависит от поведения потребителя. Если сериализатор меняет форму, проверьте, принимает ли клиент неизвестные ключи. Если меняется перечисление, проверьте, что происходит с новым значением. Если меняется ошибка, проверьте отрицательный путь: клиент не должен сообщать «не найдено», когда сервис вернул «доступ запрещён».</p><p>Версия сама по себе не лечит несовместимость. Она только даёт адрес, по которому можно найти правила. Владелец должен связать версию с конкретной схемой, списком ошибок и известными потребителями. Потребитель должен хранить поддерживаемые версии и обязательные поля. Без этой пары строка <code>1.3.0</code> остаётся декоративной меткой.</p><p>OpenAPI помогает описать HTTP-поверхность так, чтобы её могли читать люди и инструменты. Но документ не знает скрытых зависимостей конкретного клиента. Семантическое версионирование требует объявить публичный API, однако не обнаруживает потребителей автоматически. Поэтому описание, инвентарь потребителей и проверка отрицательных ветвей дополняют друг друга.</p>\n<h2>Порядок действий</h2><ol><li>Назовите одну операцию, её contract family и версию.</li><li>Выпишите обязательные и необязательные поля запроса и ответа.</li><li>Назовите известные ошибки и поведение клиента для каждой.</li><li>Отделите гарантии от текущих свойств реализации: порядок, задержку, кэш и доступность.</li><li>Соберите карточку каждого потребителя: family, поддерживаемые версии, обязательные поля и ошибки.</li><li>Сначала отсеките другую family; не вычисляйте совместимость по похожему имени.</li><li>Проверьте удаление поля, новое значение перечисления, неизвестную ошибку и перестановку ответа.</li><li>Для особого случая задайте имя, версию и отрицательную границу либо удалите его.</li><li>Зафиксируйте результат для конкретной пары API и потребителя.</li></ol>\n<h2>Ограничения</h2>\n<p>Эта схема не доказывает доступность, задержку, безопасность или пропускную способность сервиса. Учебный код не заменяет контрактные тесты, интеграционный запуск и проверку прав. Он показывает форму решения и место, где нужно задать вопрос. Нельзя объявлять реальный API совместимым по одному описанию или одному успешному запросу.</p><p>HTTP-статус тоже не описывает всю прикладную семантику. Два ответа с кодом 200 могут содержать разные состояния, а одинаковый 404 может означать разные причины для разных операций. Потребитель должен видеть объявленные поля и ошибки, а не угадывать смысл по случайному тексту.</p><p>Жёсткий контракт имеет цену. Если команда запрещает любые дополнительные поля и особые режимы, потребители начнут копировать данные или обращаться к хранилищу напрямую. Поэтому escape hatch допустим, когда его стоимость и граница видны. Он не должен скрывать внутренние поля, не должен обещать будущее и не должен обходить проверку совместимости.</p>\n<h2>Проверяемый критерий готовности</h2><p>Изменение готово к следующему этапу, если другой инженер без чтения реализации может ответить на пять вопросов: какая операция меняется; какую версию поддерживает потребитель; какие поля он требует; какие ошибки он обрабатывает; где проходит граница особого случая. Для удаления поля, нового значения и неизвестной ошибки есть отдельный отрицательный сценарий. В нём система останавливается явно, а не возвращает правдоподобный, но неверный результат.</p><p>Если хотя бы на один вопрос приходится отвечать догадкой, контракт не готов. Сначала назовите скрытое ожидание и решите, должно ли оно стать публичной гарантией. Затем добавьте его в версию, оформите миграцию или удалите зависимость. Только после этого сравнивайте потребителей. Учебный пример не сообщает, что ваш сервис уже совместим; он задаёт проверяемую форму доказательства.</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 и снимает догадки о форме операции. Спецификация не обнаруживает скрытые зависимости конкретного потребителя.</li><li><a href=\"https://semver.org/\" target=\"_blank\" rel=\"noopener noreferrer\">Semantic Versioning 2.0.0</a> — требует объявлять публичный API и связывает изменения версии с совместимостью. SemVer не заменяет инвентарь потребителей и не проверяет поведение клиента.</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 и не решает миграцию конкретного API.</li></ul>\"\n}"}