Hono、Zod、RPC 与 OpenAPI 如何保持同一份契约
我整理 Hono 接口时,曾经同时维护两套餐定义。
真实路由写在业务文件里:
app.post('/auth/login', validator('json', LoginRequestSchema), handler)另一个 openapi.ts 再登记一次路径、方法、请求和响应:
registry.registerPath({
method: 'post',
path: '/auth/login',
// ...
})这套结构刚写完时很清楚。接口多起来以后,问题也很直接:路由已经换了地址,OpenAPI 还留着旧路径;处理函数新增了一个错误状态,文档没有补;请求 Schema 虽然复用了 Zod,方法和响应仍然要手工同步。
我想要的“同一份契约”,不是把所有内容塞进一个巨大的定义文件,而是让每项事实只在最接近它的地方写一次。
四个工具看到的东西并不相同
Hono、Zod、RPC 和 OpenAPI 经常一起出现,但它们处理的不是同一个问题。
Zod 在运行时校验外部数据,同时从 Schema 推导 TypeScript 类型。它知道 email 是字符串、page 是数字,却不知道 URL 是什么,也不知道哪个状态码代表登录失败。
Hono 路由掌握 HTTP 方法、路径、中间件顺序和处理函数。RPC 再从这条路由链推导客户端可以怎样调用,以及 c.json() 返回了什么类型。
OpenAPI 还需要另一类信息:接口摘要、鉴权方式、响应说明、示例和各状态码的 Schema。这些内容无法从 TypeScript 类型完整恢复。类型在运行时已经不存在,业务说明更不可能靠推导得到。
所以“同一份契约”不等于零标注。实际能消除的是重复的路径、方法和请求字段定义;说明文字与响应状态仍要有人写。
保留普通 Hono 路由,把文档放到旁边
如果项目已经使用普通的 .get()、.post() 和 Hono RPC,我更倾向于用 hono-openapi 的中间件式写法。它不要求把应用改成另一种路由模型:
import { Hono } from 'hono'
import { describeRoute, resolver, validator } from 'hono-openapi'
import * as z from 'zod'
const LoginRequestSchema = z.object({
email: z.email(),
password: z.string().min(8),
})
const LoginResponseSchema = z.object({
accessToken: z.string(),
expiresIn: z.number().int().positive(),
})
const authRoutes = new Hono().post(
'/login',
describeRoute({
summary: '登录账户',
responses: {
200: {
description: '已认证会话',
content: {
'application/json': {
schema: resolver(LoginResponseSchema),
},
},
},
401: {
description: '邮箱或密码错误',
},
},
}),
validator('json', LoginRequestSchema),
async (c) => {
const input = c.req.valid('json')
const session = await login(input)
return c.json(session, 200)
},
)这段路由中,路径和方法只出现一次。LoginRequestSchema 同时提供:
- 请求体的运行时校验;
c.req.valid('json')的类型;- Hono RPC 客户端的请求类型;
- OpenAPI 中的请求 Schema。
hono-openapi 官方示例明确说明,通过它的 validator() 添加的 query、json、param 和 form 校验会自动进入 OpenAPI,不需要在 describeRoute() 里再抄一遍请求参数。
describeRoute() 只补充无法自动得到的信息。文档标注和处理函数靠在一起,改接口时很难看不见它。
RPC 与 OpenAPI 仍然是两条链
路由写到一起以后,很容易误以为 RPC 和 OpenAPI 已经合并。其实没有。
Hono RPC 依赖 TypeScript 类型:
const app = new Hono().route('/auth', authRoutes)
export type AppType = typeof app客户端把 AppType 交给 hc:
import { hc } from 'hono/client'
import type { AppType } from './server-types'
const client = hc<AppType>('https://api.example.com')
const response = await client.auth.login.$post({
json: {
email: 'reader@example.com',
password: 'REDACTED_PASSWORD',
},
})客户端的请求类型来自 Validator,响应类型来自处理函数返回的 c.json()。Hono 官方 RPC 文档也要求大型应用保持路由链式组合,否则类型可能在拆分和挂载时丢失。
OpenAPI 走的是运行时元数据。describeRoute() 保存说明和响应定义,resolver() 把 Zod Schema 转成文档能使用的 Schema,最后再生成 OpenAPI 文档。
这两条链会使用同一条 Hono 路由和同一个请求 Schema,但产物不同:
Hono 路由 + Validator + c.json()
-> AppType
-> Hono RPC 客户端类型
Hono 路由 + Validator + describeRoute()
-> OpenAPI 文档
-> Scalar、Swagger UI 或其他文档工具describeRoute() 里的 summary 和 description 不会变成客户端 $post 方法上的 JSDoc。为了让编辑器悬停时多显示一句中文,再维护一套手写客户端接口,通常得不偿失。调用处看类型,需要理解业务语义时看 API 文档即可。
响应是最容易继续漂移的地方
请求 Schema 的复用比较完整,响应则没有那么自动。
下面两段代码在类型系统里没有直接联系:
schema: resolver(LoginResponseSchema)return c.json(session, 200)前者告诉 OpenAPI 响应应该是什么,后者决定 RPC 客户端实际推导出什么。如果 session 多了字段、少了字段,或者状态码改成 201,OpenAPI 不一定会自动报错。
我会根据接口风险选择约束方式。普通内部接口可以给业务函数声明返回类型:
type LoginResponse = z.output<typeof LoginResponseSchema>
async function login(input: LoginInput): Promise<LoginResponse> {
// ...
}边界更严格的接口可以在返回前执行 LoginResponseSchema.parse(result)。这会增加一次运行时校验,却能保证实现、RPC 类型和 OpenAPI Schema 使用同一个结构。是否值得,要看响应来源是否可信,以及接口错误的代价。
无论是否在运行时解析响应,都应该增加 OpenAPI 契约测试。至少检查公开路径、HTTP 方法、主要状态码和响应 Schema,避免出现“接口能调用,文档却漏了一半”的情况。
全局错误不能指望 RPC 自动推导
认证失败、参数错误和 500 响应通常由中间件或全局 onError() 处理。Hono RPC 官方文档说明,全局错误处理器和全局中间件的响应不会自动进入客户端响应类型。
当前 Hono 提供了 ApplyGlobalResponse,可以显式补上客户端需要识别的全局响应:
import type { ApplyGlobalResponse } from 'hono/client'
type ClientAppType = ApplyGlobalResponse<
AppType,
{
401: { json: { error: string } }
500: { json: { error: string } }
}
>它解决的是 RPC 类型缺口,不会自动生成 OpenAPI,也不会替代服务端的错误处理。
这意味着错误契约要主动整理,但不必把所有业务错误都做成共享枚举。
我会在服务端集中保留这些内容:
- 统一的错误响应 Schema;
- 参数校验失败的格式化函数;
- 401、403、404、500 等公共响应描述;
- 全局异常到受控 HTTP 响应的转换。
它们适合放在 errors.ts 一类文件中。某条业务判断为什么失败,仍留在对应业务代码旁边。只有客户端真的要根据稳定错误码跳转或改变交互时,才把那一小部分错误类型放进共享契约。
为了“以后可能会用”而共享全部错误,会让服务端文案、客户端分支和 OpenAPI 同时背上一套没有消费者的协议。
不要为了文档重写整套路由
Hono 还有 @hono/zod-openapi。它通过 createRoute() 和 OpenAPIHono.openapi() 把路由契约组织成专门的对象,适合新项目或愿意采用这种模型的代码库。
它不是错误方案。问题在于迁移成本。
如果现有项目依赖普通 Hono 路由、链式类型推导和已经生成的 RPC 客户端,仅仅为了让 OpenAPI 靠近路由,就把所有 .get()、.post() 改成 createRoute(),变更会越过文档边界。HTTP 行为也许没变,路由声明、类型推导和客户端生成链却都要重新验证。
选择集成方式时,我现在会先写下不能改变的东西:已有路由写法、RPC 类型、错误响应和客户端调用。满足这些边界以后,再判断使用哪一个 OpenAPI 方案。包名相似,不代表迁移方式相同。
最后检查四份结果
完成一条接口后,我不会只打开文档页面看一眼。至少要确认:
- HTTP 测试能覆盖成功、校验失败和主要业务错误。
- 生成的 OpenAPI 包含正确路径、方法、请求和响应 Schema。
AppType重新生成后,客户端类型检查仍能通过。- 路由文件中没有重复定义请求字段,独立 OpenAPI 注册表也没有重新出现。
所谓同一份契约,最后仍然需要这些交叉验证。它能减少人工同步,却不能让运行时行为、TypeScript 类型和文档格式自动变成同一种东西。
对我来说,比较稳定的边界是:Zod 定义数据,Hono 路由定义真实接口,RPC 从真实接口推导客户端类型,OpenAPI 标注紧挨着路由补充说明。每一层只负责自己知道的事实,重复自然会少很多。
契约统一以后,还有两个相邻问题值得单独处理:如果第三方监控只能按真实 pathname 聚合,可以参考当 RESTful 路径设计遇到不可控的第三方统计;如果文档只准备在开发环境使用,可以继续看开发环境保留 API 文档,生产包彻底移除。