עימוד
חלק מהיחידה 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 מונעת מלקוח לבקש את הכול בבת אחת.
אתגר
קלרשימה של עשרת אלפים משימות ארוכה מדי לתגובה אחת. שלח את הרשימה בעמודים, עם המידע שהלקוח צריך כדי להציג "Page 1 of 2".
- ב־
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הוא מספר העמודים. - קרא את
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" } } - בצע עימוד בסוף: תחילה סנן ומיין, ואז חתוך את העמוד.
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;
השיעור הזה כולל חידון קצר. התחילו את השיעור כדי לענות עליו ולעקוב אחרי ההתקדמות.
כל השיעורים ביחידה REST APIs
תרגלו בעצמכם: Express אונליין