跳至主要內容
Skip to content

回應設計原則:打造專業的 API

好的 API 不只是功能正確,回應設計同樣重要。本篇將介紹如何設計一致、清晰、易用的 API 回應。


一、 狀態碼選擇

1.1 常用狀態碼

狀態碼說明使用場景
200OK成功的 GET/PUT/PATCH
201Created成功的 POST(創建資源)
204No Content成功的 DELETE
400Bad Request請求格式錯誤
401Unauthorized未認證
403Forbidden無權限
404Not Found資源不存在
409Conflict資源衝突
422Unprocessable Entity驗證失敗
429Too Many Requests請求過多
500Internal Server Error伺服器錯誤

1.2 狀態碼決策樹

1.3 常見錯誤

javascript
// ❌ 錯誤:所有錯誤都返回 200
res.status(200).json({ success: false, error: "Not found" });

// ✅ 正確:使用適當的狀態碼
res.status(404).json({ error: "Resource not found" });

// ❌ 錯誤:業務錯誤用 500
res.status(500).json({ error: "Email already exists" });

// ✅ 正確:業務錯誤用 4xx
res.status(409).json({ error: "Email already exists" });

二、 回應格式設計

2.1 成功回應

javascript
// 單一資源
{
  "id": 123,
  "name": "John",
  "email": "john@example.com",
  "createdAt": "2025-01-13T00:00:00Z"
}

// 列表資源
{
  "data": [
    { "id": 1, "name": "John" },
    { "id": 2, "name": "Jane" }
  ],
  "pagination": {
    "page": 1,
    "limit": 10,
    "total": 100,
    "totalPages": 10
  }
}

// 操作結果
{
  "message": "User created successfully",
  "data": { "id": 123, "name": "John" }
}

2.2 錯誤回應

javascript
// 簡單錯誤
{
  "error": {
    "code": "NOT_FOUND",
    "message": "User not found"
  }
}

// 驗證錯誤
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      { "field": "email", "message": "Invalid email format" },
      { "field": "age", "message": "Age must be a positive number" }
    ]
  }
}

// 開發模式(包含堆疊追蹤)
{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "Something went wrong",
    "stack": "Error: Something went wrong\n    at ..."
  }
}

2.3 統一回應結構

javascript
// 封裝回應
function sendSuccess(res, data, statusCode = 200) {
  res.status(statusCode).json(data);
}

function sendError(res, code, message, details = null, statusCode = 400) {
  res.status(statusCode).json({
    error: {
      code,
      message,
      ...(details && { details }),
      ...(process.env.NODE_ENV === "development" && {
        stack: new Error().stack,
      }),
    },
  });
}

function sendPaginated(res, data, pagination) {
  res.json({
    data,
    pagination,
  });
}

三、 分頁設計

3.1 Offset 分頁

javascript
// GET /users?page=2&limit=10

app.get("/users", async (req, res) => {
  const page = Math.max(1, parseInt(req.query.page) || 1);
  const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 10));
  const offset = (page - 1) * limit;

  const [users, total] = await Promise.all([
    User.find().skip(offset).limit(limit),
    User.countDocuments(),
  ]);

  res.json({
    data: users,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
      hasNext: page * limit < total,
      hasPrev: page > 1,
    },
  });
});

優點:直觀、可以跳頁 缺點:大數據量時效能差、資料變更會跳過/重複

3.2 Cursor 分頁

javascript
// GET /users?cursor=abc123&limit=10

app.get("/users", async (req, res) => {
  const limit = parseInt(req.query.limit) || 10;
  const cursor = req.query.cursor;

  const query = cursor ? { _id: { $gt: cursor } } : {};

  const users = await User.find(query)
    .sort({ _id: 1 })
    .limit(limit + 1); // 多取一個判斷 hasNext

  const hasNext = users.length > limit;
  if (hasNext) users.pop();

  res.json({
    data: users,
    pagination: {
      nextCursor: hasNext ? users[users.length - 1]._id : null,
      hasNext,
    },
  });
});

優點:效能好、資料一致 缺點:不能跳頁、需要排序欄位

javascript
app.get("/users", async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = 10;
  const total = await User.countDocuments();
  const totalPages = Math.ceil(total / limit);

  const links = [];
  if (page > 1) {
    links.push(`<${baseUrl}?page=1>; rel="first"`);
    links.push(`<${baseUrl}?page=${page - 1}>; rel="prev"`);
  }
  if (page < totalPages) {
    links.push(`<${baseUrl}?page=${page + 1}>; rel="next"`);
    links.push(`<${baseUrl}?page=${totalPages}>; rel="last"`);
  }

  res.set("Link", links.join(", "));
  res.set("X-Total-Count", total);

  res.json(users);
});

四、 錯誤處理架構

4.1 錯誤類別

javascript
class ApiError extends Error {
  constructor(statusCode, code, message, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
    this.isOperational = true;
  }
}

class NotFoundError extends ApiError {
  constructor(resource = "Resource") {
    super(404, "NOT_FOUND", `${resource} not found`);
  }
}

class ValidationError extends ApiError {
  constructor(details) {
    super(422, "VALIDATION_ERROR", "Validation failed", details);
  }
}

class UnauthorizedError extends ApiError {
  constructor(message = "Authentication required") {
    super(401, "UNAUTHORIZED", message);
  }
}

class ForbiddenError extends ApiError {
  constructor(message = "Permission denied") {
    super(403, "FORBIDDEN", message);
  }
}

4.2 錯誤處理中介軟體

javascript
function errorHandler(err, req, res, next) {
  // 記錄錯誤
  console.error({
    error: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    requestId: req.id,
  });

  // 已知的操作錯誤
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
      },
    });
  }

  // 未知錯誤
  res.status(500).json({
    error: {
      code: "INTERNAL_ERROR",
      message:
        process.env.NODE_ENV === "production"
          ? "An unexpected error occurred"
          : err.message,
    },
  });
}

app.use(errorHandler);

4.3 非同步錯誤處理

javascript
// 包裝非同步路由
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

// 使用
app.get(
  "/users/:id",
  asyncHandler(async (req, res) => {
    const user = await User.findById(req.params.id);
    if (!user) {
      throw new NotFoundError("User");
    }
    res.json(user);
  })
);

五、 回應標頭

5.1 常用標頭

javascript
app.use((req, res, next) => {
  // 內容類型
  res.setHeader("Content-Type", "application/json; charset=utf-8");

  // 快取控制
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");

  // 安全標頭
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.setHeader("X-Frame-Options", "DENY");
  res.setHeader("X-XSS-Protection", "1; mode=block");

  // 請求追蹤
  res.setHeader("X-Request-Id", req.id);

  next();
});

5.2 CORS 標頭

javascript
app.use((req, res, next) => {
  res.setHeader("Access-Control-Allow-Origin", "https://example.com");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  res.setHeader("Access-Control-Max-Age", "86400");
  next();
});

5.3 限流標頭

javascript
function rateLimit(req, res, next) {
  const limit = 100;
  const remaining = getRemainingRequests(req.ip);
  const reset = getResetTime(req.ip);

  res.setHeader("X-RateLimit-Limit", limit);
  res.setHeader("X-RateLimit-Remaining", remaining);
  res.setHeader("X-RateLimit-Reset", reset);

  if (remaining <= 0) {
    res.setHeader("Retry-After", Math.ceil((reset - Date.now()) / 1000));
    return res.status(429).json({ error: "Too many requests" });
  }

  next();
}

六、 效能優化

6.1 壓縮

javascript
const compression = require("compression");

app.use(
  compression({
    filter: (req, res) => {
      if (req.headers["x-no-compression"]) {
        return false;
      }
      return compression.filter(req, res);
    },
    threshold: 1024, // 只壓縮超過 1KB 的回應
  })
);

6.2 條件請求(ETag)

javascript
const etag = require("etag");

app.get("/users/:id", async (req, res) => {
  const user = await User.findById(req.params.id);
  const userJson = JSON.stringify(user);
  const hash = etag(userJson);

  res.setHeader("ETag", hash);

  if (req.headers["if-none-match"] === hash) {
    return res.status(304).end();
  }

  res.json(user);
});

6.3 欄位篩選

javascript
// GET /users?fields=id,name,email

app.get("/users", async (req, res) => {
  const fields = req.query.fields?.split(",").join(" ") || "";
  const users = await User.find().select(fields);
  res.json(users);
});

七、 時間格式

7.1 使用 ISO 8601

javascript
// ✅ 推薦:ISO 8601 格式
{
  "createdAt": "2025-01-13T12:00:00Z",
  "updatedAt": "2025-01-13T14:30:00+08:00"
}

// ❌ 避免:Unix 時間戳
{
  "createdAt": 1736769600
}

// ❌ 避免:自定義格式
{
  "createdAt": "2025/01/13 12:00:00"
}

7.2 統一處理

javascript
// 序列化時自動轉換
const userSchema = new Schema(
  {
    createdAt: { type: Date, default: Date.now },
  },
  {
    toJSON: {
      transform: (doc, ret) => {
        ret.createdAt = ret.createdAt.toISOString();
        return ret;
      },
    },
  }
);

總結

原則說明
正確狀態碼用狀態碼表達結果
一致格式成功/錯誤結構統一
清晰錯誤錯誤碼 + 訊息 + 詳情
標準分頁offset 或 cursor
適當快取ETag、Cache-Control
ISO 8601時間格式標準化

> **黃金法則**:API 回應應該讓開發者看一眼就知道:成功或失敗、失敗原因、如何修正。


進階挑戰

  1. 設計一個錯誤碼系統(如 AUTH_001USER_002),讓錯誤可追蹤和文檔化。
  2. 實作一個支援 fieldsexpand 的欄位選擇系統。
  3. 研究 GraphQL,思考它如何解決 REST 的過度/不足獲取問題。

延伸閱讀與資源