Advanced (更新: 2026/6/2)

用 Claude Code 安全设计 API 版本控制实战指南

用 Claude Code 设计不破坏客户端的 API 版本控制:OpenAPI、兼容测试、废弃头与灰度发布。

用 Claude Code 安全设计 API 版本控制实战指南

API 版本控制不是简单地给路由加上/v2。它是对已经接入 API 的移动端、合作伙伴系统、内部服务、Webhook 消费者和批处理任务做出的兼容承诺。一个字段被改名,新的接口也许更整洁,但旧客户端可能立刻失败。

Claude Code 可以读取代码库、修改文件并执行命令,这一点在Claude Code 官方文档中有说明。因此它很适合帮助改造 API。但如果提示词只写“重构这个接口”或“做一个 v2”,Claude Code 往往会把代码收敛到最新形态,而忽略旧消费者。正确做法是在编码前交给它契约、兼容规则、发布计划和验证命令。

本文会完整讲解 URL、请求头、媒体类型三种版本控制方式的取舍,OpenAPI 契约、向后兼容实现、DeprecationSunset响应头、变更日志策略、consumer test、灰度发布和回退方案,以及能防止破坏性变更的 Claude Code 提示词。事实依据优先使用官方资料:OpenAPI SpecificationRFC 9745 Deprecation HeaderRFC 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: 2URL 稳定,适合受控客户端很容易漏传;缓存需要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 值,例如@1774915200Sunset则使用 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;保留显式转换层;发布DeprecationSunset信号;写出可执行的 CHANGELOG;在重构前运行 consumer test。

如果团队想把 Claude Code 放进 API 开发流程,可以通过Claude Code 咨询与培训把 API 契约、CI 检查、审查提示词和灰度发布清单整理成可复用流程。想先自己试,可以从免费速查表开始,把本文的提示词和验证命令固定下来。

我用上面的 Node 小服务器实际验证了核心模式:v1 和 v2 可以共享同一行内部数据,同时保持不同的公开响应形状;consumer test 能立即发现字段改名。最容易漏掉的是 RFC 9745 的Deprecation日期格式、头部/媒体类型版本控制需要的Vary、以及 OpenAPI、实现、测试、CHANGELOG 必须一起审查。

#Claude Code #API 设计 #API 版本控制 #OpenAPI #TypeScript
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。