Menu
CoddyTech

Vor dem Speichern validieren

Teil des Abschnitts Datenbanken mit MongoDB der Node.js-Journey von Coddy. Lektion 28 von 29.

MongoDB speichert jedes Objekt, das ihm übergeben wird. Wenn req.body direkt in insertOne übergeben wird, kann ein Client für ein Jahr den Wert "soon" oder zusätzliche Felder wie "admin": true speichern.

Validiere mit zod vor dem Einfügen und füge ein, was zod zurückgibt, nicht req.body. Ein Schema mit z.object verwirft jeden Schlüssel, den es nicht aufführt. Die geparsten Daten enthalten daher nur die Felder, die du zulässt.

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 }

Eine Collection hat keine feste Struktur. Daher sorgt das Schema in deinem Code dafür, dass ihre Dokumente dieselbe Struktur haben.

challenge icon

Aufgabe

Einfach

Derzeit kann ein Client ein Jahr als "soon" speichern oder ein Feld wie "admin": true einschleusen. Prüfe jedes neue Buch mit zod, bevor es die Datenbank erreicht.

  1. Importiere in server.js z aus 'zod'. Schreibe ein Schema für ein neues Buch: title, author, isbn und genre sind nicht leere Zeichenfolgen, und year ist eine ganze Zahl. Alle fünf Felder sind erforderlich.
  2. Prüfe in POST /books req.body mit safeParse des Schemas, bevor du den Datensatz einfügst. Wenn die Prüfung fehlschlägt, antworte mit Status 400 und einer Fehlermeldung, indem du sendError verwendest, und speichere nichts. Die Prüfung schlägt bei einem fehlenden Feld, einer leeren Zeichenfolge oder einem Jahr fehl, das keine ganze Zahl ist, etwa "soon" oder 1965.5. Die Nachricht kannst du frei wählen, zum Beispiel { "error": "year: Invalid input: expected number, received string" }.
  3. Zusätzliche Felder sind kein Fehler. Die Anfrage ist trotzdem erfolgreich, aber die zusätzlichen Felder werden verworfen: Füge die von safeParse zurückgegebenen Daten ein, nicht req.body. Ein einfaches z.object verwirft jeden Schlüssel, den es nicht aufführt. Füge hier nicht .strict() hinzu, denn dadurch würde die Anfrage stattdessen mit 400 abgelehnt.
  4. Ein gültiges Buch erhält weiterhin den Status 201 und das gespeicherte Buch. Eine ISBN, die bereits vorhanden ist, führt weiterhin zu 409. Probiere es aus: Sende POST /books mit { "title": "Beloved", "author": "Toni Morrison", "year": "soon", "isbn": "9781400033416", "genre": "literary" }: Du erhältst 400. Sende die Anfrage erneut mit "year": 1987 und einem zusätzlichen Feld "admin": true: Du erhältst 201, und das Buch in der Antwort enthält kein admin. Der Tab „Data“ zeigt Beloved ohne admin. Sende ein Buch ohne title: Du erhältst 400.

Probier es selbst

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

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

// Jeder Fehler wird mit { error: message } beantwortet.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// Die :id als ObjectId oder null, nachdem bei einer ungültigen id mit 400 geantwortet wurde.
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');
});

Alle Lektionen in Datenbanken mit MongoDB

Übe selbstständig: Express-Playground