
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:
- Consistent naming conventions và resource structure
- Proper HTTP methods và status codes
- Comprehensive error handling với clear error messages
- Efficient pagination strategies
- Flexible filtering và searching
- Robust authentication và authorization
- Rate limiting để protect API
- Versioning strategy cho backward compatibility
- 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

