9 lines
23 KiB
JSON
9 lines
23 KiB
JSON
{
|
||
"index": 72,
|
||
"slug": "editorial-2026-01-practice-platform-api",
|
||
"title": "Платформенный API без скрытых обещаний: как проверить контракт до hand-off",
|
||
"excerpt": "Инженерный маршрут для ситуации, когда потребителю нужен особый флаг, порядок или внутреннее поле: зафиксировать решение, проверить названного consumer и остановиться там, где начинается неописанное обязательство.",
|
||
"contentHtml": "<p>В понедельник инженер платформенной команды открыл релизную задачу от адаптера каталога: клиент должен был показать состояние записи, но в ответе не хватало поля <code>legacyMode</code>. Сначала он проверил ручной запрос и увидел этот признак в сыром JSON. В браузере ответ выглядел правильным, поэтому команда почти добавила поле в общий response.</p>\n<p>Через несколько минут разработчик адаптера запустил строгий декодер и получил другую картину: его версия клиента читала только <code>id</code> и <code>state</code>, а значение <code>legacyMode</code> требовалось лишь одной старой ветке. Одно наблюдение успели принять за обещание всему API. Это учебный сценарий, но его цена реальна: случайное поле превращается в зависимость, а последующее изменение — в спор о том, был ли контракт нарушен.</p>\n<p>Надёжный hand-off начинается не с добавления поля и не с номера версии. Сначала нужно назвать решение потребителя, семью контракта, версию, вход, ответ, ошибки и гарантии. Затем сравнить эти пункты с кодом конкретного клиента. Если требование выходит за описанную поверхность, есть только три честных результата: расширить public contract с правилами совместимости, оформить отдельный ограниченный escape hatch или вернуть stop с причиной.</p>\n<h2>Сценарий: как случайное поле стало спорным обещанием</h2>\n<p>Платформенный сервис в нашем примере обслуживает чтение фиксированной записи. Его владелец обещает операцию <code>readFixedRecord</code>, обязательный вход <code>recordId</code>, поля <code>id</code> и <code>state</code> в ответе и ошибку <code>not_found</code>. Сортировка массива, задержка ответа и внутренние поля объекта в этот список не входят.</p>\n<p>Сначала потребитель прислал короткий запрос: нужен флаг, чтобы выбрать старый экран. Команда посмотрела на фактический ответ, нашла <code>legacyMode</code> и предложила добавить его без изменения маршрута. После этого инженер проверил reader: старый экран действительно использовал флаг, но новый адаптер его не читал. Дальше проверка показала ещё одну границу — один клиент строго отклонял неизвестные поля.</p>\n<p>Поворот здесь не в том, что особые поля запрещены. Поворот в различии между тремя утверждениями: поле однажды вернулось, поле разрешено читать этому consumer и поле гарантировано всем потребителям версии. Только второе и третье являются предметом контракта. Если их не разделить, сервер будет поддерживать не API, а набор случайных наблюдений.</p>\n<figure><img src='/assets/editorial/2026/platform-api-2026-contract-surface.svg' alt='Схема проверки платформенного API: потребность consumer проходит через операцию, запрос, ответ, ошибки и гарантии; отдельный escape hatch имеет имя и границу, а скрытый флаг ведёт к остановке' loading='lazy' /><figcaption>Контрактная поверхность должна быть обозримой: у ожидания есть операция, версия, поля и границы. Скрытый флаг не получает статус гарантии только потому, что его видно в ответе.</figcaption></figure>\n<h2>Контракт — это список разрешённых ожиданий</h2>\n<p>Тип или пример JSON отвечает на вопрос, какие данные могут встретиться. Контракт отвечает на более узкий вопрос: на какие данные и свойства потребитель может опереться, не договариваясь с реализацией заново. Для каждой операции полезно выписать пять слоёв.</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>Имя, действие и contract family</td><td><code>readFixedRecord</code>, <code>catalog-read-v1</code></td><td>Что похожее поле означает то же действие в command API</td></tr><tr><td>Запрос</td><td>Обязательные и допустимые входы</td><td><code>recordId</code></td><td>Скрытый query-параметр для отладки</td></tr><tr><td>Ответ</td><td>Обязательные и явно optional поля</td><td><code>id</code>, <code>state</code>, optional <code>label</code></td><td>Любое поле, которое сегодня видно в wire-форме</td></tr><tr><td>Ошибки</td><td>Состояния, которые consumer различает</td><td><code>not_found</code>, <code>invalid_request</code></td><td>Что timeout или <code>403</code> можно молча заменить пустым ответом</td></tr><tr><td>Гарантии</td><td>Смысл значения, порядок, расширяемость и другие обещания</td><td><code>state</code> имеет перечисленные значения; порядок не обещан</td><td>Производительность, стабильность массива и будущие поля без записи</td></tr></tbody></table></div>\n<p>Отдельно фиксируйте поведение при расширении объекта. Tolerant reader может игнорировать неизвестные поля. Строгий декодер может отклонить их. Третий клиент может считать полный ответ частью подписи или хэша. Поэтому добавление поля нельзя оценивать только по схеме сервера: нужно проверить реальные правила чтения у названного consumer.</p>\n<p>Та же осторожность нужна для перечислений и порядка. Схема может оставить <code>state</code> строкой, но старый клиент всё равно упадёт на новом значении. Массив может обычно приходить отсортированным, но без гарантии это лишь наблюдение. Если consumer берёт первый элемент, зависимость существует в его коде, однако обязательство API ещё нужно отдельно принять и проверить.</p>\n<h2>Начните с решения потребителя</h2>\n<p>Просьба о поле почти всегда скрывает действие: показать экран, выбрать fallback, повторить запрос, передать запись в другой сервис. Запишите действие до обсуждения формата. Если потребитель не может сказать, какое решение зависит от поля, команда не знает, что именно она должна поддерживать.</p>\n<p>Следующий вопрос — та ли это семья контракта. Read API, command API и webhook могут иметь одинаковые <code>id</code> и <code>state</code>, но различаться побочными эффектами, идемпотентностью и жизненным циклом. Сравнивать их поля до проверки family опасно: совпадение названий создаёт ложное ощущение совместимости.</p>\n<p>После family проверяются версия и минимальный набор полей. Потребитель, которому нужен только <code>id</code>, не требует добавлять в public contract весь внутренний объект. Потребитель, которому нужен <code>legacyMode</code>, получает не молчаливое расширение, а явный выбор: поле становится частью версии, появляется адаптер или проверка останавливается.</p>\n<h2>Воспроизводимая проверка в чистой среде</h2>\n<p>Ниже самодостаточная команда для Node.js 18 и новее. Она работает только с объектами в памяти: сеть не вызывается, настоящий сервис не меняется, а положительный исход не доказывает готовность релиза. Запустите её в пустом каталоге так:</p>\n<pre><code>node --input-type=module <<'NODE'\nconst contract = {\n family: 'catalog-read-v1',\n version: '1.3.0',\n request: { required: ['recordId'] },\n response: { required: ['id', 'state'], optional: ['label'] },\n errors: ['not_found', 'invalid_request'],\n guarantees: { state: ['active', 'archived'], order: 'unspecified' },\n};\n\nfunction review(contract, consumer) {\n if (contract.family !== consumer.family) {\n return { status: 'stop-incomparable-family', reason: 'different-contract-family' };\n }\n\n const missing = consumer.requiredFields.filter(\n (field) => !contract.response.required.includes(field),\n );\n if (missing.length) {\n return { status: 'stop-missing-field', missing };\n }\n\n if (consumer.needsStableOrder && contract.guarantees.order !== 'stable') {\n return { status: 'stop-undeclared-guarantee', reason: 'stable-order' };\n }\n\n return { status: 'compatible-for-check', contract: contract.version };\n}\n\nconst consumers = [\n { name: 'fixed-reader', family: 'catalog-read-v1', requiredFields: ['id', 'state'], needsStableOrder: false },\n { name: 'legacy-reader', family: 'catalog-read-v1', requiredFields: ['id', 'legacyMode'], needsStableOrder: false },\n { name: 'command-adapter', family: 'catalog-command-v1', requiredFields: ['id', 'state'], needsStableOrder: false },\n];\n\nconsole.log(consumers.map((consumer) => ({ name: consumer.name, ...review(contract, consumer) })));\n// fixed-reader: compatible-for-check\n// legacy-reader: stop-missing-field, missing: ['legacyMode']\n// command-adapter: stop-incomparable-family\nNODE</code></pre>\n<p>Вызов <code>review</code> специально возвращает причину, а не булево значение. Для <code>legacy-reader</code> причина показывает, какое обязательство отсутствует. Для <code>command-adapter</code> проверка не доходит до полей: сначала нужно завести отдельную карточку command-контракта. Для <code>fixed-reader</code> положительный статус ограничен проверенными условиями и не означает, что сервер доступен, выдерживает нагрузку или соответствует этому объекту на практике.</p>\n<p>Перед использованием в проекте замените фикстуру реальным описанием: имя операции, supported versions, допустимые значения, политику неизвестных полей и список ошибок. Затем добавьте тесты на удаление обязательного поля, новый символ <code>state</code>, стабильный порядок и изменение family. Учебный код показывает последовательность; он не извлекает inventory клиентов и не заменяет contract test.</p>\n<h2>Как отличить расширение от утечки</h2>\n<p>Публичное расширение отвечает на три вопроса: кому доступно новое поле, что означает его отсутствие и появление, и как клиент должен пережить будущие добавления. Если ответы записаны в схеме, документации и тесте конкретного reader, изменение можно обсуждать как часть public contract. Если ответ звучит как «пока отдаём, потому что удобно», это ещё не гарантия.</p>\n<p>Особый маршрут не обязательно плох. Иногда отдельному внутреннему инструменту действительно нужна расширенная representation. Тогда оформите <code>raw-envelope-v1</code> как самостоятельную поверхность: укажите получателя, версию, вход, формат результата, права доступа и список того, что не обещается. В список границ могут входить порядок ключей, задержка, полнота внутренних полей, срок хранения и доступность.</p>\n<p>Скрытый <code>debug-wire</code> отличается не названием, а отсутствием владельца и границы. Его легко скопировать в новый клиент, но трудно удалить: никто не знает, какие зависимости уже возникли. Поэтому documented escape hatch должен быть виден в реестре API, иметь тест отрицательного пути и прекращаться по понятному условию. Если это невозможно, безопаснее удалить обход или вернуть stop.</p>\n<h2>Порядок действий перед передачей изменения</h2>\n<ol><li><strong>Зафиксируйте симптом.</strong> Запишите, что увидел конкретный клиент, какой ответ получил и какое неверное решение может принять.</li><li><strong>Назовите участников.</strong> Укажите владельца API, владельца consumer, операцию, contract family и поддерживаемую версию.</li><li><strong>Опишите поверхность.</strong> Перечислите входы, обязательные и optional поля ответа, значения перечислений, ошибки и гарантии.</li><li><strong>Проверьте reader.</strong> Найдите строгий декодер, fallback, сравнение полного объекта, зависимость от первого элемента и чтение скрытых параметров.</li><li><strong>Сравните изменение.</strong> Проверьте удаление, переименование, добавление enum, отсутствие, <code>null</code> и unknown fields. После этого отдельно проверьте family.</li><li><strong>Выберите форму.</strong> Расширьте public contract, оформите версионированный hatch или верните stop. Не подменяйте причину словом «совместимо».</li><li><strong>Передайте доказательства.</strong> Приложите diff, фикстуру, тест положительного пути и тест каждого ожидаемого stop. После hand-off отдельно решите вопросы нагрузки, доступа и отката.</li></ol>\n<h2>Что дают стандарты и чего они не дают</h2>\n<p>OpenAPI Specification описывает язык интерфейсов для HTTP API: операции, параметры, request bodies, responses и схемы. Это полезная форма для публикации surface, но спецификация не знает скрытый код consumer и не подтверждает, что реализация документу соответствует.</p>\n<p>Semantic Versioning требует сначала объявить public API, а затем связывает несовместимое изменение объявленного public API с major-версией и совместимое расширение с minor-версией. Из строки <code>1.3.0</code> нельзя вывести, является ли наблюдаемое поле публичным. Сначала нужно определить обязательство, затем классифицировать его изменение.</p>\n<p>RFC 9110 задаёт общие семантики HTTP, request/response и representation. Он помогает не путать транспортный протокол с прикладным договором, но не определяет политику неизвестных полей, особый query-флаг или совместимость конкретного декодера. Эти решения остаются у владельцев API и его потребителя.</p>\n<h2>Ограничения и критерий готовности</h2>\n<p>Проверка не находит клиентов, которых нет в inventory. Если API доступен за пределами известной команды, отсутствие зависимости в списке нельзя считать доказательством безопасности. Неописанное поведение следует считать риском до отдельного поиска по коду, логам, схемам и владельцам интеграций.</p>\n<p>Метод также не заменяет security review, проверку прав, нагрузочный тест, анализ данных, SLA, миграцию и план отката. Положительный результат команды выше означает только совместимость одной фикстуры с перечисленными условиями. Он не является разрешением на публикацию.</p>\n<p>Проверка готова, когда другой инженер без устного контекста находит named operation, family, версию, обязательные поля, ошибки, явные гарантии, профиль проверенного consumer, diff и автоматические проверки отрицательных путей. Если не хватает хотя бы одного пункта, следующий результат должен называться конкретным stop и содержать имя недостающего доказательства.</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 и его request/response surface. Граница применимости: спецификация не доказывает соответствие реализации и не задаёт migration policy конкретного consumer.</li><li><a href='https://semver.org/spec/v2.0.0.html' target='_blank' rel='noopener noreferrer'>Semantic Versioning 2.0.0</a> — официальная спецификация правил версий. Граница применимости: SemVer классифицирует изменения уже объявленного public API, но не решает, какое наблюдаемое поле стало публичным.</li><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html' target='_blank' rel='noopener noreferrer'>RFC 9110: HTTP Semantics</a> — нормативное описание общих семантик HTTP, сообщений и representations. Граница применимости: RFC не определяет прикладную совместимость reader, порядок элементов и правила escape hatch.</li></ul>",
|
||
"readingMinutes": 12
|
||
}
|