路由器就是注册表
Hanzo Cloud 没有签入的 OpenAPI 文件。活的路由表才是源头,以三种方式投影,任一投影漂移,双射测试就让构建失败。963 条路径上的 1,362 个操作,在请求时按进程生成。

本页内容
Hanzo Cloud 仓库里没有 openapi.yaml。没有第二份列出端点的注册表,没有注解文件,也没有 CI 里把规范写到磁盘的生成步骤。想知道 API 是什么,就问一个正在运行的进程,它读自己的路由器来回答。
这篇文章讲的是,为什么最后只有这种安排站得住,以及其中不工作的部分。不工作的那一半,反而更有用。
一张表,三种投影
serve.go 组成一张路由表。然后三样东西读它,都在 MountAll 之后,于是各自看到完整的表:
/zap在 ZAP 传输上 重放/v1的处理器。- 控制台 渲染 它们。
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,再加上 KMS 的 if kc != nil 这类内部闸门。不挂载管理功能的部署就不提供管理路由。所以那个部署的诚实规范不会宣传它们。
所以文档按进程、在请求时生成,而不是在 CI 里构建一次。CI 里构建的规范描述的是一个假想的最大部署,未必是你正在交谈的那个。从活路由器读出的规范描述的是回答你的那个进程。
让这件事为真的测试
规范不能漂移的架构是一项主张,主张需要一道门。我们的门是双射,在 cmd/cloud/openapi_test.go:
func TestSpecDoesNotDriftFromLiveRouter(t *testing.T) {
它挂载 apps.Wire() 里的每个子系统,生成文档,并双向断言映射:每条活路由都作为操作出现,每个操作都有活路由支撑。任一方向都不是子集。
今天跑一遍:
bijection holds over 1362 operations / 963 paths / 138 products
这是仓库里唯一一个失败就意味着已发布文档在说谎的测试。别的东西坏了,是产品错了;这个坏了,是契约错了,更糟,因为集成方已经照着它建过了。
计数移动不是问题。每加一个子系统它就动。要紧的数是两边的差,那个数是 0,否则构建失败。
产品轴是机械的
/v1/ 之后的第一段路径就是产品。openapi.Product 读它,并用它给每个操作打标签,于是 CLI 可以构造 hanzo <product> <resource> <verb>,而不需要谁去判断一条命令该住在哪里。
这故意不是子系统的名字。clients/billing 也提供 /v1/finance/*,调用方看到的是 URL,所以 URL 赢。若按内部包给这条轴命名,CLI 就会变成一件有意见、得有人维护的策展物,而不是投影。
路由器不能告诉你的事
这就是限度,我们不打算绕过它。
方法、路径、路径参数和产品可以从路由器导出。请求与响应的模式、查询和头参数、状态码以及认证则 不能。路由器持有一个 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 已经转换到线路允许的地步:41 条里有 20 条是带类型的操作。另外 21 条仍是原始处理器,每一条都列在 ops_projection_test.go 的 rawRoutes 映射里,并写明它不能移动的原因:
- 13 个浏览器重定向。 OAuth 回调和每一段链接都以
302应答。带类型的操作返回一个值,而不是Location头和 300 段的状态。 - 6 个 webhook。 Slack 与 GitHub 的 HMAC、Discord 的 Ed25519、Teams Bot Framework 的 JWT、Telegram 的秘密令牌头。每一个都在 原始请求字节 上认证签名。带类型的操作收到解码后的
In,永远看不到计算签名所用的字节,所以给它们加类型会破坏认证。 - 2 个
202 Accepted的创建者。cloud.Created给我们一条 201 的接缝。没有 202 的接缝。两条路由需要一条,所以两条路由等待。
我们认为那份清单是整个系统里最有价值的制品。它是一份枚举的、被测试钉住的清单,精确标出抽象在哪里停下,每一条都有评审可以争辩的理由。它防止的失败是:一条路由悄悄滑回原始处理器,同时从文档、MCP 工具集和 CLI 里消失,看不见,因为没有任何东西在断言它曾经在那里。
架构不是那些能够推广的部分。它是能够推广的部分,再加上对不能推广的部分一份诚实、被强制执行的交代。
这买到什么
对接 Hanzo Cloud 的调用方读到的文档,是回答他们请求的那个进程、在他们真正交谈的部署里、几毫秒前产出的。经 MCP 调用的智能体到达同一个处理器,带同样的租户检查,和 REST 调用方一样。一次匿名的 /mcp 调用得到 REST 给出的同一个 403,因为那是同一条代码路径,并且有测试把它钉住。
这一切都不需要谁记得去重新生成任何东西。
一般形式是:若两件制品必须一致,就不要从一件生成另一件然后指望。让其中一件成为另一件的视图,并写下它们不一致时会失败的测试。
继续阅读

Cloud-Native Commerce: Why Architecture Matters
How we built Crowdstart as a cloud-native platform from day one, and why this matters for commerce.

在智能体大模型时代把无头电商准备好
买家正在变成智能体。要让大语言模型在没有人介入时读得懂、签得了名、付得了款,需要什么,以及 Hanzo 今天为此交付了什么。

Introducing Enso — one API, routed to the best frontier intelligence
Enso is Hanzo's learned router: no single frontier model leads every benchmark, so Enso routes each request to the model most likely to win it — and learns from your feedback as it goes. Microsecond routing on a CPU, a transparent per-request meter, and a 1% fee that pays for itself. Here is how it works, the measured results, and the cost-transparency angle a monolith cannot offer.