Express.js Routing và Request Handling

Trong bài trước, bạn đã nắm vững middleware — xương sống của mọi ứng dụng Express. Giờ là lúc giải quyết routing, phần quyết định chuyện gì xảy ra khi một request đến server.
Nếu app có nhiều hơn một vài endpoint, nhồi hết vào một file app.js sẽ thành mớ bòng bong rất nhanh. Express Router cho phép bạn tách route thành từng module, lồng nhau, và giữ codebase sạch sẽ khi project lớn dần.
Bạn Sẽ Học Được Gì
Kết Quả Học Tập:
✅ Tổ chức route thành các module với Express Router
✅ Xây dựng cấu trúc route lồng nhau và có prefix
✅ Sử dụng route parameters, query strings và request body đúng cách
✅ Validate và sanitize input với express-validator
✅ Xử lý file upload với multer
✅ Thiết kế RESTful API theo chuẩn industry
✅ Trả về HTTP status code chính xác cho mọi response
1. Express Router Cơ Bản
Từ trước đến giờ, bạn vẫn define route trực tiếp trên app:
app.get('/api/todos', (req, res) => { ... });
app.post('/api/todos', (req, res) => { ... });Cách này ổn cho app nhỏ. Nhưng khi có users, todos, products, orders, comments, và admin route — tất cả trong một file — thì thành ác mộng. Express Router giải quyết chuyện này.
Tạo Router
Một Router là một ứng dụng Express thu nhỏ, tự quản lý route riêng:
// routes/todos.js
const express = require('express');
const router = express.Router();
// Các path này tương đối với nơi router được mount
router.get('/', (req, res) => {
res.json({ success: true, data: todos });
});
router.post('/', (req, res) => {
const todo = { id: Date.now(), title: req.body.title, completed: false };
todos.push(todo);
res.status(201).json({ success: true, data: todo });
});
router.get('/:id', (req, res) => {
const todo = todos.find((t) => t.id === Number(req.params.id));
if (!todo) {
return res.status(404).json({ success: false, message: 'Todo not found' });
}
res.json({ success: true, data: todo });
});
module.exports = router;Mount Router
Trong file app.js chính, mount router tại một path prefix:
// app.js
const express = require('express');
const todoRoutes = require('./routes/todos');
const userRoutes = require('./routes/users');
const app = express();
app.use(express.json());
// Mount router tại các prefix
app.use('/api/todos', todoRoutes);
app.use('/api/users', userRoutes);
app.listen(3000, () => {
console.log('Server running on port 3000');
});Giờ router.get('/') trong routes/todos.js sẽ map tới GET /api/todos, và router.get('/:id') map tới GET /api/todos/:id. Sạch sẽ và rõ ràng.
Cấu Trúc Project
Đây là cách một Express project được tổ chức tốt trông như thế nào:
src/
├── app.js # Express app setup
├── server.js # Entry point (khởi động server)
├── routes/
│ ├── index.js # Gộp tất cả router
│ ├── todos.js # Todo routes
│ ├── users.js # User routes
│ └── admin.js # Admin routes
├── controllers/
│ ├── todoController.js # Todo business logic
│ └── userController.js # User business logic
├── middleware/
│ ├── auth.js # Authentication middleware
│ ├── validate.js # Validation middleware
│ └── errorHandler.js # Error handling middleware
└── utils/
└── errors.js # Custom error classes2. Route Parameters và Query Strings
Express cho bạn ba cách chính để nhận data từ client: route parameters, query strings, và request body.
Route Parameters
Route parameters là các segment được đặt tên trong URL path. Dùng để xác định resource cụ thể:
// Một parameter
router.get('/users/:id', (req, res) => {
console.log(req.params.id); // "42"
// GET /users/42
});
// Nhiều parameters
router.get('/users/:userId/posts/:postId', (req, res) => {
console.log(req.params.userId); // "42"
console.log(req.params.postId); // "7"
// GET /users/42/posts/7
});Optional Parameters
Dùng hậu tố ? để làm parameter tùy chọn:
// :format là tùy chọn
router.get('/reports/:year/:format?', (req, res) => {
const { year, format } = req.params;
// GET /reports/2025 → year: "2025", format: undefined
// GET /reports/2025/pdf → year: "2025", format: "pdf"
});Query Strings
Query strings là các cặp key-value sau dấu ? trong URL. Dùng cho filter, sort và pagination:
router.get('/todos', (req, res) => {
const { page, limit, sort, completed } = req.query;
// GET /todos?page=2&limit=10&sort=date&completed=true
console.log(page); // "2" (luôn là string)
console.log(limit); // "10"
console.log(sort); // "date"
console.log(completed); // "true" (string, không phải boolean!)
});Quan trọng: Query parameters luôn là string. Bạn cần convert:
router.get('/todos', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 10, 100); // Giới hạn 100
const completed = req.query.completed === 'true';
const start = (page - 1) * limit;
const paginatedTodos = todos
.filter((t) => (completed !== undefined ? t.completed === completed : true))
.slice(start, start + limit);
res.json({
success: true,
data: paginatedTodos,
pagination: {
page,
limit,
total: todos.length,
},
});
});Request Body
Request body chứa data cho các request POST, PUT và PATCH:
// Đảm bảo express.json() middleware đã được apply
app.use(express.json());
router.post('/todos', (req, res) => {
const { title, description, priority } = req.body;
// Body đã được parse thành JavaScript object
});Khi Nào Dùng Cái Gì
| Nguồn Data | Use Case | Ví Dụ |
|---|---|---|
req.params | Xác định resource cụ thể | GET /users/42 |
req.query | Filter, sort, paginate | GET /users?role=admin&page=2 |
req.body | Gửi data (tạo/cập nhật) | POST /users với JSON body |
req.headers | Auth token, content type | Authorization: Bearer ... |
3. Advanced Routing Patterns
Route Chaining
Khi nhiều HTTP method dùng chung path, chain chúng với route():
router
.route('/:id')
.get((req, res) => {
// GET /todos/:id
const todo = findTodo(req.params.id);
res.json({ success: true, data: todo });
})
.put((req, res) => {
// PUT /todos/:id
const todo = updateTodo(req.params.id, req.body);
res.json({ success: true, data: todo });
})
.delete((req, res) => {
// DELETE /todos/:id
deleteTodo(req.params.id);
res.status(204).send();
});Router-Level Middleware
Apply middleware cho route cụ thể hoặc tất cả route trong một router:
const express = require('express');
const router = express.Router();
const { authenticate } = require('../middleware/auth');
// Apply cho TẤT CẢ route trong router này
router.use(authenticate);
// Hoặc apply cho route cụ thể
router.get('/public', (req, res) => {
// Vẫn yêu cầu auth vì router.use() ở trên
});
// Tốt hơn: tách public và protected router
const publicRouter = express.Router();
const protectedRouter = express.Router();
protectedRouter.use(authenticate);
publicRouter.get('/health', (req, res) => res.json({ status: 'ok' }));
protectedRouter.get('/profile', (req, res) => res.json({ user: req.user }));Nested Routers
Router có thể lồng bên trong router khác cho URL structure phức tạp:
// routes/users.js
const express = require('express');
const router = express.Router();
const postRouter = require('./userPosts');
// Lồng post route dưới users
// GET /api/users/:userId/posts → xử lý bởi postRouter
router.use('/:userId/posts', postRouter);
router.get('/', (req, res) => {
res.json({ success: true, data: users });
});
router.get('/:id', (req, res) => {
const user = users.find((u) => u.id === Number(req.params.id));
res.json({ success: true, data: user });
});
module.exports = router;// routes/userPosts.js
const express = require('express');
// mergeParams: true cho phép truy cập params từ router cha
const router = express.Router({ mergeParams: true });
router.get('/', (req, res) => {
const { userId } = req.params; // Từ router cha
const userPosts = posts.filter((p) => p.userId === Number(userId));
res.json({ success: true, data: userPosts });
});
router.post('/', (req, res) => {
const { userId } = req.params;
const post = {
id: Date.now(),
userId: Number(userId),
title: req.body.title,
content: req.body.content,
};
posts.push(post);
res.status(201).json({ success: true, data: post });
});
module.exports = router;Chi tiết quan trọng: mergeParams: true — thiếu cái này, req.params.userId sẽ là undefined trong nested router.
Route Index File
Gộp tất cả router vào một entry point duy nhất:
// routes/index.js
const express = require('express');
const router = express.Router();
const todoRoutes = require('./todos');
const userRoutes = require('./users');
const adminRoutes = require('./admin');
const { authenticate } = require('../middleware/auth');
const { requireAdmin } = require('../middleware/roles');
// Public routes
router.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }));
// Resource routes
router.use('/todos', todoRoutes);
router.use('/users', userRoutes);
// Admin routes (yêu cầu authentication + admin role)
router.use('/admin', authenticate, requireAdmin, adminRoutes);
module.exports = router;// app.js
const express = require('express');
const routes = require('./routes');
const app = express();
app.use(express.json());
app.use('/api', routes);
// Tất cả route giờ nằm dưới /api/...4. Response Methods
Express cung cấp nhiều method để gửi response. Dùng đúng method rất quan trọng.
Các Response Method Thiết Yếu
// Gửi JSON (phổ biến nhất cho API)
res.json({ success: true, data: user });
// Gửi plain text
res.send('Hello World');
// Set status code + JSON
res.status(201).json({ success: true, data: newUser });
// Không có content (sau DELETE)
res.status(204).send();
// Redirect
res.redirect(301, '/new-url');
// Gửi file
res.sendFile('/path/to/file.pdf');
// Trigger download
res.download('/path/to/report.pdf', 'report-2025.pdf');Set Headers
// Set một header
res.set('X-Request-Id', '123-abc');
// Set nhiều headers
res.set({
'X-Request-Id': '123-abc',
'X-Response-Time': '42ms',
'Cache-Control': 'no-cache',
});
// Set cookie
res.cookie('session', 'abc123', {
httpOnly: true,
secure: true,
maxAge: 3600000, // 1 giờ tính bằng ms
});
// Xóa cookie
res.clearCookie('session');HTTP Status Codes Bạn Cần Biết
| Code | Tên | Khi Nào Dùng |
|---|---|---|
200 | OK | GET, PUT, PATCH thành công |
201 | Created | POST thành công tạo resource |
204 | No Content | DELETE thành công |
400 | Bad Request | Input không hợp lệ, validation error |
401 | Unauthorized | Thiếu hoặc sai auth token |
403 | Forbidden | Token hợp lệ nhưng không đủ quyền |
404 | Not Found | Resource không tồn tại |
409 | Conflict | Resource trùng (ví dụ email đã tồn tại) |
422 | Unprocessable Entity | Cú pháp đúng nhưng lỗi về nghĩa |
429 | Too Many Requests | Vượt quá rate limit |
500 | Internal Server Error | Lỗi server không mong đợi |
5. Input Validation với express-validator
Đừng bao giờ tin user input. Dù frontend có validate, API vẫn phải validate — ai cũng có thể gửi request bằng curl hoặc Postman.
Setup
npm install express-validatorValidation Cơ Bản
const { body, param, query, validationResult } = require('express-validator');
// Validation rules dưới dạng middleware
const validateCreateTodo = [
body('title')
.trim()
.notEmpty()
.withMessage('Title is required')
.isLength({ max: 200 })
.withMessage('Title must be 200 characters or fewer'),
body('description')
.optional()
.trim()
.isLength({ max: 1000 })
.withMessage('Description must be 1000 characters or fewer'),
body('priority')
.optional()
.isIn(['low', 'medium', 'high'])
.withMessage('Priority must be low, medium, or high'),
];
// Middleware kiểm tra kết quả validation
function handleValidationErrors(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array().map((err) => ({
field: err.path,
message: err.msg,
})),
});
}
next();
}
// Apply vào route
router.post('/', validateCreateTodo, handleValidationErrors, (req, res) => {
// req.body đã được validate và sanitize ở đây
const todo = createTodo(req.body);
res.status(201).json({ success: true, data: todo });
});Validate Parameters và Query Strings
const validateGetTodo = [
param('id')
.isInt({ min: 1 })
.withMessage('ID must be a positive integer'),
];
const validateListTodos = [
query('page')
.optional()
.isInt({ min: 1 })
.withMessage('Page must be a positive integer'),
query('limit')
.optional()
.isInt({ min: 1, max: 100 })
.withMessage('Limit must be between 1 and 100'),
query('sort')
.optional()
.isIn(['date', 'title', 'priority'])
.withMessage('Sort must be date, title, or priority'),
query('completed')
.optional()
.isBoolean()
.withMessage('Completed must be true or false'),
];
router.get('/', validateListTodos, handleValidationErrors, (req, res) => {
// Query params đã được validate
const todos = listTodos(req.query);
res.json({ success: true, data: todos });
});
router.get('/:id', validateGetTodo, handleValidationErrors, (req, res) => {
const todo = findTodo(req.params.id);
res.json({ success: true, data: todo });
});Validation Chains Tái Sử Dụng
Đừng lặp lại validation logic giữa các route:
// validators/todo.js
const { body, param, query } = require('express-validator');
const todoId = param('id')
.isInt({ min: 1 })
.withMessage('ID must be a positive integer');
const todoTitle = body('title')
.trim()
.notEmpty()
.withMessage('Title is required')
.isLength({ max: 200 })
.withMessage('Title must be 200 characters or fewer');
const todoDescription = body('description')
.optional()
.trim()
.isLength({ max: 1000 })
.withMessage('Description must be 1000 characters or fewer');
const todoPriority = body('priority')
.optional()
.isIn(['low', 'medium', 'high'])
.withMessage('Priority must be low, medium, or high');
const pagination = [
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 }),
];
module.exports = {
create: [todoTitle, todoDescription, todoPriority],
update: [todoId, todoTitle.optional(), todoDescription, todoPriority],
getOne: [todoId],
list: [...pagination],
};// routes/todos.js
const validate = require('../validators/todo');
const { handleValidationErrors } = require('../middleware/validate');
router.get('/', validate.list, handleValidationErrors, todoController.list);
router.get('/:id', validate.getOne, handleValidationErrors, todoController.getOne);
router.post('/', validate.create, handleValidationErrors, todoController.create);
router.put('/:id', validate.update, handleValidationErrors, todoController.update);Custom Validators
Đôi khi built-in validators không đủ:
const { body } = require('express-validator');
const validateUniqueEmail = body('email')
.isEmail()
.withMessage('Must be a valid email')
.normalizeEmail()
.custom(async (email) => {
const existingUser = await User.findByEmail(email);
if (existingUser) {
throw new Error('Email already registered');
}
});
const validateDateRange = [
body('startDate')
.isISO8601()
.withMessage('Start date must be a valid ISO 8601 date'),
body('endDate')
.isISO8601()
.withMessage('End date must be a valid ISO 8601 date')
.custom((endDate, { req }) => {
if (new Date(endDate) <= new Date(req.body.startDate)) {
throw new Error('End date must be after start date');
}
return true;
}),
];6. File Uploads với Multer
Multer là middleware phổ biến nhất để xử lý multipart/form-data — encoding type dùng cho file upload.
Setup
npm install multerFile Upload Cơ Bản
const multer = require('multer');
const path = require('path');
// Storage đơn giản: lưu file với tên ngẫu nhiên
const upload = multer({
dest: 'uploads/', // Thư mục cho file upload
limits: {
fileSize: 5 * 1024 * 1024, // Giới hạn 5MB
},
});
// Upload một file
router.post('/avatar', upload.single('avatar'), (req, res) => {
// req.file chứa thông tin upload
console.log(req.file);
// {
// fieldname: 'avatar',
// originalname: 'photo.jpg',
// encoding: '7bit',
// mimetype: 'image/jpeg',
// destination: 'uploads/',
// filename: 'a1b2c3d4e5f6',
// path: 'uploads/a1b2c3d4e5f6',
// size: 234567
// }
res.json({
success: true,
data: {
filename: req.file.filename,
size: req.file.size,
},
});
});Custom Storage Configuration
Cho production, bạn cần kiểm soát filename và cách tổ chức:
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
// unique-timestamp-originalname.ext
const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const ext = path.extname(file.originalname);
cb(null, `${uniqueName}${ext}`);
},
});
// File filter: chỉ cho phép ảnh
const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image files (JPEG, PNG, GIF, WebP) are allowed'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 5, // Tối đa 5 file cùng lúc
},
});Upload Nhiều File
// Nhiều file cùng field name
router.post('/gallery', upload.array('photos', 5), (req, res) => {
// req.files là một array
const uploaded = req.files.map((f) => ({
filename: f.filename,
size: f.size,
mimetype: f.mimetype,
}));
res.json({ success: true, data: uploaded });
});
// Khác field cho khác file
router.post(
'/document',
upload.fields([
{ name: 'cover', maxCount: 1 },
{ name: 'attachments', maxCount: 3 },
]),
(req, res) => {
const cover = req.files['cover']?.[0];
const attachments = req.files['attachments'] || [];
res.json({
success: true,
data: {
cover: cover?.filename,
attachments: attachments.map((f) => f.filename),
},
});
}
);Xử Lý Upload Errors
Multer errors cần được bắt cụ thể:
function handleUploadError(err, req, res, next) {
if (err instanceof multer.MulterError) {
// Lỗi riêng của Multer
const messages = {
LIMIT_FILE_SIZE: 'File quá lớn. Tối đa 5MB.',
LIMIT_FILE_COUNT: 'Quá nhiều file. Tối đa 5.',
LIMIT_UNEXPECTED_FILE: 'Field file không mong đợi.',
};
return res.status(400).json({
success: false,
message: messages[err.code] || 'File upload error',
});
}
if (err) {
// Custom error (ví dụ từ fileFilter)
return res.status(400).json({
success: false,
message: err.message,
});
}
next();
}
// Apply sau upload routes
router.use(handleUploadError);7. RESTful API Design
REST (Representational State Transfer) không phải library — mà là tập hợp các convention để thiết kế API nhất quán, dễ đoán và dễ sử dụng.
Resource Naming
Resource là danh từ, không phải động từ. Dùng dạng số nhiều:
✅ Tốt ❌ Xấu
GET /api/users GET /api/getUsers
GET /api/users/42 GET /api/getUserById
POST /api/users POST /api/createUser
PUT /api/users/42 POST /api/updateUser
DELETE /api/users/42 POST /api/deleteUserNested Resources
Dùng nesting cho resource thuộc về resource khác:
GET /api/users/42/posts # Tất cả post của user 42
POST /api/users/42/posts # Tạo post cho user 42
GET /api/users/42/posts/7 # Post 7 của user 42
GET /api/posts/7/comments # Tất cả comment trên post 7
POST /api/posts/7/comments # Thêm comment vào post 7Nguyên tắc: Giới hạn nesting tối đa 2 level. Quá mức đó, dùng query parameters:
# Thay vì /api/users/42/posts/7/comments/3/replies
GET /api/replies?commentId=3Response Format Nhất Quán
Chọn một format và dùng xuyên suốt:
// Success response
{
"success": true,
"data": { ... }, // hoặc [...] cho list
"pagination": { // chỉ cho list endpoint
"page": 1,
"limit": 10,
"total": 42,
"totalPages": 5
}
}
// Error response
{
"success": false,
"message": "Validation failed",
"errors": [ // tùy chọn: chi tiết lỗi
{ "field": "email", "message": "Must be a valid email" }
]
}Response Helper
// utils/response.js
function sendSuccess(res, data, statusCode = 200) {
res.status(statusCode).json({
success: true,
data,
});
}
function sendPaginated(res, data, pagination) {
res.json({
success: true,
data,
pagination: {
page: pagination.page,
limit: pagination.limit,
total: pagination.total,
totalPages: Math.ceil(pagination.total / pagination.limit),
},
});
}
function sendError(res, message, statusCode = 500, errors = null) {
const response = { success: false, message };
if (errors) response.errors = errors;
res.status(statusCode).json(response);
}
module.exports = { sendSuccess, sendPaginated, sendError };// Sử dụng helper
const { sendSuccess, sendPaginated } = require('../utils/response');
router.get('/', (req, res) => {
const { todos, total } = getTodos(req.query);
sendPaginated(res, todos, {
page: req.query.page || 1,
limit: req.query.limit || 10,
total,
});
});
router.post('/', (req, res) => {
const todo = createTodo(req.body);
sendSuccess(res, todo, 201);
});8. Kết Hợp Tất Cả — Complete Project
Giờ hãy build một task management API hoàn chỉnh sử dụng mọi thứ đã học: Router, validation, file upload và RESTful design.
Cấu Trúc Project
project/
├── package.json
├── src/
│ ├── app.js
│ ├── server.js
│ ├── routes/
│ │ ├── index.js
│ │ ├── todos.js
│ │ └── users.js
│ ├── controllers/
│ │ ├── todoController.js
│ │ └── userController.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── validate.js
│ │ └── errorHandler.js
│ ├── validators/
│ │ ├── todo.js
│ │ └── user.js
│ ├── utils/
│ │ ├── response.js
│ │ └── errors.js
│ └── data/
│ └── store.js
└── uploads/Data Store (In-Memory)
// src/data/store.js
let users = [
{ id: 1, name: 'Alice', email: '[email protected]', role: 'admin' },
{ id: 2, name: 'Bob', email: '[email protected]', role: 'user' },
];
let todos = [
{
id: 1,
userId: 1,
title: 'Learn Express Router',
description: 'Organize routes into modules',
priority: 'high',
completed: false,
attachment: null,
createdAt: new Date().toISOString(),
},
{
id: 2,
userId: 2,
title: 'Add validation',
description: 'Use express-validator',
priority: 'medium',
completed: false,
attachment: null,
createdAt: new Date().toISOString(),
},
];
let nextUserId = 3;
let nextTodoId = 3;
module.exports = {
users,
todos,
getNextUserId: () => nextUserId++,
getNextTodoId: () => nextTodoId++,
};Custom Errors
// src/utils/errors.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(message = 'Validation failed', errors = []) {
super(message, 400);
this.errors = errors;
}
}
class ConflictError extends AppError {
constructor(message = 'Resource already exists') {
super(message, 409);
}
}
module.exports = { AppError, NotFoundError, ValidationError, ConflictError };Response Helpers
// src/utils/response.js
function sendSuccess(res, data, statusCode = 200) {
res.status(statusCode).json({ success: true, data });
}
function sendPaginated(res, data, pagination) {
res.json({
success: true,
data,
pagination: {
page: pagination.page,
limit: pagination.limit,
total: pagination.total,
totalPages: Math.ceil(pagination.total / pagination.limit),
},
});
}
module.exports = { sendSuccess, sendPaginated };Validators
// src/validators/todo.js
const { body, param, query } = require('express-validator');
const todoId = param('id')
.isInt({ min: 1 })
.withMessage('Todo ID must be a positive integer');
const todoTitle = body('title')
.trim()
.notEmpty()
.withMessage('Title is required')
.isLength({ max: 200 })
.withMessage('Title must be 200 characters or fewer');
const todoDescription = body('description')
.optional()
.trim()
.isLength({ max: 1000 })
.withMessage('Description must be 1000 characters or fewer');
const todoPriority = body('priority')
.optional()
.isIn(['low', 'medium', 'high'])
.withMessage('Priority must be low, medium, or high');
module.exports = {
create: [todoTitle, todoDescription, todoPriority],
update: [todoId, todoTitle.optional(), todoDescription, todoPriority],
getOne: [todoId],
list: [
query('page').optional().isInt({ min: 1 }),
query('limit').optional().isInt({ min: 1, max: 100 }),
query('priority').optional().isIn(['low', 'medium', 'high']),
query('completed').optional().isBoolean(),
],
};// src/validators/user.js
const { body, param } = require('express-validator');
const { users } = require('../data/store');
module.exports = {
create: [
body('name')
.trim()
.notEmpty()
.withMessage('Name is required')
.isLength({ max: 100 })
.withMessage('Name must be 100 characters or fewer'),
body('email')
.isEmail()
.withMessage('Must be a valid email')
.normalizeEmail()
.custom((email) => {
if (users.find((u) => u.email === email)) {
throw new Error('Email already registered');
}
return true;
}),
body('role')
.optional()
.isIn(['user', 'admin'])
.withMessage('Role must be user or admin'),
],
getOne: [
param('id').isInt({ min: 1 }).withMessage('User ID must be a positive integer'),
],
};Validation Middleware
// src/middleware/validate.js
const { validationResult } = require('express-validator');
function handleValidationErrors(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array().map((err) => ({
field: err.path,
message: err.msg,
})),
});
}
next();
}
module.exports = { handleValidationErrors };Error Handler
// src/middleware/errorHandler.js
const multer = require('multer');
function errorHandler(err, req, res, next) {
// Lỗi Multer
if (err instanceof multer.MulterError) {
const messages = {
LIMIT_FILE_SIZE: 'File quá lớn. Tối đa 5MB.',
LIMIT_FILE_COUNT: 'Quá nhiều file.',
LIMIT_UNEXPECTED_FILE: 'Field file không mong đợi.',
};
return res.status(400).json({
success: false,
message: messages[err.code] || 'File upload error',
});
}
// Custom AppError
if (err.isOperational) {
return res.status(err.statusCode).json({
success: false,
message: err.message,
...(err.errors && { errors: err.errors }),
});
}
// Lỗi không mong đợi
console.error('Unexpected error:', err);
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
module.exports = errorHandler;Controllers
// src/controllers/todoController.js
const { todos, getNextTodoId } = require('../data/store');
const { sendSuccess, sendPaginated } = require('../utils/response');
const { NotFoundError } = require('../utils/errors');
exports.list = (req, res) => {
let filtered = [...todos];
// Filter theo priority
if (req.query.priority) {
filtered = filtered.filter((t) => t.priority === req.query.priority);
}
// Filter theo completed status
if (req.query.completed !== undefined) {
const completed = req.query.completed === 'true';
filtered = filtered.filter((t) => t.completed === completed);
}
// Pagination
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const start = (page - 1) * limit;
const paginatedTodos = filtered.slice(start, start + limit);
sendPaginated(res, paginatedTodos, { page, limit, total: filtered.length });
};
exports.getOne = (req, res) => {
const todo = todos.find((t) => t.id === parseInt(req.params.id));
if (!todo) throw new NotFoundError('Todo');
sendSuccess(res, todo);
};
exports.create = (req, res) => {
const todo = {
id: getNextTodoId(),
userId: req.user?.id || null,
title: req.body.title,
description: req.body.description || null,
priority: req.body.priority || 'medium',
completed: false,
attachment: req.file?.filename || null,
createdAt: new Date().toISOString(),
};
todos.push(todo);
sendSuccess(res, todo, 201);
};
exports.update = (req, res) => {
const index = todos.findIndex((t) => t.id === parseInt(req.params.id));
if (index === -1) throw new NotFoundError('Todo');
todos[index] = {
...todos[index],
...(req.body.title && { title: req.body.title }),
...(req.body.description !== undefined && { description: req.body.description }),
...(req.body.priority && { priority: req.body.priority }),
...(req.body.completed !== undefined && { completed: req.body.completed }),
};
sendSuccess(res, todos[index]);
};
exports.remove = (req, res) => {
const index = todos.findIndex((t) => t.id === parseInt(req.params.id));
if (index === -1) throw new NotFoundError('Todo');
todos.splice(index, 1);
res.status(204).send();
};// src/controllers/userController.js
const { users, getNextUserId } = require('../data/store');
const { sendSuccess } = require('../utils/response');
const { NotFoundError } = require('../utils/errors');
exports.list = (req, res) => {
// Không expose data nhạy cảm
const safeUsers = users.map(({ id, name, email, role }) => ({
id,
name,
email,
role,
}));
sendSuccess(res, safeUsers);
};
exports.getOne = (req, res) => {
const user = users.find((u) => u.id === parseInt(req.params.id));
if (!user) throw new NotFoundError('User');
const { id, name, email, role } = user;
sendSuccess(res, { id, name, email, role });
};
exports.create = (req, res) => {
const user = {
id: getNextUserId(),
name: req.body.name,
email: req.body.email,
role: req.body.role || 'user',
};
users.push(user);
sendSuccess(res, user, 201);
};Routes
// src/routes/todos.js
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const todoController = require('../controllers/todoController');
const todoValidator = require('../validators/todo');
const { handleValidationErrors } = require('../middleware/validate');
// Multer config cho todo attachment
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => {
const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, `${uniqueName}${path.extname(file.originalname)}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'application/pdf'];
cb(null, allowed.includes(file.mimetype));
},
});
// GET /api/todos
router.get('/', todoValidator.list, handleValidationErrors, todoController.list);
// GET /api/todos/:id
router.get('/:id', todoValidator.getOne, handleValidationErrors, todoController.getOne);
// POST /api/todos (với optional file attachment)
router.post(
'/',
upload.single('attachment'),
todoValidator.create,
handleValidationErrors,
todoController.create
);
// PUT /api/todos/:id
router.put('/:id', todoValidator.update, handleValidationErrors, todoController.update);
// DELETE /api/todos/:id
router.delete('/:id', todoValidator.getOne, handleValidationErrors, todoController.remove);
module.exports = router;// src/routes/users.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const userValidator = require('../validators/user');
const { handleValidationErrors } = require('../middleware/validate');
router.get('/', userController.list);
router.get('/:id', userValidator.getOne, handleValidationErrors, userController.getOne);
router.post('/', userValidator.create, handleValidationErrors, userController.create);
module.exports = router;// src/routes/index.js
const express = require('express');
const router = express.Router();
const todoRoutes = require('./todos');
const userRoutes = require('./users');
router.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
router.use('/todos', todoRoutes);
router.use('/users', userRoutes);
module.exports = router;App và Server
// src/app.js
const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
const helmet = require('helmet');
const routes = require('./routes');
const errorHandler = require('./middleware/errorHandler');
const app = express();
// ─── Security & Logging ──────────────────────────────────────────────
app.use(helmet());
app.use(cors());
app.use(morgan('dev'));
// ─── Body Parsing ────────────────────────────────────────────────────
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// ─── Static Files (file attachment đã upload) ────────────────────────
app.use('/uploads', express.static('uploads'));
// ─── Routes ──────────────────────────────────────────────────────────
app.use('/api', routes);
// ─── 404 Handler ─────────────────────────────────────────────────────
app.use((req, res) => {
res.status(404).json({
success: false,
message: `Cannot ${req.method} ${req.originalUrl}`,
});
});
// ─── Error Handler ───────────────────────────────────────────────────
app.use(errorHandler);
module.exports = app;// src/server.js
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Health check: http://localhost:${PORT}/api/health`);
});Test API
Khởi động server và test với curl:
node src/server.jsList todos với pagination:
curl http://localhost:3000/api/todos?page=1&limit=5Tạo todo:
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "Write tests", "priority": "high"}'Tạo todo với attachment:
curl -X POST http://localhost:3000/api/todos \
-F "title=Upload example" \
-F "priority=medium" \
-F "attachment=@./screenshot.png"Update todo:
curl -X PUT http://localhost:3000/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}'Xóa todo:
curl -X DELETE http://localhost:3000/api/todos/1Test validation error:
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"priority": "urgent"}'Kết quả mong đợi:
{
"success": false,
"message": "Validation failed",
"errors": [
{ "field": "title", "message": "Title is required" },
{ "field": "priority", "message": "Priority must be low, medium, or high" }
]
}9. Lỗi Thường Gặp
❌ Define route sau 404 handler
app.use((req, res) => res.status(404).json({ message: 'Not found' }));
app.use('/api/todos', todoRoutes); // ← Không bao giờ chạy tới!✅ Luôn define route TRƯỚC catch-all handler
app.use('/api/todos', todoRoutes);
app.use((req, res) => res.status(404).json({ message: 'Not found' }));❌ Quên mergeParams cho nested router
const router = express.Router(); // Không có mergeParams
router.get('/', (req, res) => {
console.log(req.params.userId); // undefined!
});✅ Luôn set mergeParams: true cho nested router
const router = express.Router({ mergeParams: true });
router.get('/', (req, res) => {
console.log(req.params.userId); // "42" ✓
});❌ Không validate query parameters
router.get('/todos', (req, res) => {
const page = req.query.page; // Có thể là "abc", "-5", hoặc array!
});✅ Validate và convert mọi input
router.get('/todos', validateListTodos, handleValidationErrors, (req, res) => {
const page = parseInt(req.query.page) || 1; // An toàn
});❌ Gửi nhiều response
router.get('/:id', (req, res) => {
if (!todo) {
res.status(404).json({ message: 'Not found' });
// Quên return — code vẫn chạy tiếp!
}
res.json({ data: todo }); // Error: headers already sent
});✅ Luôn return sau khi gửi response
router.get('/:id', (req, res) => {
if (!todo) {
return res.status(404).json({ message: 'Not found' });
}
res.json({ data: todo });
});Tóm Tắt và Điểm Chính
Điểm Chính:
✅ Dùng Express Router để tổ chức route thành các file module dễ bảo trì
✅ Route parameters (req.params) xác định resource, query strings (req.query) filter chúng
✅ Dùng mergeParams: true khi nesting router
✅ Validate mọi user input với express-validator trước business logic
✅ Dùng multer cho file upload với size limit và type filter phù hợp
✅ Theo REST convention: danh từ số nhiều, HTTP method đúng, response format nhất quán
✅ Tách concerns: routes → validators → controllers → response helpers
✅ Trả HTTP status code chính xác cho mọi operation
Bài Tập Thực Hành
-
Thêm search cho todo list — Implement query parameter
?search=keywordđể filter todo theo title và description (case-insensitive). Thêm express-validator rules cho nó. -
Build hệ thống comment — Tạo nested endpoint
POST /api/todos/:id/commentsvới router riêng (dùngmergeParams). Mỗi comment cóid,text,authorvàcreatedAt. -
Implement PATCH vs PUT — Thêm route
PATCH /api/todos/:idchỉ update những field được gửi (partial update), trong khiPUT /api/todos/:idyêu cầu tất cả field (full replacement). Thêm validator riêng cho mỗi cái. -
Thêm pagination headers — Thay vì đặt pagination trong response body, set trong response headers:
X-Total-Count,X-Total-Pages,X-Current-Page, và thêmLinkheader vớirel="next"vàrel="prev"URL. -
Multi-file todo attachment — Sửa endpoint tạo/update todo để nhận tối đa 3 attachment. Lưu metadata attachment (filename, size, mimetype) dưới dạng array trên todo object. Thêm endpoint để xóa từng attachment.
Tiếp Theo
Trong bài tiếp, chúng ta sẽ kết nối API này với database thực — MongoDB với Mongoose:
- Setup MongoDB local và trên cloud
- Mongoose schema và model
- CRUD operations với Mongoose
- Query building, population và pagination
- Data validation ở database level
Mọi thứ chúng ta build ở đây — cấu trúc Router, validator, controller — sẽ tiếp tục được dùng khi thêm database persistence.
Tài Liệu Tham Khảo
- Express Routing Guide
- express-validator Docs
- Multer Docs
- HTTP Status Codes Reference
- REST API Design Best Practices
Bài Tiếp Theo Trong Series:
- Bài 4: Working with MongoDB and Mongoose
- Bài 5: Working with PostgreSQL and Prisma
- Bài 6: Database Best Practices and Performance
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.