--- name: api-design description: REST API 設計模式,包含資源命名、狀態碼、分頁、篩選、錯誤回應、版本控制以及生產環境 API 的速率限制。 origin: ECC --- # API 設計模式 (API Design Patterns) 設計一致且對開發者友好的 REST API 的慣例與最佳實踐。 ## 何時啟用 - 設計新的 API 端點 (Endpoints) - 評估現有的 API 合約 - 新增分頁、篩選或排序功能 - 為 API 實作錯誤處理 - 規劃 API 版本控制策略 - 構建面向公眾或合作夥伴的 API ## 資源設計 (Resource Design) ### 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 ``` ### 命名規則 ``` # 推薦 (GOOD) /api/v1/team-members # 多單字資源使用 kebab-case /api/v1/orders?status=active # 使用查詢參數進行篩選 /api/v1/users/123/orders # 使用巢狀資源表示歸屬關係 # 不推薦 (BAD) /api/v1/getUsers # URL 中包含動詞 /api/v1/user # 使用單數 (應使用複數) /api/v1/team_members # URL 中使用 snake_case /api/v1/users/123/getOrders # 巢狀資源中包含動詞 ``` ## HTTP 方法與狀態碼 ### 方法語意 (Method Semantics) | 方法 | 冪等性 (Idempotent) | 安全性 (Safe) | 用途 | |--------|-----------|------|---------| | GET | 是 | 是 | 檢索資源 | | POST | 否 | 否 | 建立資源、觸發動作 | | PUT | 是 | 否 | 完整替換資源 | | PATCH | 否* | 否 | 部分更新資源 | | DELETE | 是 | 否 | 移除資源 | *PATCH 若有正確實作也可以具備冪等性。 ### 狀態碼參考 ``` # 成功 (Success) 200 OK — GET, PUT, PATCH (含回應主體) 201 Created — POST (應包含 Location 標頭) 204 No Content — DELETE, PUT (無回應主體) # 客戶端錯誤 (Client Errors) 400 Bad Request — 驗證失敗、JSON 格式錯誤 401 Unauthorized — 缺失或無效的身分驗證 403 Forbidden — 已驗證但無權限 404 Not Found — 資源不存在 409 Conflict — 重複輸入、狀態衝突 422 Unprocessable Entity — 語法正確但語意無效 (JSON 合法,數據有誤) 429 Too Many Requests — 超過速率限制 # 伺服器錯誤 (Server Errors) 500 Internal Server Error — 非預期失敗 (絕對不要暴露詳細細節) 502 Bad Gateway — 上游服務失敗 503 Service Unavailable — 暫時性超載,應包含 Retry-After ``` ### 常見錯誤 ``` # 錯誤範例 (BAD):不論什麼情況都回傳 200 { "status": 200, "success": false, "error": "Not found" } # 正確案例 (GOOD):語意化地使用 HTTP 狀態碼 HTTP/1.1 404 Not Found { "error": { "code": "not_found", "message": "User not found" } } # 錯誤範例 (BAD):針對驗證錯誤回傳 500 # 正確案例 (GOOD):回傳 400 或 422 並提供欄位級別的詳細資訊 # 錯誤範例 (BAD):針對建立的資源回傳 200 # 正確案例 (GOOD):回傳 201 並附帶 Location 標頭 HTTP/1.1 201 Created Location: /api/v1/users/abc-123 ``` ## 回應格式 (Response Format) ### 成功回應 ```json { "data": { "id": "abc-123", "email": "alice@example.com", "name": "Alice", "created_at": "2025-01-15T10:30:00Z" } } ``` ### 集合回應 (包含分頁) ```json { "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" } } ``` ### 錯誤回應 ```json { "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" } ] } } ``` ### 回應外殼 (Envelope) 變體 ```typescript // 選項 A:帶有數據包裝的外殼 (建議用於公眾 API) interface ApiResponse { data: T; meta?: PaginationMeta; links?: PaginationLinks; } interface ApiError { error: { code: string; message: string; details?: FieldError[]; }; } // 選項 B:扁平化回應 (較簡單,常見於內部 API) // 成功:直接回傳資源 // 錯誤:回傳錯誤物件 // 透過 HTTP 狀態碼進行區分 ``` ## 分頁 (Pagination) ### 基於偏移量 (Offset-Based,簡單) ``` GET /api/v1/users?page=2&per_page=20 # 實作概念 SELECT * FROM users ORDER BY created_at DESC LIMIT 20 OFFSET 20; ``` **點:** 易於實作,支援「跳轉到第 N 頁」。 **缺點:** 對於大型偏移量 (OFFSET 100000) 效能較慢,且在併發插入時會出現不一致的情況。 ### 基於游標 (Cursor-Based,具擴展性) ``` GET /api/v1/users?cursor=eyJpZCI6MTIzfQ&limit=20 # 實作概念 SELECT * FROM users WHERE id > :cursor_id ORDER BY id ASC LIMIT 21; -- 多抓一個以判斷是否有下一頁 (has_next) ``` ```json { "data": [...], "meta": { "has_next": true, "next_cursor": "eyJpZCI6MTQzfQ" } } ``` **優點:** 不論位置如何效能皆保持一致,且在併發插入時表現穩定。 **缺點:** 無法跳轉到任意頁碼,且游標內容不具備可讀性。 ### 該選用哪一種? | 使用情境 | 分頁類型 | |----------|----------------| | 管理後台、小型資料集 (<10K) | 偏移量 (Offset) | | 無限滾動、動態牆、大型資料集 | 游標 (Cursor) | | 公眾 API | 預設游標 (可選配偏移量) | | 搜尋結果 | 偏移量 (使用者預期看到頁碼) | ## 篩選、排序與搜尋 ### 篩選 (Filtering) ``` # 簡單等值 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 ``` ### 排序 (Sorting) ``` # 單一欄位 (前綴 - 表示降冪) GET /api/v1/products?sort=-created_at # 多個欄位 (逗號分隔) GET /api/v1/products?sort=-featured,price,-created_at ``` ### 全文檢索 (Full-Text Search) ``` # 搜尋查詢參數 GET /api/v1/products?q=wireless+headphones # 特定欄位搜尋 GET /api/v1/users?email=alice ``` ### 稀疏欄位集 (Sparse Fieldsets) ``` # 僅返回指定欄位 (減少回應體體積) GET /api/v1/users?fields=id,name,email GET /api/v1/orders?fields=id,total,status&include=customer.name ``` ## 身分驗證與授權 ### 基於 Token 的驗證 ``` # Authorization 標頭中的 Bearer token GET /api/v1/users Authorization: Bearer eyJhbGciOiJIUzI1NiIs... # API key (用於伺服器對伺服器通訊) GET /api/v1/data X-API-Key: sk_live_abc123 ``` ### 授權模式 (Authorization Patterns) ```typescript // 資源級別:檢查所有權 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(); }); ``` ## 速率限制 (Rate Limiting) ### 標頭 (Headers) ``` 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/min | 每 IP | 公眾端點 | | 已驗證 | 100/min | 每使用者 | 標準 API 存取 | | 高級 (Premium) | 1000/min | 每 API key | 付費 API 方案 | | 內部 | 10000/min | 每服務 | 服務間通訊 | ## 版本控制 (Versioning) ### URL 路徑版本控制 (建議方案) ``` /api/v1/users /api/v2/users ``` **優點:** 明確、易於路由、可緩存。 **缺點:** 不同版本間 URL 會更動。 ### 標頭版本控制 (Header Versioning) ``` GET /api/users Accept: application/vnd.myapp.v2+json ``` **優點:** URL 保持乾淨。 **缺點:** 較難測試、容易遺忘。 ### 版本控制策略 ``` 1. 以 /api/v1/ 開始 — 在有需求之前不要進行版本控制。 2. 最多維持 2 個活動版本 (當前版 + 前一版)。 3. 棄用 (Deprecation) 時間表: - 宣佈棄用 (公眾 API 提前 6 個月通知)。 - 加入 Sunset 標頭:Sunset: Sat, 01 Jan 2026 00:00:00 GMT。 - 棄用日期後,回傳 410 Gone。 4. 非破壞性變更不需要新版本: - 在回應中新增欄位。 - 新增可選的查詢參數。 - 新增端點。 5. 破壞性變更需要新版本: - 移除或重新命名欄位。 - 更改欄位型別。 - 更改 URL 結構。 - 更改身分驗證方法。 ``` ## 實作模式 ### TypeScript (Next.js API Route) ```typescript 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) ```python 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) ```go 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 是錯誤的) - [ ] 輸入經過 Schema 驗證 (Zod, Pydantic, Bean Validation) - [ ] 錯誤回應遵循標準格式,包含代碼與訊息 - [ ] 對於清單端點已實作分頁 (游標或偏移量) - [ ] 要求身分驗證 (或明確標註為公眾存取) - [ ] 已檢查授權 (使用者僅能存取自己的資源) - [ ] 已配置速率限制 - [ ] 回應中不會洩漏內部細節 (堆疊追蹤、SQL 錯誤等) - [ ] 與現有端點的命名方式保持一致 (camelCase vs snake_case) - [ ] 已撰寫文件 (OpenAPI/Swagger 規範已更新)