Menu
CoddyTech

אימות לפני שמירה

חלק מהיחידה מסדי נתונים עם MongoDB במסלול ה-Node.js של Coddy. שיעור 28 מתוך 29.

MongoDB שומר כל אובייקט שנמסר לו. כאשר req.body מועבר ישירות אל insertOne, לקוח יכול לשמור שנה עם הערך "soon" או שדות נוספים כמו "admin": true.

יש לבצע אימות באמצעות zod לפני ההוספה, ולהוסיף את מה ש-zod מחזירה, ולא את req.body. סכמת z.object מסירה כל מפתח שלא צוין בה, כך שהנתונים שעברו ניתוח מכילים רק את השדות שהתירה.

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 }

למסמך באוסף אין מבנה קבוע, ולכן הסכמה שבקוד שלך היא זו ששומרת על אחידות המסמכים שבו.

challenge icon

אתגר

קל

כרגע לקוח יכול לשמור שנה בתור "soon", או להגניב שדה כמו "admin": true. צריך לבדוק כל ספר חדש באמצעות zod לפני שהוא מגיע למסד הנתונים.

  1. ב־server.js, ייבא את z מתוך 'zod'. כתוב סכימה לספר חדש: title, author, isbn ו־genre הם מחרוזות לא ריקות, ו־year הוא מספר שלם. כל חמשת השדות נדרשים.
  2. ב־POST /books, בדוק את req.body באמצעות safeParse של הסכימה לפני ההכנסה. אם הבדיקה נכשלת, החזר שגיאה עם סטטוס 400 באמצעות sendError, ואל תשמור דבר. הבדיקה נכשלת כשחסר שדה, כשיש מחרוזת ריקה, או כשהשנה אינה מספר שלם, כמו "soon" או 1965.5. בחירת הודעת השגיאה נתונה לך, לדוגמה { "error": "year: Invalid input: expected number, received string" }.
  3. שדות נוספים אינם נחשבים לשגיאה. הבקשה עדיין מצליחה, אבל השדות הנוספים מוסרים: הכנס את הנתונים ש־safeParse מחזיר, ולא את req.body. z.object רגיל מסיר כל מפתח שאינו מופיע בו. אל תוסיף כאן .strict(), כי הוא ידחה את הבקשה עם 400 במקום זאת.
  4. ספר תקין עדיין מקבל 201 ואת הספר שנשמר. ISBN חוזר עדיין מקבל 409. נסה זאת: שלח POST /books עם { "title": "Beloved", "author": "Toni Morrison", "year": "soon", "isbn": "9781400033416", "genre": "literary" }: תקבל 400. שלח אותו שוב עם "year": 1987 ועם "admin": true נוסף: תקבל 201, והספר בתגובה לא יכלול admin. בכרטיסייה Data מופיע Beloved ללא admin. שלח ספר ללא title: תקבל 400.

נסו בעצמכם

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

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

// כל שגיאה מחזירה { error: message }.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// מחזירה את :id בתור ObjectId, או null לאחר החזרת תשובה עם קוד 400 עבור id לא תקין.
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');
});

כל השיעורים ביחידה מסדי נתונים עם MongoDB

תרגלו בעצמכם: Express אונליין