بناء مشروع REST API - الجزء الثاني
بناء مشروع REST API كامل - الجزء الثاني
في هذا الدرس، سنواصل بناء واجهة برمجة تطبيقات إدارة المهام من خلال تنفيذ عمليات CRUD للمهام والفئات، وإضافة وظيفة رفع الملفات، وتنفيذ الترقيم والتصفية. هذا يبني على نظام المصادقة الذي أنشأناه في الجزء الأول.
الخطوة 1: وسيط معالج الأخطاء
أولاً، لنقم بإنشاء وسيط معالجة أخطاء مركزي (src/middleware/errorHandler.js):
const ApiError = require('../utils/ApiError');
const errorHandler = (err, req, res, next) => {
let error = err;
// خطأ تحقق Mongoose
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
error = ApiError.badRequest(messages.join(', '));
}
// خطأ مفتاح مكرر Mongoose
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
error = ApiError.conflict(`${field} موجود بالفعل`);
}
// خطأ cast Mongoose (معرف كائن غير صالح)
if (err.name === 'CastError') {
error = ApiError.badRequest('تنسيق معرف غير صالح');
}
// الافتراضي إلى خطأ الخادم 500
if (!error.statusCode) {
error = ApiError.internal(err.message);
}
// إرسال استجابة الخطأ
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
...(process.env.NODE_ENV === 'development' && { stack: error.stack })
});
};
module.exports = errorHandler;
الخطوة 2: متحكم الفئة
إنشاء متحكم الفئة (src/controllers/categoryController.js):
const Category = require('../models/Category');
const Task = require('../models/Task');
const ApiResponse = require('../utils/ApiResponse');
const ApiError = require('../utils/ApiError');
// الحصول على جميع الفئات للمستخدم الحالي
exports.getCategories = async (req, res, next) => {
try {
const categories = await Category.find({ userId: req.user._id })
.sort({ name: 1 });
// الحصول على عدد المهام لكل فئة
const categoriesWithCount = await Promise.all(
categories.map(async (category) => {
const taskCount = await Task.countDocuments({
categoryId: category._id,
userId: req.user._id
});
return {
...category.toObject(),
taskCount
};
})
);
res.json(
ApiResponse.success(
categoriesWithCount,
'تم استرداد الفئات بنجاح'
)
);
} catch (error) {
next(error);
}
};
// الحصول على فئة واحدة
exports.getCategory = async (req, res, next) => {
try {
const category = await Category.findOne({
_id: req.params.id,
userId: req.user._id
});
if (!category) {
throw ApiError.notFound('الفئة غير موجودة');
}
res.json(
ApiResponse.success(category, 'تم استرداد الفئة بنجاح')
);
} catch (error) {
next(error);
}
};
// إنشاء فئة جديدة
exports.createCategory = async (req, res, next) => {
try {
const category = new Category({
...req.body,
userId: req.user._id
});
await category.save();
res.status(201).json(
ApiResponse.created(category, 'تم إنشاء الفئة بنجاح')
);
} catch (error) {
next(error);
}
};
// تحديث الفئة
exports.updateCategory = async (req, res, next) => {
try {
const category = await Category.findOneAndUpdate(
{ _id: req.params.id, userId: req.user._id },
req.body,
{ new: true, runValidators: true }
);
if (!category) {
throw ApiError.notFound('الفئة غير موجودة');
}
res.json(
ApiResponse.success(category, 'تم تحديث الفئة بنجاح')
);
} catch (error) {
next(error);
}
};
// حذف الفئة
exports.deleteCategory = async (req, res, next) => {
try {
const category = await Category.findOne({
_id: req.params.id,
userId: req.user._id
});
if (!category) {
throw ApiError.notFound('الفئة غير موجودة');
}
// التحقق مما إذا كانت الفئة تحتوي على مهام
const taskCount = await Task.countDocuments({
categoryId: category._id
});
if (taskCount > 0) {
throw ApiError.badRequest(
`لا يمكن حذف فئة تحتوي على ${taskCount} مهمة. يرجى إعادة تعيين المهام أو حذفها أولاً.`
);
}
await category.deleteOne();
res.json(
ApiResponse.success(null, 'تم حذف الفئة بنجاح')
);
} catch (error) {
next(error);
}
};
الخطوة 3: مسارات الفئة
إنشاء مسارات الفئة (src/routes/categories.js):
const express = require('express');
const router = express.Router();
const { body } = require('express-validator');
const categoryController = require('../controllers/categoryController');
const { auth } = require('../middleware/auth');
const { validate } = require('../middleware/validation');
// قواعد التحقق
const categoryValidation = [
body('name')
.trim()
.notEmpty().withMessage('اسم الفئة مطلوب')
.isLength({ max: 30 }).withMessage('لا يمكن أن يتجاوز الاسم 30 حرفاً'),
body('description')
.optional()
.trim()
.isLength({ max: 200 }).withMessage('لا يمكن أن يتجاوز الوصف 200 حرف'),
body('color')
.optional()
.matches(/^#[0-9A-F]{6}$/i).withMessage('يرجى توفير لون hex صحيح'),
body('icon')
.optional()
.trim()
];
// جميع المسارات تتطلب المصادقة
router.use(auth);
router.get('/', categoryController.getCategories);
router.get('/:id', categoryController.getCategory);
router.post('/', categoryValidation, validate, categoryController.createCategory);
router.patch('/:id', categoryValidation, validate, categoryController.updateCategory);
router.delete('/:id', categoryController.deleteCategory);
module.exports = router;
الخطوة 4: وسيط رفع الملفات
إنشاء وسيط رفع الملفات باستخدام multer (src/middleware/upload.js):
const multer = require('multer');
const path = require('path');
const ApiError = require('../utils/ApiError');
// تكوين التخزين
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
// تصفية الملفات
const fileFilter = (req, file, cb) => {
const allowedTypes = process.env.ALLOWED_FILE_TYPES.split(',');
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new ApiError(400, `نوع الملف ${file.mimetype} غير مسموح به`), false);
}
};
// تكوين Multer
const upload = multer({
storage: storage,
limits: {
fileSize: parseInt(process.env.MAX_FILE_SIZE) || 5242880 // 5 ميجابايت افتراضي
},
fileFilter: fileFilter
});
// معالج أخطاء Multer
const handleMulterError = (err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return next(ApiError.badRequest('حجم الملف يتجاوز الحد المسموح'));
}
return next(ApiError.badRequest(err.message));
}
next(err);
};
module.exports = { upload, handleMulterError };
npm install multerالخطوة 5: متحكم المهام
إنشاء متحكم المهام مع عمليات CRUD والتصفية (src/controllers/taskController.js):
const Task = require('../models/Task');
const Category = require('../models/Category');
const ApiResponse = require('../utils/ApiResponse');
const ApiError = require('../utils/ApiError');
const fs = require('fs').promises;
// الحصول على جميع المهام مع التصفية والترقيم
exports.getTasks = async (req, res, next) => {
try {
const {
status,
priority,
categoryId,
tags,
search,
sortBy = 'createdAt',
order = 'desc',
page = 1,
limit = 10
} = req.query;
// بناء التصفية
const filter = { userId: req.user._id };
if (status) filter.status = status;
if (priority) filter.priority = priority;
if (categoryId) filter.categoryId = categoryId;
if (tags) filter.tags = { $in: tags.split(',') };
// البحث في العنوان والوصف
if (search) {
filter.$or = [
{ title: { $regex: search, $options: 'i' } },
{ description: { $regex: search, $options: 'i' } }
];
}
// بناء الترتيب
const sortOptions = {};
sortOptions[sortBy] = order === 'asc' ? 1 : -1;
// تنفيذ الاستعلام مع الترقيم
const skip = (parseInt(page) - 1) * parseInt(limit);
const [tasks, total] = await Promise.all([
Task.find(filter)
.populate('categoryId', 'name color icon')
.sort(sortOptions)
.skip(skip)
.limit(parseInt(limit)),
Task.countDocuments(filter)
]);
// حساب معلومات الترقيم
const totalPages = Math.ceil(total / parseInt(limit));
const hasNextPage = parseInt(page) < totalPages;
const hasPrevPage = parseInt(page) > 1;
res.json(
ApiResponse.success({
tasks,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages,
hasNextPage,
hasPrevPage
}
}, 'تم استرداد المهام بنجاح')
);
} catch (error) {
next(error);
}
};
// الحصول على مهمة واحدة
exports.getTask = async (req, res, next) => {
try {
const task = await Task.findOne({
_id: req.params.id,
userId: req.user._id
}).populate('categoryId', 'name color icon');
if (!task) {
throw ApiError.notFound('المهمة غير موجودة');
}
res.json(
ApiResponse.success(task, 'تم استرداد المهمة بنجاح')
);
} catch (error) {
next(error);
}
};
// إنشاء مهمة جديدة
exports.createTask = async (req, res, next) => {
try {
// التحقق من أن الفئة تنتمي إلى المستخدم إذا تم توفيرها
if (req.body.categoryId) {
const category = await Category.findOne({
_id: req.body.categoryId,
userId: req.user._id
});
if (!category) {
throw ApiError.badRequest('فئة غير صالحة');
}
}
const task = new Task({
...req.body,
userId: req.user._id
});
await task.save();
// ملء الفئة
await task.populate('categoryId', 'name color icon');
res.status(201).json(
ApiResponse.created(task, 'تم إنشاء المهمة بنجاح')
);
} catch (error) {
next(error);
}
};
// تحديث المهمة
exports.updateTask = async (req, res, next) => {
try {
// التحقق من أن الفئة تنتمي إلى المستخدم إذا تم تحديثها
if (req.body.categoryId) {
const category = await Category.findOne({
_id: req.body.categoryId,
userId: req.user._id
});
if (!category) {
throw ApiError.badRequest('فئة غير صالحة');
}
}
const task = await Task.findOneAndUpdate(
{ _id: req.params.id, userId: req.user._id },
req.body,
{ new: true, runValidators: true }
).populate('categoryId', 'name color icon');
if (!task) {
throw ApiError.notFound('المهمة غير موجودة');
}
res.json(
ApiResponse.success(task, 'تم تحديث المهمة بنجاح')
);
} catch (error) {
next(error);
}
};
// حذف المهمة
exports.deleteTask = async (req, res, next) => {
try {
const task = await Task.findOne({
_id: req.params.id,
userId: req.user._id
});
if (!task) {
throw ApiError.notFound('المهمة غير موجودة');
}
// حذف الملفات المرتبطة
if (task.attachments && task.attachments.length > 0) {
await Promise.all(
task.attachments.map(async (attachment) => {
try {
await fs.unlink(attachment.path);
} catch (err) {
console.error(`فشل حذف الملف: ${attachment.path}`);
}
})
);
}
await task.deleteOne();
res.json(
ApiResponse.success(null, 'تم حذف المهمة بنجاح')
);
} catch (error) {
next(error);
}
};
// رفع مرفق إلى المهمة
exports.uploadAttachment = async (req, res, next) => {
try {
if (!req.file) {
throw ApiError.badRequest('لم يتم رفع ملف');
}
const task = await Task.findOne({
_id: req.params.id,
userId: req.user._id
});
if (!task) {
// حذف الملف المرفوع إذا لم يتم العثور على المهمة
await fs.unlink(req.file.path);
throw ApiError.notFound('المهمة غير موجودة');
}
// إضافة المرفق إلى المهمة
task.attachments.push({
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
path: req.file.path
});
await task.save();
res.json(
ApiResponse.success(
task.attachments[task.attachments.length - 1],
'تم رفع المرفق بنجاح'
)
);
} catch (error) {
// تنظيف الملف المرفوع عند حدوث خطأ
if (req.file) {
try {
await fs.unlink(req.file.path);
} catch (err) {
console.error('فشل حذف الملف المرفوع');
}
}
next(error);
}
};
// حذف المرفق من المهمة
exports.deleteAttachment = async (req, res, next) => {
try {
const task = await Task.findOne({
_id: req.params.id,
userId: req.user._id
});
if (!task) {
throw ApiError.notFound('المهمة غير موجودة');
}
const attachmentIndex = task.attachments.findIndex(
a => a._id.toString() === req.params.attachmentId
);
if (attachmentIndex === -1) {
throw ApiError.notFound('المرفق غير موجود');
}
// حذف الملف من القرص
const attachment = task.attachments[attachmentIndex];
try {
await fs.unlink(attachment.path);
} catch (err) {
console.error(`فشل حذف الملف: ${attachment.path}`);
}
// إزالة من المصفوفة
task.attachments.splice(attachmentIndex, 1);
await task.save();
res.json(
ApiResponse.success(null, 'تم حذف المرفق بنجاح')
);
} catch (error) {
next(error);
}
};
// الحصول على إحصائيات المهام
exports.getStatistics = async (req, res, next) => {
try {
const userId = req.user._id;
const [
total,
pending,
inProgress,
completed,
cancelled,
overdue
] = await Promise.all([
Task.countDocuments({ userId }),
Task.countDocuments({ userId, status: 'pending' }),
Task.countDocuments({ userId, status: 'in-progress' }),
Task.countDocuments({ userId, status: 'completed' }),
Task.countDocuments({ userId, status: 'cancelled' }),
Task.countDocuments({
userId,
status: { $ne: 'completed' },
dueDate: { $lt: new Date() }
})
]);
// المهام حسب الأولوية
const byPriority = await Task.aggregate([
{ $match: { userId } },
{ $group: { _id: '$priority', count: { $sum: 1 } } }
]);
const statistics = {
total,
byStatus: {
pending,
inProgress,
completed,
cancelled
},
byPriority: byPriority.reduce((acc, item) => {
acc[item._id] = item.count;
return acc;
}, {}),
overdue
};
res.json(
ApiResponse.success(statistics, 'تم استرداد الإحصائيات بنجاح')
);
} catch (error) {
next(error);
}
};
Promise.all() لاستعلامات قاعدة البيانات المتوازية يمكن أن يحسن الأداء بشكل كبير عند جلب مجموعات بيانات مستقلة متعددة.الخطوة 6: مسارات المهام
إنشاء مسارات المهام (src/routes/tasks.js):
const express = require('express');
const router = express.Router();
const { body, query } = require('express-validator');
const taskController = require('../controllers/taskController');
const { auth } = require('../middleware/auth');
const { validate } = require('../middleware/validation');
const { upload, handleMulterError } = require('../middleware/upload');
// قواعد التحقق
const taskValidation = [
body('title')
.trim()
.notEmpty().withMessage('عنوان المهمة مطلوب')
.isLength({ min: 3, max: 100 }).withMessage('يجب أن يكون العنوان من 3 إلى 100 حرف'),
body('description')
.optional()
.trim()
.isLength({ max: 1000 }).withMessage('لا يمكن أن يتجاوز الوصف 1000 حرف'),
body('status')
.optional()
.isIn(['pending', 'in-progress', 'completed', 'cancelled'])
.withMessage('حالة غير صالحة'),
body('priority')
.optional()
.isIn(['low', 'medium', 'high', 'urgent'])
.withMessage('أولوية غير صالحة'),
body('dueDate')
.optional()
.isISO8601().withMessage('تنسيق تاريخ غير صالح'),
body('categoryId')
.optional()
.isMongoId().withMessage('معرف فئة غير صالح'),
body('tags')
.optional()
.isArray().withMessage('يجب أن تكون العلامات مصفوفة')
];
const queryValidation = [
query('page')
.optional()
.isInt({ min: 1 }).withMessage('يجب أن تكون الصفحة عدد صحيح موجب'),
query('limit')
.optional()
.isInt({ min: 1, max: 100 }).withMessage('يجب أن يكون الحد بين 1 و 100'),
query('status')
.optional()
.isIn(['pending', 'in-progress', 'completed', 'cancelled']),
query('priority')
.optional()
.isIn(['low', 'medium', 'high', 'urgent'])
];
// جميع المسارات تتطلب المصادقة
router.use(auth);
router.get('/', queryValidation, validate, taskController.getTasks);
router.get('/statistics', taskController.getStatistics);
router.get('/:id', taskController.getTask);
router.post('/', taskValidation, validate, taskController.createTask);
router.patch('/:id', taskValidation, validate, taskController.updateTask);
router.delete('/:id', taskController.deleteTask);
// مسارات رفع الملفات
router.post(
'/:id/attachments',
upload.single('file'),
handleMulterError,
taskController.uploadAttachment
);
router.delete('/:id/attachments/:attachmentId', taskController.deleteAttachment);
module.exports = router;
الخطوة 7: ملف التطبيق الرئيسي
إنشاء ملف التطبيق الرئيسي (src/app.js):
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
require('express-async-errors');
const authRoutes = require('./routes/auth');
const taskRoutes = require('./routes/tasks');
const categoryRoutes = require('./routes/categories');
const errorHandler = require('./middleware/errorHandler');
const ApiError = require('./utils/ApiError');
const app = express();
// الوسيطات
app.use(helmet());
app.use(cors());
app.use(compression());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// السجلات
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
// المسارات
app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/categories', categoryRoutes);
// فحص الصحة
app.get('/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() });
});
// معالج 404
app.use((req, res, next) => {
next(ApiError.notFound('المسار غير موجود'));
});
// معالج الأخطاء
app.use(errorHandler);
module.exports = app;
الخطوة 8: نقطة دخول الخادم
إنشاء نقطة دخول الخادم (server.js):
require('dotenv').config();
const app = require('./src/app');
const connectDB = require('./src/config/database');
const PORT = process.env.PORT || 5000;
// الاتصال بقاعدة البيانات
connectDB();
// بدء الخادم
const server = app.listen(PORT, () => {
console.log(`الخادم يعمل في وضع ${process.env.NODE_ENV} على المنفذ ${PORT}`);
});
// معالجة رفض الوعود غير المعالج
process.on('unhandledRejection', (err) => {
console.error('رفض غير معالج:', err);
server.close(() => process.exit(1));
});
الخطوة 9: تحديث نصوص package.json
إضافة نصوص التطوير والإنتاج إلى package.json:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
الخطوة 10: اختبار الواجهة البرمجية
اختبار جميع نقاط النهاية باستخدام curl أو Postman. إليك بعض الأمثلة على الطلبات:
# تسجيل مستخدم
curl -X POST http://localhost:5000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"محمد أحمد","email":"john@example.com","password":"password123"}'
# تسجيل الدخول
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"john@example.com","password":"password123"}'
# إنشاء فئة (مع الرمز)
curl -X POST http://localhost:5000/api/categories \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"عمل","description":"مهام العمل","color":"#3498db"}'
# إنشاء مهمة
curl -X POST http://localhost:5000/api/tasks \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"إكمال المشروع","status":"pending","priority":"high"}'
# الحصول على المهام مع التصفية والترقيم
curl -X GET "http://localhost:5000/api/tasks?status=pending&page=1&limit=10" \
-H "Authorization: Bearer YOUR_TOKEN"
# رفع مرفق
curl -X POST http://localhost:5000/api/tasks/TASK_ID/attachments \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@/path/to/file.pdf"
تمرين تطبيقي
أكمل المهام التالية:
- تنفيذ جميع عمليات CRUD للمهام والفئات
- اختبار وظيفة رفع الملفات مع أنواع ملفات مختلفة
- اختبار الترقيم والتصفية مع معاملات استعلام مختلفة
- إنشاء مهمة مع فئة والتحقق من العلاقة
- محاولة حذف فئة تحتوي على مهام والتحقق من معالجة الأخطاء
- اختبار نقطة نهاية الإحصائيات والتحقق من الأعداد
- اختبار اكتشاف المهام المتأخرة من خلال إنشاء مهمة بتاريخ استحقاق سابق
الخلاصة
في هذا الدرس، أكملنا الجزء الثاني من مشروع REST API الخاص بنا:
- تنفيذ وسيط معالجة أخطاء مركزي
- إنشاء عمليات CRUD للفئات
- تنفيذ وظيفة رفع الملفات باستخدام multer
- بناء إدارة المهام الكاملة مع عمليات CRUD
- إضافة قدرات تصفية وبحث متقدمة
- تنفيذ الترقيم لاسترداد البيانات بكفاءة
- إنشاء نقطة نهاية إحصائيات المهام
- إضافة إدارة مرفقات الملفات للمهام
- اختبار جميع نقاط النهاية مع أمثلة الطلبات
في الدرس التالي (الجزء 3)، سنضيف اختباراً شاملاً، وتوثيق API، ومعالجة أخطاء محسنة، وتقوية الأمان لإكمال واجهة REST API الاحترافية الخاصة بنا.