0

# Bài 06 — API sản phẩm

⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️


🎯 Mục tiêu

Xây dựng 4 API sản phẩm — trái tim của backend:

Method Đường dẫn Chức năng
GET /products Tất cả sản phẩm, kèm thông tin danh mục
GET /products/id/:id Chi tiết 1 sản phẩm, kèm danh mục
GET /products/byCategory/:id Sản phẩm thuộc 1 danh mục
GET /products/topRating Top 10 sản phẩm đánh giá cao

Bạn cũng sẽ học được cách ghép dữ liệu giữa 2 collectionvì sao thứ tự khai báo route lại quan trọng.


📚 1. Route parameter — :id

router.get("/id/:id", async (req, res, next) => {
  console.log(req.params.id);   // "6675905ecb0dded448a58bb0"
});

Dấu : báo cho Express biết đây là phần thay đổi được. Giá trị thực tế nằm trong req.params.

router.get("/id/:productId/review/:reviewId", (req, res) => {
  req.params.productId;   // phần đầu
  req.params.reviewId;    // phần sau
});

Phân biệt params, query, body

Nằm ở đâu Ví dụ URL Cách đọc
req.params Trong đường dẫn /products/id/**123** req.params.id
req.query Sau dấu ? /products?**sort=asc&page=2** req.query.sort
req.body Trong thân request (POST/PUT) req.body.name

⚠️ 2. Thứ tự route rất quan trọng

Express duyệt route từ trên xuống, dừng ở cái khớp đầu tiên.

// ❌ SAI
router.get("/:id", handlerA);         // 1
router.get("/topRating", handlerB);   // 2 — KHÔNG BAO GIỜ CHẠY

Khi gọi /products/topRating, Express thấy route 1 khớp (với id = "topRating") và dừng lại. Route 2 vô nghĩa.

// ✅ ĐÚNG — route cụ thể đứng trước route có tham số
router.get("/topRating", handlerB);   // cụ thể
router.get("/:id", handlerA);         // tổng quát

Source gốc tránh được lỗi này nhờ thiết kế tiền tố rõ ràng: /id/:id, /byCategory/:id, /topRating — không cái nào "nuốt" cái nào. Đây là cách làm thông minh, dù hơi dài dòng so với REST chuẩn.


💻 3. Code đầy đủ backend/routes/products.js

Đây là phiên bản bám sát source gốc (phần upload sẽ thêm ở bài 07):

var express = require("express");
var router = express.Router();

// Import model
const connectDb = require("../model/db");
const { ObjectId } = require("mongodb");

// ============================================================
// GET /products — Lấy tất cả sản phẩm, kèm thông tin danh mục
// ============================================================
router.get("/", async (req, res, next) => {
  const db = await connectDb();
  const productCollection = db.collection("products");
  const products = await productCollection.find().toArray();

  const categoriesCollection = db.collection("categories");
  const categories = await categoriesCollection.find().toArray();

  if (products) {
    products.map((item) => {
      const category = categories.find(
        (cat) => cat._id.toString() === item.categoryId.toString()
      );
      item.category = category;
      return item;
    });
    res.status(200).json(products);
  } else {
    res.status(404).json({ message: "Không tìm thấy" });
  }
});

// ============================================================
// GET /products/id/:id — Chi tiết 1 sản phẩm, kèm danh mục
// ============================================================
router.get("/id/:id", async (req, res, next) => {
  const db = await connectDb();
  const productCollection = db.collection("products");
  const products = await productCollection.findOne({
    _id: new ObjectId(req.params.id),
  });

  const categoriesCollection = db.collection("categories");
  const categories = await categoriesCollection.findOne({
    _id: new ObjectId(products.categoryId),
  });

  if (products) {
    products.category = categories;
    res.status(200).json(products);
  } else {
    res.status(404).json({ message: "Không tìm thấy" });
  }
});

// ============================================================
// GET /products/byCategory/:id — Sản phẩm theo danh mục
// ============================================================
router.get("/byCategory/:id", async (req, res, next) => {
  const db = await connectDb();
  const productCollection = db.collection("products");
  const products = await productCollection
    .find({ categoryId: new ObjectId(req.params.id) })
    .toArray();

  if (products) {
    res.status(200).json(products);
  } else {
    res.status(404).json({ message: "Không tìm thấy" });
  }
});

// ============================================================
// GET /products/topRating — Top 10 đánh giá cao nhất
// ============================================================
router.get("/topRating", async (req, res, next) => {
  const db = await connectDb();
  const productCollection = db.collection("products");
  const products = await productCollection
    .find()
    .sort({ rating: -1 })
    .limit(10)
    .toArray();

  if (products) {
    res.status(200).json(products);
  } else {
    res.status(404).json({ message: "Không tìm thấy" });
  }
});

module.exports = router;

Đừng quên bỏ comment trong app.js:

var productsRouter = require('./routes/products');
app.use('/products', productsRouter);

🔍 4. Giải thích chi tiết

Route GET /products — kỹ thuật ghép dữ liệu

Đây là route phức tạp nhất. MongoDB không có JOIN, nên phải ghép bằng JavaScript.

Bước 1 — Lấy hết sản phẩm:

const products = await productCollection.find().toArray();
// [{ _id, name, price, categoryId: ObjectId("669a978e...") }, ...]

Bước 2 — Lấy hết danh mục:

const categories = await categoriesCollection.find().toArray();
// [{ _id: ObjectId("669a978e..."), name: "Gucci", image: "h8.png" }, ...]

Bước 3 — Ghép:

products.map((item) => {
  const category = categories.find(
    (cat) => cat._id.toString() === item.categoryId.toString()
  );
  item.category = category;
  return item;
});

Với mỗi sản phẩm, đi tìm trong mảng categories cái nào có _id khớp với categoryId của nó, rồi gán vào field mới item.category.

Vì sao phải .toString()?

cat._iditem.categoryId đều là kiểu ObjectId — một object, không phải chuỗi. Trong JavaScript, so sánh 2 object bằng === luôn cho false trừ khi chúng là cùng một object trong bộ nhớ:

new ObjectId("669a978e6e282b058a8e3f2c") === new ObjectId("669a978e6e282b058a8e3f2c")
// → false! (2 object khác nhau dù cùng giá trị)

new ObjectId("669a978e...").toString() === new ObjectId("669a978e...").toString()
// → true  ("669a978e..." === "669a978e...")

.toString() biến cả hai về chuỗi, lúc đó === so sánh giá trị nên đúng.

💡 Cách khác đúng hơn về mặt MongoDB: cat._id.equals(item.categoryId) — phương thức có sẵn của ObjectId để so sánh.

Kết quả trả về:

[
  {
    "_id": "6675905ecb0dded448a58bb0",
    "name": "Gucci Flora Gorgeous Magnolia",
    "price": 5050000,
    "categoryId": "669a978e6e282b058a8e3f2c",
    "category": {
      "_id": "669a978e6e282b058a8e3f2c",
      "name": "Gucci",
      "image": "h8.png"
    }
  }
]

Frontend nhờ vậy hiển thị được product.category.name mà không cần gọi API thứ hai.

⚠️ Ba vấn đề của route này

a) Dùng .map() sai mục đích

products.map((item) => { item.category = category; return item; });

.map() sinh ra mảng mới, nhưng ở đây kết quả không được gán cho ai cả. Nó "chạy được" chỉ vì item.category = category sửa trực tiếp object gốc (side effect). Đúng ra phải dùng .forEach():

products.forEach((item) => {
  item.category = categories.find((cat) => cat._id.equals(item.categoryId));
});

Hoặc dùng .map() đúng cách:

const result = products.map((item) => ({
  ...item,
  category: categories.find((cat) => cat._id.equals(item.categoryId)),
}));
res.status(200).json(result);

b) Tải toàn bộ categories cho mọi request

Với 10 danh mục thì không sao. Nhưng nếu có 10.000 danh mục, mỗi request tải hết là lãng phí. Giải pháp: dùng $lookup (bài 03 mục 7) hoặc cache danh mục.

c) Không phân trang

GET /products trả về toàn bộ sản phẩm. Với 12 sản phẩm thì ổn, với 10.000 thì response nặng hàng chục MB. Bài 21 sẽ thêm phân trang.


Route GET /products/id/:id — có bug nghiêm trọng

const products = await productCollection.findOne({ _id: new ObjectId(req.params.id) });

const categories = await categoriesCollection.findOne({
  _id: new ObjectId(products.categoryId),      // ⚠️ DÒNG NÀY
});

if (products) { ... }                          // ⚠️ KIỂM TRA QUÁ MUỘN

Bug: nếu không tìm thấy sản phẩm, productsnull. Dòng products.categoryId sẽ ném lỗi:

TypeError: Cannot read properties of null (reading 'categoryId')

Và vì không có try/catch, request bị treo vô hạn.

Kiểm tra if (products) nằm sau chỗ gây lỗi nên hoàn toàn vô dụng.

Cách sửa — kiểm tra trước khi dùng:

router.get("/id/:id", async (req, res, next) => {
  try {
    if (!ObjectId.isValid(req.params.id)) {
      return res.status(400).json({ message: "ID không hợp lệ" });
    }

    const db = await connectDb();
    const product = await db.collection("products")
      .findOne({ _id: new ObjectId(req.params.id) });

    if (!product) {
      return res.status(404).json({ message: "Không tìm thấy sản phẩm" });
    }

    // Chỉ tìm danh mục KHI đã chắc chắn có sản phẩm
    product.category = await db.collection("categories")
      .findOne({ _id: new ObjectId(product.categoryId) });

    res.status(200).json(product);
  } catch (error) {
    next(error);
  }
});

Thử nghiệm để thấy bug:

URL Bản gốc Bản sửa
/products/id/6675905ecb0dded448a58bb0 ✅ Trả về sản phẩm ✅ Trả về sản phẩm
/products/id/000000000000000000000000 ❌ Treo vô hạn ✅ 404 + thông báo
/products/id/abc ❌ Treo vô hạn ✅ 400 + thông báo

Route GET /products/byCategory/:id — bug phụ thuộc dữ liệu

const products = await productCollection
  .find({ categoryId: new ObjectId(req.params.id) })
  .toArray();

Route này chỉ chạy đúng nếu categoryId trong DB là kiểu ObjectId.

Nếu bạn đã làm đúng bài 03 mục 5.2 (chuyển categoryId thành {"$oid": ...}) thì route này chạy tốt. Nếu import nguyên bản products.txt (categoryId là chuỗi) thì route này luôn trả về [] — và người dùng thấy trang menu danh mục trống trơn.

Cách viết an toàn — chấp nhận cả hai kiểu:

router.get("/byCategory/:id", async (req, res, next) => {
  try {
    const { id } = req.params;
    if (!ObjectId.isValid(id)) {
      return res.status(400).json({ message: "ID danh mục không hợp lệ" });
    }

    const db = await connectDb();
    // $or: khớp cả khi categoryId là ObjectId lẫn khi là chuỗi
    const products = await db.collection("products")
      .find({ $or: [{ categoryId: new ObjectId(id) }, { categoryId: id }] })
      .toArray();

    res.status(200).json(products);
  } catch (error) {
    next(error);
  }
});

💡 Đây là "miếng vá" hữu ích khi bạn không kiểm soát được dữ liệu. Nhưng giải pháp đúng vẫn là chuẩn hóa dữ liệu như bài 03.


Route GET /products/topRating

const products = await productCollection
  .find()
  .sort({ rating: -1 })   // -1 = giảm dần
  .limit(10)              // lấy 10 cái đầu
  .toArray();

Đơn giản và đúng. .find().sort().limit() là chuỗi phương thức trên cursor — MongoDB sẽ tối ưu và chỉ đọc đúng 10 document.

⚠️ Route này frontend không dùng đến. Trang chủ gọi /products rồi hiển thị cùng một danh sách 2 lần với 2 tiêu đề "SẢN PHẨM NỔI BẬT" và "SẢN PHẨM BÁN CHẠY". Bài tập cuối bài sẽ sửa việc này.


💻 5. Phiên bản hoàn chỉnh (khuyến nghị)

Nếu muốn code sạch, dùng file này:

// backend/routes/products.js — PHIÊN BẢN CẢI TIẾN
var express = require("express");
var router = express.Router();

const connectDb = require("../model/db");
const { ObjectId } = require("mongodb");

// Hàm dùng chung: kiểm tra id hợp lệ
function validateId(req, res, next) {
  if (!ObjectId.isValid(req.params.id)) {
    return res.status(400).json({ message: "ID không hợp lệ" });
  }
  next();
}

// GET /products — tất cả sản phẩm kèm danh mục
router.get("/", async (req, res, next) => {
  try {
    const db = await connectDb();
    const [products, categories] = await Promise.all([
      db.collection("products").find().toArray(),
      db.collection("categories").find().toArray(),
    ]);

    const result = products.map((item) => ({
      ...item,
      category: categories.find((cat) => cat._id.equals(item.categoryId)) || null,
    }));

    res.status(200).json(result);
  } catch (error) {
    next(error);
  }
});

// GET /products/topRating — đặt TRƯỚC các route có tham số
router.get("/topRating", async (req, res, next) => {
  try {
    const db = await connectDb();
    const products = await db.collection("products")
      .find()
      .sort({ rating: -1 })
      .limit(10)
      .toArray();
    res.status(200).json(products);
  } catch (error) {
    next(error);
  }
});

// GET /products/id/:id — chi tiết sản phẩm
router.get("/id/:id", validateId, async (req, res, next) => {
  try {
    const db = await connectDb();
    const product = await db.collection("products")
      .findOne({ _id: new ObjectId(req.params.id) });

    if (!product) {
      return res.status(404).json({ message: "Không tìm thấy sản phẩm" });
    }

    product.category = await db.collection("categories")
      .findOne({ _id: new ObjectId(product.categoryId) });

    res.status(200).json(product);
  } catch (error) {
    next(error);
  }
});

// GET /products/byCategory/:id — theo danh mục
router.get("/byCategory/:id", validateId, async (req, res, next) => {
  try {
    const { id } = req.params;
    const db = await connectDb();
    const products = await db.collection("products")
      .find({ $or: [{ categoryId: new ObjectId(id) }, { categoryId: id }] })
      .toArray();
    res.status(200).json(products);
  } catch (error) {
    next(error);
  }
});

module.exports = router;

Hai kỹ thuật mới trong bản này

Promise.all — chạy song song 2 truy vấn

// Tuần tự: 20ms + 20ms = 40ms
const products = await db.collection("products").find().toArray();
const categories = await db.collection("categories").find().toArray();

// Song song: max(20ms, 20ms) = 20ms
const [products, categories] = await Promise.all([
  db.collection("products").find().toArray(),
  db.collection("categories").find().toArray(),
]);

Dùng được vì 2 truy vấn không phụ thuộc nhau.

Middleware riêng cho route

router.get("/id/:id", validateId, async (req, res, next) => { ... });
//                    ↑ chạy trước handler chính

Bạn có thể xâu chuỗi bao nhiêu middleware cũng được. Đây là cách tái sử dụng logic kiểm tra thay vì copy-paste vào từng route. Bài 19 sẽ dùng kỹ thuật này cho middleware xác thực.


✅ 6. Kiểm thử

Chạy npm run dev rồi thử từng URL:

# URL Kết quả mong đợi
1 http://localhost:5000/products Mảng 12 sản phẩm, mỗi cái có field category
2 http://localhost:5000/products/topRating Mảng ≤10, rating giảm dần
3 http://localhost:5000/products/id/6675905ecb0dded448a58bb0 1 sản phẩm Gucci Flora + category
4 http://localhost:5000/products/byCategory/669a978e6e282b058a8e3f2c Mảng sản phẩm Gucci (phải > 0)
5 http://localhost:5000/products/id/abc 400 "ID không hợp lệ"
6 http://localhost:5000/products/id/000000000000000000000000 404 "Không tìm thấy sản phẩm"

Nếu test số 4 ra []categoryId trong DB vẫn là chuỗi. Quay lại bài 03 mục 6 để chạy script sửa.


📚 7. Lấy ID để test ở đâu?

Bạn cần ID thật để test. Ba cách:

Cách 1 — MongoDB Compass: mở collection products, copy giá trị _id.

Cách 2 — mongosh:

db.products.find({}, { _id: 1, name: 1 }).limit(3)

Cách 3 — Gọi chính API: vào http://localhost:5000/products, copy _id đầu tiên.


📝 Bài tập

  1. Thêm route GET /products/count trả về { total: 12 }. Chú ý đặt đúng vị trí để không bị route khác "nuốt".

  2. Thêm lọc theo khoảng giá: GET /products?minPrice=3000000&maxPrice=5000000 Gợi ý:

    const filter = {};
    if (req.query.minPrice) filter.price = { $gte: Number(req.query.minPrice) };
    if (req.query.maxPrice) filter.price = { ...filter.price, $lte: Number(req.query.maxPrice) };
    
  3. Viết lại GET /products bằng $lookup thay vì ghép thủ công. So sánh kết quả với bản cũ — có gì khác về cấu trúc JSON?

  4. Route "sản phẩm liên quan": GET /products/related/:id trả về 4 sản phẩm khác cùng danh mục với sản phẩm :id (loại chính nó ra). Gợi ý: { categoryId: ..., _id: { $ne: new ObjectId(id) } }

  5. Tự tìm bug: gọi GET /products/id/ (thiếu id, có dấu / cuối). Route nào khớp? Kết quả là gì? Vì sao?


⬅️ Bài trước | Mục lục | Bài tiếp theo: Upload ảnh với Multer ➡️


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí