Menu
CoddyTech

Memvalidasi Sebelum Menyimpan

Termasuk bagian Database dengan MongoDB dalam perjalanan Node.js di Coddy. Pelajaran 28 dari 29.

MongoDB menyimpan objek apa pun yang diberikan kepadanya. Saat req.body langsung diteruskan ke insertOne, klien bisa menyimpan tahun dengan nilai "soon" atau kolom tambahan seperti "admin": true.

Validasi dengan zod sebelum penyisipan, lalu sisipkan hasil yang dikembalikan zod, bukan req.body. Skema z.object menghapus setiap kunci yang tidak tercantum di dalamnya, jadi data yang sudah diuraikan hanya berisi kolom yang kamu izinkan.

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 }

Collection tidak memiliki bentuk tetap, jadi skema dalam kodemu yang menjaga agar dokumennya tetap seragam.

challenge icon

Tantangan

Mudah

Saat ini, client dapat menyimpan tahun berupa "soon", atau menyisipkan field seperti "admin": true. Periksa setiap buku baru dengan zod sebelum buku itu masuk ke database.

  1. Di server.js, impor z dari 'zod'. Tulis skema untuk buku baru: title, author, isbn, dan genre harus berupa string yang tidak kosong, sedangkan year harus berupa bilangan bulat. Kelima field wajib diisi.
  2. Di POST /books, periksa req.body dengan safeParse dari skema sebelum melakukan penyisipan. Jika pemeriksaan gagal, kirim respons dengan status 400 dan error menggunakan sendError, lalu jangan simpan apa pun. Pemeriksaan gagal jika ada field yang tidak diisi, string kosong, atau tahun yang bukan bilangan bulat, seperti "soon" atau 1965.5. Kamu bebas menentukan pesannya, misalnya { "error": "year: Invalid input: expected number, received string" }.
  3. Field tambahan bukan error. Permintaan tetap berhasil, tetapi field tambahan akan dihapus: sisipkan data yang dikembalikan oleh safeParse, bukan req.body. z.object biasa akan menghapus setiap key yang tidak tercantum di dalamnya. Jangan tambahkan .strict() di sini, karena itu akan menolak permintaan dengan status 400.
  4. Buku yang valid tetap mendapat status 201 dan buku yang tersimpan. ISBN yang sama tetap mendapat status 409. Coba: Kirim POST /books dengan { "title": "Beloved", "author": "Toni Morrison", "year": "soon", "isbn": "9781400033416", "genre": "literary" }: kamu mendapat status 400. Kirim lagi dengan "year": 1987 dan field tambahan "admin": true: kamu mendapat status 201, dan buku dalam respons tidak memiliki admin. Tab Data menampilkan Beloved tanpa admin. Kirim buku tanpa title: kamu mendapat status 400.

Coba sendiri

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

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

// Setiap error menghasilkan respons { error: message }.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// Mengembalikan :id sebagai ObjectId, atau null setelah mengirim respons 400 jika id tidak valid.
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');
});

Semua pelajaran di Database dengan MongoDB

Latihan sendiri: Playground Express