طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 242 · أطر الويب
الدرس 17 · الوحدة 7: تصميم واجهات برمجة التطبيقات RESTful

تصميم REST API كامل في Express

واجهة برمجية يفهمها أي مطوّر من أول نظرة ⏱ نحو 45 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تصمم مسارات REST بأسماء موارد وطرق مناسبة.
  • تختار رموز الحالة الصحيحة لكل نتيجة.
  • تضيف الترقيم والتصفية والفرز والإصدار.
  • توحّد شكل الردود والأخطاء وتوثّق الواجهة.

١ما REST؟

REST أسلوب لتصميم واجهات برمجة التطبيقات عبر HTTP، يجعلها متوقعة: من يعرف مسار أحد الموارد يخمّن بقية المسارات دون قراءة التوثيق. مبادئه العملية:

العمليةالطريقة والمسارالنجاحأخطاء محتملة
قائمةGET /api/v1/courses200—
عنصرGET /api/v1/courses/7200404
إنشاءPOST /api/v1/courses201 + Location400 / 422 / 409
تعديل جزئيPATCH /api/v1/courses/7200404 / 422
استبدال كاملPUT /api/v1/courses/7200404 / 422
حذفDELETE /api/v1/courses/7204404
علاقة متداخلةGET /api/v1/courses/7/students200404

٢واجهة كاملة للمقررات

هذا تطبيق يجمع ما سبق: موجّه بإصدار /api/v1، وترقيم وتصفية وفرز، وغلاف ثابت للرد { data, meta }، وأخطاء بشكل موحّد { error: { code, message } }. وكل الأخطاء تمر على وسيط واحد (في Express 5 يصل إليه الخطأ المرمي من دالة async تلقائيًّا، فلا نحتاج try/catch في كل مسار):

const express = require('express');const { Sequelize, DataTypes, Op } = require('sequelize');const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }); const Course = sequelize.define('Course', {  code:  { type: DataTypes.STRING, allowNull: false, unique: true },  title: { type: DataTypes.STRING, allowNull: false },  hours: { type: DataTypes.INTEGER, allowNull: false, validate: { min: 1, max: 6 } },  dept:  { type: DataTypes.STRING, defaultValue: 'CS' },}, { timestamps: false }); const app = express();app.use(express.json());const v1 = express.Router(); // GET /api/v1/courses?dept=CS&sort=-hours&page=1&limit=2v1.get('/courses', async (req, res) => {  const page = Math.max(1, Number(req.query.page) || 1);  const limit = Math.min(50, Number(req.query.limit) || 10);  const where = req.query.dept ? { dept: req.query.dept } : {};  const sort = req.query.sort || 'code';  const order = [[sort.replace('-', ''), sort.startsWith('-') ? 'DESC' : 'ASC']];  const { rows, count } = await Course.findAndCountAll({ where, order, limit, offset: (page - 1) * limit });  res.json({ data: rows, meta: { page, limit, total: count, pages: Math.ceil(count / limit) } });}); v1.get('/courses/:id', async (req, res) => {  const c = await Course.findByPk(req.params.id);  if (!c) return res.status(404).json({ error: { code: 'NOT_FOUND', message: 'المقرر غير موجود' } });  res.json({ data: c });}); v1.post('/courses', async (req, res) => {  const c = await Course.create(req.body);  res.status(201).location(`/api/v1/courses/${c.id}`).json({ data: c });}); v1.patch('/courses/:id', async (req, res) => {  const c = await Course.findByPk(req.params.id);  if (!c) return res.status(404).json({ error: { code: 'NOT_FOUND', message: 'المقرر غير موجود' } });  await c.update(req.body);  res.json({ data: c });}); v1.delete('/courses/:id', async (req, res) => {  const n = await Course.destroy({ where: { id: req.params.id } });  if (!n) return res.status(404).json({ error: { code: 'NOT_FOUND', message: 'المقرر غير موجود' } });  res.status(204).end();}); app.use('/api/v1', v1); // شكل موحّد لكل الأخطاءapp.use((err, req, res, next) => {  if (err.name === 'SequelizeValidationError') {    return res.status(422).json({ error: { code: 'VALIDATION', message: 'بيانات غير صالحة', fields: err.errors.map(e => e.path) } });  }  if (err.name === 'SequelizeUniqueConstraintError') {    return res.status(409).json({ error: { code: 'DUPLICATE', message: 'الرمز مستخدم' } });  }  console.error(err);  res.status(500).json({ error: { code: 'SERVER', message: 'خطأ غير متوقع' } });}); sequelize.sync().then(async () => {  await Course.bulkCreate([    { code: 'CS241', title: 'برمجة الويب المتقدمة', hours: 4 },    { code: 'CS242', title: 'أطر الويب', hours: 3 },    { code: 'CS243', title: 'أمن الويب', hours: 3 },    { code: 'MA101', title: 'رياضيات', hours: 3, dept: 'MATH' },  ]);  app.listen(3000);});

لاحظ: الترقيم يعيد meta لتعرف الواجهة عدد الصفحات؛ والإنشاء يعيد الترويسة Location؛ و422 للبيانات غير الصالحة مع الحقول المخالفة، و409 للتكرار.

٣قرارات تصميم صغيرة بأثر كبير

المسألةالتوصيةالسبب
الإصدار/api/v1/…تغيّر شكل الرد لاحقًا في v2 دون كسر التطبيقات القديمة
الترقيم?page=2&limit=20 مع حد أعلىلا تُرجع 10 آلاف صف في طلب واحد
التصفية والفرز?dept=CS&sort=-hoursالعلامة - للتنازلي
PUT أم PATCHPATCH للحقول المرسلة فقط، و PUT يستبدل العنصر كاملًاPATCH أكثر استخدامًا في الواجهات
التكرار الآمن IdempotentGET و PUT و DELETE تكرارها لا يغيّر النتيجةإعادة إرسال POST قد تنشئ عنصرين
التواريخISO 8601: 2025-09-01T08:00:00Zصيغة واحدة تفهمها كل اللغات
الأسماءجمع وبحروف صغيرة: /courses، /order-itemsاتساق

٤التوثيق والاختبار

الواجهة البرمجية بلا توثيق لا يستطيع فريق الواجهة استخدامها. المعيار الشائع OpenAPI (Swagger): ملف يصف كل مسار ومعاملاته وشكل رده، وتولّد منه أدوات صفحة توثيق تفاعلية. ولتجربة الواجهة يدويًّا: Postman أو إضافة Thunder Client أو REST Client في VS Code، أو curl في الطرفية:

🖥 الطرفية
$ curl -X POST http://localhost:3000/api/v1/courses \    -H "Content-Type: application/json" \    -d '{"code":"CS250","title":"الحوسبة السحابية","hours":3}'{"data":{"id":5,"code":"CS250","title":"الحوسبة السحابية","hours":3,"dept":"CS"}}
# openapi.yaml (مقتطف)paths:  /api/v1/courses/{id}:    get:      summary: جلب مقرر برقمه      parameters:        - { name: id, in: path, required: true, schema: { type: integer } }      responses:        '200': { description: المقرر }        '404': { description: غير موجود }

وتبويب «📮 طلب يدوي» في مختبرات هذه الصفحات يؤدي عمل Postman نفسه: اختر الطريقة، واكتب المسار والجسم والترويسات، وأرسل.

🧪 مختبر Expressعدّل الملفات، ثم أرسل طلبًا يدويًّا أو افتح الرابط في المتصفح المصغّر. الخادم يعمل داخل صفحتك.

توقّع وتحقّق

أي مسار يتبع أسلوب REST لحذف الطالب 5؟

المورد + الطريقة.

ما الرمز المناسب لرد ناجح على DELETE بلا محتوى؟

No Content.

أي الطرق غير آمنة للتكرار (قد تنشئ عنصرين)؟

Idempotency.

لماذا /api/v1 ؟

الإصدارات.

الخلاصة

تحدٍّ برمجي: واجهة الفعاليات

متقدم

واجهة REST لفعاليات النادي الطلابي (في الذاكرة) بتصميم موحّد.

المطلوب:
  • المسارات تحت /api/v1/events: قائمة، وعنصر، وإنشاء، وتعديل جزئي PATCH، وحذف.
  • القائمة ترد بـ {"data": […], "meta": {"total": n}} وتقبل ?type= للتصفية.
  • الإنشاء يتطلب title و type؛ وإلا 422 مع {"error": {"code": "VALIDATION"}}. وينجح بـ 201 مع {"data": …}.
  • العنصر غير الموجود: 404 مع {"error": {"code": "NOT_FOUND"}}؛ والحذف الناجح 204.
👀 اعرض الاستجابات المطلوبة
✓ التحقق الآلي: يشغّل المختبر خادمك من جديد ويرسل إليه هذه الطلبات بالترتيب ثم يفحص الردود
  1. GET /api/v1/events
  2. GET /api/v1/events?type=sport
  3. POST /api/v1/events {"title": "معرض المشاريع", "type": "tech"}
  4. POST /api/v1/events {"title": "بلا نوع"}
  5. PATCH /api/v1/events/3 {"title": "معرض مشاريع التخرج"}
  6. GET /api/v1/events/3
  7. DELETE /api/v1/events/1
  8. DELETE /api/v1/events/1
  9. GET /api/v1/events/99
  10. GET /api/v1/events?type=tech
  • الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
  • القائمة بغلاف data و meta.total = 2
  • التصفية ?type=sport تعيد الدوري فقط
  • الإنشاء ← 201
  • … والرد {"data": {"id": 3, …}}
  • نقص الحقول ← خطأ VALIDATION
  • … بالحالة 422
  • PATCH يعدّل العنوان فقط
  • الحذف ← 204
  • حذف المحذوف ← 404
  • غير الموجود ← NOT_FOUND
  • بقيت فعالية tech واحدة

💡 تلميح 1
استخدم express.Router() وركّبه على /api/v1.
💡 تلميح 2
دالة صغيرة notFound(res) توحّد شكل خطأ 404.

حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.

const express = require('express');const app = express();app.use(express.json()); let events = [  { id: 1, title: 'هاكاثون الويب', type: 'tech' },  { id: 2, title: 'دوري كرة القدم', type: 'sport' },];let nextId = 3;const v1 = express.Router();const notFound = (res) => res.status(404).json({ error: { code: 'NOT_FOUND', message: 'غير موجود' } }); v1.get('/events', (req, res) => {  const data = req.query.type ? events.filter(e => e.type === req.query.type) : events;  res.json({ data, meta: { total: data.length } });});v1.get('/events/:id', (req, res) => {  const e = events.find(x => x.id === Number(req.params.id));  e ? res.json({ data: e }) : notFound(res);});v1.post('/events', (req, res) => {  const { title, type } = req.body;  if (!title || !type) return res.status(422).json({ error: { code: 'VALIDATION', message: 'title و type مطلوبان' } });  const e = { id: nextId++, title, type };  events.push(e);  res.status(201).json({ data: e });});v1.patch('/events/:id', (req, res) => {  const e = events.find(x => x.id === Number(req.params.id));  if (!e) return notFound(res);  Object.assign(e, req.body, { id: e.id });  res.json({ data: e });});v1.delete('/events/:id', (req, res) => {  const n = events.length;  events = events.filter(x => x.id !== Number(req.params.id));  events.length < n ? res.sendStatus(204) : notFound(res);});app.use('/api/v1', v1); app.listen(3000);
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

📝 معلم؟ أرسل ملاحظة للمؤلف عن هذا الدرس