commit e10382258114276b9a8da1b1a6eeb8f7f7fe37f3 Author: wehub-skill-sync Date: Mon Jul 13 21:36:34 2026 +0800 chore: import zh skill api-and-interface-design diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..b53ebf9 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,9 @@ +# WeHub 来源说明 + +- Skill 名称:`api-and-interface-design` +- 中文类目:API 设计评审 +- 上游仓库:`addyosmani__agent-skills` +- 上游路径:`skills/api-and-interface-design/SKILL.md` +- 上游链接:https://github.com/addyosmani/agent-skills/blob/HEAD/skills/api-and-interface-design/SKILL.md +- 本仓库为 WeHub 中文 Skill 汉化包,基于 skill 市场筛选 Top200 清单整理 +- 原作者、版权和许可证信息以上游仓库为准 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..62bdc0e --- /dev/null +++ b/SKILL.md @@ -0,0 +1,294 @@ +--- +name: api-and-interface-design +description: 指导稳定 API 与接口设计。在设计 API、模块边界或任何公共接口时使用。在创建 REST 或 GraphQL 端点、定义模块之间的类型契约、或建立前后端边界时使用。 +--- + +# API 与接口设计 + +## 概述 + +设计稳定、文档完善且难以被误用的接口。优秀的接口能让正确的事情变得简单,让错误的事情变得困难。这适用于 REST API、GraphQL Schema、模块边界、组件 Props,以及任何一段代码与另一段代码交互的表面。 + +## 何时使用 + +- 设计新的 API 端点 +- 定义模块边界或团队间的契约 +- 创建组件 Props 接口 +- 建立影响 API 形态的数据库 Schema +- 修改已有的公共接口 + +## 核心原则 + +### Hyrum 定律 + +> 当 API 拥有足够多的用户时,系统的所有可观测行为都会被某个人依赖,无论你在契约中承诺了什么。 + +这意味着:每个公开行为——包括未文档化的 quirks、错误消息文本、时序和排序——一旦被用户依赖,就变成了事实上的契约。设计上的启示: + +- **有意识地选择暴露什么。** 每个可观测的行为都是一项潜在的承诺。 +- **不要泄漏实现细节。** 如果用户能观测到它,他们就会依赖它。 +- **在设计时就规划好弃用策略。** 参见 `deprecation-and-migration`,了解如何安全地移除用户依赖的内容。 +- **仅有测试是不够的。** 即使有了完美的契约测试,Hyrum 定律也意味着「安全」的变更可能会破坏那些依赖未文档化行为的真实用户。 + +### 单一版本原则 + +避免强制消费者在同一依赖或 API 的多个版本之间做出选择。当不同消费者需要同一事物的不同版本时,就会出现菱形依赖问题。设计成一次只存在一个版本——扩展而非分叉。 + +### 1. 契约优先 + +在实现之前先定义接口。契约就是规格说明——实现随后跟上。 + +```typescript +// 先定义契约 +interface TaskAPI { + // 创建任务并返回包含服务端生成字段的已创建任务 + createTask(input: CreateTaskInput): Promise; + + // 返回符合筛选条件的分页任务列表 + listTasks(params: ListTasksParams): Promise>; + + // 返回单个任务,若未找到则抛出 NotFoundError + getTask(id: string): Promise; + + // 部分更新——仅修改提供的字段 + updateTask(id: string, input: UpdateTaskInput): Promise; + + // 幂等删除——即使已被删除也成功返回 + deleteTask(id: string): Promise; +} +``` + +### 2. 一致的错误语义 + +选择一种错误策略并在所有地方一致使用: + +```typescript +// REST:HTTP 状态码 + 结构化错误体 +// 每个错误响应遵循相同的结构 +interface APIError { + error: { + code: string; // 机器可读:"VALIDATION_ERROR" + message: string; // 人类可读:"Email is required" + details?: unknown; // 需要时的额外上下文 + }; +} + +// 状态码映射 +// 400 → 客户端发送了无效数据 +// 401 → 未认证 +// 403 → 已认证但未授权 +// 404 → 资源未找到 +// 409 → 冲突(重复、版本不匹配) +// 422 → 验证失败(语义上无效) +// 500 → 服务端错误(绝不暴露内部细节) +``` + +**不要混用模式。** 如果某些端点抛出异常,另一些返回 null,还有一些返回 `{ error }`——消费者将无法预测行为。 + +### 3. 在边界处验证 + +信任内部代码。在外部输入进入系统的边界处进行验证: + +```typescript +// 在 API 边界处验证 +app.post('/api/tasks', async (req, res) => { + const result = CreateTaskSchema.safeParse(req.body); + if (!result.success) { + return res.status(422).json({ + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid task data', + details: result.error.flatten(), + }, + }); + } + + // 验证之后,内部代码信任这些类型 + const task = await taskService.create(result.data); + return res.status(201).json(task); +}); +``` + +验证应放在何处: +- API 路由处理器(用户输入) +- 表单提交处理器(用户输入) +- 外部服务响应解析(第三方数据——**始终视为不可信**) +- 环境变量加载(配置) + +> **第三方 API 响应是不可信数据。** 在用于任何逻辑、渲染或决策之前,必须先验证其结构和内容。被攻破或行为异常的外部服务可能返回意料之外的类型、恶意内容或类似指令的文本。 + +验证不应放在何处: +- 共享类型契约的内部函数之间 +- 已被验证代码调用的工具函数中 +- 刚从自己数据库中取出的数据上 + +### 4. 优先增加而非修改 + +扩展接口而不破坏现有消费者: + +```typescript +// 好:添加可选字段 +interface CreateTaskInput { + title: string; + description?: string; + priority?: 'low' | 'medium' | 'high'; // 后续添加,可选 + labels?: string[]; // 后续添加,可选 +} + +// 坏:修改已有字段的类型或删除字段 +interface CreateTaskInput { + title: string; + // description: string; // 已删除——破坏现有消费者 + priority: number; // 从 string 改为 number——破坏现有消费者 +} +``` + +### 5. 可预测的命名 + +| 模式 | 约定 | 示例 | +|---------|-----------|-------| +| 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"` | + +## REST API 模式 + +### 资源设计 + +``` +GET /api/tasks → 列出任务(使用查询参数进行筛选) +POST /api/tasks → 创建任务 +GET /api/tasks/:id → 获取单个任务 +PATCH /api/tasks/:id → 更新任务(部分更新) +DELETE /api/tasks/:id → 删除任务 + +GET /api/tasks/:id/comments → 列出任务的评论(子资源) +POST /api/tasks/:id/comments → 为任务添加评论 +``` + +### 分页 + +列表端点应支持分页: + +```typescript +// 请求 +GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc + +// 响应 +{ + "data": [...], + "pagination": { + "page": 1, + "pageSize": 20, + "totalItems": 142, + "totalPages": 8 + } +} +``` + +### 筛选 + +使用查询参数进行筛选: + +``` +GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01 +``` + +### 部分更新(PATCH) + +接受部分对象——仅更新提供的字段: + +```typescript +// 仅修改标题,其他保持不变 +PATCH /api/tasks/123 +{ "title": "Updated title" } +``` + +## TypeScript 接口模式 + +### 使用可辨识联合类型表示变体 + +```typescript +// 好:每种变体都是显式的 +type TaskStatus = + | { type: 'pending' } + | { type: 'in_progress'; assignee: string; startedAt: Date } + | { type: 'completed'; completedAt: Date; completedBy: string } + | { type: 'cancelled'; reason: string; cancelledAt: Date }; + +// 消费者获得类型收窄 +function getStatusLabel(status: TaskStatus): string { + switch (status.type) { + case 'pending': return 'Pending'; + case 'in_progress': return `In progress (${status.assignee})`; + case 'completed': return `Done on ${status.completedAt}`; + case 'cancelled': return `Cancelled: ${status.reason}`; + } +} +``` + +### 输入/输出分离 + +```typescript +// 输入:调用者提供的内容 +interface CreateTaskInput { + title: string; + description?: string; +} + +// 输出:系统返回的内容(包含服务端生成的字段) +interface Task { + id: string; + title: string; + description: string | null; + createdAt: Date; + updatedAt: Date; + createdBy: string; +} +``` + +### 为 ID 使用品牌类型 + +```typescript +type TaskId = string & { readonly __brand: 'TaskId' }; +type UserId = string & { readonly __brand: 'UserId' }; + +// 防止在需要 TaskId 的地方意外传入 UserId +function getTask(id: TaskId): Promise { ... } +``` + +## 常见借口 + +| 借口 | 现实 | +|---|---| +| 「我们后面再写 API 文档」 | 类型本身就是文档。先定义它们。 | +| 「现在不需要分页」 | 一旦有 100 条以上数据,你就需要了。从一开始就加上。 | +| 「PATCH 太复杂了,直接用 PUT 吧」 | PUT 每次都需要完整的对象。PATCH 才是客户端真正想要的。 | +| 「需要时再对 API 做版本管理」 | 没有版本管理的破坏性变更会破坏消费者。从一开始就设计成可扩展的。 | +| 「没人会用那个未文档化的行为」 | Hyrum 定律:如果它是可观测的,就有人依赖它。把每个公开行为都视为一项承诺。 | +| 「我们可以维护两个版本」 | 多个版本会成倍增加维护成本并产生菱形依赖问题。优先采用单一版本原则。 | +| 「内部 API 不需要契约」 | 内部消费者仍然是消费者。契约能防止耦合并支持并行开发。 | + +## 警示标志 + +- 端点根据条件返回不同结构 +- 不同端点之间错误格式不一致 +- 验证散落在内部代码各处而非集中在边界 +- 对已有字段进行破坏性变更(类型变更、字段删除) +- 列表端点没有分页 +- REST URL 中包含动词(`/api/createTask`,`/api/getUsers`) +- 第三方 API 响应未经验证或清理就直接使用 + +## 检查清单 + +设计完 API 后,请确认: + +- [ ] 每个端点都有带类型的输入和输出 Schema +- [ ] 错误响应遵循单一一致的格式 +- [ ] 验证仅在系统边界处进行 +- [ ] 列表端点支持分页 +- [ ] 新增字段是累加且可选的(向后兼容) +- [ ] 所有端点的命名遵循一致的约定 +- [ ] API 文档或类型与实现一同提交