关于 api-design
api-design 是用于设计面向消费者的 API 契约与协议语义的技能,涵盖兼容性、错误、验证和演进,适合定义或修改接口的团队。
主要功能
- 01契约优先的接口设计
- 02保持向后兼容的 API 演进
- 03统一错误响应语义
- 04边界输入验证指导
- 05请求幂等性指导
适用场景
- 01设计新的 API 端点
- 02在不破坏消费者的前提下修改公开接口
- 03为独立发布的团队定义契约
- 04根据数据库模式确定 API 形态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill api-design安装后在 Claude Code 中输入/api-design确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
LICENSEresources/api-evolution.mdapi-security.mdauth-security.mdhttp-fundamentals.mdproblem-details.mdsource-notes.md详细介绍
API 与接口设计
在关注使用方兼容性和协议语义时使用此技能。若要了解进程内模块的职责、调用方的全部负担、信息隐藏和深度,请加载 codebase-design;如果内部模块同时对外或跨团队提供契约,则两者都要使用。只有当重要的网关、框架、SDK、提供方或协议实现的选择仍未确定时,才使用 evaluate-existing-solutions;API 语义和兼容性仍由此技能负责。
关于 TypeScript 类型模式(品牌类型、判别联合、模式优先),请参阅 typescript-strict 技能。关于不可变性模式,请参阅 functional 技能。关于 API 行为测试,请参阅 testing 技能。关于 OAuth 2.0 或 OpenID Connect,请加载 secure-oauth-oidc 技能,不要将身份验证视为普通的 API 密钥决策。关于面向浏览器的 BFF 入口——公开/受保护访问分类、会话 Cookie、CSRF/Origin/Fetch Metadata 策略、受保护的 SSE/WebSocket 注册以及端点保护强制措施——请加载 bff-entry-points 技能;错误格式和 REST 语义仍由此技能负责。关于 BFF 模式本身(采用、粒度、聚合、上游身份),请加载 bff-design——与前端同步部署、仅供单个使用方使用的 BFF,可以合理放宽此技能对外部使用契约所要求的版本管理规范。
支持资源位于 resources/ 目录中。按需加载:
| 资源 | 在以下情况下加载…… |
|---|---|
problem-details.md | 实现 RFC 9457 错误响应——成员语义、单错误和验证错误 JSON 示例、扩展成员、第 5 节安全指南 |
api-evolution.md | 版本管理策略和弃用模式 |
api-security.md | 保护 API 边界 |
auth-security.md | JWT BCP 安全以及如何转向专门的 OAuth/OIDC 技能 |
http-fundamentals.md | HTTP 协议基础——缓存指令、内容协商、浏览器安全、状态码、标头设计 |
source-notes.md | 审查来源、不可变审计基线、许可范围和本地差异 |
适用场景
- 设计新的 API 端点
- 定义团队之间或独立发布的使用方之间的契约
- 更改现有公共接口
- 建立影响 API 形态的数据库架构
核心原则
Hyrum 定律
API 的用户数量足够多时,无论你在契约中如何承诺,总会有人依赖系统所有可观察到的行为。
一旦用户开始依赖,所有公开行为——包括未记录的特性、错误消息文本、时序和顺序——都会成为事实上的契约。
- 谨慎决定公开哪些内容。 每种可观察到的行为都可能成为承诺。
- 不要泄露实现细节。 只要用户能够观察到,就会有人依赖它。
- 在设计时规划弃用,并在弃用发生时立即记录。 移除用户依赖的内容,成本总是高于预期。已发布的名称被取代时,要在使用方看得到的地方记录:在字段或其类型上添加弃用注释;对仍包含该名称的响应添加
Deprecation/Sunset标头;并在 README 或 API 文档中列出替代名称。未记录的取代就是未记录的变更——有关标头语法和弃用检查清单,请参阅resources/api-evolution.md。 - 测试还不够。 即使契约测试无懈可击,Hyrum 定律也意味着“安全”的变更仍可能破坏依赖未记录行为的真实用户。
单版本规则
避免迫使使用方在同一依赖项或 API 的多个版本之间做出选择。当不同使用方需要同一事物的不同版本时,就会产生菱形依赖问题。应按同一时间只存在一个版本来设计——扩展而非分叉。
契约优先
先定义接口,再进行实现。契约就是规范——实现应遵循规范。
type TaskAPI = {
readonly createTask: (input: CreateTaskInput) => Promise<Task>;
readonly listTasks: (params: ListTasksParams) => Promise<PaginatedResult<Task>>;
readonly getTask: (id: TaskId) => Promise<Task>;
readonly updateTask: (id: TaskId, input: UpdateTaskInput) => Promise<Task>;
readonly deleteTask: (id: TaskId) => Promise<void>;
};
这与 TDD 一致:定义契约(你想要什么),据此编写测试,然后进行实现。
优先新增,而非修改
扩展接口时,不要破坏现有使用者。
此规则的优先级高于请求的措辞。 如果请求要重命名、重新定义类型、缩小范围或移除已发布契约中的内容——例如响应字段、标头、状态或路由——那么请求的是新的形式,而不是故障。采用增量方式交付:在保留旧名称的同时添加新名称,让旧名称继续正常工作并携带相同的值,将旧名称标记为已被取代,并在回复中说明你采用了新增而非替换的方式,以及要弃用旧名称需要做什么。不要先实施破坏性改动,再事后说明;也不要停下来询问对方想要哪种方式——增量改动既能满足请求,又能保持兼容性,而且如果请求者确实想要破坏性改动,也可以撤回。
按以下方式扩展接口:
type CreateTaskInput = {
readonly title: string;
readonly description?: string;
readonly priority?: 'low' | 'medium' | 'high'; // Added later, optional
readonly labels?: ReadonlyArray<string>; // Added later, optional
};
会破坏向后兼容性的改动:
- 移除字段
- 更改字段类型
- 将可选字段改为必填
- 更改枚举值
能保持向后兼容性的改动:
- 添加新的可选字段
- 添加新的枚举值(前提是使用者能处理未知值)
- 添加新的端点
保持错误语义一致
选择一种错误处理策略,并在各处统一使用。不要混用模式,例如有些端点抛出异常,有些返回 null,还有些返回 { error }。
选择错误格式
在同一个有版本的 API 契约中,应使用一种有文档说明的错误结构,除非特定协议的端点需要采用其他格式。保持一致比选择哪种兼容格式更重要。
对于有外部使用者的公共 API,请使用 RFC 9457(Problem Details)。这是业界标准,机器可读,也是第三方开发者的预期格式。将 application/problem+json 用作 Content-Type。
对于只有一个前端的内部 API,采用更简单但一致的结构也是有效的选择。满足最低要求的错误响应需要包含:机器可读的错误代码、可选的人类可读消息,以及正确的 HTTP 状态代码。与 RFC 9457 相比,这种方式更简洁,同时仍能保持一致且便于采取行动。
// Simpler shape — sufficient for internal APIs
type ApiError = {
readonly error: string; // Machine-readable code (UPPER_SNAKE_CASE)
readonly message?: string; // Human-readable description
readonly fieldErrors?: Record<string, string>; // For validation errors
};
如果一开始采用更简单的格式,应设计成以后可以演进为 RFC 9457 的形式(例如,error 对应 title,message 对应 detail)。不要让自己陷入无法演进的局面。
RFC 9457(HTTP API 的 Problem Details)
这是公共 API 机器可读错误的标准格式。将 application/problem+json 用作 Content-Type。标准成员包括:type(标识错误类型的 URI)、title(稳定且人类可读的摘要)、status(必须与实际 HTTP 状态一致)、detail(针对特定事件的说明)、instance(可选的事件 URI)。允许使用扩展成员——客户端必须忽略自己无法识别的扩展成员。
错误应当便于采取行动:使用者应能知道发生了什么、为什么发生,以及该怎么做。错误响应不是调试工具——绝不要暴露堆栈跟踪、内部路径或实现细节。
添加关联标识符(跟踪 ID,或指向跟踪 ID 的不透明引用)作为扩展成员,或通过 instance 提供,这样用户报告的错误就能关联到相应的跟踪记录和规范日志事件——参见 observability 技能。跟踪 ID 不会泄露任何内部信息;它是查找键,而非细节泄露。
有关成员的完整语义、单错误和验证错误的 JSON 示例、扩展成员规则、不应使用 Problem Details 的情况,以及 RFC 9457 §5 的安全指南,请参阅 resources/problem-details.md。
HTTP 状态码映射
| 状态码 | 含义 | 使用场景 |
|---|---|---|
| 400 | 错误请求 | 客户端发送了格式错误的数据 |
| 401 | 未授权 | 未通过身份验证 |
| 403 | 禁止访问 | 已通过身份验证,但未获授权 |
| 404 | 未找到 | 资源不存在 |
| 409 | 冲突 | 重复、版本不匹配 |
| 422 | 无法处理的内容 | 验证失败(语义无效) |
| 429 | 请求过多 | 超出速率限制;已知有意义的重试时间时,请包含 Retry-After |
| 500 | 内部服务器错误 | 服务器错误(绝不暴露内部详情) |
在 API 边界进行验证
在不可信表示和端点架构输入进入系统时进行验证,然后在内部代码中传递派生类型。这不能取代领域智能构造函数或状态转换检查:不变量仍由拥有该值的模型负责强制执行。参见 typescript-strict 和 domain-driven-design。
表示本身无法解析时(例如 JSON 格式错误),返回 400。解析成功后,如果值未通过端点架构或业务验证,则返回 422。只有在所有示例、错误转换器和使用方都一致采用另一套映射时,才选择并记录不同的统一映射。
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
type: 'https://api.example.com/problems/validation-error',
title: 'Validation Error',
status: 422,
detail: 'Invalid task data',
errors: result.error.flatten(),
});
}
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
第三方 API 响应是不可信数据——使用前务必验证其结构和内容。
幂等性
网络故障时有发生,客户端会重试。没有幂等性,重试就会产生重复扣款、重复订单和重复记录。
HTTP 方法的幂等性
| 方法 | 安全 | 幂等 | 备注 |
|---|---|---|---|
| GET | 是 | 是 | 请求语义为只读;允许附带日志记录和指标收集 |
| PUT | 否 | 是 | 相同请求具有相同的预期效果;响应状态码或正文可能不同 |
| DELETE | 否 | 是 | 删除两次 = 结果相同 |
| POST | 否 | 否 | 需要显式处理幂等性 |
| PATCH | 否 | 不保证 | 取决于实现 |
POST 的幂等键
对于非幂等操作(尤其是涉及资金、订单或状态变更的操作),请使用客户端提供的幂等键:
app.post('/api/payments', async (req, res) => {
const rawIdempotencyKey = req.headers['idempotency-key'];
const idempotencyKey =
typeof rawIdempotencyKey === 'string' &&
/^[\x21-\x7e]{1,128}$/.test(rawIdempotencyKey)
? rawIdempotencyKey
: undefined;
if (!idempotencyKey) {
return res.status(400).json({
type: 'https://api.example.com/problems/invalid-idempotency-key',
title: 'Invalid Idempotency Key',
status: 400,
detail: 'POST /api/payments requires one visible-ASCII Idempotency-Key of at most 128 characters',
});
}
const result = CreatePaymentSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json(toValidationProblem(result.error));
}
const scope = req.user.id;
const fingerprint = stableHash(result.data);
const claim = await idempotencyStore.claim({
scope,
key: idempotencyKey,
fingerprint,
});
if (claim.status === 'parameters-mismatch') {
return res.status(409).json(toProblem('IDEMPOTENCY_PARAMETERS_MISMATCH'));
}
if (claim.status === 'completed') {
return res.status(claim.response.status).json(claim.response.body);
}
if (claim.status === 'in-progress') {
res.setHeader('Retry-After', '1');
return res.status(409).json(toProblem('IDEMPOTENCY_REQUEST_IN_PROGRESS'));
}
// createOnce durably owns operationId + fingerprint beyond response-cache
// expiry. For an external provider it also persists/reuses the provider's
// payment/intent ID, so recovery never depends on a time-limited request key.
const payment = await paymentService.createOnce({
operationId: claim.operationId,
input: result.data,
});
await idempotencyStore.complete(claim.id, { status: 201, body: payment });
return res.status(201).json(payment);
});
设计原则:
- 键的作用范围应限定为 API 密钥/经过身份验证的用户
- 根据文档记录的客户端最长重试/对账窗口和操作风险,确定响应缓存和请求占用记录的保留期限;24 小时并非通用规则
- 缓存响应被清除后,仍保留持久化的操作身份标识(以及指纹或墓碑记录),或者返回明确的过期/未知结果,直到能够确认不会重复执行为止——绝不要将重复使用且有风险的键悄然视为新操作
- 如果使用相同键重试时参数不同,则返回错误
- 以原子方式占用键;先读取再创建的独立操作容易发生竞态
- 通过同一事务或持久化的下游操作 ID,将业务操作与键占用协调起来
- 对于外部付款,持久化并对账支付服务提供商的付款/意图 ID;任何请求幂等键的重试期限都不得超过服务提供商文档规定的保留期限
- 显式表示正在进行的请求占用状态,并定义工作进程占用请求后失败时的重试/恢复机制
- 按“至少一次”投递来设计——假设每个请求都可能发送多次
实现 DELETE 的幂等性
DELETE 必须具有幂等效果:重复执行不会删除其他内容,也不会产生第二次副作用。重复请求的响应可以仍为 204;如果对客户端而言资源不存在这一状态有意义,也可以返回 404。请选择并记录一套契约:
app.delete('/api/tasks/:id', async (req, res) => {
const deleted = await taskService.delete(req.params.id);
// This API deliberately treats already-absent as success.
return res.status(204).send();
});
速率限制
当 API 提供配额策略时,应在对客户端有用的响应中一致地说明适用策略和剩余配额状态。没有此类策略的端点,不要臆造配额标头。
标准头部(IETF 草案——尚未成为 RFC)
IETF 标准化工作为 draft-ietf-httpapi-ratelimit-headers(第 11 版,2026 年 5 月——仍是活跃的工作组草案)。当前草案定义了两个结构化字段:
RateLimit-Policy: "hour";q=1000;w=3600 # Policy: quota of 1000 per 3600s window
RateLimit: "hour";r=742;t=60 # Current state: 742 remaining, resets in 60s
早期草案使用三个字段——RateLimit-Limit、RateLimit-Remaining、RateLimit-Reset——许多已发布的 API 仍使用这三个字段,或未标准化的 X-RateLimit-* 系列。调用 API 时,这些字段都有可能出现。对于新 API,优先使用当前草案中的字段;无论选择哪些名称,都要将其写入文档——头部名称是契约的一部分,而且草案在成为 RFC 前仍可能变更。
如果服务器能提供有意义的重试时间,请在 429 响应中包含 Retry-After。这是一个稳定的标准 HTTP 头部,但 HTTP 允许 429 响应不包含它:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Policy: "hour";q=1000;w=3600
RateLimit: "hour";r=0;t=30
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded 1000 requests per hour. Retry after 30 seconds."
}
设计注意事项
- 针对不同操作设置不同限制——读取比写入成本低
- 在文档中说明限制——不要让调用方通过触发 429 才发现限制
- 在文档中建议使用带抖动的指数退避——简单的重试循环会造成惊群效应
HTTP 缓存
为响应指定明确的新鲜度有效期。不要依赖启发式新鲜度。
实用规则:
- 优先使用
Cache-Control: max-age=N,而不是Expires——即使新鲜度有效期很短(例如max-age=5),也能让多个客户端重复使用响应 - 设置 ETag,以便高效地重新验证,而不必重新传输响应正文
- 如果请求头部会改变响应,则对该资源的所有响应(包括默认响应)都使用
Vary - 对包含敏感数据的响应使用
no-store——no-cache并不表示“不要缓存”(它表示“可以存储,但使用前必须重新验证”)
有关完整的 Cache-Control 指令表,以及内容协商、头部设计和协议版本独立性的说明,请参见 resources/http-fundamentals.md。
REST 约定
资源命名
| 模式 | 约定 | 示例 |
|---|---|---|
| 端点 | 使用复数名词,不使用动词 | GET /api/tasks、POST /api/tasks |
| 查询参数 | 使用 camelCase | ?sortBy=createdAt&pageSize=20 |
| 响应字段 | 使用 camelCase | { createdAt, updatedAt, taskId } |
| 布尔字段 | 使用 is/has/can 前缀 | isComplete、hasAttachments |
| 枚举值 | 使用 UPPER_SNAKE | "IN_PROGRESS"、"COMPLETED" |
| 头部 | 不使用 X- 前缀(RFC 6648/BCP 178) | Example-Request-Id |
资源设计
GET /api/tasks → List tasks (with query params for filtering)
POST /api/tasks → Create a task
GET /api/tasks/:id → Get a single task
PATCH /api/tasks/:id → Update a task (partial)
DELETE /api/tasks/:id → Delete a task
GET /api/tasks/:id/comments → List comments for a task (sub-resource)
POST /api/tasks/:id/comments → Add a comment to a task
使用 PATCH 进行部分更新(只更改已提供的字段)。仅当客户端发送完整对象时才使用 PUT。
分页
对于结果可能超出有界安全响应的列表端点,应进行分页。 对于较小且封闭的枚举,如果该上限属于契约的一部分,则可以返回完整集合:
// Request
// GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// Response shape
type PaginatedResult<T> = {
readonly data: ReadonlyArray<T>;
readonly pagination: {
readonly page: number;
readonly pageSize: number;
readonly totalItems: number;
readonly totalPages: number;
};
};
筛选
使用查询参数进行筛选:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
输入/输出分离
将调用方提供的内容与系统返回的内容分开:
// Input: what the caller provides
type CreateTaskInput = {
readonly title: string;
readonly description?: string;
};
// Output: includes server-generated fields
type Task = {
readonly id: TaskId;
readonly title: string;
readonly description: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly createdBy: UserId;
};
常见的自我安慰
| 自我安慰 | 实际情况 |
|---|---|
| “我们以后再编写 API 文档” | 类型就是文档。先定义类型。 |
| “这个不断增长的列表不需要上限” | 在结果增长到单次响应无法容纳之前,定义分页或其他有文档说明的安全上限。范围封闭且有合同约束的列表无需形式化分页。 |
| “PATCH 太复杂了,我们直接用 PUT 吧” | 每次使用 PUT 都需要提供完整对象。客户端实际需要的是 PATCH。 |
| “等需要时再给 API 加版本” | 不做版本控制的破坏性变更会影响使用方。从一开始就为扩展做好设计。 |
| “他们要求重命名,所以可以接受破坏性变更” | 要求使用更清晰名称,指的是名称,而不是破坏性变更。添加新名称,让旧名称继续可用,将其记录为已被取代,并在回复中解释这一选择——不要发布破坏性变更,也不要停下工作去询问对方的本意。 |
| “没人会用那些未记录的行为” | Hyrum 定律:只要行为可观察到,就会有人依赖它。 |
| “内部 API 不需要契约” | 内部使用方也是使用方。契约能避免耦合,并支持并行工作。 |
| “重试是客户端的问题” | 没有幂等性,重试会产生重复项。按至少一次交付来设计。 |
| “等这个昂贵的公共操作遭到攻击后,我们再添加滥用防护” | 从一开始就保护未经身份验证、成本高昂或容易遭到滥用的操作。只有在面向客户端的配额确实属于契约时,才发布配额响应头。 |
| “错误消息只是用于调试” | 错误是 API 开发者体验的一部分。让错误信息具有可操作性,而不只是用于诊断。 |
危险信号
- 根据不同条件返回不同结构的端点
- 各端点的错误格式不一致
- 暴露堆栈跟踪或内部路径的错误响应
- 对现有字段进行破坏性变更(类型变更、移除字段)
- 可能无界且没有分页或其他明确上限的列表端点
- REST URL 中包含动词(
/api/createTask、/api/getUsers) - 未经验证便使用第三方 API 响应
- 端点没有类型化的输入/输出架构
- 可重试的 POST 操作中,重复尝试可能重复产生状态变更效果,且未定义幂等策略
- 有文档说明配额政策,但响应未向客户端暴露足够状态,导致客户端无法正确处理
- 重试逻辑没有指数退避
- 浏览器提供的响应缺少入口点适用的安全响应头
- 自定义的
X-前缀响应头(已被 RFC 6648 弃用)
验证
设计 API 后:
- 每个端点都有带类型的输出契约;存在输入时,也有带类型的输入
- 错误响应遵循统一格式(公共 API 使用 RFC 9457,内部 API 使用更简单但一致的结构)
- 错误响应绝不泄露实现细节(堆栈跟踪、内部路径)
- 在信任边界处验证不可信的表示形式/架构;领域不变量仍由其所有者负责强制执行
- 不断增长的列表端点都提供分页或其他有文档说明的安全上限
- 新字段均为新增且可选(向后兼容);本次变更未移除或重新定义任何已发布的名称、类型、状态或路由
- 每个已被取代的名称仍以旧值提供服务,并在消费者可见之处标记为已被取代
- 所有端点的命名遵循一致的惯例
- 在实现前定义契约(契约优先)
- 可重试的 POST 操作规定如何避免重复尝试造成重复效果
- 有文档说明配额策略的 API,以一致的方式公开客户端可见的限额状态
- RFC 9457 响应的 Content-Type 为
application/problem+json;更简单格式的响应为application/json - 面向浏览器的入口点应用适合所提供内容和部署策略的安全标头
- 定义缓存策略(明确设置
Cache-Control,使用 ETag 重新验证,并在需要时使用Vary)
致谢
此技能改编自 Addy Osmani 采用 MIT 许可证授权的 api-and-interface-design 技能。本地历史记录了此次改编,但未记录原始上游导入修订版本。请参阅 resources/source-notes.md,了解不可变的审计基准(这并不表示导入修订版本)、保留的理念以及本地差异;适用的上游声明请参阅 LICENSE。