Menu
CoddyTech

עימוד

חלק מהיחידה REST APIs במסלול ה-Node.js של Coddy. שיעור 17 מתוך 25.

רשימה של עשרת אלפים משימות ארוכה מדי לתשובה אחת. שלחו אותה בעמודים: הלקוח מבקש ?page=2&limit=20 ומקבל את החלק הזה, וגם את מה שנחוץ לו כדי להציג "עמוד 2 מתוך 5":

{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }
const start = (page - 1) * limit;    // עמוד 1 מתחיל ב-0
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);

חשבו את total אחרי הסינון, כי הוא מתאר את הרשימה שהלקוח ביקש. אם במחרוזת השאילתה אין page, השתמשו ב־1, וב־10 עבור limit, לפני ההמרה: Number(undefined) הוא NaN. לאחר מכן דחו כל ערך שאינו מספר שלם בטווח, והחזירו 400. הגבלת מקסימום עבור limit מונעת מלקוח לבקש את הכול בבת אחת.

challenge icon

אתגר

קל

רשימה של עשרת אלפים משימות ארוכה מדי לתגובה אחת. שלח את הרשימה בעמודים, עם המידע שהלקוח צריך כדי להציג "Page 1 of 2".

  1. ב־routes/tasks.js, שנה את מה שמחזיר נתיב הרשימה: אובייקט במקום מערך. עבור GET /api/tasks?limit=2 עם המשימות ההתחלתיות, הוא מחזיר:
    {
      "items": [
        { "id": 1, "title": "Write the project brief", "done": true, "createdAt": "2026-03-02T09:00:00.000Z" },
        { "id": 2, "title": "Book the meeting room", "done": false, "createdAt": "2026-03-03T10:30:00.000Z" }
      ],
      "page": 1,
      "total": 3,
      "pages": 2
    }
    items מכיל את המשימות בעמוד הזה, page הוא מספר העמוד, total הוא מספר המשימות שתואמות למסננים, ו־pages הוא מספר העמודים.
  2. קרא את page ואת limit ממחרוזת השאילתה:
    • page הוא מספר שלם החל מ־1. אם הוא לא מופיע, השתמש ב־1.
    • limit הוא מספר שלם מ־1 עד 50. אם הוא לא מופיע, השתמש ב־10.

    השתמש בערך ברירת המחדל לפני ההמרה, כי Number(undefined) הוא NaN. כל ערך אחר (page=0, page=abc, page=1.5, limit=0, limit=51) מחזיר סטטוס 400, עם ההודעה limit must be a whole number from 1 to 50 במקרה של ערך limit שגוי:

    { "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } }
  3. בצע עימוד בסוף: תחילה סנן ומיין, ואז חתוך את העמוד. total סופר את המשימות שנותרו אחרי הסינון, ולכן ?done=false&limit=1 מחזיר "total": 2 ו־"pages": 2.

השינוי הזה משנה את מבנה התגובה, ולכן גם לקוח שציפה לקבל מערך צריך להשתנות. שיעור ניהול הגרסאות מראה איך לטפל בזה.

נסה זאת: GET /api/tasks?limit=2 מחזיר את משימות 1 ו־2 עם "page": 1, "total": 3 ו־"pages": 2. GET /api/tasks?page=2&limit=2 מחזיר רק את משימה 3. GET /api/tasks רגיל מחזיר את כל 3 המשימות עם "page": 1 ו־"pages": 1. GET /api/tasks?page=0 מחזיר 400 עם הקוד invalid_query.

נסו בעצמכם

import { Router } from 'express';
import { z } from 'zod';
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);
    }
    res.json(list);
});

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/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;
quiz iconבחנו את עצמכם

השיעור הזה כולל חידון קצר. התחילו את השיעור כדי לענות עליו ולעקוב אחרי ההתקדמות.

כל השיעורים ביחידה REST APIs

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