تواريخ الاستحقاق
جزء من قسم واجهات REST API في رحلة Node.js على Coddy. الدرس 25 من 25.
يُحدَّد للمهام تاريخ استحقاق، وتُظهر اللوحة المهام المتأخرة. يُمثَّل التاريخ الذي لا يتضمن وقتًا كسلسلة ISO، 2026-03-15، ويتحقق منه zod باستخدام z.iso.date():
z.iso.date().safeParse('2026-07-01').success // true
z.iso.date().safeParse('2026-02-30').success // false: لا يوجد يوم بهذا التاريخ
'2026-03-01' < '2026-03-15' // trueللتواريخ بصيغة ISO الطول نفسه، وتأتي السنة أولًا، لذا فإن مقارنتها كسلاسل نصية تعطي ترتيبًا زمنيًا. يجب أن يسبق مسار ثابت مثل /overdue المسار /:id في موجّه Express: يجرّب Express المسارات بالترتيب، ويطابق /:id أي كلمة.
التحدي
سهلتوضح اللوحة المهام المتأخرة. أضف تاريخ استحقاق اختياريًا إلى المهام، وأضف قائمة بالمهام التي فات موعد استحقاقها.
- في
routes/tasks.js، أضف حقلdueاختياريًا إلى مخططTask، وتحقق منه باستخدامz.iso.date(). يقبل هذا الحقل التواريخ الحقيقية المكتوبة فقط بصيغةYYYY-MM-DD. تُقبل"2026-03-15"، بينما ينتج عن"15/03/2026"و"2026-02-30"و"tomorrow"خطأ 400 المعتادinvalid_body. تأكد من أن POST يحفظdueفي المهمة الجديدة. - أضف
GET /api/v1/tasks/overdueإلى الموجّه. عرّف هذا المسار قبل المسار/:id، وإلا فسيرسل Express القيمةoverdueإلى/:idباعتبارها معرّفًا. - يقرأ المسار التاريخ المراد المقارنة به من
?today=، مثل?today=2026-03-20. إذا لم تُحدَّد قيمةtoday، فسيستخدم التاريخ الحالي المكتوب بصيغةYYYY-MM-DD. إذا لم تكن قيمةtodayتاريخًا حقيقيًا، مثل?today=soon، فستكون حالة الاستجابة 400:{ "error": { "code": "invalid_query", "message": "today must be a date like 2026-03-10" } } - يعيد المسار مصفوفة عادية من المهام، وليس كائن الصفحة الذي تعيده قائمة المهام. تكون المهمة متأخرة عند تحقق الشروط الثلاثة كلها: لم تُنجز، ولها تاريخ
due، وذلك التاريخ يسبقtoday. لا تُعد المهمة التي يحل تاريخ استحقاقها فيtodayمتأخرة بعد. رتّب المصفوفة حسبdue، بحيث يأتي التاريخ الأسبق أولًا.
جرّب ذلك: في تشغيل جديد، أرسل POST بالقيمة { "title": "Renew the lease", "due": "2026-03-15" }، ثم بالقيمة { "title": "File the taxes", "due": "2026-03-01" } إلى /api/v1/tasks. ستعيد كلتا العمليتين الحالة 201 مع قيمة due. بعد ذلك، يعيد GET /api/v1/tasks/overdue?today=2026-03-20 المهمة ذات تاريخ الاستحقاق الأسبق أولًا (ستختلف أوقات createdAt لديك):
[
{ "id": 5, "title": "File the taxes", "done": false, "labels": [], "due": "2026-03-01", "createdAt": "2026-10-30T09:15:02.118Z" },
{ "id": 4, "title": "Renew the lease", "done": false, "labels": [], "due": "2026-03-15", "createdAt": "2026-10-30T09:14:40.006Z" }
]يعيد GET /api/v1/tasks/overdue?today=2026-03-15 المهمة "File the taxes" فقط. ويعيد GET /api/v1/tasks/overdue?today=soon الحالة 400 مع الرمز invalid_query.
جرّب بنفسك
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(),
labels: z.array(z.string().trim().min(1).max(20)).max(5).optional(),
})
.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, label } = 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 (label !== undefined) {
list = list.filter((t) => t.labels.includes(String(label)));
}
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, labels = [] } = result.data;
if (titleTaken(title)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
const task = { id: nextId(), title, done, labels, 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, labels: result.data.labels ?? [], 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