Skip to content

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-openapiZod Schema → OpenAPI 3.xdevDependency

需同步加入 docs/approved-deps.md 白名单。


3. Zod Schema 增强

现有 schema 文件上原地添加 .openapi() 元数据,不移动、不拆分文件。

规范

typescript
// 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 体验
敏感字段不加 examplepassword、token 等字段省略示例

4. 路由注册

每个业务域一个注册文件,放在 lib/openapi/schemas/ 下。

示例:Users 域

typescript
// 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 将样板收敛为声明式配置。

接口设计

typescript
// 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 行):

typescript
// 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 行):

typescript
// 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));
  },
});

迁移策略

  1. 逐个迁移 — 每次迁移一个路由文件,运行测试确认行为一致
  2. 跳过特殊路由 — 有自定义限流、特殊响应格式的路由(如 auth/login)暂不迁移
  3. parseBody 辅助函数 — 封装 schema.parse(body),统一抛出 422 ValidationError

6. OpenAPI 生成脚本

脚本

typescript
// 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 脚本

json
{
  "scripts": {
    "generate:openapi": "tsx scripts/generate-openapi.ts"
  }
}

CI 检查(可选)

yaml
# .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 页面

markdown
<!-- 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

javascript
// 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与本工作流的关系
/specspec.md 的 API Surface 表产出的端点列表,是路由注册的输入
/plan中大功能方案中"API 契约"部分,应同步考虑 .openapi() 元数据
/code-standards检查新 Route 是否使用 apiHandler 包装器
/doc检查 openapi.json 是否与最新 schema 同步
/review审查 .openapi() 元数据完整性(description 必填)

CLAUDE.md 规则补充

在项目 CLAUDE.md 中追加以下规则:

markdown
## 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 内容包含所有已注册路由
4VitePress 集成 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-技术栈选型指南

面向个人开发者的 AI 辅助编程工程化方案