الملصقات
جزء من قسم واجهات 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 })): تفك الأقواس كل زوج، وتُعيد الأقواس الدائرية كائنًا.
التحدي
سهليريد تطبيق اللوحة تجميع المهام حسب التسميات مثل 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هناك. يعيد ذلك كل تسمية مستخدمة مع عدد المهام التي تحملها، مرتبة أبجديًا من 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
1الموارد والأفعال
الموارد وعناوين URLالإنشاء باستخدام POST4المتصفحات والإصدارات والإعدادات
ما هو المصدرالسماح لمصدر آخرCORSصفحة تستدعي APIتدرّب بنفسك: Playground لـ Express