Menu
CoddyTech

Kiểm tra tính hợp lệ trước khi lưu

Thuộc phần Cơ sở dữ liệu với MongoDB trong hành trình Node.js của Coddy. Bài học 28 trên 29.

MongoDB lưu bất kỳ đối tượng nào được truyền vào. Khi req.body được đưa thẳng vào insertOne, client có thể lưu một năm dưới dạng "soon" hoặc các trường bổ sung như "admin": true.

Hãy xác thực bằng zod trước khi chèn, rồi chèn dữ liệu zod trả về, không phải req.body. Lược đồ z.object loại bỏ mọi khóa không được liệt kê, vì vậy dữ liệu đã phân tích chỉ chứa những trường bạn cho phép.

const Player = z.object({
    name: z.string().min(1),
    level: z.number().int().min(1),
});

const parsed = Player.safeParse({ name: 'Ada', level: 2, admin: true });
parsed.data; // { name: 'Ada', level: 2 }

Bộ sưu tập không có cấu trúc cố định, vì vậy lược đồ trong code giúp các tài liệu trong đó có cùng cấu trúc.

challenge icon

Thử thách

Dễ

Hiện tại, client có thể lưu năm xuất bản là "soon", hoặc lén thêm một trường như "admin": true. Hãy kiểm tra mọi sách mới bằng zod trước khi sách được lưu vào cơ sở dữ liệu.

  1. Trong server.js, import z từ 'zod'. Viết schema cho sách mới: title, author, isbn và genre là các chuỗi không rỗng, còn year là số nguyên. Cả năm trường đều bắt buộc.
  2. Trong POST /books, kiểm tra req.body bằng safeParse của schema trước khi thêm dữ liệu. Nếu kiểm tra không đạt, hãy phản hồi với mã trạng thái 400 và một lỗi bằng cách dùng sendError, đồng thời không lưu gì cả. Kiểm tra không đạt nếu thiếu trường, chuỗi rỗng hoặc năm không phải số nguyên, chẳng hạn như "soon" hoặc 1965.5. Bạn có thể tự chọn thông báo, ví dụ { "error": "year: Invalid input: expected number, received string" }.
  3. Các trường bổ sung không phải là lỗi. Yêu cầu vẫn thành công, nhưng những trường bổ sung sẽ bị loại bỏ: hãy thêm dữ liệu mà safeParse trả về, không phải req.body. Một z.object thông thường sẽ loại bỏ mọi khóa không được liệt kê. Đừng thêm .strict() ở đây, vì như vậy yêu cầu sẽ bị từ chối với mã 400.
  4. Sách hợp lệ vẫn nhận mã 201 và sách được lưu. ISBN trùng lặp vẫn nhận mã 409. Thử ngay: Gửi POST /books với { "title": "Beloved", "author": "Toni Morrison", "year": "soon", "isbn": "9781400033416", "genre": "literary" }: bạn nhận được mã 400. Gửi lại với "year": 1987 và trường bổ sung "admin": true: bạn nhận được mã 201, và sách trong phản hồi không có admin. Tab Data hiển thị Beloved mà không có admin. Gửi một sách không có title: bạn nhận được mã 400.

Tự làm thử

import express from 'express';
import { ObjectId } from 'mongodb';
import { books, ratings } from './db.js';

const app = express();
app.use(express.json());

// Mỗi lỗi trả về { error: message }.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// Trả về :id dưới dạng ObjectId, hoặc null sau khi trả về 400 nếu id không hợp lệ.
function parseId(req, res) {
    if (ObjectId.isValid(req.params.id)) {
        return new ObjectId(req.params.id);
    }
    sendError(res, 400, 'Not a valid id');
    return null;
}

app.get('/', (req, res) => {
    res.send('Bookshelf API');
});

app.get('/books', async (req, res) => {
    const filter = {};
    if (req.query.author) {
        filter.author = req.query.author;
    }
    if (req.query.from || req.query.to) {
        filter.year = {};
        if (req.query.from) {
            filter.year.$gte = Number(req.query.from);
        }
        if (req.query.to) {
            filter.year.$lte = Number(req.query.to);
        }
    }
    const page = Number(req.query.page) || 1;
    const limit = Number(req.query.limit) || 10;
    const items = await books
        .find(filter)
        .skip((page - 1) * limit)
        .limit(limit)
        .toArray();
    const total = await books.countDocuments(filter);
    res.json({ items, total });
});

app.post('/books', async (req, res) => {
    const book = req.body;
    try {
        await books.insertOne(book);
    } catch (err) {
        if (err.code === 11000) {
            return sendError(res, 409, 'That ISBN is already on the shelf');
        }
        throw err;
    }
    res.status(201).json(book);
});

app.get('/books/newest', async (req, res) => {
    const n = Number(req.query.n) || 3;
    const list = await books
        .find({}, { projection: { title: 1, year: 1, _id: 0 } })
        .sort({ year: -1 })
        .limit(n)
        .toArray();
    res.json(list);
});

app.get('/books/:id', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const book = await books.findOne({ _id: id });
    if (!book) {
        return sendError(res, 404, 'No such book');
    }
    res.json(book);
});

app.patch('/books/:id', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.updateOne({ _id: id }, { $set: req.body });
    if (result.matchedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.json(await books.findOne({ _id: id }));
});

app.post('/books/:id/copies', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.updateOne({ _id: id }, { $inc: { copies: 1 } });
    if (result.matchedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.json(await books.findOne({ _id: id }));
});

app.post('/books/:id/tags', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.updateOne({ _id: id }, { $push: { tags: req.body.tag } });
    if (result.matchedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.json(await books.findOne({ _id: id }));
});

app.delete('/books/:id', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.deleteOne({ _id: id });
    if (result.deletedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.status(204).end();
});

app.put('/books/:id/ratings', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const { user, stars } = req.body;
    await ratings.updateOne({ bookId: id, user }, { $set: { stars } }, { upsert: true });
    res.json({ user, stars });
});

app.get('/books/:id/ratings', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    res.json(await ratings.find({ bookId: id }).toArray());
});

app.get('/stats', async (req, res) => {
    const filter = req.query.genre ? { genre: req.query.genre } : {};
    res.json({
        books: await books.countDocuments(filter),
        ratings: await ratings.countDocuments(),
    });
});

app.get('/stats/genres', async (req, res) => {
    const genres = await books
        .aggregate([{ $group: { _id: '$genre', count: { $sum: 1 } } }])
        .toArray();
    res.json(genres);
});

app.get('/stats/top', async (req, res) => {
    const top = await ratings
        .aggregate([
            { $group: { _id: '$bookId', avg: { $avg: '$stars' }, count: { $sum: 1 } } },
            { $match: { count: { $gte: 2 } } },
            { $sort: { avg: -1 } },
            { $limit: 3 },
        ])
        .toArray();
    res.json(top);
});

app.get('/books/:id/full', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const [book] = await books
        .aggregate([
            { $match: { _id: id } },
            {
                $lookup: {
                    from: 'authors',
                    localField: 'author',
                    foreignField: 'name',
                    as: 'authorInfo',
                },
            },
        ])
        .toArray();
    if (!book) {
        return sendError(res, 404, 'No such book');
    }
    res.json(book);
});

app.listen(3000, () => {
    console.log('Bookshelf is running on port 3000');
});

Tất cả bài học trong Cơ sở dữ liệu với MongoDB

Tự luyện tập: Playground Express