8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 72,
|
||
"slug": "editorial-2026-01-practice-platform-api",
|
||
"title": "Платформенный API без скрытых обещаний: как проверить контракт до hand-off",
|
||
"excerpt": "Потребитель просит особый флаг или поле, а команда рискует превратить случайное поведение в обязательство. Разбираем контрактную поверхность, узкий escape hatch и проверяемый stop для несовместимых случаев.",
|
||
"contentHtml": "<p>Проблема часто начинается с безобидной просьбы: потребителю нужен ещё один флаг, сырой фрагмент ответа или особый порядок элементов. Платформенная команда добавляет параметр и закрывает задачу. Через месяц другой клиент начинает зависеть от этого поведения. Затем команда меняет внутренний формат, а клиент ломается на поле, которое никто не называл публичным.</p>\n<p>Цена ошибки — не только откат релиза. Клиент может принять неверное решение, сохранить неправильное состояние или повторить операцию. Владельцы API тратят время на спор: это баг, новая гарантия или локальный обход? Номер версии и проходящий schema-check не отвечают на этот вопрос.</p>\n<p>Тезис простой: платформенный API нужно проверять как договор между конкретным контрактом и конкретным потребителем. Сначала назовите операцию, вход, ответ, ошибки и гарантии. Потом сравните их с решением потребителя. Если требование выходит за поверхность, оформите узкое documented escape hatch или остановите hand-off с причиной.</p>\n<h2>Что считается контрактом</h2>\n<p>Контракт описывает не все детали реализации. Он описывает то, на чём потребитель вправе строить решение. Для API чтения это обычно операция, версия, обязательные поля запроса, обязательные поля ответа, допустимые ошибки и смысл значений. Дополнительное поле не становится гарантией только потому, что его видно в JSON.</p>\n<p>Отдельно фиксируйте поведение при расширении. Один reader игнорирует неизвестные поля. Другой закрыто десериализует объект. Третий строит хэш полного ответа. Для них одно и то же добавление имеет разный риск. Проверять нужно reader, а не только схему.</p>\n<p>То же относится к порядку. Если клиент берёт первый элемент массива, порядок стал частью его фактического ожидания. Но это ещё не значит, что API его обещал. Пока команда не записала такую гарантию и не проверила её, результатом должен быть stop, а не новая версия с более уверенным названием.</p>\n<figure><img src=\"/assets/editorial/2026/platform-api-2026-contract-surface.svg\" alt=\"Контрактная поверхность платформенного API: операция, запрос, ответ, ошибки и гарантии\" loading=\"lazy\" /><figcaption>Потребитель должен проходить через названную поверхность API. Скрытый параметр и неописанная гарантия не расширяют договор автоматически.</figcaption></figure>\n<h2>Сначала решение потребителя, потом форма ответа</h2>\n<p>Потребитель редко просит поле ради самого поля. Он хочет выбрать ветку: показать статус, повторить запрос, отобразить объяснение или передать запись дальше. Запишите это решение первым. Затем спросите, какой минимальный факт ему нужен.</p>\n<p>Например, fixed reader читает только <code>id</code> и <code>state</code>. Ему не нужен весь внутренний объект. Другой reader требует <code>legacyMode</code>. Это не повод молча добавить поле в общий договор. Сначала проверьте семью контракта, версию, обязательность поля и условие миграции.</p>\n<p>Такой порядок сдерживает две крайности. Команда не превращает ответ в бесконечный объект «на будущее». И она не отказывает потребителю общей фразой «так нельзя». Для каждого требования появляется конкретный путь: публичное расширение, отдельное исключение или stop с недостающим фактом.</p>\n<h2>Учебный пример проверки</h2>\n<p>Ниже — учебная модель. Она не отправляет HTTP-запросы, не читает production-трафик и не доказывает совместимость настоящего сервиса. Код показывает только порядок проверки и названия отрицательных результатов.</p>\n<pre><code>type Contract = {\n family: 'catalog-read';\n version: '1.3.0';\n requiredResponse: Array<'id' | 'state'>;\n errors: Array<'not_found' | 'invalid_request'>;\n guarantees: Array<'order_is_irrelevant'>;\n};\n\ntype Consumer = {\n family: string;\n requiredFields: string[];\n needsStableOrder: boolean;\n};\n\nfunction review(contract: Contract, consumer: Consumer) {\n if (consumer.family !== contract.family) {\n return 'stop-incomparable-family';\n }\n\n const missing = consumer.requiredFields.filter(\n (field) => !contract.requiredResponse.includes(field as 'id' | 'state'),\n );\n if (missing.length > 0) return 'stop-missing-contract-field';\n if (consumer.needsStableOrder && !contract.guarantees.includes('order_is_stable')) {\n return 'stop-undeclared-guarantee';\n }\n return 'bounded-review-hand-off';\n}</code></pre>\n<p>Положительный результат здесь узкий. Он означает, что учебный consumer относится к той же семье, его обязательные поля описаны, а требуемые гарантии не выходят за контракт. Он не означает, что реальный клиент работает, что API выдержит нагрузку или что выпуск безопасен.</p>\n<p>Отрицательный путь важнее. Для command API возвращается <code>stop-incomparable-family</code>. Для требования <code>legacyMode</code> — <code>stop-missing-contract-field</code>. Для непроверенного порядка — <code>stop-undeclared-guarantee</code>. Сохраняйте причину. Слово «совместимо» без причины не помогает следующему инженеру продолжить проверку.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<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>Клиент использует поле, которого нет в документации</td><td>Наблюдение приняли за гарантию</td><td>Сравнить reader с declared response fields</td><td>Остановить hand-off или объявить поле отдельным изменением</td></tr><tr><td>Добавление поля ломает старый reader</td><td>Клиент строго разбирает объект или хэширует ответ</td><td>Проверить декодер на unknown fields, отсутствие и null</td><td>Сохранить совместимую форму либо подготовить migration</td></tr><tr><td>Потребитель зависит от первого элемента</td><td>Порядок не назван, но стал скрытой гарантией</td><td>Найти сортировку и проверку порядка в коде consumer</td><td>Добавить явную гарантию и тест или убрать зависимость</td></tr><tr><td>Особый query-флаг нужен одному клиенту</td><td>Escape hatch не имеет владельца и границы</td><td>Проверить имя, версию, вход, ответ и negative boundary</td><td>Оформить узкий hatch или удалить скрытый обход</td></tr><tr><td>Read API сравнивают с command API</td><td>Не названа contract family</td><td>Сопоставить операцию, вход и побочный эффект</td><td>Вернуть incomparable и завести отдельный review</td></tr></tbody></table></div>\n<h2>Escape hatch — отдельный договор</h2>\n<p>Escape hatch полезен, когда общий контракт честно не покрывает ограниченную задачу. Он должен иметь имя, версию, разрешённый вход, форму результата и отрицательную границу. Например, <code>raw-envelope-v1</code> может дать одному названному consumer одну дополнительную representation. Это не обещает порядок, задержку, хранение, доступность или сохранение будущих полей.</p>\n<p>Скрытый <code>debug-wire</code> устроен иначе. У него нет понятного получателя и предела. Один клиент прочитает внутреннее поле, второй скопирует его в свою схему, третий начнёт рассчитывать на случайный порядок. Название debug не ограничивает зависимость. Ограничивает её только записанный договор и проверяемая граница.</p>\n<p>Не расширяйте общий response «на всякий случай». Если потребителю нужен raw envelope, это отдельная поверхность с отдельным риском. Если потребитель не может назвать решение, которое он принимает с помощью особого поля, сначала уточните задачу. Без этого команда не знает, что именно должна поддерживать.</p>\n<h2>Порядок действий</h2>\n<ol><li><strong>Зафиксируйте симптом.</strong> Запишите вход, ответ, ветку consumer и цену неверного решения до изменения кода.</li><li><strong>Назовите участников.</strong> Укажите владельца API, конкретный consumer, contract family и версию. Слово «клиенты» недостаточно.</li><li><strong>Опишите поверхность.</strong> Выпишите required fields, допустимые значения, ошибки, порядок и гарантии. Отделите наблюдение от обещания.</li><li><strong>Найдите фактическую зависимость.</strong> Проверьте decoder, fallback, сравнение полного объекта, чтение первого элемента и особые параметры.</li><li><strong>Сравните изменение.</strong> Проверьте удаление, переименование, новый enum, неизвестные поля, отсутствие и <code>null</code>. Для разных family остановите сравнение.</li><li><strong>Выберите форму.</strong> Расширьте public contract с правилами совместимости, оформите versioned escape hatch или верните stop с причиной.</li><li><strong>Передайте ограниченный результат.</strong> Приложите diff, профиль consumer, тест положительного пути и тест каждого ожидаемого stop. Не выдавайте учебную проверку за rollout.</li></ol>\n<h2>Ограничения</h2>\n<p>Метод не обнаруживает потребителя, которого нет в inventory. Если API доступен за пределами известной команды, список зависимостей может быть неполным. В таком случае неописанное поведение безопаснее считать риском до отдельной проверки.</p>\n<p>Метод не заменяет нагрузочное тестирование, проверку доступа, анализ данных, SLA и план отката. OpenAPI описывает форму интерфейса, но не подтверждает, что реализация ей соответствует. SemVer помогает назвать изменение после определения public API, но номер версии сам не создаёт гарантию. HTTP-стандарт задаёт общие семантики, но не решает прикладной вопрос совместимости конкретного reader.</p>\n<p>Положительный учебный пример также не даёт production-результата. Его граница — порядок мышления: назвать контракт, назвать потребителя, проверить нужный факт и остановиться там, где факта нет.</p>\n<h2>Критерий готовности</h2>\n<p>Проверка готова, когда другой инженер без устного пояснения находит в одном месте contract family, версию, required fields, допустимые ошибки, явные гарантии, профиль каждого проверенного consumer, diff изменения и автоматические проверки положительного и отрицательного путей.</p>\n<p>Тест должен падать, если удалили обязательное поле, добавили неподдерживаемое значение, нарушили объявленный порядок или вернули скрытый hatch без имени и границы. Если хотя бы одного элемента нет, результат — не «совместимо», а конкретный 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. Спецификация описывает поверхность интерфейса, но не выбирает поведение reader при неизвестном поле.</li><li><a href=\"https://semver.org/spec/v2.0.0.html\" target=\"_blank\" rel=\"noopener noreferrer\">Semantic Versioning 2.0.0</a> — официальные правила версий для объявленного public API. Номер версии не заменяет inventory потребителей и проверку гарантий.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — официальный стандарт общих семантик HTTP, включая request, response, status и representation. Он не задаёт прикладной escape hatch и не выносит verdict о совместимости конкретного клиента.</li></ul>"
|
||
}
|