Communitygithub.com

YOUSSFE9/bolt-skills

指导稳定API和接口设计。在设计API、模块边界或任何公共接口时使用。在创建REST或GraphQL端点、定义模块之间的类型契约或建立前后端边界时使用。

bolt-skills 是什么?

该技能提供了一套全面的API和接口设计指南,旨在帮助开发者和AI代理创建稳定、易于使用且难以误用的接口。它涵盖了REST API、GraphQL schema、模块边界、组件属性等多种接口类型。其核心原则包括Hyrum定律(即所有可观察行为都可能成为依赖)、单一版本规则、契约优先设计、一致的错误语义、边界验证、优先扩展而非修改、可预测的命名以及幂等性键的正确实现。技能文档还提供了REST API模式(如资源设计、分页、过滤、部分更新)和TypeScript接口模式(如判别联合类型、输入/输出分离、品牌化ID)的具体示例。它支持Claude Code、Cursor、Codex等主流AI代理平台,通过详细的检查和红标列表帮助用户避免常见的设计陷阱,确保API的设计质量。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/YOUSSFE9/bolt-skills/tree/HEAD/api-and-interface-design

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

API 与接口设计

概述

设计稳定、文档完善且难以误用的接口。良好的接口使得正确的操作变得简单,错误的操作变得困难。这适用于 REST API、GraphQL schema、模块边界、组件属性以及任何代码片段之间进行通信的表面。

何时使用

  • 设计新的 API 端点
  • 定义模块边界或团队间的契约
  • 创建组件属性接口
  • 建立影响 API 形态的数据库 schema
  • 更改现有的公共接口

核心原则

海勒姆定律

当 API 拥有足够多的用户时,你系统中的所有可观察行为都将被某个人所依赖,无论你在契约中承诺了什么。

这意味着:每一个公共行为——包括未记录的怪癖、错误消息文本、时序和顺序——一旦被用户依赖,就成为了事实上的契约。设计启示:

  • 对你暴露的内容要深思熟虑。 每一个可观察的行为都可能成为一个承诺。
  • 不要泄露实现细节。 如果用户能观察到它,他们就会依赖它。
  • 在设计时就规划好弃用。 参见 deprecation-and-migration 了解如何安全地移除用户依赖的内容。
  • 仅有测试是不够的。 即使拥有完美的契约测试,海勒姆定律也意味着“安全”的更改可能会破坏那些依赖未记录行为的真实用户。

单一版本规则

避免强制消费者在同一个依赖或 API 的多个版本之间进行选择。当不同的消费者需要同一个东西的不同版本时,就会出现菱形依赖问题。设计一个在任一时刻只存在一个版本的世界——扩展而非分叉。

1. 契约优先

在实现接口之前先定义它。契约就是规范——实现紧随其后。

// 先定义契约
interface TaskAPI {
  // 创建一个任务,并返回包含服务器生成字段的创建后的任务
  createTask(input: CreateTaskInput): Promise<Task>;

  // 返回符合过滤条件的分页任务列表
  listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;

  // 返回单个任务,若不存在则抛出 NotFoundError
  getTask(id: string): Promise<Task>;

  // 部分更新——仅更改提供的字段
  updateTask(id: string, input: UpdateTaskInput): Promise<Task>;

  // 幂等删除——即使任务已删除也成功执行
  deleteTask(id: string): Promise<void>;
}

2. 一致的错误语义

选择一种错误处理策略并始终如一地使用:

// 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. 在边界进行验证

信任内部代码。在外部输入进入的系统边界进行验证:

// 在 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. 优先扩展而非修改

在不破坏现有消费者的情况下扩展接口:

// 好:添加可选字段
interface CreateTaskInput {
  title: string;
  description?: string;
  priority?: 'low' | 'medium' | 'high';  // 后来添加,可选
  labels?: string[];                       // 后来添加,可选
}

// 坏:更改现有字段类型或移除字段
interface CreateTaskInput {
  title: string;
  // description: string;  // 已移除——破坏现有消费者
  priority: 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"

6. 尊重幂等性键

接受 Idempotency-Key 是契约。遵守它是实现,而这也是代价所在——一个被接受但处理不当的键比没有键更糟糕,因为客户端会因此认为重试是安全的。

从意图中派生键,而不是从尝试中派生。 键必须在同一意图的重试中保持稳定,在不同意图之间必须不同:

crypto.randomUUID()                    // ✗ 每次尝试生成新键——每次重试都是一次新的收费
`${userId}:${amount}`                  // ✗ 两笔合法的 50 美元收费被合并为一笔
`${orderId}:${Date.now()}`             // ✗ 时间戳就是戴了面具的 randomUUID()

req.headers['idempotency-key']         // ✓ 客户端生成一次,重试时复用
`charge:v1:${orderId}`                 // ✓ 从不可变标识符派生

键必须来自客户端或发起事件——绝不要来自执行重试的层。

原子性声明。先检查后执行是一个竞态条件:

// ✗ TOCTOU:两个并发重试都读取“未看到”,都进行收费
if (!(await db.exists(key))) {
  await chargeCard(amount);
  await db.insert(key);
}

// ✓ 让唯一约束来决定胜者
try {
  await db.insert({ key, state: 'in_progress', requestHash });
} catch (e) {
  if (isUniqueViolation(e)) return replayOrReject(key);
  throw e;
}
const result = await chargeCard(amount);
await db.update({ key, state: 'succeeded', response: result });

唯一约束就是机制。一个无法在一次操作中强制执行唯一性的存储无法支持此功能。

保护有效负载。 同一键对应不同的请求体是一个客户端错误,必须大声失败,而不是将第一个响应提供给第二个请求:

if (existing.requestHash !== hash(req.body)) {
  return res.status(422).json({ error: 'idempotency key reused with a different payload' });
}

决定正在进行的重复请求应得到什么响应。 第一个请求仍在运行时,第二个请求到达——这是重试风暴下的常见情况:

策略响应使用场景
拒绝409 Conflict客户端稍后可以重试;最简单、最安全
等待阻塞等待结果,有限时间调用者需要同步获取结果
返回 pending202 + 状态 URL长时间运行的效果

绝不要让第二个调用者通过,因为第一个“似乎卡住了”。一个状态未知的停滞尝试正是重复成本最高的时候。

每次调用都有三种结果,而非两种:成功、失败和_未知_。超时无法告知你效果是否已应用。在调用之前记录意图,以便调用和响应之间的崩溃留下证据,表明某些事情必须在以后解决——而不是静默地重复收费。

根据最长的重试链设置保留期,而不是根据磁盘成本。键的生命周期必须超过所有可能重新传递同一意图的路径,包括一周后重放的死信队列以及任何提供商的争议窗口。在 7 天 DLQ 之后的 24 小时键 TTL 是等待发生的重复。

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 → 为任务添加评论

分页

为列表端点实现分页:

// 请求
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)

接受部分对象——仅更新提供的字段:

// 仅更改标题,其他所有内容保持不变
PATCH /api/tasks/123
{ "title": "Updated title" }

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 '待处理';
    case 'in_progress': return `进行中 (${status.assignee})`;
    case 'completed': return `完成于 ${status.completedAt}`;
    case 'cancelled': return `已取消: ${status.reason}`;
  }
}

输入/输出分离

// 输入:调用者提供的内容
interface CreateTaskInput {
  title: string;
  description?: string;
}

// 输出:系统返回的内容(包含服务器生成的字段)
interface Task {
  id: string;
  title: string;
  description: string | null;
  createdAt: Date;
  updatedAt: Date;
  createdBy: string;
}

为 ID 使用品牌化类型

type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };

// 防止意外将 UserId 传递给期望 TaskId 的位置
function getTask(id: TaskId): Promise<Task> { ... }

常见合理化借口

合理化借口现实
"我们以后会编写 API 文档"类型本身就是文档。先定义它们。
"我们现在不需要分页"一旦有人拥有 100 多个项目,你马上就需要。从一开始就添加它。
"PATCH 很复杂,我们直接用 PUT 吧"PUT 每次都需要完整对象。PATCH 才是客户端真正想要的。
"需要时我们再对 API 进行版本控制"没有版本控制的破坏性更改会破坏消费者。从一开始就为扩展而设计。
"没人会用那个未记录的行为"海勒姆定律:如果它可以被观察到,就有人依赖它。将每个公共行为视为一个承诺。
"我们可以只维护两个版本"多版本会成倍增加维护成本并产生菱形依赖问题。优先选择单一版本规则。
"内部 API 不需要契约"内部消费者仍然是消费者。契约可以防止耦合并支持并行工作。
"接受 Idempotency-Key 头就足够了"头部是契约;根据结果存储键是实现。你接受但不遵守的键告诉客户端重试是安全的,而事实并非如此。
"我们的队列保证恰好一次交付"没有队列能在消费者崩溃时做到这一点——代理的确认和你的副作用不在一个事务中。使用幂等处理为至少一次交付设计。
"重复请求很少见"它们是相关的。重试恰好发生在依赖项降级时激增——正是重复最可能发生且代价最高的时候。

红旗警告

  • 根据条件返回不同形状的端点
  • 不同端点之间不一致的错误格式
  • 验证分散在内部代码中,而不是在边界处
  • 对现有字段的破坏性更改(类型更改、移除)
  • 没有分页的列表端点
  • REST URL 中包含动词(/api/createTask,/api/getUsers)
  • 未经验证或清理就使用的第三方 API 响应
  • 先执行 SELECT 检查幂等性键,再执行 INSERT —— 这是竞态条件,不是防护
  • 从 UUID、时间戳或每次尝试重新生成的任何内容派生的幂等性键
  • 同一键被不同的请求体接受,静默返回第一个响应
  • 键的保留窗口短于可以重新传递请求的最长路径

验证

设计 API 后:

  • 每个端点都有类型化的输入和输出 schema
  • 错误响应遵循单一一致的格式
  • 验证仅在系统边界进行
  • 列表端点支持分页
  • 新字段是增加性的且可选(向后兼容)
  • 命名在所有端点之间遵循一致的约定
  • API 文档已生成

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

相关技能