Menu
CoddyTech

Walidacja przed zapisem

Część sekcji Bazy danych z MongoDB ścieżki Node.js w Coddy. Lekcja 28 z 29.

MongoDB przechowuje dowolny przekazany mu obiekt. Gdy req.body trafia bezpośrednio do insertOne, klient może zapisać rok jako "soon" albo dodatkowe pola, takie jak "admin": true.

Przed wstawieniem danych przeprowadź walidację za pomocą zod i wstaw to, co zwróci zod, a nie req.body. Schemat z.object usuwa każdy klucz, którego nie wymieniono w schemacie, więc sparsowane dane zawierają tylko dozwolone przez ciebie pola.

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 }

Kolekcja nie ma ustalonej struktury, więc to schemat w twoim kodzie sprawia, że jej dokumenty mają taką samą strukturę.

challenge icon

Wyzwanie

Łatwy

Obecnie klient może zapisać rok jako "soon" albo przemycić pole takie jak "admin": true. Sprawdzaj każdą nową książkę za pomocą zod, zanim trafi do bazy danych.

  1. W pliku server.js zaimportuj z z 'zod'. Napisz schemat nowej książki: title, author, isbn i genre to niepuste ciągi znaków, a year to liczba całkowita. Wszystkie pięć pól jest wymaganych.
  2. W POST /books sprawdź req.body za pomocą safeParse ze schematu przed wstawieniem danych. Gdy sprawdzenie się nie powiedzie, odpowiedz statusem 400 i błędem, używając sendError, i niczego nie zapisuj. Sprawdzenie nie powiedzie się, jeśli brakuje pola, ciąg znaków jest pusty lub rok nie jest liczbą całkowitą, na przykład "soon" lub 1965.5. Treść komunikatu zależy od ciebie, na przykład { "error": "year: Invalid input: expected number, received string" }.
  3. Dodatkowe pola nie są błędem. Żądanie nadal się powiedzie, ale dodatkowe pola zostaną odrzucone: wstaw dane zwrócone przez safeParse, a nie req.body. Zwykłe z.object odrzuca każdy klucz, którego nie wymieniono. Nie dodawaj tutaj .strict(), ponieważ zamiast tego spowodowałoby odrzucenie żądania ze statusem 400.
  4. Prawidłowa książka nadal otrzymuje status 201 i zostaje zapisana. Powtórzony ISBN nadal zwraca status 409. Wypróbuj: Wyślij POST /books z { "title": "Beloved", "author": "Toni Morrison", "year": "soon", "isbn": "9781400033416", "genre": "literary" }: otrzymasz status 400. Wyślij to ponownie z "year": 1987 i dodatkowym polem "admin": true: otrzymasz status 201, a książka w odpowiedzi nie będzie zawierać admin. Karta Data pokazuje Beloved bez pola admin. Wyślij książkę bez title: otrzymasz status 400.

Spróbuj swoich sił

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

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

// Każdy błąd zwraca odpowiedź w postaci { error: message }.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// Wartość :id jako ObjectId albo null po zwróceniu odpowiedzi 400 dla nieprawidłowego 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');
});

Wszystkie lekcje w sekcji Bazy danych z MongoDB

Poćwicz samodzielnie: Edytor online Express