תוויות
חלק מהיחידה 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 })): הסוגריים המרובעים מפרקים כל זוג; הסוגריים העגולים מחזירים אובייקט.
אתגר
קלאפליקציית הלוח רוצה לקבץ משימות לפי תוויות כמו office או money, ולהציג תפריט של כל התוויות ומספר המשימות שמשויכות לכל אחת מהן. הוסיפו תוויות ל-API שלכם.
- ב-
routes/tasks.js, הוסיפו שדהlabelsאופציונלי לסכמתTask: רשימה של עד 5 תוויות, שכל אחת מהן היא מחרוזת באורך 1 עד 20 תווים לאחר הסרת הרווחים בתחילתה ובסופה. גם POST וגם PATCH יכללו אותו, מכיוון שהסכמות שלהם נבנות מתוךTask. רשימה לא תקינה תחזיר את שגיאת 400 הרגילה מסוגinvalid_body. רשימות לא תקינות כוללות"labels": "office"(טקסט, לא רשימה),"labels": [""], ושש תוויות. - לכל משימה שנשמרת יש רשימת
labels:- כשבקשת POST או PUT לא שולחת
labels, שמרו[]. PUT מחליפה את המשימה כולה, לכן בקשת PUT ללא תוויות מנקה אותן. - PATCH משנה את
labelsרק אם היא שולחת אותן.
- כשבקשת POST או PUT לא שולחת
- ב-
store.js, הוסיפו תוויות למשימות ההתחלתיות:["office"]למשימות 1 ו-2, ו-["money"]למשימה 3. - עדיין ב-
routes/tasks.js, הוסיפו מסנן לנתיב הרשימה:GET /api/v1/tasks?label=officeמחזירה רק את המשימות שהרשימהlabelsשלהן כוללת בדיוק אתoffice. המסנן פועל עם המסננים האחרים ועם העמודים, כמו קודם. - ב-
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 אונליין