用 Claude Code 安全设计 API 版本控制实战指南
用 Claude Code 设计不破坏客户端的 API 版本控制:OpenAPI、兼容测试、废弃头与灰度发布。
API 版本控制不是简单地给路由加上/v2。它是对已经接入 API 的移动端、合作伙伴系统、内部服务、Webhook 消费者和批处理任务做出的兼容承诺。一个字段被改名,新的接口也许更整洁,但旧客户端可能立刻失败。
Claude Code 可以读取代码库、修改文件并执行命令,这一点在Claude Code 官方文档中有说明。因此它很适合帮助改造 API。但如果提示词只写“重构这个接口”或“做一个 v2”,Claude Code 往往会把代码收敛到最新形态,而忽略旧消费者。正确做法是在编码前交给它契约、兼容规则、发布计划和验证命令。
本文会完整讲解 URL、请求头、媒体类型三种版本控制方式的取舍,OpenAPI 契约、向后兼容实现、Deprecation和Sunset响应头、变更日志策略、consumer test、灰度发布和回退方案,以及能防止破坏性变更的 Claude Code 提示词。事实依据优先使用官方资料:OpenAPI Specification、RFC 9745 Deprecation Header、RFC 8594 Sunset Header。
相关主题可以继续阅读Claude Code API 开发、Claude Code 代码审查和Changesets 版本管理。
先定义兼容契约
版本控制的目标不是永久保留旧代码,而是让消费者可以按计划迁移。Masa 在一个小型订单 API 上试过:提示词只写“添加 v2,并把 customer 字段改得更清楚”时,生成代码能通过新后台页面,却让旧的 CSV 导出任务失败。缺少的不是代码能力,而是约束:v1 响应形状必须保持,废弃日期必须公开,consumer test 必须覆盖,迁移文档必须同步更新。
常见场景有三类:
| 场景 | 关键约束 | 通常适合的方式 |
|---|---|---|
| 面向移动应用的公开 REST API | 旧 App 版本会在用户手机里保留数月 | URL 路径版本 |
| B2B SaaS 合作伙伴 API | 客户按自己的排期迁移 | URL 路径或显式请求头 |
| 内部微服务 | 客户端可一起升级 | 请求头或媒体类型版本 |
在让 Claude Code 写代码前,先列出当前消费者、最短支持窗口、破坏性变更的定义和监控指标。破坏性变更不只是不再提供某个路由,也包括响应字段改名、新增必填请求字段、错误格式变化、默认排序变化、分页结构变化等。
比较 URL、请求头和媒体类型
版本放在哪里,会影响网关路由、缓存、文档、SDK 生成和客服排障。对多数公开 API 来说,URL 路径版本是最稳妥的默认选择,因为日志中清晰可见,API Gateway 配置简单,curl示例也直观。缺点是资源 URI 中混入了产品版本,/api/v1/orders/123和/api/v2/orders/123看起来像两个不同资源。
| 方式 | 示例 | 优点 | 常见失败点 |
|---|---|---|---|
| URL 路径 | /api/v1/orders | 路由、文档、调试都清楚 | 旧路径长期存在,路由代码容易重复 |
| 自定义请求头 | API-Version: 2 | URL 稳定,适合受控客户端 | 很容易漏传;缓存需要Vary: API-Version |
| 媒体类型 | Accept: application/vnd.acme.orders.v2+json | 把表示形式交给 HTTP 内容协商 | OpenAPI、SDK 生成和排障更复杂 |
如果选择媒体类型方式,要发送Vary: Accept,避免中间缓存混用 v1 和 v2 响应。选择自定义请求头时要发送Vary: API-Version。即使使用 URL 方式,只要响应兼容性变化,也应该在 OpenAPI 中把 v1 和 v2 当作两份明确契约。
用 OpenAPI 作为唯一事实来源
OpenAPI 是描述 HTTP API 的机器可读格式,包含路径、方法、参数、请求体、响应和认证。通俗地说,它是在实现前写下的 API 约定。openapi字段表示 OpenAPI 规范版本,info.version表示你们这份 API 文档的版本,二者不要混淆。
下面示例保留 v1 文档并标记废弃,同时新增 v2。示例使用openapi: 3.1.0,因为很多验证器和生成器对它支持稳定;如果要采用更新规范,请先查看 OpenAPI 官方文档和团队工具链。
openapi: 3.1.0
info:
title: Acme Orders API
version: 2.0.0
servers:
- url: https://api.example.com
paths:
/api/v1/orders/{orderId}:
get:
operationId: getOrderV1
summary: Get an order in the legacy v1 shape
deprecated: true
x-deprecated-at: "2026-03-31T00:00:00Z"
x-sunset-at: "2026-12-31T23:59:59Z"
parameters:
- name: orderId
in: path
required: true
schema:
type: string
responses:
"200":
description: Legacy order response
headers:
Deprecation:
schema:
type: string
description: RFC 9745 structured date, for example @1774915200
Sunset:
schema:
type: string
description: RFC 8594 HTTP-date when v1 may stop responding
content:
application/json:
schema:
$ref: "#/components/schemas/OrderV1Envelope"
/api/v2/orders/{orderId}:
get:
operationId: getOrderV2
summary: Get an order in the current v2 shape
parameters:
- name: orderId
in: path
required: true
schema:
type: string
responses:
"200":
description: Current order response
content:
application/json:
schema:
$ref: "#/components/schemas/OrderV2Envelope"
components:
schemas:
OrderV1Envelope:
type: object
required: [data]
properties:
data:
type: object
required: [id, customerName, totalCents, currency]
properties:
id:
type: string
customerName:
type: string
totalCents:
type: integer
currency:
type: string
OrderV2Envelope:
type: object
required: [data]
properties:
data:
type: object
required: [id, customer, amount, status]
properties:
id:
type: string
customer:
type: object
required: [displayName]
properties:
displayName:
type: string
amount:
type: object
required: [value, currency]
properties:
value:
type: integer
currency:
type: string
status:
type: string
enum: [paid, shipped]
把这份 YAML 先交给 Claude Code,再要求它实现。提示词要明确:不能删除 v1 字段,不能改变 v1 状态码,契约变化必须同步测试和 CHANGELOG。
用 Node 实现向后兼容
下面的 TypeScript 服务只使用 Node 内置模块。复制为api-versioning-demo.ts后,可以同时测试 URL 路径、API-Version请求头和Accept媒体类型。v1 保持旧响应,v2 返回新响应,v1 会带上废弃相关响应头。
import { createServer } from "node:http";
import { parse } from "node:url";
type ApiVersion = "v1" | "v2";
type OrderRow = {
id: string;
customerName: string;
totalCents: number;
currency: "JPY" | "USD";
status: "paid" | "shipped";
createdAt: string;
};
const orders = new Map<string, OrderRow>([
[
"o_100",
{
id: "o_100",
customerName: "Masa Tanaka",
totalCents: 129800,
currency: "JPY",
status: "paid",
createdAt: "2026-06-02T09:00:00.000Z",
},
],
]);
function detectVersion(req: { headers: Record<string, string | string[] | undefined> }, pathname: string) {
const pathVersion = pathname.match(/^\/api\/(v[12])\//)?.[1] as ApiVersion | undefined;
if (pathVersion) return { version: pathVersion, source: "path" };
const header = req.headers["api-version"];
if (typeof header === "string") {
const normalized = header.startsWith("v") ? header : `v${header}`;
if (normalized === "v1" || normalized === "v2") {
return { version: normalized, source: "header" };
}
throw new Error(`Unsupported API-Version: ${header}`);
}
const accept = req.headers.accept;
if (typeof accept === "string") {
const mediaMatch = accept.match(/application\/vnd\.acme\.orders\.v([12])\+json/);
if (mediaMatch) {
return { version: `v${mediaMatch[1]}` as ApiVersion, source: "media-type" };
}
}
return { version: "v1" as ApiVersion, source: "default" };
}
function orderIdFrom(pathname: string) {
return pathname.match(/^\/api\/(?:v[12]\/)?orders\/([^/]+)$/)?.[1];
}
function toV1(row: OrderRow) {
return {
data: {
id: row.id,
customerName: row.customerName,
totalCents: row.totalCents,
currency: row.currency,
},
};
}
function toV2(row: OrderRow) {
return {
data: {
id: row.id,
customer: { displayName: row.customerName },
amount: { value: row.totalCents, currency: row.currency },
status: row.status,
createdAt: row.createdAt,
},
};
}
function addDeprecationHeaders(res: import("node:http").ServerResponse) {
const deprecatedAt = Math.floor(Date.parse("2026-03-31T00:00:00Z") / 1000);
res.setHeader("Deprecation", `@${deprecatedAt}`);
res.setHeader("Sunset", new Date("2026-12-31T23:59:59Z").toUTCString());
res.setHeader(
"Link",
[
'<https://docs.example.com/api/deprecations/v1-to-v2>; rel="deprecation"; type="text/html"',
'<https://docs.example.com/api/sunset-policy>; rel="sunset"; type="text/html"',
].join(", "),
);
}
function sendJson(res: import("node:http").ServerResponse, status: number, body: unknown) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body, null, 2));
}
const server = createServer((req, res) => {
const pathname = parse(req.url ?? "/").pathname ?? "/";
const orderId = orderIdFrom(pathname);
if (!orderId) {
return sendJson(res, 404, { error: "not_found", message: "Route not found" });
}
let detected: ReturnType<typeof detectVersion>;
try {
detected = detectVersion(req, pathname);
} catch (error) {
return sendJson(res, 400, {
error: "unsupported_version",
message: error instanceof Error ? error.message : "Unsupported API version",
supportedVersions: ["v1", "v2"],
});
}
const row = orders.get(orderId);
if (!row) {
return sendJson(res, 404, { error: "order_not_found", orderId });
}
res.setHeader("Vary", "Accept, API-Version");
res.setHeader("X-API-Version", detected.version);
res.setHeader("X-API-Version-Source", detected.source);
if (detected.version === "v1") {
addDeprecationHeaders(res);
return sendJson(res, 200, toV1(row));
}
return sendJson(res, 200, toV2(row));
});
const port = Number(process.env.PORT ?? 18080);
server.listen(port, () => {
console.log(`API versioning demo: http://localhost:${port}`);
});
npm init -y
npm install -D tsx typescript @types/node
PORT=18080 npx tsx api-versioning-demo.ts &
SERVER_PID=$!
sleep 1
curl -i http://localhost:18080/api/v1/orders/o_100
curl -i -H "API-Version: 2" http://localhost:18080/api/orders/o_100
curl -i -H "Accept: application/vnd.acme.orders.v2+json" http://localhost:18080/api/orders/o_100
kill "$SERVER_PID"
这里的关键是转换层。v1 不应该先生成 v2 响应再希望旧客户端能容忍,而是从内部数据行映射到 v1 明确承诺的公开形状。
发布废弃头和版本策略
很多旧文章会写Deprecation: true,但当前应使用 RFC 9745 定义的结构化 Date 值,例如@1774915200。Sunset则使用 RFC 8594 的 HTTP-date,表示某个资源在该时间之后可能停止响应。响应头是运行时信号,不等于完整迁移计划。
把版本策略放进仓库,让 Claude Code、审查者和业务负责人看到同一套规则。
currentApiVersion: v2
minimumSupportWindowMonths: 12
breakingChangeRequires:
- new-major-version
- migration-guide
- consumer-test
- owner-approval
deprecatedVersions:
- version: v1
deprecatedAt: "2026-03-31T00:00:00Z"
sunsetAt: "2026-12-31T23:59:59Z"
replacement: "/api/v2/orders/{orderId}"
migrationGuide: "https://docs.example.com/api/deprecations/v1-to-v2"
CHANGELOG 要分清新增、变更、废弃和计划删除。好的记录会说明谁受影响、要改什么、替代端点是什么、旧版本何时可能停止响应。只写“v1 已废弃”无法帮助合作伙伴安排迁移。
用 Consumer Test 阻止破坏性变更
consumer test 表达的是消费者仍然依赖什么。Claude Code 做重构时,看到重复的字段转换可能会想合并,这时测试能保护旧契约。下面的测试确认 v1 仍有customerName,且不会意外返回 v2 的customer对象。
import assert from "node:assert/strict";
import test from "node:test";
const baseUrl = process.env.API_BASE_URL ?? "http://localhost:18080";
test("v1 keeps the legacy response shape", async () => {
const res = await fetch(`${baseUrl}/api/v1/orders/o_100`);
assert.equal(res.status, 200);
assert.match(res.headers.get("deprecation") ?? "", /^@\d+$/);
assert.match(res.headers.get("sunset") ?? "", /GMT$/);
const body = await res.json();
assert.equal(body.data.customerName, "Masa Tanaka");
assert.equal(body.data.customer, undefined);
});
test("v2 returns the current response shape", async () => {
const res = await fetch(`${baseUrl}/api/orders/o_100`, {
headers: { "API-Version": "2" },
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.data.customer.displayName, "Masa Tanaka");
assert.equal(body.data.amount.currency, "JPY");
assert.equal(body.data.customerName, undefined);
});
PORT=18080 npx tsx api-versioning-demo.ts &
SERVER_PID=$!
sleep 1
API_BASE_URL=http://localhost:18080 node --test version-contract.test.mjs
kill "$SERVER_PID"
如果项目中使用 OpenAPI lint,也把它加进验证路径:
npx @redocly/cli lint openapi.yaml
这样 Claude Code 才有清晰的通过条件,而不是凭一句“保持兼容”去猜。
分阶段发布并准备回退
API 版本控制事故通常很相似:数据库结构和响应结构在同一次发布中改变,导致回滚困难;没有看真实 v1 流量就宣布停用日期;只更新 SDK,却忘了直接调用 HTTP 的客户;文档写了废弃,但指标和告警没有识别剩余调用方。
发布顺序建议拆开:添加 v2,给 v1 加废弃响应头,统计版本使用率,发布迁移指南,更新 SDK,通知合作伙伴,执行 Sunset 策略,最后才删除 v1。回退方案也要明确:关闭 v2 时 v1 是否仍可用,旧客户端是否能忽略新字段,数据库迁移是否至少保持读取兼容。
mkdir -p tmp/version-snapshots
BASE_URL=${BASE_URL:-http://localhost:18080}
for order_id in o_100 missing; do
curl -sS -D "tmp/version-snapshots/${order_id}.v1.headers" \
"$BASE_URL/api/v1/orders/$order_id" \
> "tmp/version-snapshots/${order_id}.v1.json" || true
curl -sS -D "tmp/version-snapshots/${order_id}.v2.headers" \
-H "API-Version: 2" \
"$BASE_URL/api/orders/$order_id" \
> "tmp/version-snapshots/${order_id}.v2.json" || true
done
把快照附到 PR,或交给 Claude Code 生成兼容性摘要。它不能替代测试,但能让审查者快速看到行为差异。
防止破坏性变更的 Claude Code 提示词
Claude Code 需要的不只是任务,还包括禁止事项和验证命令。
为现有 API 添加 v2。请把 OpenAPI 文件视为唯一事实来源。不要改变 v1 的响应形状、状态码或废弃响应头。
编辑前请列出:
- 可能成为破坏性变更的地方
- v1 必须保留的字段
- v2 新增或改变的字段
- 将添加的 consumer test
编辑后请运行:
- npm test
- npx @redocly/cli lint openapi.yaml
- 用 curl 对比 v1 和 v2 响应
最终回答中请包含兼容性风险、迁移指南要点和回退步骤。
合并前再使用审查提示词:
请把这个差分当作 API 兼容性审查。
检查:
- v1 必填响应字段没有被删除、改名或改类型
- 错误格式、HTTP 状态码、分页和排序没有意外变化
- Deprecation、Sunset、Link、Vary 响应头符合策略
- OpenAPI、实现、测试和 CHANGELOG 内容一致
- 回滚不会破坏 v1 消费者
如有问题,请给出文件名和具体修复建议。
这些提示词会把 Claude Code 的目标从“让代码更干净”拉回到“保护公开契约”。对 API 来说,这比选择哪种版本号位置更重要。
总结
安全的 API 版本控制从契约开始。根据消费者和基础设施选择 URL、请求头或媒体类型方式;用 OpenAPI 记录 v1 和 v2;保留显式转换层;发布Deprecation和Sunset信号;写出可执行的 CHANGELOG;在重构前运行 consumer test。
如果团队想把 Claude Code 放进 API 开发流程,可以通过Claude Code 咨询与培训把 API 契约、CI 检查、审查提示词和灰度发布清单整理成可复用流程。想先自己试,可以从免费速查表开始,把本文的提示词和验证命令固定下来。
我用上面的 Node 小服务器实际验证了核心模式:v1 和 v2 可以共享同一行内部数据,同时保持不同的公开响应形状;consumer test 能立即发现字段改名。最容易漏掉的是 RFC 9745 的Deprecation日期格式、头部/媒体类型版本控制需要的Vary、以及 OpenAPI、实现、测试、CHANGELOG 必须一起审查。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code Permission Receipt Pattern:记录权限、证据和回滚方式
Claude Code 权限 receipt:记录允许动作、需要批准的边界、验证命令、回滚说明,以及 Gumroad 和咨询 CTA 检查。
Claude Code/Codex 安全 Agent Harness 实战:权限、验证与回滚
用权限策略、执行计划、验证脚本和回滚日志,为 Claude Code 与 Codex 搭建更安全的 AI Agent 工作流。
Claude Code 子代理实战指南:安全委派并行文章与代码工作
用 Claude Code 子代理安全拆分文章和代码工作:委派规则、提示词模板、失败模式与检查清单。