2 lines
28 KiB
JSON
2 lines
28 KiB
JSON
{"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) => !(field in api.response),\n );\n if (missing.length) return `stop-missing:${missing.join(',')}`;\n\n const unknownErrors = api.errors.filter(\n (error) => !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}
|