التحقق قبل الحفظ
جزء من قسم قواعد البيانات باستخدام 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 }ليس للمجموعة بنية ثابتة، لذا فإن المخطط في شيفرتك هو ما يحافظ على تشابه مستنداتها.
التحدي
سهليمكن للعميل حاليًا تخزين سنة بقيمة "soon"، أو تمرير حقل مثل "admin": true خلسةً. تحقّق من كل كتاب جديد باستخدام zod قبل وصوله إلى قاعدة البيانات.
- في
server.js، استوردzمن'zod'. اكتب مخططًا لكتاب جديد: يجب أن تكونtitleوauthorوisbnوgenreسلاسل نصية غير فارغة، وأن تكونyearعددًا صحيحًا. الحقول الخمسة كلها مطلوبة. - في
POST /books، تحقّق منreq.bodyباستخدامsafeParseالخاص بالمخطط قبل الإدراج. إذا فشل التحقق، فأرسل استجابة برمز الحالة 400 ورسالة خطأ باستخدامsendError، ولا تخزّن أي شيء. يفشل التحقق عند فقدان حقل، أو إذا كانت قيمته سلسلة نصية فارغة، أو إذا لم تكن السنة عددًا صحيحًا، مثل"soon"أو1965.5. اختر الرسالة التي تناسبك، مثل{ "error": "year: Invalid input: expected number, received string" }. - الحقول الإضافية ليست خطأ. يظل الطلب ناجحًا، لكن تُحذف الحقول الإضافية: أدرج البيانات التي يعيدها
safeParse، لاreq.body. يحذفz.objectالعادي كل مفتاح لم تُدرجه فيه. لا تضف.strict()هنا، لأنه سيرفض الطلب برمز 400 بدلًا من ذلك. - يظل الكتاب الصالح يحصل على الرمز 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 إذا كان المعرّف غير صالح.
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
تدرّب بنفسك: Playground لـ Express