Skip to content
返回构建教程

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 更新非复制粘贴。