Menu
CoddyTech

Validare prima di salvare

Fa parte della sezione Database con MongoDB del percorso Node.js di Coddy. Lezione 28 di 29.

MongoDB memorizza qualsiasi oggetto gli venga fornito. Quando req.body viene passato direttamente a insertOne, un client può salvare un anno con il valore "soon" oppure campi aggiuntivi come "admin": true.

Convalida con zod prima dell'inserimento e inserisci ciò che restituisce zod, non req.body. Uno schema z.object elimina ogni chiave che non elenca, quindi i dati analizzati contengono solo i campi che consenti.

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 }

Una raccolta non ha una struttura fissa, quindi è lo schema nel tuo codice a mantenere uniformi i suoi documenti.

challenge icon

Sfida

Facile

Al momento un client può memorizzare un anno come "soon" o inserire di nascosto un campo come "admin": true. Verifica ogni nuovo libro con zod prima che raggiunga il database.

  1. In server.js, importa z da 'zod'. Scrivi uno schema per un nuovo libro: title, author, isbn e genre sono stringhe non vuote, mentre year è un numero intero. Tutti e cinque i campi sono obbligatori.
  2. In POST /books, verifica req.body con safeParse dello schema prima dell'inserimento. Se la verifica fallisce, rispondi con stato 400 e un errore, usando sendError, e non memorizzare nulla. La verifica fallisce se manca un campo, se una stringa è vuota o se l'anno non è un numero intero, ad esempio "soon" o 1965.5. Il messaggio è a tua scelta, per esempio { "error": "year: Invalid input: expected number, received string" }.
  3. I campi aggiuntivi non sono un errore. La richiesta va comunque a buon fine, ma i campi aggiuntivi vengono scartati: inserisci i dati restituiti da safeParse, non req.body. Un semplice z.object scarta ogni chiave che non è elencata. Non aggiungere .strict() qui, perché rifiuterebbe la richiesta con stato 400.
  4. Un libro valido riceve comunque stato 201 e viene memorizzato. Un ISBN duplicato riceve comunque stato 409. Prova: invia POST /books con { "title": "Beloved", "author": "Toni Morrison", "year": "soon", "isbn": "9781400033416", "genre": "literary" }: riceverai 400. Invialo di nuovo con "year": 1987 e un campo aggiuntivo "admin": true: riceverai 201 e il libro nella risposta non avrà admin. La scheda Data mostra Beloved senza admin. Invia un libro senza title: riceverai 400.

Provalo tu

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

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

// Ogni errore restituisce { error: message }.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// Il parametro :id come ObjectId, oppure null dopo aver restituito 400 per un id non valido.
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');
});

Tutte le lezioni di Database con MongoDB

Esercitati da solo: Playground Express