Communitygithub.com

YOUSSFE9/bolt-skills

指導穩定的 API 和介面設計。用於設計 API、模組邊界或任何公開介面。用於建立 REST 或 GraphQL 端點、定義模組之間的型別合約,或建立前端和後端之間的邊界。

bolt-skills 是什麼?

此技能提供了一套經過深思熟慮的原則和實務模式,用於設計穩定、易於使用且難以誤用的 API 與介面。它強調整份優先(Contract First)的設計方法、一致的錯誤語義、在系統邊界進行驗證,以及透過增加而非修改的方式來擴展介面,以確保向後相容性。技能文件深入探討了 Hyrum 定律和單一版本規則等核心概念,並將冪等鍵(Idempotency-Key)的設計與實作視為一個關鍵機制,涵蓋了從金鑰推導、原子性索取到處理重複請求的各種策略。它支援 REST API、GraphQL 綱要、TypeScript 介面以及模組邊界,適合在 Cursor、Claude Code、Codex 等 AI 代理工具中使用,協助開發者建立高品質、可維護的系統介面。

相容平台~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 綱要、模組邊界、元件屬性 (props),以及任何一段程式碼與另一段程式碼溝通的地方。

何時使用

  • 設計新的 API 端點
  • 定義模組邊界或跨團隊的合約
  • 建立元件屬性介面
  • 建立影響 API 形狀的資料庫綱要
  • 更改現有的公開介面

核心原則

Hyrum 定律

當一個 API 有足夠多的使用者時,你在合約中承諾什麼並不重要——你的系統中所有可觀察的行為都會被某些人依賴。

這意味著:每一個公開行為——包括未記錄的怪癖、錯誤訊息文字、時間和順序——一旦使用者依賴它,就會變成事實上的合約。設計啟示:

  • 有意識地決定要暴露什麼。 每一個可觀察的行為都是一個潛在的承諾。
  • 不要洩漏實作細節。 如果使用者可以觀察到,他們就會依賴它。
  • 在設計時就規劃好棄用方式。 請參閱 deprecation-and-migration 了解如何安全地移除使用者依賴的東西。
  • 測試是不夠的。 即使有完美的合約測試,Hyrum 定律也意味著「安全」的變更可能會破壞那些依賴於未記錄行為的真實使用者。

單一版本規則

避免強迫消費者在同一時間點,在同一個依賴項或 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;         // 從 string 更改——破壞現有消費者
}

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客戶端可以稍後重試;最簡單且最安全
等待阻塞直到結果返回,有界呼叫者需要同步取得結果
返回進行中202 + 狀態 URL長時間執行的副作用

永遠不要因為第一個請求「看起來卡住了」就讓第二個呼叫者通過。一個命運未知、陷入停頓的嘗試,正是重複操作成本最高的時候。

每個呼叫有三種結果,不是兩種:成功、失敗和未知。 逾時告訴不了你效果是否已套用。在呼叫外部服務之前記錄意圖,如此一來,呼叫和回應之間的崩潰就會留下證據,表明某些事情必須稍後解決——而不是默默地重試一次扣款。

設定保留期時,應基於最長的重試鏈,而不是磁碟成本。 金鑰必須比能夠重新傳遞相同意圖的每一條路徑都存活得更久,包括一週後重播的死信佇列 (DLQ) 以及任何提供者爭議窗口。一個 24 小時的金鑰 TTL 後面跟著一個 7 天的 DLQ,就是一顆等待發生的重複炸彈。

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 介面模式

對變體使用受歧別聯合 (Discriminated Unions)

// 好:每個變體都明確
型 TaskStatus =
  | { type: 'pending' }
  | { type: 'in_progress'; assignee: string; startedAt: Date }
  | { type: 'completed'; completedAt: Date; completedBy: string }
  | { type: 'cancelled'; reason: string; cancelledAt: Date };

// 消費者獲得型別縮小 (type narrowing)
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}`;
  }
}

輸入/輸出分離

// 輸入:呼叫者提供的內容
interface CreateTaskInput {
  title: string;
  description?: string;
}

// 輸出:系統返回的內容(包含伺服器生成的欄位)
interface Task {
  id: string;
  title: string;
  description: string | null;
  createdAt: Date;
  updatedAt: Date;
  createdBy: string;
}

對 ID 使用品牌型別 (Branded Types)

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 做版本控制」沒有版本控制的破壞性變更會破壞消費者。從一開始就設計成可擴展的。
「沒人用那個未記錄的行為」Hyrum 定律:如果可以觀察到,就有人依賴它。將每個公開行為視為一種承諾。
「我們可以維護兩個版本」多個版本會倍增維護成本並造成菱形依賴問題。偏好單一版本規則。
「內部 API 不需要合約」內部消費者仍然是消費者。合約能防止耦合並實現平行工作。
「接受 Idempotency-Key 標頭就夠了」標頭是合約;儲存金鑰與結果之間的對應才是實作。一個你接受但不恪守的金鑰,告訴客戶端重試是安全的,但實際上並非如此。
「我們的佇列保證恰好一次傳遞」沒有任何佇列能在消費者崩潰時做到這一點——代理的確認和你自己的副作用不在同一個交易中。設計時應假設至少一次傳遞,並搭配冪等處理。
「重複請求很少見」它們是相關的。重試恰好會在依賴項降級時激增——正是重複最可能發生且成本最高的時刻。

紅旗警示

  • 根據條件返回不同形狀的端點
  • 跨端點不一致的錯誤格式
  • 驗證散落在內部程式碼各處,而非在邊界
  • 對現有欄位進行破壞性變更(型別變更、移除)
  • 列表端點沒有分頁
  • REST URL 中包含動詞(/api/createTask, /api/getUsers)
  • 第三方 API 回應未經驗證或清理即使用
  • 先 SELECT 檢查冪等鍵,再 INSERT——這是競爭條件,不是保護機制
  • 從每次嘗試都會重新產生的 UUID、時間戳或其他東西中推導的冪等鍵
  • 接受相同的金鑰搭配不同的請求主體,卻默默地返回第一個回應
  • 金鑰保留期限短於可以重新傳遞該請求的最長路徑

驗證

設計完 API 後:

  • 每個端點都有型別的輸入和輸出綱要
  • 錯誤回應遵循單一且一致的格式
  • 驗證僅在系統邊界進行
  • 列表端點支援分頁
  • 新增的欄位是附加性的且可選(向後相容)
  • 命名在所有端點中遵循一致的慣例
  • API 文件與實作同步
  • 使用冪等鍵的端點會原子性地認領它,並在請求主體變更時正確拒絕
  • 設計時已考量到棄用策略

Individual skills in this repo

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

相關技能