Back to blog

Express.js Routing và Request Handling

expressnodejsbackendjavascriptrest-api
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 classes

2. 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 DataUse CaseVí Dụ
req.paramsXác định resource cụ thểGET /users/42
req.queryFilter, sort, paginateGET /users?role=admin&page=2
req.bodyGửi data (tạo/cập nhật)POST /users với JSON body
req.headersAuth token, content typeAuthorization: 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

CodeTênKhi Nào Dùng
200OKGET, PUT, PATCH thành công
201CreatedPOST thành công tạo resource
204No ContentDELETE thành công
400Bad RequestInput không hợp lệ, validation error
401UnauthorizedThiếu hoặc sai auth token
403ForbiddenToken hợp lệ nhưng không đủ quyền
404Not FoundResource không tồn tại
409ConflictResource trùng (ví dụ email đã tồn tại)
422Unprocessable EntityCú pháp đúng nhưng lỗi về nghĩa
429Too Many RequestsVượt quá rate limit
500Internal Server ErrorLỗ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-validator

Validation 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 multer

File 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/deleteUser

Nested 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 7

Nguyê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=3

Response 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.js

List todos với pagination:

curl http://localhost:3000/api/todos?page=1&limit=5

Tạ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/1

Test 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

  1. 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ó.

  2. Build hệ thống comment — Tạo nested endpoint POST /api/todos/:id/comments với router riêng (dùng mergeParams). Mỗi comment có id, text, author và createdAt.

  3. Implement PATCH vs PUT — Thêm route PATCH /api/todos/:id chỉ update những field được gửi (partial update), trong khi PUT /api/todos/:id yêu cầu tất cả field (full replacement). Thêm validator riêng cho mỗi cái.

  4. 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êm Link header với rel="next" và rel="prev" URL.

  5. 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

Bài Tiếp Theo Trong Series:

📬 Đă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.