api-design
RESTful, GraphQL, gRPC, and API best practices
SKILL.md
Full skill instructions
API Design
Overview
Design principles for building APIs that are intuitive, consistent, and scalable. Covers REST, GraphQL, gRPC, and real-time protocols.
RESTful API Design
Resource Naming
✅ Good (nouns, plural):
GET /users # List users
GET /users/123 # Get user
POST /users # Create user
PUT /users/123 # Update user
DELETE /users/123 # Delete user
❌ Bad (verbs, actions):
GET /getUsers
POST /createUser
POST /users/123/delete
Nested Resources
# Hierarchical relationship
GET /users/123/orders # User's orders
GET /users/123/orders/456 # Specific order
# Alternative: Query parameter for filtering
GET /orders?userId=123 # Filter orders by user
# Rule: Nest max 2 levels deep
❌ /users/123/orders/456/items/789/reviews
✅ /order-items/789/reviews
HTTP Methods & Status Codes
| Method | Purpose | Success | Error |
|---|---|---|---|
| GET | Read | 200 | 404 |
| POST | Create | 201 | 400, 409 |
| PUT | Replace | 200 | 400, 404 |
| PATCH | Partial update | 200 | 400, 404 |
| DELETE | Remove | 204 | 404 |
// Response structure
interface ApiResponse<T> {
data: T;
meta?: {
page: number;
limit: number;
total: number;
};
}
interface ApiError {
error: {
code: string; // Machine-readable
message: string; // Human-readable
details?: object; // Validation errors, etc.
};
}
Pagination
// Offset-based (simple, has issues with large datasets)
GET /users?page=2&limit=20
// Cursor-based (stable, performant)
GET /users?cursor=eyJpZCI6MTIzfQ&limit=20
// Response
{
"data": [...],
"meta": {
"nextCursor": "eyJpZCI6MTQzfQ",
"hasMore": true
}
}
Filtering & Sorting
// Query parameters
GET /products?category=electronics&minPrice=100&maxPrice=500
GET /products?sort=-createdAt,name // - prefix for descending
// Filter operators
GET /users?age[gte]=18&age[lte]=65
GET /users?status[in]=active,pending
GET /users?name[like]=john*
GraphQL Design
Schema Design
type User {
id: ID!
email: String!
name: String!
posts(first: Int, after: String): PostConnection!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
}
# Connections for pagination (Relay spec)
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type Query {
user(id: ID!): User
users(first: Int, after: String, filter: UserFilter): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
input CreateUserInput {
email: String!
name: String!
password: String!
}
type CreateUserPayload {
user: User
errors: [Error!]
}
Resolver Patterns
// N+1 prevention with DataLoader
const userLoader = new DataLoader(async (ids: string[]) => {
const users = await db.users.findMany({ where: { id: { in: ids } } });
return ids.map(id => users.find(u => u.id === id));
});
const resolvers = {
Post: {
author: (post, _, { loaders }) => loaders.user.load(post.authorId),
},
};
// Field-level authorization
const resolvers = {
User: {
email: (user, _, { currentUser }) => {
if (currentUser.id !== user.id && !currentUser.isAdmin) {
return null; // Hide from other users
}
return user.email;
},
},
};
Error Handling
// Union types for expected errors
type CreatePostResult = Post | ValidationError | NotAuthorizedError
// Or use errors field in payload
type CreatePostPayload {
post: Post
errors: [CreatePostError!]
}
union CreatePostError = ValidationError | RateLimitError
gRPC Design
Protocol Buffers
syntax = "proto3";
package users.v1;
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
// Streaming
rpc WatchUsers(WatchUsersRequest) returns (stream UserEvent);
rpc BatchCreateUsers(stream CreateUserRequest) returns (BatchCreateResponse);
}
message User {
string id = 1;
string email = 2;
string name = 3;
google.protobuf.Timestamp created_at = 4;
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
User user = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
UserFilter filter = 3;
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2;
}
When to Use gRPC
| Use Case | REST | GraphQL | gRPC |
|---|---|---|---|
| Public API | ✅ | ✅ | ❌ |
| Mobile apps | ✅ | ✅ | ⚠️ |
| Microservices | ⚠️ | ❌ | ✅ |
| Real-time | ❌ | ⚠️ | ✅ |
| Browser clients | ✅ | ✅ | ⚠️ |
API Versioning
Strategies
# URL versioning (most common)
GET /v1/users
GET /v2/users
# Header versioning
GET /users
Accept: application/vnd.api+json; version=2
# Query parameter
GET /users?version=2
Breaking vs Non-Breaking Changes
Non-Breaking (safe):
✅ Add new optional field
✅ Add new endpoint
✅ Add new optional query parameter
✅ Expand enum values (if client ignores unknown)
Breaking (requires new version):
❌ Remove field
❌ Rename field
❌ Change field type
❌ Make optional field required
❌ Change URL structure
Deprecation Strategy
// OpenAPI deprecation
/**
* @deprecated Use /v2/users instead. Will be removed on 2025-06-01.
*/
app.get('/v1/users', ...);
// Response header
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 01 Jun 2025 00:00:00 GMT');
res.setHeader('Link', '</v2/users>; rel="successor-version"');
Real-time APIs
WebSocket
// Server
wss.on('connection', (ws) => {
ws.on('message', (data) => {
const message = JSON.parse(data);
switch (message.type) {
case 'subscribe':
subscriptions.add(ws, message.channel);
break;
case 'unsubscribe':
subscriptions.remove(ws, message.channel);
break;
}
});
});
// Client
const ws = new WebSocket('wss://api.example.com/ws');
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'orders:user:123'
}));
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleUpdate(data);
};
Server-Sent Events (SSE)
// Server
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const sendEvent = (data: object) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Subscribe to events
eventEmitter.on('update', sendEvent);
req.on('close', () => {
eventEmitter.off('update', sendEvent);
});
});
// Client
const source = new EventSource('/events');
source.onmessage = (event) => {
const data = JSON.parse(event.data);
handleUpdate(data);
};
API Documentation
OpenAPI (Swagger)
openapi: 3.0.3
info:
title: User API
version: 1.0.0
paths:
/users:
get:
summary: List users
parameters:
- name: limit
in: query
schema:
type: integer
default: 20
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
required: [id, email, name]
properties:
id:
type: string
format: uuid
email:
type: string
format: email
name:
type: string
Security Best Practices
| Practice | Implementation |
|---|---|
| Authentication | Bearer tokens, API keys |
| Rate limiting | X-RateLimit-* headers |
| Input validation | Schema validation (Zod, Joi) |
| CORS | Whitelist allowed origins |
| HTTPS | Always in production |
| Request IDs | X-Request-ID for tracing |
// Rate limit headers
res.setHeader('X-RateLimit-Limit', '100');
res.setHeader('X-RateLimit-Remaining', '95');
res.setHeader('X-RateLimit-Reset', '1640000000');
Related Skills
- [[architecture-patterns]] - API Gateway, microservices
- [[security-practices]] - Authentication, authorization
- [[documentation]] - API documentation tools
Sharp Edges(常見陷阱)
這些是 API 設計中最常見且代價最高的錯誤
SE-1: 破壞性變更 (Breaking Changes)
- 嚴重度: critical
- 情境: 修改現有 API 導致客戶端崩潰,沒有適當的版本控制
- 原因: 刪除欄位、改變欄位類型、修改必填性、重新命名
- 症狀:
- 客戶端突然出現錯誤
- 舊版 App 無法使用
- 用戶投訴「昨天還能用,今天就壞了」
- 檢測:
\-.*field|rename.*property|required.*true.*→.*false|type.*string.*→.*number - 解法: 使用 API 版本控制、只做 additive changes、設定 deprecation 期限
SE-2: 過度取得 (Over-fetching)
- 嚴重度: medium
- 情境: API 回傳太多客戶端不需要的資料
- 原因: 「反正都有就回傳」的心態、沒有考慮不同使用場景
- 症狀:
- API 響應很大但客戶端只用其中一小部分
- 行動裝置載入緩慢
- 頻寬浪費
- 檢測:
select\s*\*|findMany\(\)|findAll\(\)(?!.*select) - 解法: 使用 fields selection、GraphQL、按需求設計 endpoint
SE-3: 取得不足 (Under-fetching)
- 嚴重度: medium
- 情境: 需要呼叫多個 API 才能取得完整資料
- 原因: 過度細分 endpoint、沒有考慮常見使用場景
- 症狀:
- 前端需要 5+ 個 API 呼叫才能渲染一個頁面
- 複雜的前端資料整合邏輯
- N+1 API 請求問題
- 檢測:
Promise\.all\(.*fetch.*fetch.*fetch|\.then\(.*fetch - 解法: 設計聚合 endpoint、使用 include/expand 參數、考慮 BFF pattern
SE-4: 不一致的錯誤格式
- 嚴重度: high
- 情境: 不同 endpoint 回傳不同格式的錯誤,客戶端難以統一處理
- 原因: 沒有統一的錯誤處理規範、不同開發者各自實作
- 症狀:
- 有的錯誤用
error,有的用message,有的用errors - HTTP status code 使用不一致
- 客戶端需要寫很多 if-else 處理不同錯誤格式
- 有的錯誤用
- 檢測:
res\.json\(\{.*error|res\.json\(\{.*message|res\.status\(500\).*error - 解法: 定義統一的錯誤回應格式、使用 global error handler、建立錯誤碼系統
SE-5: 缺乏 Rate Limiting
- 嚴重度: critical
- 情境: API 沒有請求頻率限制,容易被濫用或攻擊
- 原因: 「先做出來再說」、不了解風險
- 症狀:
- DDoS 攻擊導致服務癱瘓
- 單一用戶耗盡所有資源
- 雲端帳單爆炸
- 檢測:
app\.use\((?!.*rateLimit)|router\.(?!.*limit)|express\(\)(?!.*rate) - 解法: 實作 rate limiting middleware、使用 Redis 追蹤請求、設定合理的限制
Validations
V-1: 禁止 HTTP status 200 回傳錯誤
- 類型: regex
- 嚴重度: high
- 模式:
res\.json\(\s*\{\s*error|res\.send\(\s*\{\s*error|\.json\(\{.*success:\s*false - 訊息: Error responses should use appropriate HTTP status codes (4xx/5xx)
- 修復建議: Use
res.status(400).json({ error: ... })for client errors - 適用:
*.ts,*.js
V-2: API 路徑使用動詞
- 類型: regex
- 嚴重度: medium
- 模式:
(get|create|update|delete|fetch|remove|add)\/|\/get|\/create|\/update|\/delete - 訊息: REST API paths should use nouns, not verbs (use HTTP methods instead)
- 修復建議: Change
/getUserstoGET /users,/createUsertoPOST /users - 適用:
*.ts,*.js
V-3: 缺少輸入驗證
- 類型: regex
- 嚴重度: critical
- 模式:
req\.body\.\w+(?!.*validate|.*schema|.*zod|.*joi|.*yup) - 訊息: Request body accessed without apparent validation
- 修復建議: Add validation with Zod/Joi before using request data
- 適用:
*.ts,*.js
V-4: 硬編碼 HTTP status code
- 類型: regex
- 嚴重度: low
- 模式:
res\.status\(\d{3}\) - 訊息: Consider using named status codes for readability
- 修復建議: Use
HttpStatus.OKor constants instead of magic numbers - 適用:
*.ts,*.js
V-5: 缺少 API 版本控制
- 類型: regex
- 嚴重度: medium
- 模式:
app\.(get|post|put|delete)\s*\(\s*['"]\/(?!v\d|api\/v\d) - 訊息: API route missing version prefix
- 修復建議: Add version prefix:
/api/v1/usersor use header-based versioning - 適用:
*.ts,*.js
