# 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 collection và vì 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._id và item.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ủaObjectIdđể 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, products là null.
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
/productsrồ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
[]→categoryIdtrong 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
-
Thêm route
GET /products/counttrả về{ total: 12 }. Chú ý đặt đúng vị trí để không bị route khác "nuốt". -
Thêm lọc theo khoảng giá:
GET /products?minPrice=3000000&maxPrice=5000000Gợ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) }; -
Viết lại
GET /productsbằng$lookupthay 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? -
Route "sản phẩm liên quan":
GET /products/related/:idtrả 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) } } -
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