Back to Blog
RESTful API Design: Best Practices và Patterns
Backend

RESTful API Design: Best Practices và Patterns

Hướng dẫn thiết kế RESTful APIs clean, maintainable và scalable.

15 tháng 12, 2023
12 phút đọc

RESTful API Design: Best Practices và Patterns

Thiết kế API tốt là chìa khóa cho sự thành công của ứng dụng. Hãy cùng tìm hiểu các best practices và patterns để tạo RESTful APIs clean, maintainable và scalable.

Resource Naming Conventions

Sử dụng Nouns, không phải Verbs

# ❌ Tránh
GET /api/getUsers
POST /api/createUser
PUT /api/updateUser/123
DELETE /api/deleteUser/123

# ✅ Tốt hơn
GET /api/users          # Lấy danh sách users
POST /api/users         # Tạo user mới
PUT /api/users/123      # Update user
DELETE /api/users/123   # Xóa user

Hierarchical Resources

# User's posts
GET /api/users/123/posts
POST /api/users/123/posts

# Specific post
GET /api/users/123/posts/456
PUT /api/users/123/posts/456
DELETE /api/users/123/posts/456

# Post's comments
GET /api/posts/456/comments
POST /api/posts/456/comments

Collection vs Resource

# Collections (plural)
GET /api/users          # Danh sách users
GET /api/posts          # Danh sách posts

# Resources (singular trong URL, nhưng endpoint vẫn plural)
GET /api/users/123      # Một user cụ thể
GET /api/posts/456      # Một post cụ thể

HTTP Methods và Status Codes

Proper HTTP Methods

// GET - Retrieve data
app.get('/api/users', async (req, res) => {
  const users = await User.find();
  res.json({
    data: users,
    total: users.length
  });
});

// POST - Create new resource
app.post('/api/users', async (req, res) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json({
      message: 'User created successfully',
      data: user
    });
  } catch (error) {
    res.status(400).json({
      error: 'Validation failed',
      details: error.message
    });
  }
});

// PUT - Update entire resource
app.put('/api/users/:id', async (req, res) => {
  try {
    const user = await User.findByIdAndUpdate(
      req.params.id, 
      req.body, 
      { new: true, runValidators: true }
    );
    
    if (!user) {
      return res.status(404).json({
        error: 'User not found'
      });
    }
    
    res.json({
      message: 'User updated successfully',
      data: user
    });
  } catch (error) {
    res.status(400).json({
      error: 'Update failed',
      details: error.message
    });
  }
});

// PATCH - Partial update
app.patch('/api/users/:id', async (req, res) => {
  try {
    const user = await User.findByIdAndUpdate(
      req.params.id,
      { $set: req.body },
      { new: true, runValidators: true }
    );
    
    if (!user) {
      return res.status(404).json({
        error: 'User not found'
      });
    }
    
    res.json({
      message: 'User updated successfully',
      data: user
    });
  } catch (error) {
    res.status(400).json({
      error: 'Update failed',
      details: error.message
    });
  }
});

// DELETE - Remove resource
app.delete('/api/users/:id', async (req, res) => {
  try {
    const user = await User.findByIdAndDelete(req.params.id);
    
    if (!user) {
      return res.status(404).json({
        error: 'User not found'
      });
    }
    
    res.status(204).send(); // No content
  } catch (error) {
    res.status(500).json({
      error: 'Delete failed',
      details: error.message
    });
  }
});

Comprehensive Status Codes

const StatusCodes = {
  // Success
  OK: 200,                    // GET, PUT, PATCH success
  CREATED: 201,              // POST success
  NO_CONTENT: 204,           // DELETE success
  
  // Client Errors
  BAD_REQUEST: 400,          // Invalid request data
  UNAUTHORIZED: 401,         // Authentication required
  FORBIDDEN: 403,            // Access denied
  NOT_FOUND: 404,            // Resource not found
  METHOD_NOT_ALLOWED: 405,   // HTTP method not supported
  CONFLICT: 409,             // Resource conflict
  UNPROCESSABLE_ENTITY: 422, // Validation errors
  TOO_MANY_REQUESTS: 429,    // Rate limiting
  
  // Server Errors
  INTERNAL_SERVER_ERROR: 500,
  NOT_IMPLEMENTED: 501,
  BAD_GATEWAY: 502,
  SERVICE_UNAVAILABLE: 503
};

Error Handling

Consistent Error Response Format

class APIError extends Error {
  constructor(message, statusCode, code = null, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
  }
}

// Error handler middleware
const errorHandler = (err, req, res, next) => {
  let error = { ...err };
  error.message = err.message;

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const message = Object.values(err.errors).map(val => val.message);
    error = new APIError('Validation Error', 400, 'VALIDATION_ERROR', message);
  }

  // Mongoose duplicate key
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    const message = `${field} already exists`;
    error = new APIError(message, 409, 'DUPLICATE_RESOURCE');
  }

  // Mongoose ObjectId error
  if (err.name === 'CastError') {
    const message = 'Resource not found';
    error = new APIError(message, 404, 'RESOURCE_NOT_FOUND');
  }

  res.status(error.statusCode || 500).json({
    success: false,
    error: {
      message: error.message || 'Server Error',
      code: error.code,
      details: error.details,
      ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
    }
  });
};

Validation Errors

const { body, validationResult } = require('express-validator');

// Validation rules
const userValidationRules = () => {
  return [
    body('email')
      .isEmail()
      .withMessage('Must be a valid email')
      .normalizeEmail(),
    body('password')
      .isLength({ min: 8 })
      .withMessage('Password must be at least 8 characters')
      .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
      .withMessage('Password must contain uppercase, lowercase and number'),
    body('name')
      .trim()
      .isLength({ min: 2, max: 50 })
      .withMessage('Name must be between 2 and 50 characters')
  ];
};

// Validation middleware
const validate = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(422).json({
      success: false,
      error: {
        message: 'Validation failed',
        code: 'VALIDATION_ERROR',
        details: errors.array().map(err => ({
          field: err.param,
          message: err.msg,
          value: err.value
        }))
      }
    });
  }
  next();
};

// Usage
app.post('/api/users', userValidationRules(), validate, createUser);

Pagination

Cursor-based Pagination

// Better for large datasets và real-time data
app.get('/api/posts', async (req, res) => {
  const { cursor, limit = 20, sort = 'created_at' } = req.query;
  
  let query = {};
  
  if (cursor) {
    // Decode cursor (base64 encoded timestamp)
    const decodedCursor = Buffer.from(cursor, 'base64').toString('ascii');
    query.created_at = { $lt: new Date(decodedCursor) };
  }
  
  const posts = await Post.find(query)
    .sort({ [sort]: -1 })
    .limit(parseInt(limit) + 1); // +1 to check if there's next page
  
  const hasNextPage = posts.length > limit;
  if (hasNextPage) posts.pop(); // Remove extra item
  
  const nextCursor = hasNextPage 
    ? Buffer.from(posts[posts.length - 1].created_at.toISOString()).toString('base64')
    : null;
  
  res.json({
    data: posts,
    pagination: {
      hasNextPage,
      nextCursor,
      limit: parseInt(limit)
    }
  });
});

Offset-based Pagination

// Simpler but less efficient for large datasets
app.get('/api/users', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 20;
  const skip = (page - 1) * limit;
  
  const [users, total] = await Promise.all([
    User.find()
      .skip(skip)
      .limit(limit)
      .sort({ created_at: -1 }),
    User.countDocuments()
  ]);
  
  const totalPages = Math.ceil(total / limit);
  
  res.json({
    data: users,
    pagination: {
      page,
      limit,
      total,
      totalPages,
      hasNextPage: page < totalPages,
      hasPrevPage: page > 1
    }
  });
});

Filtering, Sorting, và Searching

Advanced Query Parameters

app.get('/api/products', async (req, res) => {
  const {
    // Filtering
    category,
    minPrice,
    maxPrice,
    inStock,
    tags,
    
    // Sorting
    sort = 'created_at',
    order = 'desc',
    
    // Searching
    search,
    
    // Pagination
    page = 1,
    limit = 20,
    
    // Field selection
    fields
  } = req.query;
  
  // Build filter object
  let filter = {};
  
  if (category) filter.category = category;
  if (minPrice || maxPrice) {
    filter.price = {};
    if (minPrice) filter.price.$gte = parseFloat(minPrice);
    if (maxPrice) filter.price.$lte = parseFloat(maxPrice);
  }
  if (inStock !== undefined) filter.inStock = inStock === 'true';
  if (tags) filter.tags = { $in: tags.split(',') };
  
  // Search
  if (search) {
    filter.$or = [
      { name: { $regex: search, $options: 'i' } },
      { description: { $regex: search, $options: 'i' } }
    ];
  }
  
  // Build sort object
  const sortObj = {};
  sortObj[sort] = order === 'desc' ? -1 : 1;
  
  // Build query
  let query = Product.find(filter).sort(sortObj);
  
  // Field selection
  if (fields) {
    const selectedFields = fields.split(',').join(' ');
    query = query.select(selectedFields);
  }
  
  // Pagination
  const skip = (page - 1) * limit;
  query = query.skip(skip).limit(parseInt(limit));
  
  const [products, total] = await Promise.all([
    query.exec(),
    Product.countDocuments(filter)
  ]);
  
  res.json({
    data: products,
    pagination: {
      page: parseInt(page),
      limit: parseInt(limit),
      total,
      totalPages: Math.ceil(total / limit)
    },
    filters: {
      category,
      priceRange: { min: minPrice, max: maxPrice },
      inStock,
      tags: tags?.split(','),
      search
    }
  });
});

Authentication và Authorization

JWT Authentication

const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

// Login endpoint
app.post('/api/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Find user
    const user = await User.findOne({ email }).select('+password');
    if (!user) {
      return res.status(401).json({
        success: false,
        error: {
          message: 'Invalid credentials',
          code: 'INVALID_CREDENTIALS'
        }
      });
    }
    
    // Check password
    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      return res.status(401).json({
        success: false,
        error: {
          message: 'Invalid credentials',
          code: 'INVALID_CREDENTIALS'
        }
      });
    }
    
    // Generate tokens
    const accessToken = jwt.sign(
      { userId: user._id, email: user.email },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );
    
    const refreshToken = jwt.sign(
      { userId: user._id },
      process.env.JWT_REFRESH_SECRET,
      { expiresIn: '7d' }
    );
    
    // Save refresh token
    user.refreshToken = refreshToken;
    await user.save();
    
    res.json({
      success: true,
      data: {
        user: {
          id: user._id,
          email: user.email,
          name: user.name
        },
        tokens: {
          accessToken,
          refreshToken
        }
      }
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: {
        message: 'Login failed',
        details: error.message
      }
    });
  }
});

// Auth middleware
const authenticate = async (req, res, next) => {
  try {
    const token = req.header('Authorization')?.replace('Bearer ', '');
    
    if (!token) {
      return res.status(401).json({
        success: false,
        error: {
          message: 'Access token required',
          code: 'TOKEN_REQUIRED'
        }
      });
    }
    
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const user = await User.findById(decoded.userId);
    
    if (!user) {
      return res.status(401).json({
        success: false,
        error: {
          message: 'Invalid token',
          code: 'INVALID_TOKEN'
        }
      });
    }
    
    req.user = user;
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        success: false,
        error: {
          message: 'Token expired',
          code: 'TOKEN_EXPIRED'
        }
      });
    }
    
    res.status(401).json({
      success: false,
      error: {
        message: 'Invalid token',
        code: 'INVALID_TOKEN'
      }
    });
  }
};

Role-based Authorization

const authorize = (...roles) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({
        success: false,
        error: {
          message: 'Authentication required',
          code: 'AUTH_REQUIRED'
        }
      });
    }
    
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        error: {
          message: 'Insufficient permissions',
          code: 'INSUFFICIENT_PERMISSIONS'
        }
      });
    }
    
    next();
  };
};

// Usage
app.get('/api/admin/users', authenticate, authorize('admin'), getUsers);
app.delete('/api/posts/:id', authenticate, authorize('admin', 'moderator'), deletePost);

Rate Limiting

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');

const redis = new Redis(process.env.REDIS_URL);

// General rate limiting
const generalLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:general:'
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: {
    success: false,
    error: {
      message: 'Too many requests, please try again later',
      code: 'RATE_LIMIT_EXCEEDED'
    }
  }
});

// Strict rate limiting for auth endpoints
const authLimiter = rateLimit({
  store: new RedisStore({
    client: redis,
    prefix: 'rl:auth:'
  }),
  windowMs: 15 * 60 * 1000,
  max: 5, // limit each IP to 5 requests per windowMs
  skipSuccessfulRequests: true
});

app.use('/api/', generalLimiter);
app.use('/api/auth/', authLimiter);

API Versioning

URL Versioning

// v1 routes
app.use('/api/v1/users', v1UserRoutes);
app.use('/api/v1/posts', v1PostRoutes);

// v2 routes
app.use('/api/v2/users', v2UserRoutes);
app.use('/api/v2/posts', v2PostRoutes);

// Default to latest version
app.use('/api/users', v2UserRoutes);
app.use('/api/posts', v2PostRoutes);

Header Versioning

const versionMiddleware = (req, res, next) => {
  const version = req.headers['api-version'] || 'v2';
  req.apiVersion = version;
  next();
};

app.use('/api/', versionMiddleware);

app.get('/api/users', (req, res) => {
  if (req.apiVersion === 'v1') {
    // Handle v1 logic
    return handleV1Users(req, res);
  } else {
    // Handle v2 logic
    return handleV2Users(req, res);
  }
});

Documentation

OpenAPI/Swagger

const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');

const options = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'My API',
      version: '1.0.0',
      description: 'A sample API'
    },
    servers: [
      {
        url: 'http://localhost:3000/api',
        description: 'Development server'
      }
    ]
  },
  apis: ['./routes/*.js'] // paths to files containing OpenAPI definitions
};

const specs = swaggerJsdoc(options);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs));

/**
 * @swagger
 * /users:
 *   get:
 *     summary: Get all users
 *     tags: [Users]
 *     parameters:
 *       - in: query
 *         name: page
 *         schema:
 *           type: integer
 *         description: Page number
 *     responses:
 *       200:
 *         description: List of users
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 data:
 *                   type: array
 *                   items:
 *                     $ref: '#/components/schemas/User'
 */

Kết luận

Thiết kế RESTful API tốt đòi hỏi:

  1. Consistent naming conventions và resource structure
  2. Proper HTTP methods và status codes
  3. Comprehensive error handling với clear error messages
  4. Efficient pagination strategies
  5. Flexible filtering và searching
  6. Robust authentication và authorization
  7. Rate limiting để protect API
  8. Versioning strategy cho backward compatibility
  9. Good documentation cho developers

Những patterns này sẽ giúp API của bạn dễ sử dụng, maintain và scale trong tương lai.


Tags: API, REST, Backend, Node.js, Express, Best Practices, Web Development

Tags:

API
REST
Backend
Node.js
Express
Best Practices
Web Development