Menu
CoddyTech

الملصقات

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

تُمنح المهام تسميات مثل office أو money، ويحتاج تطبيق لوحة المهام إلى قائمة بكل تسمية وعدد المهام المرتبطة بها. تضيف هذه الخطوة ثلاثة أشياء: قاعدة في المخطط لقائمة، ومرشحًا، ومسارًا يحصي التسميات. أما التجميع في كائن، الموضح أدناه، فهو جديد.

// genres: z.array(z.string()).max(3).optional() في المخطط
const counts = {};
for (const book of books) {
    for (const genre of book.genres) {
        counts[genre] = (counts[genre] ?? 0) + 1;
    }
}
// { poetry: 2, drama: 1 }

تحوّل Object.entries(counts) ذلك الكائن إلى أزواج مثل [["poetry", 2], ...]، لتكون جاهزة للتحويل إلى قائمة. وملخص كهذا مورد مستقل له عنوان URL خاص به.

تسمح optional() بغياب هذا الحقل، مع الاستمرار في التحقق من قيمته عند وجوده. حوّل أزواج الإدخالات إلى كائنات باستخدام Object.entries(counts).map(([genre, count]) => ({ genre, count })): تفك الأقواس كل زوج، وتُعيد الأقواس الدائرية كائنًا.

challenge icon

التحدي

سهل

يريد تطبيق اللوحة تجميع المهام حسب التسميات مثل office أو money، وعرض قائمة بكل تسمية وعدد المهام التي تحملها. أضف التسميات إلى API.

  1. في routes/tasks.js، أضف حقل labels اختياريًا إلى مخطط Task: قائمة تضم 5 تسميات كحد أقصى، وكل تسمية سلسلة طولها من 1 إلى 20 حرفًا بعد إزالة المسافات الزائدة. سيتعامل POST وPATCH معها أيضًا، لأن مخططاتهما مبنية من Task. تؤدي القائمة غير الصالحة إلى خطأ 400 المعتاد invalid_body. تشمل القوائم غير الصالحة "labels": "office" (نص، وليس قائمة)، و"labels": [""]، وست تسميات.
  2. تحتوي كل مهمة محفوظة على قائمة labels:
    • عندما لا يرسل POST أو PUT قيمة labels، احفظ []. يستبدل PUT المهمة بأكملها، لذا فإن PUT من دون تسميات يمحوها.
    • لا يغيّر PATCH قيمة labels إلا إذا أرسلها.
  3. في store.js، أضف تسميات إلى المهام الأولية: ["office"] للمهمتين 1 و2، و["money"] للمهمة 3.
  4. وفي routes/tasks.js أيضًا، أضف عامل تصفية إلى مسار القائمة: يعيد GET /api/v1/tasks?label=office المهام التي تتضمن labels فيها office تحديدًا فقط. يعمل ذلك مع عوامل التصفية الأخرى ومع الصفحات، كما في السابق.
  5. في server.js، أضف GET /api/v1/labels بجوار تركيب الموجّه، واستورد tasks من store.js هناك. يعيد ذلك كل تسمية مستخدمة مع عدد المهام التي تحملها، مرتبة أبجديًا من A إلى Z حسب التسمية. مع المهام الأولية:
    [
      { "label": "money", "count": 1 },
      { "label": "office", "count": 2 }
    ]

جرّب ذلك:

  • يعيد GET /api/v1/labels القائمة أعلاه.
  • أرسل POST { "title": "Order chairs", "labels": ["office"] } إلى /api/v1/tasks: ستحصل على 201، وستحتوي المهمة الجديدة على "labels": ["office"]. يعرض GET /api/v1/labels الآن "count": 3 للتسمية office.
  • أرسل POST { "title": "Water the plants" }: ستكون قيمة "labels": [] في المهمة الجديدة.
  • يعيد GET /api/v1/tasks?label=money المهمة 3 فقط. ويعيد GET /api/v1/tasks?label=nope "items": [] و"total": 0.
  • أرسل PUT /api/v1/tasks/2 مع { "title": "Book the meeting room", "done": false }؛ وسيعيد المهمة 2 مع "labels": [].

جرّب بنفسك

import { Router } from 'express';
import { z } from 'zod';
import { config } from '../config.js';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';

const router = Router();

// مهمة كاملة كما يرسلها PUT. ترفض .strict() الحقول التي لا تدرجها.
const Task = z
    .object({
        title: z.string().trim().min(1).max(100),
        done: z.boolean(),
    })
    .strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];

// تكون القيمة True عندما تحمل مهمة أخرى هذا العنوان مسبقًا، بغض النظر عن حالة الأحرف.
const titleTaken = (title, exceptId) =>
    tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());

router.get('/', (req, res) => {
    const { done, q, sort } = req.query;
    let list = tasks;
    if (done !== undefined) {
        if (done !== 'true' && done !== 'false') {
            return sendError(res, 400, 'invalid_query', 'done must be true or false');
        }
        list = list.filter((t) => t.done === (done === 'true'));
    }
    if (q !== undefined) {
        const words = String(q).toLowerCase();
        list = list.filter((t) => t.title.toLowerCase().includes(words));
    }
    if (sort !== undefined) {
        const field = String(sort).startsWith('-') ? String(sort).slice(1) : String(sort);
        if (!SORT_FIELDS.includes(field)) {
            return sendError(
                res,
                400,
                'invalid_query',
                'sort must be title or createdAt, with - in front to reverse',
            );
        }
        const direction = String(sort).startsWith('-') ? -1 : 1;
        list = list.toSorted((a, b) => a[field].localeCompare(b[field]) * direction);
    }
    const page = req.query.page === undefined ? 1 : Number(req.query.page);
    const limit = req.query.limit === undefined ? config.pageSize : Number(req.query.limit);
    if (!Number.isInteger(page) || page < 1) {
        return sendError(res, 400, 'invalid_query', 'page must be a whole number from 1');
    }
    if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
        return sendError(res, 400, 'invalid_query', 'limit must be a whole number from 1 to 50');
    }
    const start = (page - 1) * limit;
    res.json({
        items: list.slice(start, start + limit),
        page,
        total: list.length,
        pages: Math.ceil(list.length / limit),
    });
});

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

router.post('/', (req, res) => {
    const result = NewTask.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { title, done = false } = result.data;
    if (titleTaken(title)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    const task = { id: nextId(), title, done, createdAt: new Date().toISOString() };
    tasks.push(task);
    res.status(201).location(`/api/v1/tasks/${task.id}`).json(task);
});

router.patch('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = TaskChanges.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    Object.assign(task, result.data);
    res.json(task);
});

router.put('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = Task.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { id, createdAt } = tasks[index];
    if (titleTaken(result.data.title, id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    tasks[index] = { id, ...result.data, 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 sendError(res, 404, 'not_found', 'Task not found');
    }
    tasks.splice(index, 1);
    res.status(204).end();
});

export default router;

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

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