Присоединяйтесь к бете Hanzo и получите скидку 50% на любой платный тариф

Маршрутизатор и есть реестр

В Hanzo Cloud нет зафиксированного файла OpenAPI. Живая таблица маршрутов — источник, спроецированный тремя способами, и тест биекции роняет сборку, если любая проекция расходится. 1,362 операции на 963 путях, порождаются на процесс в момент запроса.

На этой странице

В репозитории Hanzo Cloud нет openapi.yaml. Нет второго реестра, который перечисляет конечные точки, нет файла аннотаций и нет шага генератора в CI, который писал бы спецификацию на диск. Если хотите знать, что такое API, спросите работающий процесс: он ответит, прочитав собственный маршрутизатор.

Этот текст о том, почему именно такое устройство оказалось единственным, которое держится, и о частях, которые не работают — они и оказываются более полезной половиной.

Одна таблица, три проекции

serve.go собирает одну таблицу маршрутов. Затем её читают три стороны, все после MountAll, чтобы каждая видела полную таблицу:

  • /zap проигрывает обработчики /v1 по транспорту ZAP.
  • Консоль их рисует.
  • GET /v1/openapi.json их описывает.

Ни одна из трёх не хранит копию чего бы то ни было. Это всё ограничение замысла, и остальное из него следует. Проекция, которая держала бы свой список конечных точек, была бы вторым источником истины, а второй источник истины расходится — не иногда, всегда, в том ритме, в каком кто-то забывает.

Проекция описания — та, о которой спрашивают, поэтому: openapi.Live(app) вызывает app.Fiber().GetRoutes(true), а всякая другая функция в openapi/ — чистая функция того []Route, который возвращается. Документ не строится из маршрутизатора. Документ и есть маршрутизатор, в другой форме.

Почему чтение живого маршрутизатора — единственный полный источник

Очевидная альтернатива — просканировать исходники. Она не работает, и причину стоит сказать точно, потому что из-за неё множество инструментов API молча недосчитывает.

POST /v1/kms/auth/login регистрируется так:

Group("/v1/kms/auth").Post("/login")

Никакой grep не находит этот путь. Строки /v1/kms/auth/login в коде нет. Она существует только в собранном маршрутизаторе, во время выполнения, после того как фреймворк склеил префикс группы и лист.

Дальше хуже, и это на самом деле правильно. Набор маршрутов — функция конфигурации развёртывания: cfg.Enabled плюс внутренние затворы вроде if kc != nil у KMS. Развёртывание, которое не монтирует admin, не обслуживает маршруты admin. Честная спецификация этого развёртывания их не объявляет.

Поэтому документ порождается на процесс, в момент запроса, а не строится один раз в CI. Спецификация, собранная в CI, описывает гипотетическое максимальное развёртывание, которое может быть не тем, с кем вы говорите. Спецификация, прочитанная с живого маршрутизатора, описывает процесс, который вам ответил.

Тест, который делает это правдой

Архитектура, в которой спецификация не может разойтись, — это утверждение, а утверждениям нужен затвор. Наш — биекция, в cmd/cloud/openapi_test.go:

func TestSpecDoesNotDriftFromLiveRouter(t *testing.T) {

Он монтирует каждую подсистему в apps.Wire(), порождает документ и утверждает соответствие в обе стороны: каждый живой маршрут есть операция, и за каждой операцией стоит живой маршрут. Не подмножество ни в одну сторону.

Запуск сегодня:

bijection holds over 1362 operations / 963 paths / 138 products

Это единственный тест в репозитории, чей провал значит, что опубликованный документ лжёт. Всё прочее, что ломается, делает неверным продукт; этот делает неверным контракт, а это хуже, потому что интеграторы уже строили против него.

Что счётчик движется — не беда: он движется, когда добавляется подсистема. Важна разница двух сторон, и это число — ноль, иначе сборка падает.

Ось продукта механическая

Первый сегмент пути после /v1/ — продукт. openapi.Product читает его и метит им каждую операцию, и поэтому CLI собирает hanzo <product> <resource> <verb> без чьего-либо суждения о том, где должна жить команда.

Это нарочно не имя подсистемы. clients/billing также обслуживает /v1/finance/*, а URL — то, что видит вызывающий, поэтому побеждает URL. Назвать ось по внутреннему пакету значило бы сделать CLI курируемым артефактом — вещью с мнениями внутри, которую кто-то должен сопровождать, — а не проекцией.

Чего маршрутизатор вам не скажет

Вот предел, и обходить его мы не собираемся.

Метод, путь, параметры пути и продукт выводятся из маршрутизатора. Схемы запроса и ответа, параметры query и заголовка, коды состояния и аутентификация — нет. Маршрутизатор держит func(*zip.Ctx) error. Тип запроса — локальная переменная внутри тела обработчика:

var req secretPutRequest
json.Unmarshal(ctx.Body(), &req)

Go не умеет отражать от значения функции в её тело. Хитрого выхода нет, а попытка в генераторе означала бы угадывание — так спецификация обрастает уверенными неверными записями.

Единственный путь к схемам — типизированная операция:

zip.Get[In, Out](...)

In и Out стоят на регистрации, поэтому схема доступна без чтения чьих-либо мыслей. И раз они на регистрации, та же запись даёт и инструмент MCP — zip/openapi.go и zip/mcp.go два читателя одного реестра. Это четвёртая проекция, и она пришла без четвёртого механизма.

Миграция инкрементальна по построению: GetRoutes() — надмножество app.ops, поэтому перевод обработчика в типизированную операцию добавляет схему, не меняя ничего выше по потоку.

Маршруты, которые нельзя типизировать, и почему

clients/integrations переведён настолько, насколько позволяет провод: 20 из 41 маршрута — типизированные операции. Остальные 21 остаются сырыми обработчиками, и каждый записан в карте rawRoutes в ops_projection_test.go с причиной, по которой сдвинуть его нельзя:

  • 13 браузерных перенаправлений. Callback OAuth и каждое звено связи отвечают 302. Типизированная операция возвращает значение, а не заголовок Location и статус в трёхсотых.
  • 6 вебхуков. HMAC Slack и GitHub, Ed25519 Discord, JWT Bot Framework у Teams, заголовок secret-token у Telegram. Каждый проверяет подпись над сырыми байтами запроса. Типизированная операция получает уже декодированный In и никогда не видит байты, по которым считалась подпись, поэтому типизация сломала бы их аутентификацию.
  • 2 создателя 202 Accepted. cloud.Created даёт нам шов 201. Шва 202 нет. Двум маршрутам он нужен, поэтому два маршрута ждут.

Мы считаем этот список самым ценным артефактом всей системы. Это перечисленный, приколотый тестами инвентарь того, где именно абстракция кончается, с причиной на каждую запись, с которой рецензент может спорить. Сбой, который он предотвращает, — маршрут молча сползает обратно в сырой обработчик и разом исчезает из документа, набора инструментов MCP и CLI, незаметно, потому что ничто не утверждало, что он там был.

Архитектура — это не те части, которые обобщаются. Это части, которые обобщаются, плюс честный и принудительный отчёт о частях, которые не обобщаются.

Что это даёт

Тот, кто интегрируется с Hanzo Cloud, читает документ, который породил процесс, отвечающий на его запросы, в том развёртывании, с которым он на самом деле говорит, несколько миллисекунд назад. Агент, вызывающий по MCP, попадает в тот же обработчик, с той же проверкой арендатора, что и вызывающий по REST: анонимный вызов /mcp получает тот же 403, что даёт REST, потому что это тот же путь кода, и тест это фиксирует.

Ничто из этого не требовало, чтобы кто-то вспомнил что-нибудь перегенерировать.

Общая форма: если два артефакта должны совпадать, не порождайте один из другого и не надейтесь. Сделайте один видом другого и напишите тест, который падает, когда это не так.

Комментарии

Войдите с Hanzo ID, чтобы комментировать.

Загрузка комментариев…

Читать далее