Hono、Zod、RPC 与 OpenAPI 如何保持同一份契约

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

我整理 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() 添加的 queryjsonparamform 校验会自动进入 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() 里的 summarydescription 不会变成客户端 $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 方案。包名相似,不代表迁移方式相同。

最后检查四份结果

完成一条接口后,我不会只打开文档页面看一眼。至少要确认:

  1. HTTP 测试能覆盖成功、校验失败和主要业务错误。
  2. 生成的 OpenAPI 包含正确路径、方法、请求和响应 Schema。
  3. AppType 重新生成后,客户端类型检查仍能通过。
  4. 路由文件中没有重复定义请求字段,独立 OpenAPI 注册表也没有重新出现。

所谓同一份契约,最后仍然需要这些交叉验证。它能减少人工同步,却不能让运行时行为、TypeScript 类型和文档格式自动变成同一种东西。

对我来说,比较稳定的边界是:Zod 定义数据,Hono 路由定义真实接口,RPC 从真实接口推导客户端类型,OpenAPI 标注紧挨着路由补充说明。每一层只负责自己知道的事实,重复自然会少很多。

契约统一以后,还有两个相邻问题值得单独处理:如果第三方监控只能按真实 pathname 聚合,可以参考当 RESTful 路径设计遇到不可控的第三方统计;如果文档只准备在开发环境使用,可以继续看开发环境保留 API 文档,生产包彻底移除

参考资料