用 Claude Code 做生产级 API 开发:OpenAPI、Next.js、Zod 与 CI
用 Claude Code 构建生产级 API:OpenAPI 契约、Next.js Route Handler、Zod 校验、测试和 CI。
用 Claude Code 写 API 时,最容易犯的错是只让它生成“能跑的接口”。演示环境里这样很快,但生产 API 需要更多边界:契约、输入校验、认证、幂等性、限流、统一错误、日志、测试和 CI。如果这些没有在一开始说清楚,后面每一次改动都会变成补洞。
本文把 Claude Code 当作生产 API 开发助手,而不是简单的代码生成器。做法是 contract-first,也就是先用 OpenAPI 定义接口承诺,再用 Next.js Route Handler 实现,用 Zod 守住输入边界,最后把测试和 GitHub Actions 交给团队继续维护。
Masa 在试做一个订单 API 时,最初只提示“实现 POST /orders”,结果每次生成的错误格式、重试行为和日志字段都不一样。后来把 OpenAPI、认证边界、幂等规则、错误信封和 CI 命令一起写进提示词,审查重点就从“代码像不像”变成了“是否遵守契约”。
先用 OpenAPI 固定 API 契约
OpenAPI 是用 YAML 或 JSON 描述 HTTP API 的标准。它会写清楚路径、方法、请求体、响应、认证方式和错误状态。通俗地说,它是实现之前先写好的 API 约定。规范请参考官方 OpenAPI Specification。
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
paths:
/api/orders:
post:
operationId: createOrder
summary: Create an order
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: true
schema:
type: string
minLength: 8
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateOrder"
responses:
"201":
description: Order created
"400":
description: Invalid request
"401":
description: Missing or invalid token
"409":
description: Idempotency key reused with another payload
"429":
description: Rate limit exceeded
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
CreateOrder:
type: object
required: [customerId, items, currency]
properties:
customerId:
type: string
minLength: 3
currency:
type: string
enum: [JPY, USD, EUR]
items:
type: array
minItems: 1
给 Claude Code 的提示词要明确“不能偏离契约”:
把 openapi.yaml 当作契约,实现 Next.js App Router 的 POST /api/orders。
要求:Zod 校验请求体,Authorization: Bearer <token> 在入口处检查,
Idempotency-Key 必填,同 key 同 payload 返回相同响应,
同 key 不同 payload 返回 409,加入 60 秒限流,
错误统一为 { error: { code, message, requestId, details } },
并生成 Vitest 测试和 GitHub Actions CI 检查。
Claude Code 的基础用法可以看官方 Claude Code overview,Next.js 的当前写法请以 Route Handlers 文档为准。
用 Next.js 和 Zod 守住入口边界
入口边界是外部数据第一次进入系统的地方。浏览器、移动端、合作伙伴系统和 webhook 都可能发送缺字段、旧枚举、坏 JSON 或重复请求。Zod 是 TypeScript 的运行时校验库,能把“类型看起来对”和“运行时真的对”连接起来,官方文档见 Zod。
下面的代码可以放在 app/api/orders/route.ts。为了方便复制运行,它用内存 Map 保存订单、幂等结果和限流桶。生产环境要把这些换成数据库、Redis 或 API Gateway。
import { z } from "zod";
export const runtime = "nodejs";
const CreateOrderSchema = z.object({
customerId: z.string().min(3),
currency: z.enum(["JPY", "USD", "EUR"]),
items: z.array(z.object({
sku: z.string().min(1),
quantity: z.number().int().positive().max(99),
})).min(1),
note: z.string().max(500).optional(),
});
type Order = z.infer<typeof CreateOrderSchema> & {
id: string;
status: "accepted";
createdAt: string;
};
const orders = new Map<string, Order>();
const idempotency = new Map<string, { fingerprint: string; status: number; body: unknown }>();
const buckets = new Map<string, { count: number; resetAt: number }>();
export function __resetForTests() {
orders.clear();
idempotency.clear();
buckets.clear();
}
function send(status: number, body: unknown, headers: Record<string, string> = {}) {
return Response.json(body, { status, headers });
}
function fail(status: number, code: string, message: string, requestId: string, details?: unknown) {
return send(status, { error: { code, message, requestId, ...(details ? { details } : {}) } });
}
function actor(req: Request) {
const expected = process.env.API_TOKEN;
const raw = req.headers.get("authorization") ?? "";
const token = raw.startsWith("Bearer ") ? raw.slice(7) : "";
return expected && token === expected ? token.slice(0, 12) : null;
}
function allowed(key: string) {
const now = Date.now();
const current = buckets.get(key);
if (!current || current.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + 60_000 });
return true;
}
if (current.count >= 30) return false;
current.count += 1;
return true;
}
export async function POST(req: Request) {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const who = actor(req);
if (!who) return fail(401, "unauthorized", "Invalid API token.", requestId);
if (!allowed(who)) return fail(429, "rate_limited", "Too many requests.", requestId);
const idempotencyKey = req.headers.get("idempotency-key");
if (!idempotencyKey || idempotencyKey.length < 8) {
return fail(400, "missing_idempotency_key", "Idempotency-Key is required.", requestId);
}
const rawBody = await req.text();
const cacheKey = `${who}:${idempotencyKey}`;
const cached = idempotency.get(cacheKey);
if (cached && cached.fingerprint !== rawBody) {
return fail(409, "idempotency_conflict", "Same key was used with another payload.", requestId);
}
if (cached) return send(cached.status, cached.body, { "x-idempotent-replay": "true" });
let payload: unknown;
try {
payload = JSON.parse(rawBody);
} catch {
return fail(400, "invalid_json", "Request body must be JSON.", requestId);
}
const parsed = CreateOrderSchema.safeParse(payload);
if (!parsed.success) {
return fail(400, "validation_failed", "Request does not match the contract.", requestId, parsed.error.flatten());
}
const order: Order = { ...parsed.data, id: crypto.randomUUID(), status: "accepted", createdAt: new Date().toISOString() };
orders.set(order.id, order);
const body = { data: order, meta: { requestId } };
idempotency.set(cacheKey, { fingerprint: rawBody, status: 201, body });
console.info("orders.create", { requestId, orderId: order.id, itemCount: order.items.length });
return send(201, body, { "x-request-id": requestId });
}
统一错误、幂等性、限流和观测性
错误信封是所有失败响应共用的格式。没有统一格式时,前端和合作伙伴很难判断是重试、提示用户,还是联系支持。上面的实现统一返回:
{
"error": {
"code": "validation_failed",
"message": "Request does not match the contract.",
"requestId": "6f0c9c0f-6db7-4bdf-930b-7cc7d13f3f77",
"details": {
"fieldErrors": {
"items": ["Array must contain at least 1 element(s)"]
}
}
}
}
幂等性表示同一个操作重试时不会产生第二次副作用。订单、支付、邮件发送和积分发放都需要它。限流用于保护系统和免费额度。观测性不是玄学,它只是让你在事故后能用 requestId 找到日志、资源 ID、耗时和错误码。
适用场景至少有三个:B2B SaaS 的订单创建 API、内部审批工具、webhook 接收端,以及带免费额度的公开 API。常见坑也很具体:OpenAPI 和 Zod 字段不一致;同一个幂等 key 搭配不同 payload 仍然放行;把内存限流直接上线;在错误或日志里暴露 token、地址、卡号等敏感信息。
在给 Claude Code 下指令时,最好把这些坑写成验收条件,而不是写成建议。例如“同一个幂等 key 和不同 payload 必须返回 409”、“日志不能包含 Authorization header”、“OpenAPI 的 required 字段必须和 Zod schema 一致”。这样做的好处是,Claude Code 生成测试时会把失败路径也覆盖进去。对团队来说,这比事后在评审里逐条提醒更可靠,因为约束会留在代码、测试和 CI 中。
还有一个容易被忽略的点是本地样例和生产实现的边界。本文为了可复制运行使用内存 Map,但这只适合单进程开发环境。如果你的服务部署在多个实例、Serverless 平台或容器集群中,幂等结果和限流计数必须放到共享存储里。把这句话写进 prompt,可以避免 Claude Code 把教学代码误当成生产架构。
测试和 CI 才是真正的交接
测试不要作为“以后再做”的任务。让 Claude Code 在同一个变更里生成 API 测试,审查时就能围绕行为讨论。下面的 Vitest 直接调用 Route Handler,不需要启动 HTTP 服务器。
import { beforeEach, describe, expect, it } from "vitest";
import { POST, __resetForTests } from "../app/api/orders/route";
const validOrder = {
customerId: "cus_123",
currency: "JPY",
items: [{ sku: "book-001", quantity: 2 }],
};
function req(body: unknown, headers: Record<string, string> = {}) {
return new Request("http://localhost/api/orders", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer test-token",
"idempotency-key": crypto.randomUUID(),
...headers,
},
body: JSON.stringify(body),
});
}
describe("POST /api/orders", () => {
beforeEach(() => {
process.env.API_TOKEN = "test-token";
__resetForTests();
});
it("creates an order", async () => {
const res = await POST(req(validOrder));
expect(res.status).toBe(201);
expect((await res.json()).data.status).toBe("accepted");
});
it("rejects invalid input", async () => {
const res = await POST(req({ ...validOrder, items: [] }));
expect(res.status).toBe(400);
expect((await res.json()).error.code).toBe("validation_failed");
});
it("returns 409 for conflicting idempotency reuse", async () => {
const key = "order-key-001";
await POST(req(validOrder, { "idempotency-key": key }));
const res = await POST(req({ ...validOrder, currency: "USD" }, { "idempotency-key": key }));
expect(res.status).toBe(409);
});
});
CI 的作用是把约定交给仓库,而不是交给人的记忆。GitHub Actions 的语法以官方 Workflow syntax为准。
name: api-contract
on:
pull_request:
paths:
- "app/api/**"
- "tests/**/*.route.test.ts"
- "openapi.yaml"
jobs:
api-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx @redocly/cli lint openapi.yaml
- run: npx vitest run tests/**/*.route.test.ts
收益导线和实际结果
如果读者正在做生产 API,他通常不是随便看看,而是担心团队能不能安全落地。可以自然引导到 Claude Code 培训与咨询,或者给个人开发者提供免费速查表。更多测试细节可以继续读API 测试自动化,版本策略可以看API 版本管理。
这套流程实际试下来,比单纯提示“写一个接口”更容易审查。尤其是 409 幂等冲突、Zod 的 fieldErrors、带 requestId 的日志和 OpenAPI lint 一起出现时,Claude Code 生成的不是演示代码,而是团队可以继续维护的 API 起点。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。