api-design

api-design

熱門

REST API 設計模式,包含資源命名、狀態碼、分頁、篩選、錯誤回應、版本控制及生產環境 API 的速率限制。

23萬星標
3.5萬分支
更新於 2026/7/14
SKILL.md
readonlyread-only
name
api-design
description

REST API 設計模式,包含資源命名、狀態碼、分頁、篩選、錯誤回應、版本控制及生產環境 API 的速率限制。

API 設計模式

設計一致且開發者友善的 REST API 的慣例與最佳實務。

何時啟用

  • 設計新的 API 端點
  • 審查現有 API 合約
  • 加入分頁、篩選或排序
  • 實作 API 錯誤處理
  • 規劃 API 版本控制策略
  • 建置公開或合作夥伴導向的 API

資源設計

URL 結構

# 資源為名詞、複數、小寫、kebab-case
GET    /api/v1/users
GET    /api/v1/users/:id
POST   /api/v1/users
PUT    /api/v1/users/:id
PATCH  /api/v1/users/:id
DELETE /api/v1/users/:id

# 子資源表示關聯
GET    /api/v1/users/:id/orders
POST   /api/v1/users/:id/orders

# 不屬於 CRUD 的動作(謹慎使用動詞)
POST   /api/v1/orders/:id/cancel
POST   /api/v1/auth/login
POST   /api/v1/auth/refresh

命名規則

# 良好
/api/v1/team-members          # 多字詞資源使用 kebab-case
/api/v1/orders?status=active  # 查詢參數用於篩選
/api/v1/users/123/orders      # 巢狀資源表示擁有關係

# 不良
/api/v1/getUsers              # URL 中包含動詞
/api/v1/user                  # 單數(應使用複數)
/api/v1/team_members          # URL 中使用 snake_case
/api/v1/users/123/getOrders   # 巢狀資源中使用動詞

HTTP 方法與狀態碼

方法語意

方法 等冪 安全 用途
GET 取得資源
POST 建立資源、觸發動作
PUT 完整取代資源
PATCH 否* 部分更新資源
DELETE 刪除資源

*PATCH 可透過適當實作達到等冪

狀態碼參考

# 成功
200 OK                    — GET、PUT、PATCH(含回應主體)
201 Created               — POST(包含 Location 標頭)
204 No Content            — DELETE、PUT(無回應主體)

# 用戶端錯誤
400 Bad Request           — 驗證失敗、JSON 格式錯誤
401 Unauthorized          — 缺少或無效的驗證
403 Forbidden             — 已驗證但無權限
404 Not Found             — 資源不存在
409 Conflict              — 重複項目、狀態衝突
422 Unprocessable Entity  — 語意無效(JSON 格式正確但資料錯誤)
429 Too Many Requests     — 超過速率限制

# 伺服器錯誤
500 Internal Server Error — 非預期失敗(絕不暴露細節)
502 Bad Gateway           — 上游服務失敗
503 Service Unavailable   — 暫時超載,請包含 Retry-After

常見錯誤

# 不良:全部回傳 200
{ "status": 200, "success": false, "error": "Not found" }

# 良好:依語意使用 HTTP 狀態碼
HTTP/1.1 404 Not Found
{ "error": { "code": "not_found", "message": "User not found" } }

# 不良:驗證錯誤回傳 500
# 良好:回傳 400 或 422 並附上欄位層級細節

# 不良:建立資源回傳 200
# 良好:回傳 201 並包含 Location 標頭
HTTP/1.1 201 Created
Location: /api/v1/users/abc-123

回應格式

成功回應

{
  "data": {
    "id": "abc-123",
    "email": "alice@example.com",
    "name": "Alice",
    "created_at": "2025-01-15T10:30:00Z"
  }
}

集合回應(含分頁)

{
  "data": [
    { "id": "abc-123", "name": "Alice" },
    { "id": "def-456", "name": "Bob" }
  ],
  "meta": {
    "total": 142,
    "page": 1,
    "per_page": 20,
    "total_pages": 8
  },
  "links": {
    "self": "/api/v1/users?page=1&per_page=20",
    "next": "/api/v1/users?page=2&per_page=20",
    "last": "/api/v1/users?page=8&per_page=20"
  }
}

錯誤回應

{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address",
        "code": "invalid_format"
      },
      {
        "field": "age",
        "message": "Must be between 0 and 150",
        "code": "out_of_range"
      }
    ]
  }
}

回應包裝變體

// 選項 A:使用 data 包裝(建議用於公開 API)
interface ApiResponse<T> {
  data: T;
  meta?: PaginationMeta;
  links?: PaginationLinks;
}

interface ApiError {
  error: {
    code: string;
    message: string;
    details?: FieldError[];
  };
}

// 選項 B:扁平回應(較簡單,常用於內部 API)
// 成功:直接回傳資源
// 錯誤:回傳錯誤物件
// 透過 HTTP 狀態碼區分

分頁

偏移量分頁(簡單)

GET /api/v1/users?page=2&per_page=20

# 實作
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;

優點: 容易實作,支援「跳到第 N 頁」
缺點: 大偏移量時效能差(OFFSET 100000),並行插入時不一致

游標分頁(可擴展)

GET /api/v1/users?cursor=eyJpZCI6MTIzfQ&limit=20

# 實作
SELECT * FROM users
WHERE id > :cursor_id
ORDER BY id ASC
LIMIT 21;  -- 多取一筆以判斷 has_next
{
  "data": [...],
  "meta": {
    "has_next": true,
    "next_cursor": "eyJpZCI6MTQzfQ"
  }
}

優點: 無論位置為何效能一致,並行插入時穩定
缺點: 無法跳到任意頁面,游標不透明

何時使用哪種

使用情境 分頁類型
管理後台、小型資料集(<1 萬筆) 偏移量
無限滾動、動態消息、大型資料集 游標
公開 API 游標(預設)搭配偏移量(選用)
搜尋結果 偏移量(用戶預期頁碼)

篩選、排序與搜尋

篩選

# 簡單相等
GET /api/v1/orders?status=active&customer_id=abc-123

# 比較運算子(使用括號表示法)
GET /api/v1/products?price[gte]=10&price[lte]=100
GET /api/v1/orders?created_at[after]=2025-01-01

# 多個值(逗號分隔)
GET /api/v1/products?category=electronics,clothing

# 巢狀欄位(點號表示法)
GET /api/v1/orders?customer.country=US

排序

# 單一欄位(前綴 - 表示降冪)
GET /api/v1/products?sort=-created_at

# 多個欄位(逗號分隔)
GET /api/v1/products?sort=-featured,price,-created_at

全文搜尋

# 搜尋查詢參數
GET /api/v1/products?q=wireless+headphones

# 特定欄位搜尋
GET /api/v1/users?email=alice

稀疏欄位集

# 僅回傳指定欄位(減少資料量)
GET /api/v1/users?fields=id,name,email
GET /api/v1/orders?fields=id,total,status&include=customer.name

驗證與授權

令牌驗證

# Bearer 令牌放在 Authorization 標頭
GET /api/v1/users
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

# API 金鑰(用於伺服器對伺服器)
GET /api/v1/data
X-API-Key: sk_live_abc123

授權模式

// 資源層級:檢查擁有權
app.get("/api/v1/orders/:id", async (req, res) => {
  const order = await Order.findById(req.params.id);
  if (!order) return res.status(404).json({ error: { code: "not_found" } });
  if (order.userId !== req.user.id) return res.status(403).json({ error: { code: "forbidden" } });
  return res.json({ data: order });
});

// 角色層級:檢查權限
app.delete("/api/v1/users/:id", requireRole("admin"), async (req, res) => {
  await User.delete(req.params.id);
  return res.status(204).send();
});

速率限制

標頭

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1640000000

# 超過限制時
HTTP/1.1 429 Too Many Requests
Retry-After: 60
{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Rate limit exceeded. Try again in 60 seconds."
  }
}

速率限制層級

層級 限制 時間窗口 使用情境
匿名 30/分鐘 每 IP 公開端點
已驗證 100/分鐘 每用戶 標準 API 存取
高級 1000/分鐘 每 API 金鑰 付費 API 方案
內部 10000/分鐘 每服務 服務間通訊

版本控制

URL 路徑版本控制(建議)

/api/v1/users
/api/v2/users

優點: 明確、易於路由、可快取
缺點: 不同版本間 URL 會變動

標頭版本控制

GET /api/users
Accept: application/vnd.myapp.v2+json

優點: URL 簡潔
缺點: 較難測試、容易遺忘

版本控制策略

1. 從 /api/v1/ 開始 — 直到需要時才進行版本控制
2. 最多維護 2 個活躍版本(目前 + 前一個)
3. 棄用時間表:
   - 宣布棄用(公開 API 提前 6 個月通知)
   - 加入 Sunset 標頭:Sunset: Sat, 01 Jan 2026 00:00:00 GMT
   - 超過日落日期後回傳 410 Gone
4. 非破壞性變更不需要新版本:
   - 在回應中新增欄位
   - 新增選用查詢參數
   - 新增端點
5. 破壞性變更需要新版本:
   - 移除或重新命名欄位
   - 變更欄位類型
   - 變更 URL 結構
   - 變更驗證方式

實作模式

TypeScript(Next.js API 路由)

import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
});

export async function POST(req: NextRequest) {
  const body = await req.json();
  const parsed = createUserSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({
      error: {
        code: "validation_error",
        message: "Request validation failed",
        details: parsed.error.issues.map(i => ({
          field: i.path.join("."),
          message: i.message,
          code: i.code,
        })),
      },
    }, { status: 422 });
  }

  const user = await createUser(parsed.data);

  return NextResponse.json(
    { data: user },
    {
      status: 201,
      headers: { Location: `/api/v1/users/${user.id}` },
    },
  );
}

Python(Django REST Framework)

from rest_framework import serializers, viewsets, status
from rest_framework.response import Response

class CreateUserSerializer(serializers.Serializer):
    email = serializers.EmailField()
    name = serializers.CharField(max_length=100)

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "email", "name", "created_at"]

class UserViewSet(viewsets.ModelViewSet):
    serializer_class = UserSerializer
    permission_classes = [IsAuthenticated]

    def get_serializer_class(self):
        if self.action == "create":
            return CreateUserSerializer
        return UserSerializer

    def create(self, request):
        serializer = CreateUserSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = UserService.create(**serializer.validated_data)
        return Response(
            {"data": UserSerializer(user).data},
            status=status.HTTP_201_CREATED,
            headers={"Location": f"/api/v1/users/{user.id}"},
        )

Go(net/http)

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
        return
    }

    if err := req.Validate(); err != nil {
        writeError(w, http.StatusUnprocessableEntity, "validation_error", err.Error())
        return
    }

    user, err := h.service.Create(r.Context(), req)
    if err != nil {
        switch {
        case errors.Is(err, domain.ErrEmailTaken):
            writeError(w, http.StatusConflict, "email_taken", "Email already registered")
        default:
            writeError(w, http.StatusInternalServerError, "internal_error", "Internal error")
        }
        return
    }

    w.Header().Set("Location", fmt.Sprintf("/api/v1/users/%s", user.ID))
    writeJSON(w, http.StatusCreated, map[string]any{"data": user})
}

API 設計檢查清單

在推出新端點之前:

  • [ ] 資源 URL 符合命名慣例(複數、kebab-case、無動詞)
  • [ ] 使用正確的 HTTP 方法(GET 用於讀取、POST 用於建立等)
  • [ ] 回傳適當的狀態碼(不是全部回傳 200)
  • [ ] 使用結構化驗證輸入(Zod、Pydantic、Bean Validation)
  • [ ] 錯誤回應遵循標準格式,包含代碼與訊息
  • [ ] 列表端點已實作分頁(游標或偏移量)
  • [ ] 需要驗證(或明確標記為公開)
  • [ ] 已檢查授權(用戶只能存取自己的資源)
  • [ ] 已設定速率限制
  • [ ] 回應不洩漏內部細節(堆疊追蹤、SQL 錯誤)
  • [ ] 與現有端點命名一致(camelCase 或 snake_case)
  • [ ] 已撰寫文件(更新 OpenAPI/Swagger 規格)