API 契约
用 OpenAPI/zod 定义版本化 API 契约,客户端和服务端都消费。
为什么
API 契约是前后端协议——无书面版本化契约,双方猜且集成运行时坏。OpenAPI 或 zod 定义的契约作单一事实源:服务端对它验证、客户端从它生成类型、文档自动生成。契约版本化(v1、v2)让你不破现有客户端演进 API——破坏性变更是新版本非惊喜。这是'部署了移动应用坏了'和'部署 v2 移动应用 v1 继续工作'的区别。
何时使用
有客户端-服务端分离时。OpenAPI 约 2-3 小时,zod 约 1 小时。无外部 API 单体可跳过。
如何做
1. 契约定义为 zod schema(内部)或 OpenAPI spec(外部)
- zod:const CreateUser = z.object({ name: z.string(), email: z.string().email() })
- OpenAPI:写 spec.yaml 或用 zod-to-openapi 从 zod 生成
2. 每请求和响应对契约验证
- 服务端:用 schema 解析输入,失败返回 400
- 客户端:用 schema 解析响应,服务端发意外数据则抛
3. API 在 URL(/api/v1/users)或头(Accept: application/vnd.api+json;version=1)版本化
- 永不对 v1 做破坏性变更——加 v2 并用 Sunset 头弃用 v1
4. CI 从契约生成客户端类型
- openapi-typescript spec.yaml -o src/api/types.ts
- 生成类型不匹配提交的则构建失败
5. 用自动生成文档(Scalar、Stoplight 或 Redoc)
- spec 即文档——无手写 API 文档与现实漂移示例
// 契约:src/api/contracts.ts
import { z } from "zod";
export const CreateUser = z.object({ name: z.string().min(1), email: z.string().email() });
export type CreateUserInput = z.infer<typeof CreateUser>;
export const User = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email() });
export type UserResponse = z.infer<typeof User>;
// 服务端:验证输入 + 输出
export async function POST(req: Request) {
const input = CreateUser.parse(await req.json()); // 非法 400
const user = await createUser(input);
return Response.json(User.parse(user)); // 也验证输出
}
// 客户端:类型从契约流
async function createUser(input: CreateUserInput): Promise<UserResponse> {
const res = await fetch("/api/v1/users", { method: "POST", body: JSON.stringify(input) });
return User.parse(await res.json()); // 服务端坏则抛
}常见问题
- ⚠对 v1 做破坏性变更(删字段、改类型)同时破所有客户端。
- ⚠验证输入不验证输出,DB bug 返回畸形数据客户端渲染为 undefined。
- ⚠手写 API 文档与实现漂移,消费者对照虚构构建。
- ⚠完全不版本化,每次变更是潜在破坏性变更无法回滚客户端。
最佳实践
- ✓zod 作单一契约定义,从它派生 OpenAPI、客户端类型和文档——一源多出。
- ✓弃用端点加 Sunset 头带移除日期,客户端有迁移截止。
- ✓开发中验证输入和输出——输出验证抓输入验证漏的 DB/ORM bug。
- ✓monorepo 里生成客户端 SDK 并发布为包,消费者通过 npm 更新非复制粘贴。