Menu
CoddyTech

תוויות

חלק מהיחידה REST APIs במסלול ה-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. הנתיב מחזיר כל תווית שנמצאת בשימוש ואת מספר המשימות שמשויכות אליה, כשהתוויות ממוינות לפי סדר האלפבית. עבור המשימות ההתחלתיות:
    [
      { "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 APIs

תרגלו בעצמכם: Express אונליין