iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0

前言

今天是詞窮的日子,所以沒有前言。但總之就是整理一下 RESTful API 是什麼,還有整理一些範例。

什麼是 RESTful API?

REST(Representational State Transfer)是一種軟體架構風格,用來設計網路服務。RESTful API 就是遵循 REST 原則的 API 設計。

想像一下,API 就像餐廳的菜單和服務流程。RESTful API 就是一套標準化的規則,讓客戶(前端)和廚房(後端)能夠用統一的方式溝通。

REST 的核心原則

  1. 無狀態性:每個請求都包含所有必要的資訊
  2. 資源導向:一切都是資源,用 URL 來識別
  3. 統一介面:使用標準的 HTTP 方法(GET、POST、PUT 等等...)
  4. 快取能力:回應可以被快取以提升效能

更詳細可以參考wiki

HTTP 方法範例

GET - 取得資料

// 用途:讀取資料,不會修改伺服器狀態
// 特性:安全的、可快取的、冪等的

const http = require('node:http');
const url = require('node:url');

function handleGetRequest(req, res, pathname) {
  res.setHeader('Content-Type', 'application/json');
  
  if (pathname === '/users') {
    // 取得所有用戶
    const users = [
      { id: 1, name: '張三', email: 'zhang@example.com' },
      { id: 2, name: '李四', email: 'li@example.com' }
    ];
    
    res.writeHead(200);
    res.end(JSON.stringify(users));
  } 
  else if (pathname.startsWith('/users/')) {
    // 取得單一用戶
    const userId = pathname.split('/')[2];
    const user = { id: userId, name: '張三', email: 'zhang@example.com' };
    
    res.writeHead(200);
    res.end(JSON.stringify(user));
  }
  else {
    res.writeHead(404);
    res.end(JSON.stringify({ error: '找不到資源' }));
  }
}

POST - 創建資料

// 用途:創建新資源
// 特性:不安全的、不可快取的、非冪等的

function handlePostRequest(req, res, pathname) {
  if (pathname === '/users') {
    let body = '';
    
    req.on('data', chunk => {
      body += chunk.toString();
    });
    
    req.on('end', () => {
      try {
        const userData = JSON.parse(body);
        
        // 基本驗證
        if (!userData.name || !userData.email) {
          res.writeHead(400, { 'Content-Type': 'application/json' });
          res.end(JSON.stringify({ 
            error: '缺少必要欄位',
            required: ['name', 'email']
          }));
          return;
        }
        
        const newUser = {
          id: Date.now(),
          ...userData,
          createdAt: new Date().toISOString()
        };
        
        res.writeHead(201, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({
          message: '用戶創建成功',
          user: newUser
        }));
      } catch (err) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'JSON 格式錯誤' }));
      }
    });
  } else {
    res.writeHead(405, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: '方法不允許' }));
  }
}

PUT - 更新整個資源

// 用途:完整更新資源
// 特性:不安全的、不可快取的、冪等的

function handlePutRequest(req, res, pathname) {
  const pathParts = pathname.split('/');
  
  if (pathParts[1] === 'users' && pathParts[2]) {
    const userId = pathParts[2];
    let body = '';
    
    req.on('data', chunk => {
      body += chunk.toString();
    });
    
    req.on('end', () => {
      try {
        const userData = JSON.parse(body);
        
        // PUT 要求完整的資源資料
        if (!userData.name || !userData.email) {
          res.writeHead(400, { 'Content-Type': 'application/json' });
          res.end(JSON.stringify({ 
            error: 'PUT 需要完整的資源資料',
            required: ['name', 'email']
          }));
          return;
        }
        
        // 更新用戶(完整替換)
        const updatedUser = {
          id: parseInt(userId),
          name: userData.name,
          email: userData.email,
          updatedAt: new Date().toISOString()
        };
        
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({
          message: '用戶更新成功',
          user: updatedUser
        }));
      } catch (err) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'JSON 格式錯誤' }));
      }
    });
  } else {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: '找不到資源' }));
  }
}

PATCH - 部分更新資源

// 用途:部分更新資源
// 特性:不安全的、不可快取的、非冪等的

function handlePatchRequest(req, res, pathname) {
  const pathParts = pathname.split('/');
  
  if (pathParts[1] === 'users' && pathParts[2]) {
    const userId = pathParts[2];
    let body = '';
    
    req.on('data', chunk => {
      body += chunk.toString();
    });
    
    req.on('end', () => {
      try {
        const updates = JSON.parse(body);
        
        // 模擬從資料庫取得現有用戶
        const existingUser = {
          id: parseInt(userId),
          name: '張三',
          email: 'zhang@example.com'
        };
        
        // 只更新提供的欄位
        const updatedUser = {
          ...existingUser,
          ...updates,
          updatedAt: new Date().toISOString()
        };
        
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({
          message: '用戶部分更新成功',
          user: updatedUser
        }));
      } catch (err) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'JSON 格式錯誤' }));
      }
    });
  } else {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: '找不到資源' }));
  }
}

DELETE - 刪除資源

// 用途:刪除資源
// 特性:不安全的、不可快取的、冪等的

function handleDeleteRequest(req, res, pathname) {
  const pathParts = pathname.split('/');
  
  if (pathParts[1] === 'users' && pathParts[2]) {
    const userId = pathParts[2];
    
    // 檢查用戶是否存在
    if (!userId || isNaN(userId)) {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: '無效的用戶 ID' }));
      return;
    }
    
    // 模擬刪除操作
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      message: `用戶 ${userId} 已被刪除`
    }));
  } else {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: '找不到資源' }));
  }
}

URL 設計原則

好的 RESTful API 需要清楚、一致的 URL 設計:

資源命名規則

// 好的 URL 設計範例
const goodUrls = {
  // 使用複數名詞表示集合
  getAllUsers: 'GET /users',
  getUser: 'GET /users/123',
  createUser: 'POST /users',
  updateUser: 'PUT /users/123',
  partialUpdateUser: 'PATCH /users/123',
  deleteUser: 'DELETE /users/123',
  
  // 巢狀資源
  getUserPosts: 'GET /users/123/posts',
  getUserPost: 'GET /users/123/posts/456',
  
  // 查詢參數用於過濾和分頁
  searchUsers: 'GET /users?name=john&page=1&limit=10',
  
  // 動作型端點(少用)
  activateUser: 'POST /users/123/activate'
};

// 避免的 URL 設計
const badUrls = {
  // 避免動詞
  getUsers: 'GET /getUsers',  // 錯誤
  createUser: 'POST /createUser',  // 錯誤
  
  // 避免混合大小寫
  getUsers: 'GET /Users',  // 錯誤,應該用小寫
  
  // 避免在 URL 中包含檔案副檔名
  getUser: 'GET /users/123.json'  // 錯誤
};

URL 解析器實作

const url = require('node:url');

class UrlParser {
  static parseRestfulUrl(urlString) {
    const parsedUrl = url.parse(urlString, true);
    const pathParts = parsedUrl.pathname.split('/').filter(part => part !== '');
    
    return {
      resource: pathParts[0],           // 'users'
      id: pathParts[1] || null,         // '123' or null
      subResource: pathParts[2] || null, // 'posts' or null
      subId: pathParts[3] || null,      // '456' or null
      query: parsedUrl.query            // { page: '1', limit: '10' }
    };
  }
  
  static isValidResourceId(id) {
    return id && !isNaN(id) && parseInt(id) > 0;
  }
}

// 使用範例
function routeRequest(req, res) {
  const parsed = UrlParser.parseRestfulUrl(req.url);
  
  console.log('解析結果:', parsed);
  // /users/123/posts?page=1
  // { resource: 'users', id: '123', subResource: 'posts', subId: null, query: { page: '1' } }
  
  if (parsed.resource === 'users') {
    if (!parsed.id) {
      // /users - 處理用戶集合
      handleUserCollection(req, res, parsed.query);
    } else if (UrlParser.isValidResourceId(parsed.id)) {
      // /users/123 - 處理單一用戶
      handleSingleUser(req, res, parsed.id, parsed.subResource);
    } else {
      res.writeHead(400);
      res.end(JSON.stringify({ error: '無效的用戶 ID' }));
    }
  }
}

完整的 CRUD 操作實作

讓我們建立一個完整的用戶管理 API:

資料存儲層

// 簡單的記憶體資料庫模擬
class UserDatabase {
  constructor() {
    this.users = [
      { id: 1, name: '張三', email: 'zhang@example.com', createdAt: '2024-01-01T00:00:00Z' },
      { id: 2, name: '李四', email: 'li@example.com', createdAt: '2024-01-02T00:00:00Z' }
    ];
    this.nextId = 3;
  }
  
  // 取得所有用戶
  findAll(filters = {}) {
    let result = [...this.users];
    
    // 名稱過濾
    if (filters.name) {
      result = result.filter(user => 
        user.name.toLowerCase().includes(filters.name.toLowerCase())
      );
    }
    
    // 分頁
    if (filters.page && filters.limit) {
      const page = parseInt(filters.page);
      const limit = parseInt(filters.limit);
      const start = (page - 1) * limit;
      result = result.slice(start, start + limit);
    }
    
    return result;
  }
  
  // 根據 ID 查找用戶
  findById(id) {
    return this.users.find(user => user.id === parseInt(id));
  }
  
  // 根據 email 查找用戶
  findByEmail(email) {
    return this.users.find(user => user.email === email);
  }
  
  // 創建用戶
  create(userData) {
    const newUser = {
      id: this.nextId++,
      ...userData,
      createdAt: new Date().toISOString()
    };
    
    this.users.push(newUser);
    return newUser;
  }
  
  // 更新用戶
  update(id, userData) {
    const index = this.users.findIndex(user => user.id === parseInt(id));
    
    if (index === -1) {
      return null;
    }
    
    this.users[index] = {
      ...this.users[index],
      ...userData,
      updatedAt: new Date().toISOString()
    };
    
    return this.users[index];
  }
  
  // 刪除用戶
  delete(id) {
    const index = this.users.findIndex(user => user.id === parseInt(id));
    
    if (index === -1) {
      return false;
    }
    
    this.users.splice(index, 1);
    return true;
  }
  
  // 取得總數量
  count(filters = {}) {
    return this.findAll(filters).length;
  }
}

用戶控制器

class UserController {
  constructor() {
    this.db = new UserDatabase();
  }
  
  // GET /users
  async getUsers(req, res, query) {
    try {
      const filters = {
        name: query.name,
        page: query.page,
        limit: Math.min(parseInt(query.limit) || 10, 100) // 限制最大值
      };
      
      const users = this.db.findAll(filters);
      const total = this.db.count({ name: query.name });
      
      const response = {
        users,
        pagination: {
          total,
          page: parseInt(filters.page) || 1,
          limit: filters.limit,
          totalPages: Math.ceil(total / filters.limit)
        }
      };
      
      ApiResponse.success(res, response);
    } catch (err) {
      console.error('取得用戶列表錯誤:', err);
      ServerError.internal(res);
    }
  }
  
  // GET /users/:id
  async getUser(req, res, userId) {
    try {
      const user = this.db.findById(userId);
      
      if (!user) {
        ApiError.notFound(res, '找不到指定的用戶');
        return;
      }
      
      ApiResponse.success(res, user);
    } catch (err) {
      console.error('取得用戶錯誤:', err);
      ServerError.internal(res);
    }
  }
  
  // POST /users
  async createUser(req, res, userData) {
    try {
      // 驗證資料
      const errors = this.validateUserData(userData);
      if (errors.length > 0) {
        ApiError.validationError(res, errors);
        return;
      }
      
      // 檢查 email 是否已存在
      if (this.db.findByEmail(userData.email)) {
        ApiError.conflict(res, '此 email 已被使用');
        return;
      }
      
      const newUser = this.db.create(userData);
      ApiResponse.created(res, newUser);
    } catch (err) {
      console.error('創建用戶錯誤:', err);
      ServerError.internal(res);
    }
  }
  
  // PUT /users/:id
  async updateUser(req, res, userId, userData) {
    try {
      // 檢查用戶是否存在
      if (!this.db.findById(userId)) {
        ApiError.notFound(res, '找不到指定的用戶');
        return;
      }
      
      // PUT 需要完整資料
      if (!userData.name || !userData.email) {
        ApiError.badRequest(res, 'PUT 請求需要提供完整的用戶資料', {
          required: ['name', 'email']
        });
        return;
      }
      
      // 驗證資料
      const errors = this.validateUserData(userData);
      if (errors.length > 0) {
        ApiError.validationError(res, errors);
        return;
      }
      
      // 檢查 email 衝突(排除自己)
      const existingUser = this.db.findByEmail(userData.email);
      if (existingUser && existingUser.id !== parseInt(userId)) {
        ApiError.conflict(res, '此 email 已被其他用戶使用');
        return;
      }
      
      const updatedUser = this.db.update(userId, userData);
      ApiResponse.success(res, updatedUser, '用戶更新成功');
    } catch (err) {
      console.error('更新用戶錯誤:', err);
      ServerError.internal(res);
    }
  }
  
  // PATCH /users/:id
  async patchUser(req, res, userId, updates) {
    try {
      // 檢查用戶是否存在
      if (!this.db.findById(userId)) {
        ApiError.notFound(res, '找不到指定的用戶');
        return;
      }
      
      // 驗證提供的欄位
      const errors = this.validatePartialUserData(updates);
      if (errors.length > 0) {
        ApiError.validationError(res, errors);
        return;
      }
      
      // 檢查 email 衝突(如果有提供 email)
      if (updates.email) {
        const existingUser = this.db.findByEmail(updates.email);
        if (existingUser && existingUser.id !== parseInt(userId)) {
          ApiError.conflict(res, '此 email 已被其他用戶使用');
          return;
        }
      }
      
      const updatedUser = this.db.update(userId, updates);
      ApiResponse.success(res, updatedUser, '用戶部分更新成功');
    } catch (err) {
      console.error('部分更新用戶錯誤:', err);
      ServerError.internal(res);
    }
  }
  
  // DELETE /users/:id
  async deleteUser(req, res, userId) {
    try {
      const success = this.db.delete(userId);
      
      if (!success) {
        ApiError.notFound(res, '找不到指定的用戶');
        return;
      }
      
      ApiResponse.noContent(res);
    } catch (err) {
      console.error('刪除用戶錯誤:', err);
      ServerError.internal(res);
    }
  }
  
  // 驗證完整用戶資料
  validateUserData(userData) {
    const errors = [];
    
    if (!userData.name || typeof userData.name !== 'string') {
      errors.push({ field: 'name', message: '姓名為必填且必須是字串' });
    } else if (userData.name.length < 2 || userData.name.length > 50) {
      errors.push({ field: 'name', message: '姓名長度必須在 2-50 字元之間' });
    }
    
    if (!userData.email || typeof userData.email !== 'string') {
      errors.push({ field: 'email', message: 'Email 為必填且必須是字串' });
    } else if (!this.isValidEmail(userData.email)) {
      errors.push({ field: 'email', message: '請提供有效的 email 格式' });
    }
    
    return errors;
  }
  
  // 驗證部分用戶資料(PATCH 用)
  validatePartialUserData(updates) {
    const errors = [];
    
    if (updates.name !== undefined) {
      if (typeof updates.name !== 'string' || updates.name.length < 2 || updates.name.length > 50) {
        errors.push({ field: 'name', message: '姓名長度必須在 2-50 字元之間' });
      }
    }
    
    if (updates.email !== undefined) {
      if (typeof updates.email !== 'string' || !this.isValidEmail(updates.email)) {
        errors.push({ field: 'email', message: '請提供有效的 email 格式' });
      }
    }
    
    return errors;
  }
  
  isValidEmail(email) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }
}

路由系統整合

把所有功能整合成完整的 API:

const http = require('node:http');
const url = require('node:url');

class RestfulApiServer {
  constructor() {
    this.userController = new UserController();
  }
  
  createServer() {
    return http.createServer((req, res) => {
      this.handleRequest(req, res).catch(err => {
        console.error('請求處理錯誤:', err);
        ServerError.internal(res);
      });
    });
  }
  
  async handleRequest(req, res) {
    // 設定 CORS
    this.setCorsHeaders(res);
    
    // 處理 OPTIONS 請求
    if (req.method === 'OPTIONS') {
      res.writeHead(200);
      res.end();
      return;
    }
    
    // 解析 URL
    const parsedUrl = url.parse(req.url, true);
    const pathname = parsedUrl.pathname;
    const query = parsedUrl.query;
    const method = req.method;
    
    console.log(`${method} ${pathname}`);
    
    // 路由分發
    if (pathname === '/api/users') {
      await this.handleUserCollection(req, res, method, query);
    } 
    else if (pathname.match(/^\/api\/users\/\d+$/)) {
      const userId = pathname.split('/')[3];
      await this.handleSingleUser(req, res, method, userId);
    }
    else if (pathname === '/api/health') {
      this.handleHealthCheck(res);
    }
    else {
      ApiError.notFound(res, 'API 端點不存在');
    }
  }
  
  async handleUserCollection(req, res, method, query) {
    switch (method) {
      case 'GET':
        await this.userController.getUsers(req, res, query);
        break;
      case 'POST':
        const userData = await this.parseRequestBody(req);
        await this.userController.createUser(req, res, userData);
        break;
      default:
        ApiError.badRequest(res, `${method} 方法不支援此端點`);
    }
  }
  
  async handleSingleUser(req, res, method, userId) {
    switch (method) {
      case 'GET':
        await this.userController.getUser(req, res, userId);
        break;
      case 'PUT':
        const putData = await this.parseRequestBody(req);
        await this.userController.updateUser(req, res, userId, putData);
        break;
      case 'PATCH':
        const patchData = await this.parseRequestBody(req);
        await this.userController.patchUser(req, res, userId, patchData);
        break;
      case 'DELETE':
        await this.userController.deleteUser(req, res, userId);
        break;
      default:
        ApiError.badRequest(res, `${method} 方法不支援此端點`);
    }
  }
  
  handleHealthCheck(res) {
    ApiResponse.success(res, {
      status: 'healthy',
      timestamp: new Date().toISOString(),
      version: '1.0.0'
    });
  }
  
  parseRequestBody(req) {
    return new Promise((resolve, reject) => {
      let body = '';
      
      req.on('data', chunk => {
        body += chunk.toString();
        
        // 防止過大的請求
        if (body.length > 1000000) { // 1MB
          reject(new Error('請求內容過大'));
        }
      });
      
      req.on('end', () => {
        try {
          if (body.trim() === '') {
            resolve({});
          } else {
            resolve(JSON.parse(body));
          }
        } catch (err) {
          reject(new Error('JSON 格式錯誤'));
        }
      });
      
      req.on('error', reject);
    });
  }
  
  setCorsHeaders(res) {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  }
  
  start(port = 3000) {
    const server = this.createServer();
    
    server.listen(port, () => {
      console.log(`RESTful API 伺服器啟動在 http://localhost:${port}`);
      console.log('可用端點:');
      console.log('  GET    /api/users           - 取得用戶列表');
      console.log('  POST   /api/users           - 創建新用戶');
      console.log('  GET    /api/users/:id       - 取得單一用戶');
      console.log('  PUT    /api/users/:id       - 完整更新用戶');
      console.log('  PATCH  /api/users/:id       - 部分更新用戶');
      console.log('  DELETE /api/users/:id       - 刪除用戶');
      console.log('  GET    /api/health          - 健康檢查');
    });
    
    return server;
  }
}

// 啟動伺服器
const apiServer = new RestfulApiServer();
apiServer.start(3000);

版本控制

class VersionedApiServer extends RestfulApiServer {
  constructor() {
    super();
    this.v1Controller = new UserController();
    this.v2Controller = new UserV2Controller(); // 假設有 v2 版本
  }
  
  async handleRequest(req, res) {
    const parsedUrl = url.parse(req.url, true);
    const pathParts = parsedUrl.pathname.split('/');
    
    // 解析版本號
    if (pathParts[1] === 'api' && pathParts[2] && pathParts[2].startsWith('v')) {
      const version = pathParts[2];
      const resource = pathParts[3];
      const id = pathParts[4];
      
      console.log(`${req.method} ${version}/${resource}${id ? '/' + id : ''}`);
      
      if (version === 'v1') {
        await this.handleV1Request(req, res, resource, id, parsedUrl.query);
      } else if (version === 'v2') {
        await this.handleV2Request(req, res, resource, id, parsedUrl.query);
      } else {
        ApiError.badRequest(res, '不支援的 API 版本');
      }
    } else {
      // 沒有版本號,預設使用最新版本或回傳錯誤
      ApiError.badRequest(res, '請在 URL 中指定 API 版本,例如: /api/v1/users');
    }
  }
  
  async handleV1Request(req, res, resource, id, query) {
    // 使用 v1 控制器處理請求
    if (resource === 'users') {
      if (id) {
        await this.handleSingleUserV1(req, res, req.method, id);
      } else {
        await this.handleUserCollectionV1(req, res, req.method, query);
      }
    }
  }
  
  // v1 版本的回應格式
  async handleUserCollectionV1(req, res, method, query) {
    if (method === 'GET') {
      const users = this.v1Controller.db.findAll(query);
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({
        success: true,
        data: users
      }));
    }
    // 其他方法...
  }
}

總結

URL 設計原則

  • 使用名詞而非動詞
  • 保持一致的命名慣例
  • 使用複數形式表示集合
  • 正確使用巢狀資源

HTTP 方法使用

  • GET:讀取資料,不修改狀態
  • POST:創建新資源
  • PUT:完整更新資源
  • PATCH:部分更新資源
  • DELETE:刪除資源

狀態碼使用

  • 2xx:成功回應
  • 4xx:客戶端錯誤
  • 5xx:伺服器錯誤

回應格式標準化

// 成功回應
{
  "status": "success",
  "message": "操作成功",
  "data": { /* 實際資料 */ }
}

// 錯誤回應
{
  "status": "error",
  "message": "錯誤描述",
  "errors": [ /* 詳細錯誤資訊 */ ]
}

這個部分後續整理註冊登入功能會再複習一次,明天可以開始 Express 系列了。

參考資料

淺談 API 與 RESTful API
表現層狀態轉換
HTTP 請求方法
HTTP狀態碼


上一篇
Day7 - Node.js 建立 HTTP API
系列文
欸欸!! 這是我的學習筆記8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言