Menu
CoddyTech

المخططات باستخدام zod

جزء من قسم واجهات REST API في رحلة Node.js على Coddy. الدرس 11 من 25.

تزداد الفحوصات اليدوية مع كل حقل وكل مسار. يصف مخطط zod البنية مرة واحدة، وتختبر safeParse قيمةً بمقارنتها به:

import { z } from 'zod';

const Event = z.object({
    name: z.string().trim().min(1).max(80),
    seats: z.number().int().min(1),
}).strict();

const result = Event.safeParse(req.body);
if (!result.success) {
    return res.status(400).json({ error: z.flattenError(result.error) });
}
// يحتوي result.data على body بعد التحقق منه، مع إزالة المسافات الزائدة من name

يرفض .strict() الحقول التي لا يدرجها المخطط. يجعل Event.partial() كل حقل اختياريًا، وهذا ما يحتاج إليه طلب PATCH. يجعل Event.partial({ seats: true }) الحقل seats وحده اختياريًا، ويستخدم const { seats = 1 } = result.data القيمة 1 إذا كان الحقل مفقودًا. يحوّل z.flattenError(result.error) المشكلات إلى { formErrors, fieldErrors }: يحتوي formErrors على المشكلات المتعلقة بالجسم بأكمله، مثل وجود حقل غير معروف، ويحتوي fieldErrors على مشكلات كل حقل. يُجري Event.parse الفحص نفسه، لكنه يطلق استثناءً عند وجود قيمة غير صالحة، والاستثناء في المسار يؤدي إلى استجابة 500.

challenge icon

التحدي

سهل

تفتح هذه الخطوة مشروع واجهة API لتعقّب المهام الذي أعدّه المقرر، بدلًا من ملفات مشروعك من درس التحقق من صحة المدخلات (بما في ذلك routes/tasks.js). يظل عملك السابق ضمن درسه الخاص. اقرأ الملفات المتوفرة قبل تعديل routes/tasks.js. تتضمن هذه النسخة مسارات CRUD للمهام والتحقق اليدوي من صحة المدخلات.

تزداد فحوصاتك اليدوية مع كل حقل وكل مسار. صِف المهمة مرة واحدة باستخدام مخطط zod، ودع المسارات الثلاثة التي تستقبل جسم الطلب تستخدمه.

  1. في routes/tasks.js، استورد z من zod واكتب مخططًا باسم Task:
    • title: سلسلة نصية، تُزال المسافات الزائدة من طرفيها، ويتراوح طولها بين 1 و100 محرف بعد إزالة المسافات.
    • done: إما true أو false (z.boolean()).
    • المخطط صارم، لذا يُرفض أي حقل غير مُدرج فيه، مثل color.

    احذف الدالة المساعدة titleError المتوفرة (نسخة المقرر من التحقق من الصحة في درس التحقق من صحة المدخلات).

  2. أنشئ نسختين من Task باستخدام .partial(). تجعل نسخة POST الحقل done وحده اختياريًا. أما نسخة PATCH فتجعل كلا الحقلين اختياريين. تستخدم PUT المخطط Task نفسه، لذا يتطلب كلا الحقلين.
  3. في POST وPATCH وPUT، تحقّق من req.body باستخدام safeParse. عند فشل التحقق، أعد استجابة بالحالة 400، وضع قائمة المشكلات من zod تحت error، باستخدام z.flattenError(result.error). بالنسبة إلى { "title": 123 }، ستبدو النتيجة هكذا:
    { "error": { "formErrors": [], "fieldErrors": { "title": ["Invalid input: expected string, received number"] } } }
  4. عند نجاح التحقق، احفظ البيانات من result.data، وليس من req.body: فهي تحتوي على العنوان بعد إزالة المسافات الزائدة. في POST، يؤدي إرسال جسم طلب بلا done إلى إنشاء المهمة مع "done": false (يكون الحقل المفقود undefined في result.data، وتوفر ?? قيمة بديلة). ويظل PUT محتفظًا بقيمتي id وcreatedAt من المهمة المخزنة.

جرّب ذلك: أرسل POST إلى /api/tasks مع { "title": " Order new chairs ", "color": "red" }. ستحصل على 400، وستذكر formErrors الرسالة Unrecognized key: "color". أعد الإرسال من دون color: ستحصل على 201 مع "title": "Order new chairs" (من دون مسافات) و"done": false. يؤدي إرسال { "done": "yes" } إلى PATCH /api/tasks/2 إلى إرجاع 400 مع إدخال done في fieldErrors. ويؤدي إرسال { "title": "Book the big room", "done": false } إلى PUT /api/tasks/2 إلى إرجاع 200، مع قيمة createdAt القديمة للمهمة 2.

جرّب بنفسك

import { Router } from 'express';
import { nextId, tasks } from '../store.js';

const router = Router();

// سبب تعذّر تخزين العنوان، أو null إذا أمكن تخزينه.
const titleError = (title) => {
    if (typeof title !== 'string' || title.trim() === '' || title.length > 100) {
        return 'title must be a string of 1 to 100 characters';
    }
    return null;
};

router.get('/', (req, res) => {
    res.json(tasks);
});

router.get('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return res.status(404).json({ error: 'Task not found' });
    }
    res.json(task);
});

router.post('/', (req, res) => {
    const { title } = req.body ?? {};
    const problem = titleError(title);
    if (problem) {
        return res.status(400).json({ error: problem });
    }
    const task = { id: nextId(), title, done: false, createdAt: new Date().toISOString() };
    tasks.push(task);
    res.status(201).location(`/api/tasks/${task.id}`).json(task);
});

router.patch('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return res.status(404).json({ error: 'Task not found' });
    }
    const { title, done } = req.body ?? {};
    if (title !== undefined && titleError(title)) {
        return res.status(400).json({ error: titleError(title) });
    }
    if (done !== undefined && typeof done !== 'boolean') {
        return res.status(400).json({ error: 'done must be true or false' });
    }
    if (title !== undefined) task.title = title;
    if (done !== undefined) task.done = done;
    res.json(task);
});

router.put('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return res.status(404).json({ error: 'Task not found' });
    }
    const { title, done } = req.body ?? {};
    if (title === undefined || done === undefined) {
        return res.status(400).json({ error: 'PUT needs every field: title and done' });
    }
    const { id, createdAt } = tasks[index];
    tasks[index] = { id, title, done, createdAt };
    res.json(tasks[index]);
});

router.delete('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return res.status(404).json({ error: 'Task not found' });
    }
    tasks.splice(index, 1);
    res.status(204).end();
});

export default router;

جميع دروس واجهات REST API

تدرّب بنفسك: Playground لـ Express