Files

2 lines
28 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":70,"slug":"editorial-2026-01-field-platform-api","title":"Совместимость платформенного API: проверка до изменения контракта","excerpt":"Как проверить изменение API на паре «контракт — потребитель»: отрезать несопоставимые операции, найти скрытые ожидания и передать результат с явными причинами и ограничениями.","contentHtml":"<p>Платформенная команда добавила в ответ новое поле и подняла версию с <code>1.2.0</code> до <code>1.3.0</code>. Ручной запрос по-прежнему возвращает <code>200 OK</code>, поэтому изменение называют обратно совместимым. Но один клиент читает ответ строгим декодером, второй ждёт старый набор значений, а третий обращается к endpoint команды, хотя сравнивает его с read API. Через час после rollout один экран показывает пустое состояние, другой повторяет запрос, а участники review спорят о том, что означает слово «совместимо».</p>\n<p>Цена ошибки — не только сломанный экран. Клиент может записать неверное состояние, повторить необратимую операцию или превратить отказ в «объект не найден». Платформа затем получает временный флаг, скрытый обход и ещё одного потребителя, которого никто не внес в список. Поэтому вопрос перед изменением звучит точнее: совместим ли конкретный контракт с конкретным потребителем, для конкретного набора входов, ответов и ошибок?</p>\n<h2>Сначала зафиксируйте предмет проверки</h2>\n<p>Совместимость появляется не у версии самой по себе. Её проверяют на паре: именованный контракт и именованный потребитель. Контракт задаёт операцию, семейство, версию, формат запроса, формат ответа, ошибки и гарантии. Потребитель задаёт поддерживаемые версии, обязательные поля, допустимые значения и правила обработки отказов.</p>\n<p>В этой статье используется синтетическая пара <code>fixed-catalog-read-v1</code> и <code>fixed-tolerant-reader-v1</code>. Она нужна для воспроизводимого рассуждения, а не для заявления о реальном сервисе. Реальный review дополнительно потребует инвентарь потребителей, контрактные тесты, права на проверку окружения и доказательства поведения конкретной версии клиента.</p>\n<figure><img src=\"/assets/editorial/2026/platform-api-2026-consumer-compatibility-loop.svg\" alt=\"Цикл проверки совместимости: именованный контракт и потребитель проходят сравнение семейства, поверхности и исключений, после чего получают ограниченный результат или карточку остановки\" loading=\"lazy\" /><figcaption>Проверка идёт от сопоставимости к полям и гарантиям. Красная ветка возвращает причину в карточку потребителя; зелёная передаёт только результат этого review и следующий шаг.</figcaption></figure>\n<h2>Контракт — это больше, чем JSON-пример</h2>\n<p>Пример ответа показывает одну representation — передаваемое представление ресурса. Он не обещает порядок ключей, время ответа, кэширование, сохранность записи или поведение при неизвестном поле. Такие свойства становятся контрактом только тогда, когда их явно описали и связали с потребителем.</p>\n<pre><code>GET /records/r-17 HTTP/1.1\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n \"id\": \"r-17\",\n \"state\": \"ready\",\n \"label\": \"Демо-запись\"\n}</code></pre>\n<p>Для учебного read-контракта минимальная гарантия такова: <code>id</code> и <code>state</code> обязательны, <code>label</code> необязателен, а <code>state</code> принимает только <code>ready</code> или <code>blocked</code>. Отсутствие записи обозначается ошибкой <code>fixed-not-found</code>. Ответ с <code>401</code>, <code>403</code>, <code>422</code> или сетевым таймаутом не является этой ошибкой. Такая граница нужна, чтобы fallback не стирал различие между отсутствием данных и проблемой доступа.</p>\n<p>OpenAPI помогает записать HTTP-поверхность в форме, пригодной для людей и инструментов: operation, параметры, responses и schemas. Но схема не знает закрытую ветку кода клиента. Строгий декодер, зависимость от порядка массива и особый query-параметр могут существовать вне OpenAPI. Документ снижает неопределённость формы, но не заменяет проверку потребителя.</p>\n<h2>Проверьте семейство до сравнения полей</h2>\n<p>Сначала сравните <code>contractFamily</code>, затем версию и поверхность. Read API <code>fixed-catalog-read-v1</code> и командный адаптер <code>fixed-catalog-command-v1</code> могут иметь одинаковые поля <code>id</code> и <code>state</code>, но отвечают на разные действия. Один читает состояние, второй запускает изменение. Называть их несовместимыми — значит уже предположить, что сравнение допустимо. Правильный результат здесь — «несопоставимо», а не отрицательный вердикт по полям.</p>\n<p>Это короткая, но важная остановка. Если её пропустить, команда начнёт чинить не тот контракт: добавит в read API поля для команды или объявит общую версию, которая скрывает разные риски повторения и идемпотентности. Семейство должно быть именованным, а не выводиться по похожему URL или совпавшим ключам.</p>\n<pre><code>const api = {\n family: 'fixed-catalog-read-v1',\n version: '1.3.0',\n requiredResponse: ['id', 'state'],\n errors: ['fixed-not-found'],\n};\n\nconst consumer = {\n id: 'fixed-command-adapter-v1',\n family: 'fixed-catalog-command-v1',\n requiredResponse: ['id', 'state'],\n};\n\nfunction compareFamily(contract, client) {\n if (contract.family !== client.family) {\n return {\n status: 'stop-incomparable',\n reason: 'contract-family-mismatch',\n nextAction: 'open-separate-command-review',\n };\n }\n\n return { status: 'family-matches' };\n}\n\nconsole.log(compareFamily(api, consumer));\n// stop-incomparable: сравнение полей ещё не началось</code></pre>\n<p>Код использует только объекты в памяти. Он не обращается к сети, не проверяет реального клиента и не выдаёт разрешение на выпуск. Его полезность в том, что отрицательный путь нельзя случайно превратить в «почти совместимо»: при другой family функция останавливается до анализа полей.</p>\n<h2>Соберите карточку именованного потребителя</h2>\n<p>Фраза «у нас есть несколько клиентов» слишком расплывчата для решения. Карточка должна отвечать на пять вопросов: кто потребитель, к какому семейству относится, какую версию поддерживает, что ему обязательно прочитать и какие ошибки он различает. Если клиент зависит от неизвестных полей, порядка элементов, заголовка или задержки, зависимость нужно записать отдельно, даже если она пока не считается допустимой.</p>\n<div class=\"table-scroll\"><table><caption>Минимальная карточка перед изменением read 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>consumer id</td><td><code>fixed-tolerant-reader-v1</code></td><td>какую пару проверяем</td><td>что неизвестных клиентов нет</td></tr><tr><td>family</td><td><code>fixed-catalog-read-v1</code></td><td>операции сопоставимы</td><td>что поля совпадают</td></tr><tr><td>supported version</td><td><code>1.3.0</code></td><td>какую поверхность клиент заявляет</td><td>что реализация действительно её принимает</td></tr><tr><td>required fields</td><td><code>id</code>, <code>state</code></td><td>минимум чтения</td><td>что новое значение enum обработано</td></tr><tr><td>known errors</td><td><code>fixed-not-found</code></td><td>какой fallback разрешён</td><td>что timeout можно считать отсутствием</td></tr><tr><td>hidden dependency</td><td>не зафиксирована</td><td>остаётся вопрос для проверки</td><td>что можно объявить compatible</td></tr></tbody></table></div>\n<p>Пустая ячейка — это не нулевой риск. Если версия или required fields неизвестны, потребитель нельзя включать в успешный список. Это отдельный статус <code>stop-unknown-consumer</code> с действием «получить карточку». Инвентарь не должен награждать отсутствие сведений положительным verdict.</p>\n<h2>Сравнивайте не только наличие ключей</h2>\n<p>Добавление необязательного поля часто безопаснее удаления обязательного, но слово «часто» не является результатом проверки. Нужно знать, как клиент обращается с неизвестными ключами. У строгого валидатора расширение может стать ошибкой. У tolerant reader оно может быть проигнорировано. У третьего клиента отсутствие нового поля может включить устаревший режим.</p>\n<p>Особенно опасно изменение перечисления. Старый клиент принимает <code>ready</code> и <code>blocked</code>. Если сервер добавляет <code>paused</code>, JSON остаётся корректным, а смысл для клиента — нет. Поэтому compatibility check должен сравнить допустимые значения и переходы, которые видит потребитель. Схема с типом <code>string</code> не доказывает, что любое строковое значение безопасно.</p>\n<p>То же относится к массивам. Если контракт не обещает порядок, клиент не вправе выбирать первый элемент как «главный». Если порядок нужен, его надо назвать гарантией, протестировать и связать с версией. Текущий порядок, который виден в одном ответе, — наблюдение, а не обещание.</p>\n<h2>Отдельно оформляйте исключения</h2>\n<p>Иногда потребителю действительно нужна расширенная форма. Это не повод открыть внутренний объект под флагом <code>?debug=1</code>. Скрытый маршрут быстро становится вторым API: его начинают вызывать из скриптов, а затем требуют сохранить навсегда. У исключения должны быть имя, версия, получатель, входные условия, гарантии и отрицательная граница.</p>\n<pre><code>const escapeHatch = {\n name: 'raw-envelope-v1',\n consumer: 'fixed-exporter-v1',\n guarantees: ['id', 'state'],\n doesNotGuarantee: [\n 'field-order',\n 'future-fields',\n 'availability',\n 'filtering',\n ],\n};\n\nif (!escapeHatch.name || !escapeHatch.consumer) {\n throw new Error('stop-undocumented-exception');\n}</code></pre>\n<p>Такая запись не делает escape hatch безопасным автоматически. Она лишь делает обещание видимым и ограниченным. После неё всё равно нужны тесты клиента, политика доступа, наблюдение и решение о сроке жизни. Если команда не может назвать владельца и отрицательные гарантии, временный обход не должен проходить compatibility review.</p>\n<h2>Версия помогает найти правила, но не заменяет их</h2>\n<p>SemVer 2.0.0 различает patch, minor и major изменения публичного API. Это полезная дисциплина, когда public API уже определён. Но строка <code>1.3.0</code> не создаёт список потребителей и не отвечает, принимает ли клиент новый enum. Номер версии — адрес набора правил, а не доказательство того, что все участники прочитали этот набор одинаково.</p>\n<p>HTTP Semantics задаёт значения методов, статус-кодов, сообщений и representations. Из этого не следует application-level совместимость: HTTP <code>200</code> не гарантирует, что клиент понял бизнес-состояние. <code>404</code> тоже не означает автоматически «можно показать пустой список» — это решение конкретного контракта и его потребителя.</p>\n<p>Наконец, OpenAPI описывает поверхность HTTP API и позволяет генерировать документацию, клиентов и тесты. Однако specification не исследует исходный код каждого consumer. Поэтому три слоя дополняют друг друга: стандарт описывает vocabulary, контракт фиксирует обещания, а карточка потребителя показывает, что именно нужно проверить.</p>\n<h2>Воспроизводимый локальный gate</h2>\n<p>Ниже — полностью локальная проверка на Node.js 18 или новее. Она не требует пакетов, сети и доступа к production. Сохраните фрагмент в любой временный файл или вставьте в Node REPL: он проверяет family, обязательные поля и допустимые ошибки на фиксированных данных. В рабочем репозитории тот же порядок следует перенести в contract test, где fixtures принадлежат контракту и потребителю.</p>\n<pre><code>const contract = {\n family: 'fixed-catalog-read-v1',\n version: '1.3.0',\n response: { id: 'r-17', state: 'ready' },\n errors: ['fixed-not-found'],\n};\n\nconst consumer = {\n id: 'fixed-tolerant-reader-v1',\n family: 'fixed-catalog-read-v1',\n supportedVersions: ['1.3.0'],\n requiredFields: ['id', 'state'],\n handledErrors: ['fixed-not-found'],\n};\n\nfunction assertCompatible(api, client) {\n if (api.family !== client.family) return 'stop-incomparable';\n if (!client.supportedVersions.includes(api.version)) return 'stop-version';\n\n const missing = client.requiredFields.filter(\n (field) =&gt; !(field in api.response),\n );\n if (missing.length) return `stop-missing:${missing.join(',')}`;\n\n const unknownErrors = api.errors.filter(\n (error) =&gt; !client.handledErrors.includes(error),\n );\n if (unknownErrors.length) return `stop-error:${unknownErrors.join(',')}`;\n\n return 'compatible-for-this-fixture';\n}\n\nconsole.log(assertCompatible(contract, consumer));\n// compatible-for-this-fixture</code></pre>\n<p>Запуск возвращает только <code>compatible-for-this-fixture</code>. Это намеренно узкий результат: фикстура не знает о TLS, авторизации, нагрузке, реальном сериализаторе, rollout, миграции данных и неизвестных клиентах. Для отрицательного теста удалите <code>state</code> из <code>contract.response</code>: результат станет <code>stop-missing:state</code>. Замените <code>family</code> у consumer: получите <code>stop-incomparable</code>. Так проверяется не красивый happy path, а причина остановки.</p>\n<h2>Передавайте результат с причиной</h2>\n<p>Одно слово <code>compatible</code> плохо переносится между командами. Минимальный hand-off должен содержать идентификатор контракта, версию, consumer, проверенные гарантии, ограничения и следующий шаг. Для отрицательного результата причина обязательна: missing field, неизвестное значение, неподдерживаемая версия, mismatch family или undocumented exception.</p>\n<div class=\"table-scroll\"><table><caption>Статусы compatibility review и действия</caption><thead><tr><th scope=\"col\">Статус</th><th scope=\"col\">Когда выдавать</th><th scope=\"col\">Следующее действие</th></tr></thead><tbody><tr><td><code>compatible-for-this-fixture</code></td><td>одна family, версия, поля и ошибки прошли указанную фикстуру</td><td>запустить реальные contract tests и review rollout</td></tr><tr><td><code>stop-incomparable</code></td><td>семейства контрактов различаются</td><td>открыть отдельный review для другой операции</td></tr><tr><td><code>stop-missing</code></td><td>нет обязательного поля или значения</td><td>сохранить поле, адаптировать consumer или выпустить новую поверхность</td></tr><tr><td><code>stop-version</code></td><td>consumer не заявляет версию API</td><td>получить supported versions и тест на переход</td></tr><tr><td><code>stop-unknown-consumer</code></td><td>нет карточки потребителя</td><td>установить владельца, family и минимальное чтение</td></tr><tr><td><code>stop-undocumented-exception</code></td><td>обход не имеет имени или отрицательной границы</td><td>оформить versioned exception либо удалить обход</td></tr></tbody></table></div>\n<p>Важно не смешивать эти исходы в один процент «готовности». Процент скрывает, что часть клиентов нельзя сопоставить, часть требует поля, а часть неизвестна. Список причин дольше, зато он подсказывает конкретную работу и не превращает неизвестность в разрешение на rollout.</p>\n<h2>Пошаговый порядок перед изменением API</h2>\n<ol><li><strong>Назовите одну операцию.</strong> Укажите метод, путь, contract family и версию. Не объединяйте read, command и webhook под общим словом API.</li><li><strong>Назовите одного потребителя.</strong> Зафиксируйте его идентификатор, владельца, поддерживаемые версии и минимальное решение, которое он принимает по ответу.</li><li><strong>Опишите поверхность.</strong> Разделите обязательные и необязательные поля, допустимые значения, ошибки, заголовки и гарантии порядка или времени.</li><li><strong>Сравните family.</strong> При mismatch остановитесь до сравнения полей. Не выводите совместимость из похожего URL, названия или набора ключей.</li><li><strong>Проверьте отрицательные ветки.</strong> Удалите обязательное поле, добавьте новое значение enum, переставьте элементы без гарантии порядка, верните неизвестную ошибку.</li><li><strong>Найдите исключения.</strong> Для каждого особого пути проверьте имя, версию, получателя, входные условия и negative boundary.</li><li><strong>Сформируйте ограниченный hand-off.</strong> Передайте status, reasons и next action. Не объявляйте rollout безопасным на основании локальной фикстуры.</li></ol>\n<h2>Границы применимости и критерий готовности</h2>\n<p>Этот алгоритм не обнаруживает неизвестные интеграции сам. Неполный инвентарь остаётся риском. Он также не заменяет security review, тестирование прав, нагрузочную проверку, SLO, миграцию данных, проверку идемпотентности команд и план отката. Для асинхронного API нужно дополнительно проверять порядок событий, повторную доставку и версию схемы сообщения. Для публичного API понадобятся правила deprecation и коммуникация с внешними клиентами.</p>\n<p>Фикстура из статьи не доказывает поведение реального сервиса. Она проверяет только заранее введённые literals и может пропустить ошибку сериализатора, конфигурации или среды. Node.js 18+ нужен лишь для воспроизведения примера; production-совместимость не зависит от одной версии Node и должна проверяться в поддерживаемых окружениях.</p>\n<p>Review можно считать завершённым только для явно ограниченной пары, если в записи есть family, версия, consumer, required fields, допустимые ошибки, проверенные гарантии, список отрицательных тестов и итоговый status. Положительный status должен содержать слово «для этой фикстуры» или эквивалентную границу. Если нет карточки потребителя или неизвестно поведение обязательного поля, честный результат — 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; в статье источник используется для границы между описанием API и поведением конкретного потребителя.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — нормативно описывает семантику методов, статус-кодов, сообщений и representations; источник не задаёт прикладную совместимость и правила rollout.</li><li><a href=\"https://semver.org/spec/v2.0.0.html\" target=\"_blank\" rel=\"noopener noreferrer\">Semantic Versioning 2.0.0</a> — определяет правила изменения публичного API по major/minor/patch; источник не создаёт инвентарь потребителей и не доказывает обработку нового значения клиентом.</li></ul>","readingMinutes":14}