17 - API 接口维护体系
1. 定位
API 接口文档的传统做法——手写 Markdown 或 Swagger 注释——容易与实际代码脱节。 本文档定义一套 Schema as Single Source of Truth 的工作流:
Zod Schema(唯一真实来源)
↓ .openapi() 元数据增强
↓ 路由注册(registry)
↓ npm run generate:openapi
↓
docs/openapi.json(可 diff、可 CI 检查)
↓
VitePress 文档站(Scalar 渲染)一处定义,三处生效:运行时校验 + API 文档 + 前端类型。
适用条件
| 条件 | 说明 |
|---|---|
| 已使用 Zod 做请求校验 | 在现有 schema 上追加元数据,零迁移成本 |
| API 端点 ≥ 5 个 | 太少时手写 Markdown 更简单 |
| 有文档站或对外交付需求 | 内部工具可以用 Markdown 替代 |
不适用
- GraphQL 项目 — 用 GraphQL 自身的 introspection
- tRPC 项目 — 类型已通过 router 共享,不需要 OpenAPI
2. 架构总览
┌─────────────────────────────────────────────────────┐
│ lib/validations/*.ts │
│ Zod Schema + .openapi() 元数据 │
└──────────────┬──────────────────────────────────────┘
│ import
┌──────────────▼──────────────────────────────────────┐
│ lib/openapi/ │
│ ├── extend-zod.ts ← 一次性 side-effect import │
│ ├── registry.ts ← OpenAPIRegistry 单例 │
│ ├── schemas/ │
│ │ ├── common.ts ← 通用响应 schema │
│ │ ├── users.ts ← 按域注册路由 │
│ │ ├── auth.ts │
│ │ └── roles.ts │
│ └── index.ts ← 汇总 + 生成 OpenAPI 文档对象 │
└──────────────┬──────────────────────────────────────┘
│ generate:openapi
┌──────────────▼──────────────────────────────────────┐
│ docs/openapi.json ← 提交到 Git,PR diff 可见 │
└──────────────┬──────────────────────────────────────┘
│ sync.mjs 复制
┌──────────────▼──────────────────────────────────────┐
│ VitePress 文档站 — Scalar API Reference 渲染 │
└─────────────────────────────────────────────────────┘核心依赖
| 包名 | 用途 | 安装方式 |
|---|---|---|
@asteasolutions/zod-to-openapi | Zod Schema → OpenAPI 3.x | devDependency |
需同步加入
docs/approved-deps.md白名单。
3. Zod Schema 增强
在现有 schema 文件上原地添加 .openapi() 元数据,不移动、不拆分文件。
规范
// lib/validations/user.ts
import "@/lib/openapi/extend-zod";
export const createUserSchema = z.object({
name: z.string().min(1).max(100)
.openapi({ description: "User display name", example: "Zhang San" }),
email: z.string().email().max(255)
.openapi({ description: "Email address", example: "user@example.com" }),
password: z.string().min(8).max(128)
.openapi({ description: "Password (8+ chars, mixed case + digit)" }),
roleId: z.string().uuid()
.openapi({ description: "Role ID to assign" }),
}).openapi("CreateUserInput");元数据规则
| 规则 | 说明 |
|---|---|
顶层 .openapi("Name") 必填 | 作为 #/components/schemas/Name 的引用名 |
description 必填 | 每个字段都需要人可读的描述 |
example 推荐 | 提高文档可读性和 Try-it 体验 |
敏感字段不加 example | password、token 等字段省略示例 |
4. 路由注册
每个业务域一个注册文件,放在 lib/openapi/schemas/ 下。
示例:Users 域
// lib/openapi/schemas/users.ts
import { registry } from "../registry";
import { createUserSchema, userResponseSchema } from "@/lib/validations/user";
import { successResponseSchema, errorResponseSchema } from "./common";
registry.registerPath({
method: "post",
path: "/api/users",
summary: "Create user",
tags: ["Users"],
security: [{ bearerAuth: [] }],
request: {
body: {
content: {
"application/json": { schema: createUserSchema },
},
},
},
responses: {
201: {
description: "User created",
content: {
"application/json": {
schema: successResponseSchema(userResponseSchema),
},
},
},
422: {
description: "Validation error",
content: {
"application/json": { schema: errorResponseSchema },
},
},
},
});文件组织
lib/openapi/
├── extend-zod.ts # import { extendZodWithOpenApi } from "..."
├── registry.ts # export const registry = new OpenAPIRegistry()
├── schemas/
│ ├── common.ts # successResponseSchema / errorResponseSchema / paginationSchema
│ ├── users.ts # Users 域路由注册
│ ├── auth.ts # Auth 域路由注册
│ ├── roles.ts # Roles 域路由注册
│ └── permissions.ts # Permissions 域路由注册
└── index.ts # 汇总所有域 + OpenAPIGenerator.generateDocument()5. apiHandler 包装器
动机
典型 Next.js Route Handler 约 28 行样板(权限检查 + try/catch + 审计日志 + 响应格式化), 不同路由间大量重复。apiHandler 将样板收敛为声明式配置。
接口设计
// lib/api-utils.ts
export function apiHandler<TResult>(options: {
permission?: { resource: ResourceType; action: ActionType } | null;
audit?: { action: string; resource: string };
successStatus?: number;
handler: (
request: NextRequest,
ctx: { user: SessionUser; params: Record<string, string> }
) => Promise<TResult>;
}) {
return async (
request: NextRequest,
routeContext?: { params: Promise<Record<string, string>> }
) => {
try {
const user = options.permission
? await withPermission(options.permission.resource, options.permission.action)
: await withAuth();
const params = routeContext ? await routeContext.params : {};
const result = await options.handler(request, { user, params });
if (options.audit) {
authService.createAuditLog({
userId: user.id,
action: options.audit.action,
resource: options.audit.resource,
resourceId: (result as Record<string, unknown>)?.id as string ?? params.id,
ipAddress: getClientIp(request),
}).catch((err) => log.error({ err }, "Audit log failed"));
}
return successResponse(result, options.successStatus ?? 200);
} catch (err) {
return handleApiError(err);
}
};
}迁移前后对比
迁移前(~28 行):
// app/api/users/route.ts — 传统写法
export async function POST(request: NextRequest) {
try {
const user = await withPermission(RESOURCES.USER_MANAGEMENT, ACTIONS.WRITE);
const body = await request.json();
const validated = createUserSchema.parse(body);
const result = await userService.createUser(validated);
authService.createAuditLog({
userId: user.id, action: "create", resource: "user",
resourceId: result.id, ipAddress: getClientIp(request),
}).catch((err) => log.error({ err }, "Audit log failed"));
return successResponse(result, 201);
} catch (err) {
return handleApiError(err);
}
}迁移后(~9 行):
// app/api/users/route.ts — apiHandler 写法
export const POST = apiHandler({
permission: { resource: RESOURCES.USER_MANAGEMENT, action: ACTIONS.WRITE },
audit: { action: "create", resource: "user" },
successStatus: 201,
handler: async (request) => {
const body = await request.json();
return userService.createUser(parseBody(createUserSchema, body));
},
});迁移策略
- 逐个迁移 — 每次迁移一个路由文件,运行测试确认行为一致
- 跳过特殊路由 — 有自定义限流、特殊响应格式的路由(如
auth/login)暂不迁移 - parseBody 辅助函数 — 封装
schema.parse(body),统一抛出 422 ValidationError
6. OpenAPI 生成脚本
脚本
// scripts/generate-openapi.ts
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { generateDocument } from "@/lib/openapi";
const doc = generateDocument();
const output = resolve(__dirname, "../docs/openapi.json");
writeFileSync(output, JSON.stringify(doc, null, 2));
console.log("✅ OpenAPI spec written to docs/openapi.json");package.json 脚本
{
"scripts": {
"generate:openapi": "tsx scripts/generate-openapi.ts"
}
}CI 检查(可选)
# .github/workflows/ci.yml
- name: Check OpenAPI spec is up to date
run: |
npm run generate:openapi
git diff --exit-code docs/openapi.json || {
echo "❌ openapi.json is outdated. Run 'npm run generate:openapi' and commit."
exit 1
}7. VitePress 文档站集成
方案:Scalar API Reference
使用 Scalar CDN 版本,零依赖安装,在 VitePress 页面中通过 <script> 标签渲染 OpenAPI JSON。
步骤
1. 创建 API Reference 页面
<!-- docs/api-reference.md -->
# API Reference
<div id="api-reference" data-url="/openapi.json" data-configuration='{"theme":"default"}'></div>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@latest/dist/browser/standalone.min.js"></script>2. sync.mjs 同步 openapi.json
在 sync.mjs 中将 docs/openapi.json 复制到 site/.vitepress/public/openapi.json:
// sync.mjs — 追加
const openapiSrc = join(ROOT, 'docs/openapi.json')
if (existsSync(openapiSrc)) {
const publicDir = join(__dirname, '.vitepress/public')
ensureDir(publicDir)
writeFileSync(
join(publicDir, 'openapi.json'),
readFileSync(openapiSrc, 'utf-8')
)
console.log(' ✓ openapi.json → .vitepress/public/')
}3. 侧边栏配置
在 site/.vitepress/config.mts 中为 API Reference 页面添加导航入口。
8. 日常工作流
新增 / 修改 API 端点
1. 修改 lib/validations/*.ts 的 Zod schema
↓ 追加 / 更新 .openapi() 元数据
2. 修改 lib/openapi/schemas/*.ts 的路由注册
↓ 新增 registerPath 或更新请求/响应 schema
3. npm run generate:openapi
↓ 生成 docs/openapi.json
4. git add + commit
↓ PR diff 中可见接口变更
5. CI 自动检查 openapi.json 是否最新与现有 Skills 的协作
| Skill | 与本工作流的关系 |
|---|---|
/spec | spec.md 的 API Surface 表产出的端点列表,是路由注册的输入 |
/plan | 中大功能方案中"API 契约"部分,应同步考虑 .openapi() 元数据 |
/code-standards | 检查新 Route 是否使用 apiHandler 包装器 |
/doc | 检查 openapi.json 是否与最新 schema 同步 |
/review | 审查 .openapi() 元数据完整性(description 必填) |
CLAUDE.md 规则补充
在项目 CLAUDE.md 中追加以下规则:
## API 接口规范
- 所有 API 输入 schema 必须添加 .openapi() 元数据(description 必填)
- 新增 API 端点时同步更新 lib/openapi/schemas/ 下的路由注册
- 提交前运行 npm run generate:openapi 并提交 docs/openapi.json
- API Route 使用 apiHandler 包装器,减少样板代码9. 分步实施路径
面向已有 Zod schema 的项目,按以下顺序实施:
| Step | 内容 | 验证方式 |
|---|---|---|
| 1 | 安装依赖、创建基础设施文件(extend-zod / registry / common / index) | npx tsc --noEmit 通过 |
| 2 | 选一个域(如 Users)作模板,增强 schema + 注册路由 | npm run generate:openapi 生成有效 JSON |
| 3 | 逐个扩展其余域 | JSON 内容包含所有已注册路由 |
| 4 | VitePress 集成 Scalar 渲染 | 本地 npm run dev 可访问 API Reference |
| 5 | 实现 apiHandler 包装器 + 单元测试 | 测试通过 |
| 6 | 逐个迁移路由文件使用 apiHandler | 全量测试通过,API 行为不变 |
Step 1-4 是文档生成链路,Step 5-6 是路由重构。两者独立,可分阶段执行。
10. 与其他子系统的关系
CLAUDE.md ← 补充 API 接口规范规则(§8)
doc-06 文档体系 ← docs/api/ 现在包含 openapi.json(自动生成产物)
doc-10 可靠性 ← CI 检查 openapi.json 是否最新(§6 CI 检查)
doc-12 最佳实践 ← apiHandler 是"锚定文件模式"的高阶形态
食谱 01 §3 ← API 契约设计时推荐同步添加 .openapi() 元数据
食谱 02 §3 ← 新增 API 端点时使用 apiHandler 模式
食谱 07 §2 ← API 文档从手写转为自动生成关联文档:06-文档体系 | 10-可靠性保障体系 | 12-效率最佳实践 | 14-技术栈选型指南