الترتيب
جزء من قسم واجهات REST API في رحلة Node.js على Coddy. الدرس 16 من 25.
يريد العميل ظهور أحدث المهام أولًا، أو ترتيب القائمة من الألف إلى الياء. من الأعراف الشائعة استخدام ?sort=field، ووضع - في البداية لعكس الترتيب:
// GET /api/books?sort=-year
const field = sort.startsWith('-') ? sort.slice(1) : sort; // 'year'
const direction = sort.startsWith('-') ? -1 : 1;
list = list.toSorted((a, b) => (a[field] - b[field]) * direction);تحقق أولًا من أن field ضمن الحقول المسموح بها، وأعِد 400 لأي قيمة أخرى، لأن الحقل الذي لا تملكه الكتب لا يوفّر ترتيبًا مفيدًا. تعيد toSorted نسخة مرتبة. أما sort فتعيد ترتيب المصفوفة نفسها، وإذا لم ينشئ أي مرشح نسخة، فستكون تلك المصفوفة هي المخزن: سيظهر الترتيب الجديد في كل طلب لاحق. يطرح هذا المثال قيمة من أخرى، وهذا لا يصلح إلا للأرقام. لمقارنة سلسلتين نصيتين، استخدم a.localeCompare(b): مثل الطرح، تعيد قيمة سالبة أو 0 أو قيمة موجبة. تواريخ ISO سلاسل نصية تُرتَّب زمنيًا.
التحدي
سهلاستبدل الفرز حسب العنوان فقط الذي كتبته في درس فرز النصوص، بمعالجة الحقل والاتجاه الموضحة أدناه.
يريد العملاء عرض أحدث المهام أولاً، أو ترتيب القائمة من الألف إلى الياء. دع القائمة ترتب نفسها باستخدام ?sort=.
- في
routes/tasks.js، ضمن مسار القائمة، اقرأsortمنreq.query. من دونه، تحتفظ القائمة بترتيبها المخزن. - يرتب
?sort=titleحسب العنوان من الألف إلى الياء. ويرتب?sort=createdAtحسب وقت الإنشاء، من الأقدم أولاً. تعكس علامة-في البداية الترتيب:?sort=-titleيرتب من الياء إلى الألف، و?sort=-createdAtيعرض الأحدث أولاً. - كلا الحقلين نصّان.
createdAtتاريخ بتنسيق ISO مثل"2026-03-03T10:30:00.000Z"، ويُرتب بشكل صحيح كنص. لذا قارِن باستخدامlocaleCompare، لا بطرح القيم. - أي حقل آخر، مثل
?sort=color، يعيد الحالة 400:{ "error": { "code": "invalid_query", "message": "sort must be title or createdAt, with - in front to reverse" } } - نفّذ الفرز بعد عوامل التصفية، لكي يعيد
?done=false&sort=-titleالمهام المفتوحة مرتبة من الياء إلى الألف. - رتّب نسخة باستخدام
toSorted. يعيدsortترتيب المصفوفة المخزنة نفسها، وسترى كل الطلبات اللاحقة الترتيب الجديد.
جرّب ذلك: يعيد GET /api/tasks?sort=title المهام 2 و3 و1 ("Book...", "Send...", "Write..."). ويعيد GET /api/tasks?sort=-createdAt المهام 3 و2 و1. ثم يعيد GET /api/tasks العادي المهام 1 و2 و3 مجدداً. ويعيد GET /api/tasks?sort=color الحالة 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();
// تكون القيمة True عندما تحمل مهمة أخرى هذا العنوان بالفعل، بغض النظر عن حالة الأحرف.
const titleTaken = (title, exceptId) =>
tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());
router.get('/', (req, res) => {
const { done, q } = 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 (req.query.sort === 'title') {
list = list.toSorted((a, b) => a.title.localeCompare(b.title));
}
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 API
1الموارد والأفعال
الموارد وعناوين URLالإنشاء باستخدام POST4المتصفحات والإصدارات والإعدادات
ما هو المصدرالسماح لمصدر آخرCORSصفحة تستدعي APIتدرّب بنفسك: Playground لـ Express