تصميم REST API كامل في Express
واجهة برمجية يفهمها أي مطوّر من أول نظرة ⏱ نحو 45 دقيقة.
- تصمم مسارات
RESTبأسماء موارد وطرق مناسبة. - تختار رموز الحالة الصحيحة لكل نتيجة.
- تضيف الترقيم والتصفية والفرز والإصدار.
- توحّد شكل الردود والأخطاء وتوثّق الواجهة.
١ما REST؟
REST أسلوب لتصميم واجهات برمجة التطبيقات عبر HTTP، يجعلها متوقعة: من يعرف مسار أحد الموارد يخمّن بقية المسارات دون قراءة التوثيق. مبادئه العملية:
- الموارد أسماء لا أفعال:
/coursesلا/getCourses. - الطريقة هي الفعل:
GETيقرأ،POSTينشئ،PUT/PATCHيعدّل،DELETEيحذف. - بلا حالة
Stateless: كل طلب يحمل كل ما يلزمه (مثل الرمز)، والخادم لا يتذكر الطلب السابق. - رموز الحالة تعبّر عن النتيجة، والجسم
JSONبشكل ثابت.
| العملية | الطريقة والمسار | النجاح | أخطاء محتملة |
|---|---|---|---|
| قائمة | GET /api/v1/courses | 200 | — |
| عنصر | GET /api/v1/courses/7 | 200 | 404 |
| إنشاء | POST /api/v1/courses | 201 + Location | 400 / 422 / 409 |
| تعديل جزئي | PATCH /api/v1/courses/7 | 200 | 404 / 422 |
| استبدال كامل | PUT /api/v1/courses/7 | 200 | 404 / 422 |
| حذف | DELETE /api/v1/courses/7 | 204 | 404 |
| علاقة متداخلة | GET /api/v1/courses/7/students | 200 | 404 |
٢واجهة كاملة للمقررات
هذا تطبيق يجمع ما سبق: موجّه بإصدار /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 أم PATCH | PATCH للحقول المرسلة فقط، و PUT يستبدل العنصر كاملًا | PATCH أكثر استخدامًا في الواجهات |
| التكرار الآمن Idempotent | GET و 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 نفسه: اختر الطريقة، واكتب المسار والجسم والترويسات، وأرسل.
توقّع وتحقّق
أي مسار يتبع أسلوب REST لحذف الطالب 5؟
ما الرمز المناسب لرد ناجح على DELETE بلا محتوى؟
No Content.أي الطرق غير آمنة للتكرار (قد تنشئ عنصرين)؟
Idempotency.لماذا /api/v1 ؟
الخلاصة
- الموارد أسماء جمع، والطريقة هي الفعل.
200/201/204للنجاح، و400/422/404/409لأخطاء العميل، و500 للخادم.- ترقيم بحد أعلى، وتصفية وفرز بمعاملات الاستعلام، و
/api/v1. - غلاف ثابت
{ data, meta }وخطأ ثابت{ error: { code, message } }. OpenAPIللتوثيق، وPostman/curlللتجربة.
تحدٍّ برمجي: واجهة الفعاليات
متقدمواجهة REST لفعاليات النادي الطلابي (في الذاكرة) بتصميم موحّد.
- المسارات تحت
/api/v1/events: قائمة، وعنصر، وإنشاء، وتعديل جزئي PATCH، وحذف. - القائمة ترد بـ
{"data": […], "meta": {"total": n}}وتقبل?type=للتصفية. - الإنشاء يتطلب
titleوtype؛ وإلا 422 مع{"error": {"code": "VALIDATION"}}. وينجح بـ 201 مع{"data": …}. - العنصر غير الموجود: 404 مع
{"error": {"code": "NOT_FOUND"}}؛ والحذف الناجح 204.
👀 اعرض الاستجابات المطلوبة
GET /api/v1/eventsGET /api/v1/events?type=sportPOST /api/v1/events{"title": "معرض المشاريع", "type": "tech"}POST /api/v1/events{"title": "بلا نوع"}PATCH /api/v1/events/3{"title": "معرض مشاريع التخرج"}GET /api/v1/events/3DELETE /api/v1/events/1DELETE /api/v1/events/1GET /api/v1/events/99GET /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);