Menu
CoddyTech

저장 전 유효성 검사

Coddy Node.js 여정의 MongoDB 데이터베이스 섹션에 포함된 레슨. 29개 중 28번째.

MongoDB는 전달받은 객체를 그대로 저장합니다. req.body를 insertOne에 바로 전달하면 클라이언트가 연도에 "soon"을 저장하거나 "admin": true 같은 추가 필드를 저장할 수 있습니다.

삽입하기 전에 zod로 검증하고, req.body가 아니라 zod가 반환한 값을 삽입하세요. 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에서 'zod'로부터 z를 가져오세요. 새 책을 위한 스키마를 작성하세요. title, author, isbn, genre는 비어 있지 않은 문자열이어야 하고, year는 정수여야 합니다. 다섯 필드는 모두 필수입니다.
  2. POST /books에서 삽입하기 전에 스키마의 safeParse로 req.body를 검사하세요. 검사가 실패하면 sendError를 사용해 상태 코드 400과 오류를 응답하고, 아무것도 저장하지 마세요. 필드가 누락되거나, 빈 문자열이거나, "soon" 또는 1965.5처럼 연도가 정수가 아닌 경우 검사가 실패합니다. 오류 메시지는 원하는 대로 정하면 됩니다. 예를 들면 { "error": "year: Invalid input: expected number, received string" }과 같습니다.
  3. 추가 필드는 오류가 아닙니다. 요청은 계속 성공하지만 추가 필드는 제거됩니다. req.body가 아니라 safeParse가 반환하는 데이터를 삽입하세요. 일반 z.object는 목록에 없는 모든 키를 제거합니다. 여기서 .strict()를 추가하지 마세요. 추가하면 대신 요청을 거부하고 400을 응답합니다.
  4. 유효한 책은 계속 201과 저장된 책을 반환합니다. 중복 ISBN은 계속 409를 반환합니다. 직접 해 보세요: { "title": "Beloved", "author": "Toni Morrison", "year": "soon", "isbn": "9781400033416", "genre": "literary" }를 포함해 POST /books를 보내면 400을 받습니다. 이번에는 "year": 1987과 추가 필드 "admin": true를 포함해 다시 보내면 201을 받고, 응답의 책에는 admin이 없습니다. Data 탭에도 admin이 없는 Beloved가 표시됩니다. 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로 반환하거나, 유효하지 않은 id에 대해 400으로 응답한 뒤 null을 반환합니다.
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 플레이그라운드