0
0
Lập trình
Admin Team
Admin Teamtechmely

Xây Dựng API CRUD Bảo Mật với Node.js, Express & MongoDB

Đăng vào 1 tháng trước

• 5 phút đọc

Giới Thiệu

Trong bài viết này, chúng ta sẽ cùng nhau xây dựng một API CRUD REST cho tài nguyên Client sử dụng Node.js, ExpressMongoose. Mục tiêu là tạo ra một API bảo mật, hiệu quả và dễ dàng triển khai. Dưới đây là những gì bạn sẽ học được:

  • Kiểm tra đầu vào và đảm bảo email là duy nhất
  • Phân trang và đọc dữ liệu nhẹ để tăng tốc độ xử lý
  • Xử lý lỗi tập trung với định dạng JSON nhất quán
  • Các biện pháp bảo mật cơ bản (Helmet, CORS, giới hạn tốc độ)
  • Cấu trúc sẵn sàng triển khai với tệp .env, kiểm tra tình trạng và tắt máy một cách thanh lịch

Mặc dù cấu trúc repo trong bài viết này sử dụng một tệp để dễ hiểu, nhưng các khái niệm sẽ sẵn sàng cho môi trường sản xuất.


1) Các yêu cầu tiên quyết

  • Node 18+ (hoặc 20+)
  • MongoDB chạy cục bộ hoặc trên đám mây (Atlas)
  • Kiến thức cơ bản về terminal và REST

Thiết lập môi trường

bash Copy
mkdir secure-crud-api && cd secure-crud-api
npm init -y
npm i express mongoose dotenv helmet cors morgan express-rate-limit
npm i -D nodemon

Cấu hình package.json

json Copy
{
  "scripts": {
    "dev": "nodemon server.js",
    "start": "node server.js"
  }
}

Tạo tệp .env (tại thư mục gốc của dự án)

bash Copy
PORT=3000
MONGO_URI=mongodb://127.0.0.1:27017/crud

2) Tạo API (Sao chép server.js)

Đây là một mẫu cơ bản nhưng vững chắc mà bạn có thể triển khai.

javascript Copy
// server.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');

const app = express();
const PORT = process.env.PORT || 3000;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/crud';

// --- Middleware chính
app.use(helmet());
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '100kb' }));
app.use(morgan('dev'));
app.use(rateLimit({ windowMs: 60_000, max: 100 }));

// --- Kết nối MongoDB
mongoose
  .connect(MONGO_URI)
  .then(() => console.log('✅ Kết nối MongoDB thành công'))
  .catch((err) => {
    console.error('❌ Lỗi kết nối MongoDB:', err);
    process.exit(1);
  });

// --- Mô hình Mongoose
const clientSchema = new mongoose.Schema(
  {
    nom: { type: String, required: true, trim: true, minlength: 1, maxlength: 120 },
    email: {
      type: String,
      required: true,
      trim: true,
      lowercase: true,
      unique: true,
      match: [/^\S+@\S+\.\S+$/, 'Email không hợp lệ'],
    },
    telephone: { type: String, trim: true },
  },
  { timestamps: true, versionKey: false }
);

// Chuyển đổi _id thành id trong JSON
clientSchema.set('toJSON', {
  transform: (_doc, ret) => {
    ret.id = ret._id;
    delete ret._id;
    return ret;
  },
});

const Client = mongoose.model('Client', clientSchema);

// --- Bọc xử lý bất đồng bộ
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

// --- Các tuyến đường (v1)
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));

// Tạo mới
app.post('/api/v1/clients', asyncHandler(async (req, res) => {
  const { nom, email, telephone } = req.body || {};
  if (!nom || !email) return res.status(400).json({ error: 'nom và email là bắt buộc' });
  const client = await Client.create({ nom, email, telephone });
  res.status(201).json(client);
}));

// Danh sách với phân trang
app.get('/api/v1/clients', asyncHandler(async (req, res) => {
  const page = Math.max(parseInt(req.query.page) || 1, 1);
  const limit = Math.min(parseInt(req.query.limit) || 20, 100);
  const skip = (page - 1) * limit;

  const [items, total] = await Promise.all([
    Client.find().lean().skip(skip).limit(limit).sort({ createdAt: -1 }),
    Client.countDocuments(),
  ]);

  res.json({ items, page, limit, total, pages: Math.ceil(total / limit) });
}));

// Đọc một
app.get('/api/v1/clients/:id', asyncHandler(async (req, res) => {
  const client = await Client.findById(req.params.id);
  if (!client) return res.status(404).json({ error: 'Client không tồn tại' });
  res.json(client);
}));

// Cập nhật một phần (PATCH) với xác thực
app.patch('/api/v1/clients/:id', asyncHandler(async (req, res) => {
  const allowed = ['nom', 'email', 'telephone'];
  const update = Object.fromEntries(
    Object.entries(req.body || {}).filter(([k]) => allowed.includes(k))
  );

  const client = await Client.findByIdAndUpdate(req.params.id, update, {
    new: true,
    runValidators: true,
    context: 'query',
  });

  if (!client) return res.status(404).json({ error: 'Client không tồn tại' });
  res.json(client);
}));

// Xóa
app.delete('/api/v1/clients/:id', asyncHandler(async (req, res) => {
  const client = await Client.findByIdAndDelete(req.params.id);
  if (!client) return res.status(404).json({ error: 'Client không tồn tại' });
  res.status(204).send();
}));

// --- Xử lý lỗi tập trung
app.use((err, _req, res, _next) => {
  if (err.name === 'CastError') return res.status(400).json({ error: 'ID không hợp lệ' });
  if (err.code === 11000) return res.status(409).json({ error: 'Email đã được sử dụng' });
  console.error(err);
  res.status(500).json({ error: 'Lỗi máy chủ nội bộ' });
});

// --- Tắt máy một cách thanh lịch
const server = app.listen(PORT, () => console.log(`🚀 http://localhost:${PORT}`));
function shutdown() {
  console.log('Đang tắt máy…');
  server.close(() => mongoose.connection.close(false, () => process.exit(0)));
  setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

Chạy ứng dụng:

bash Copy
npm run dev
# ➜ http://localhost:3000/healthz

3) Kiểm tra các điểm cuối (cURL)

Tạo mới:

bash Copy
curl -X POST http://localhost:3000/api/v1/clients \
  -H "Content-Type: application/json" \
  -d '{"nom":"Ada Lovelace","email":"ada@example.com","telephone":"+33 6 12 34 56 78"}'

Danh sách (phân trang):

bash Copy
curl "http://localhost:3000/api/v1/clients?page=1&limit=10"

Lấy một:

bash Copy
curl http://localhost:3000/api/v1/clients/<id>

Cập nhật (PATCH):

bash Copy
curl -X PATCH http://localhost:3000/api/v1/clients/<id> \
  -H "Content-Type: application/json" \
  -d '{"telephone":"+33 7 98 76 54 32"}'

Xóa:

bash Copy
curl -X DELETE http://localhost:3000/api/v1/clients/<id> -i
# 204 No Content

4) Tại sao lại chọn những thứ này?

  • Xác thực và tính duy nhất trong schema giúp dữ liệu sạch và ngăn chặn trùng lặp (unique: true, regex trên email).
  • PATCH + runValidators đảm bảo các cập nhật vẫn tuân theo các quy tắc.
  • Phân trang + .lean() giúp danh sách nhanh và tiết kiệm bộ nhớ.
  • Xử lý lỗi tập trung → phản hồi JSON nhất quán mà frontend có thể tin cậy.
  • Helmet, CORS, giới hạn tốc độ → những biện pháp bảo mật nhanh chóng.
  • Kiểm tra tình trạng & tắt máy thanh lịch → thân thiện hơn với Docker/K8s và CI.

5) Tài liệu tham khảo API

Phương thức Đường dẫn Mô tả
POST /api/v1/clients Tạo một client
GET /api/v1/clients Danh sách client (phân trang)
GET /api/v1/clients/:id Lấy một client
PATCH /api/v1/clients/:id Cập nhật các trường cho phép
DELETE /api/v1/clients/:id Xóa một client
GET /healthz Kiểm tra tình trạng

Tham số phân trang: page (mặc định 1), limit (mặc định 20, tối đa 100)

Hình dạng lỗi:

json Copy
{ "error": "Thông điệp dễ hiểu" }

6) Mẹo cho môi trường sản xuất

  • Sử dụng MongoDB Atlas và lưu trữ MONGO_URI trong bí mật.
  • Thêm ghi log yêu cầu vào tệp trong môi trường sản xuất (morgan “combined”).
  • Thực thi danh sách cho phép CORS (các miền cụ thể) thay vì origin: true.
  • Cân nhắc sử dụng thư viện DTO/xác thực (Zod, express-validator) cho các ứng dụng lớn hơn.
  • Chia nhỏ mã thành tuyến / bộ điều khiển / dịch vụ / mô hình khi API của bạn phát triển.

7) Những tính năng bổ sung (Tùy chọn)

  • Dockerize (node:20-alpine) và chạy Mongo như một dịch vụ
  • Thử nghiệm E2E với supertest + vitest/jest
  • Tài liệu OpenAPI/Swagger cho các nhóm frontend & QA

Kết Luận

Mẫu này cung cấp cho bạn một cơ sở sạch sẽ, an toàn mà bạn có thể mở rộng với xác thực, lọc và truy cập dựa trên vai trò. Hãy để lại bình luận nếu bạn muốn phiên bản ESM + thư mục mô-đun hoặc một bổ sung Swagger/OpenAPI!

Gợi ý câu hỏi phỏng vấn
Không có dữ liệu

Không có dữ liệu

Bài viết được đề xuất
Bài viết cùng tác giả

Bình luận

Chưa có bình luận nào

Chưa có bình luận nào