بناء مشروع REST API - الجزء الأول
بناء مشروع REST API كامل - الجزء الأول
في هذا الدرس، سنبدأ ببناء واجهة برمجة تطبيقات REST كاملة لإدارة المهام من الصفر. سيتضمن هذا المشروع جميع المفاهيم التي تعلمناها خلال الدورة، بما في ذلك المصادقة، وعمليات قاعدة البيانات، والتحقق، ومعالجة الأخطاء، وأفضل ممارسات الأمان.
نظرة عامة على المشروع
سنقوم ببناء واجهة برمجة تطبيقات لإدارة المهام بالميزات التالية:
- تسجيل المستخدمين والمصادقة (JWT)
- إنشاء وقراءة وتحديث وحذف المهام
- فئات وعلامات المهام
- تصفية وترقيم المهام
- مرفقات الملفات للمهام
- إدارة ملف تعريف المستخدم
- التحكم في الوصول على أساس الدور
- توثيق الواجهة البرمجية
الخطوة 1: إعداد المشروع
أولاً، لنقم بإنشاء وتهيئة مشروعنا:
# إنشاء مجلد المشروع
mkdir task-management-api
cd task-management-api
# تهيئة مشروع npm
npm init -y
# تثبيت التبعيات الأساسية
npm install express mongoose dotenv bcryptjs jsonwebtoken
npm install express-validator express-async-errors
npm install cors helmet compression morgan
# تثبيت تبعيات التطوير
npm install --save-dev nodemon
الخطوة 2: بنية المجلدات
إنشاء بنية مجلدات منظمة جيداً لمشروعنا:
task-management-api/
├── src/
│ ├── config/
│ │ └── database.js
│ ├── models/
│ │ ├── User.js
│ │ ├── Task.js
│ │ └── Category.js
│ ├── controllers/
│ │ ├── authController.js
│ │ ├── taskController.js
│ │ ├── categoryController.js
│ │ └── userController.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── errorHandler.js
│ │ ├── validation.js
│ │ └── upload.js
│ ├── routes/
│ │ ├── auth.js
│ │ ├── tasks.js
│ │ ├── categories.js
│ │ └── users.js
│ ├── utils/
│ │ ├── ApiResponse.js
│ │ └── ApiError.js
│ └── app.js
├── uploads/
├── .env
├── .gitignore
├── package.json
└── server.js
إنشاء بنية المجلدات:
mkdir -p src/{config,models,controllers,middleware,routes,utils}
mkdir uploads
الخطوة 3: تكوين البيئة
إنشاء ملف .env لمتغيرات البيئة:
# .env
NODE_ENV=development
PORT=5000
# قاعدة البيانات
MONGODB_URI=mongodb://localhost:27017/task-management
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=7d
# الأمان
BCRYPT_ROUNDS=10
# رفع الملفات
MAX_FILE_SIZE=5242880
ALLOWED_FILE_TYPES=image/jpeg,image/png,application/pdf
.env الخاص بك في نظام التحكم في الإصدار. أضفه إلى .gitignore فوراً.إنشاء ملف .gitignore:
node_modules/
.env
uploads/
*.log
.DS_Store
الخطوة 4: تكوين قاعدة البيانات
إنشاء ملف تكوين قاعدة البيانات (src/config/database.js):
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
});
console.log(`MongoDB Connected: ${conn.connection.host}`);
// معالجة أحداث الاتصال
mongoose.connection.on('error', (err) => {
console.error('خطأ في اتصال MongoDB:', err);
});
mongoose.connection.on('disconnected', () => {
console.log('تم قطع اتصال MongoDB');
});
// إغلاق رشيق
process.on('SIGINT', async () => {
await mongoose.connection.close();
console.log('تم إغلاق اتصال MongoDB من خلال إنهاء التطبيق');
process.exit(0);
});
} catch (error) {
console.error('خطأ في الاتصال بـ MongoDB:', error.message);
process.exit(1);
}
};
module.exports = connectDB;
الخطوة 5: الفئات المساعدة
إنشاء فئات مساعدة لاستجابات API والأخطاء (src/utils/ApiResponse.js):
class ApiResponse {
constructor(statusCode, data, message = 'نجح') {
this.statusCode = statusCode;
this.success = statusCode < 400;
this.message = message;
this.data = data;
}
static success(data, message = 'نجح', statusCode = 200) {
return new ApiResponse(statusCode, data, message);
}
static created(data, message = 'تم إنشاء المورد بنجاح') {
return new ApiResponse(201, data, message);
}
}
module.exports = ApiResponse;
إنشاء فئة أخطاء API (src/utils/ApiError.js):
class ApiError extends Error {
constructor(statusCode, message, isOperational = true, stack = '') {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
this.success = false;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
static badRequest(message = 'طلب غير صالح') {
return new ApiError(400, message);
}
static unauthorized(message = 'غير مصرح') {
return new ApiError(401, message);
}
static forbidden(message = 'محظور') {
return new ApiError(403, message);
}
static notFound(message = 'المورد غير موجود') {
return new ApiError(404, message);
}
static conflict(message = 'تعارض') {
return new ApiError(409, message);
}
static internal(message = 'خطأ داخلي في الخادم') {
return new ApiError(500, message, false);
}
}
module.exports = ApiError;
الخطوة 6: نموذج المستخدم
إنشاء نموذج المستخدم (src/models/User.js):
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'الاسم مطلوب'],
trim: true,
minlength: [2, 'يجب أن يكون الاسم على الأقل حرفين'],
maxlength: [50, 'لا يمكن أن يتجاوز الاسم 50 حرفاً']
},
email: {
type: String,
required: [true, 'البريد الإلكتروني مطلوب'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'يرجى توفير بريد إلكتروني صحيح']
},
password: {
type: String,
required: [true, 'كلمة المرور مطلوبة'],
minlength: [6, 'يجب أن تكون كلمة المرور على الأقل 6 أحرف'],
select: false // لا تضمن كلمة المرور في الاستعلامات بشكل افتراضي
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
avatar: {
type: String,
default: null
},
isActive: {
type: Boolean,
default: true
},
refreshTokens: [{
token: String,
createdAt: {
type: Date,
default: Date.now,
expires: 604800 // 7 أيام بالثواني
}
}]
}, {
timestamps: true
});
// تشفير كلمة المرور قبل الحفظ
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
const rounds = parseInt(process.env.BCRYPT_ROUNDS) || 10;
this.password = await bcrypt.hash(this.password, rounds);
next();
});
// طريقة لمقارنة كلمة المرور
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
// طريقة لإنشاء رمز JWT
userSchema.methods.generateAuthToken = function() {
const token = jwt.sign(
{
id: this._id,
email: this.email,
role: this.role
},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
return token;
};
// طريقة للحصول على الملف الشخصي العام
userSchema.methods.toJSON = function() {
const user = this.toObject();
delete user.password;
delete user.refreshTokens;
delete user.__v;
return user;
};
// طريقة ثابتة للعثور على المستخدم بواسطة بيانات الاعتماد
userSchema.statics.findByCredentials = async function(email, password) {
const user = await this.findOne({ email, isActive: true }).select('+password');
if (!user) {
throw new Error('بريد إلكتروني أو كلمة مرور غير صحيحة');
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
throw new Error('بريد إلكتروني أو كلمة مرور غير صحيحة');
}
return user;
};
const User = mongoose.model('User', userSchema);
module.exports = User;
select: false على حقل كلمة المرور يمنع تضمينه في نتائج الاستعلام بشكل افتراضي. استخدم .select('+password') عندما تحتاج للوصول إليه.الخطوة 7: نموذج الفئة
إنشاء نموذج الفئة (src/models/Category.js):
const mongoose = require('mongoose');
const categorySchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'اسم الفئة مطلوب'],
trim: true,
unique: true,
maxlength: [30, 'لا يمكن أن يتجاوز اسم الفئة 30 حرفاً']
},
description: {
type: String,
trim: true,
maxlength: [200, 'لا يمكن أن يتجاوز الوصف 200 حرف']
},
color: {
type: String,
match: [/^#[0-9A-F]{6}$/i, 'يرجى توفير لون hex صحيح'],
default: '#3498db'
},
icon: {
type: String,
default: 'folder'
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}
}, {
timestamps: true
});
// فهرس لاستعلامات أسرع
categorySchema.index({ userId: 1, name: 1 });
const Category = mongoose.model('Category', categorySchema);
module.exports = Category;
الخطوة 8: نموذج المهمة
إنشاء نموذج المهمة (src/models/Task.js):
const mongoose = require('mongoose');
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'عنوان المهمة مطلوب'],
trim: true,
minlength: [3, 'يجب أن يكون العنوان على الأقل 3 أحرف'],
maxlength: [100, 'لا يمكن أن يتجاوز العنوان 100 حرف']
},
description: {
type: String,
trim: true,
maxlength: [1000, 'لا يمكن أن يتجاوز الوصف 1000 حرف']
},
status: {
type: String,
enum: ['pending', 'in-progress', 'completed', 'cancelled'],
default: 'pending'
},
priority: {
type: String,
enum: ['low', 'medium', 'high', 'urgent'],
default: 'medium'
},
dueDate: {
type: Date,
validate: {
validator: function(value) {
return value >= new Date();
},
message: 'يجب أن يكون تاريخ الاستحقاق في المستقبل'
}
},
categoryId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Category'
},
tags: [{
type: String,
trim: true,
maxlength: [20, 'لا يمكن أن تتجاوز العلامة 20 حرفاً']
}],
attachments: [{
filename: String,
originalName: String,
mimeType: String,
size: Number,
path: String,
uploadedAt: {
type: Date,
default: Date.now
}
}],
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
completedAt: {
type: Date
}
}, {
timestamps: true
});
// فهارس لأداء استعلام أفضل
taskSchema.index({ userId: 1, status: 1 });
taskSchema.index({ userId: 1, categoryId: 1 });
taskSchema.index({ userId: 1, dueDate: 1 });
taskSchema.index({ tags: 1 });
// افتراضي للتحقق مما إذا كانت المهمة متأخرة
taskSchema.virtual('isOverdue').get(function() {
return this.dueDate && this.dueDate < new Date() && this.status !== 'completed';
});
// تعيين completedAt تلقائياً عندما تتغير الحالة إلى مكتملة
taskSchema.pre('save', function(next) {
if (this.isModified('status')) {
if (this.status === 'completed' && !this.completedAt) {
this.completedAt = new Date();
} else if (this.status !== 'completed') {
this.completedAt = null;
}
}
next();
});
// تمكين الافتراضيات في إخراج JSON
taskSchema.set('toJSON', { virtuals: true });
taskSchema.set('toObject', { virtuals: true });
const Task = mongoose.model('Task', taskSchema);
module.exports = Task;
الخطوة 9: وسيط المصادقة
إنشاء وسيط المصادقة (src/middleware/auth.js):
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const ApiError = require('../utils/ApiError');
const auth = async (req, res, next) => {
try {
// الحصول على الرمز من الرأس
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
throw ApiError.unauthorized('لم يتم توفير رمز مصادقة');
}
// التحقق من الرمز
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// العثور على المستخدم
const user = await User.findById(decoded.id);
if (!user || !user.isActive) {
throw ApiError.unauthorized('المستخدم غير موجود أو غير نشط');
}
// إرفاق المستخدم بالطلب
req.user = user;
req.token = token;
next();
} catch (error) {
if (error.name === 'JsonWebTokenError') {
return next(ApiError.unauthorized('رمز غير صالح'));
}
if (error.name === 'TokenExpiredError') {
return next(ApiError.unauthorized('انتهت صلاحية الرمز'));
}
next(error);
}
};
// وسيط للتحقق مما إذا كان المستخدم مسؤولاً
const isAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return next(ApiError.forbidden('الوصول مرفوض. مطلوب امتيازات المسؤول.'));
}
next();
};
module.exports = { auth, isAdmin };
الخطوة 10: متحكم المصادقة
إنشاء متحكم المصادقة (src/controllers/authController.js):
const User = require('../models/User');
const ApiResponse = require('../utils/ApiResponse');
const ApiError = require('../utils/ApiError');
// تسجيل مستخدم جديد
exports.register = async (req, res, next) => {
try {
const { name, email, password } = req.body;
// التحقق مما إذا كان المستخدم موجوداً بالفعل
const existingUser = await User.findOne({ email });
if (existingUser) {
throw ApiError.conflict('مستخدم بهذا البريد الإلكتروني موجود بالفعل');
}
// إنشاء مستخدم
const user = new User({
name,
email,
password
});
await user.save();
// إنشاء رمز
const token = user.generateAuthToken();
res.status(201).json(
ApiResponse.created(
{ user, token },
'تم تسجيل المستخدم بنجاح'
)
);
} catch (error) {
next(error);
}
};
// تسجيل دخول المستخدم
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
// العثور على المستخدم بواسطة بيانات الاعتماد
const user = await User.findByCredentials(email, password);
// إنشاء رمز
const token = user.generateAuthToken();
res.json(
ApiResponse.success(
{ user, token },
'تم تسجيل الدخول بنجاح'
)
);
} catch (error) {
next(ApiError.unauthorized(error.message));
}
};
// الحصول على الملف الشخصي للمستخدم الحالي
exports.getProfile = async (req, res, next) => {
try {
res.json(
ApiResponse.success(
req.user,
'تم استرداد الملف الشخصي بنجاح'
)
);
} catch (error) {
next(error);
}
};
// تسجيل خروج المستخدم
exports.logout = async (req, res, next) => {
try {
// في تطبيق حقيقي، قد ترغب في وضع الرمز في القائمة السوداء
// أو إزالته من قائمة رموز التحديث
res.json(
ApiResponse.success(
null,
'تم تسجيل الخروج بنجاح'
)
);
} catch (error) {
next(error);
}
};
الخطوة 11: مسارات المصادقة
إنشاء مسارات المصادقة (src/routes/auth.js):
const express = require('express');
const router = express.Router();
const { body } = require('express-validator');
const authController = require('../controllers/authController');
const { auth } = require('../middleware/auth');
const { validate } = require('../middleware/validation');
// قواعد التحقق
const registerValidation = [
body('name')
.trim()
.notEmpty().withMessage('الاسم مطلوب')
.isLength({ min: 2, max: 50 }).withMessage('يجب أن يكون الاسم من 2 إلى 50 حرفاً'),
body('email')
.trim()
.notEmpty().withMessage('البريد الإلكتروني مطلوب')
.isEmail().withMessage('يرجى توفير بريد إلكتروني صحيح')
.normalizeEmail(),
body('password')
.notEmpty().withMessage('كلمة المرور مطلوبة')
.isLength({ min: 6 }).withMessage('يجب أن تكون كلمة المرور على الأقل 6 أحرف')
];
const loginValidation = [
body('email')
.trim()
.notEmpty().withMessage('البريد الإلكتروني مطلوب')
.isEmail().withMessage('يرجى توفير بريد إلكتروني صحيح')
.normalizeEmail(),
body('password')
.notEmpty().withMessage('كلمة المرور مطلوبة')
];
// المسارات
router.post('/register', registerValidation, validate, authController.register);
router.post('/login', loginValidation, validate, authController.login);
router.get('/profile', auth, authController.getProfile);
router.post('/logout', auth, authController.logout);
module.exports = router;
الخطوة 12: وسيط التحقق
إنشاء وسيط التحقق (src/middleware/validation.js):
const { validationResult } = require('express-validator');
const ApiError = require('../utils/ApiError');
exports.validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const errorMessages = errors.array().map(err => err.msg);
throw ApiError.badRequest(errorMessages.join(', '));
}
next();
};
تمرين تطبيقي
أكمل المهام التالية:
- إعداد بنية المشروع كما هو موضح أعلاه
- تثبيت جميع التبعيات المطلوبة
- إنشاء جميع النماذج (المستخدم، الفئة، المهمة)
- تنفيذ نقاط نهاية المصادقة
- اختبار نقاط نهاية التسجيل وتسجيل الدخول باستخدام Postman أو curl
- التحقق من إنشاء رموز JWT بشكل صحيح
الخلاصة
في هذا الدرس، أكملنا الجزء الأول من بناء مشروع REST API الخاص بنا:
- إعداد بنية مشروع منظمة جيداً
- تكوين متغيرات البيئة واتصال قاعدة البيانات
- إنشاء فئات مساعدة لاستجابات API متسقة
- تنفيذ نماذج المستخدم والفئة والمهمة مع التحقق
- بناء وسيط المصادقة مع JWT
- إنشاء نقاط نهاية المصادقة (التسجيل، تسجيل الدخول، الملف الشخصي، تسجيل الخروج)
- إضافة التحقق من المدخلات باستخدام express-validator
في الدرس التالي، سنواصل بتنفيذ عمليات CRUD للمهام والفئات، وإضافة وظيفة رفع الملفات، وتنفيذ الترقيم والتصفية.