بناء Node.js / TypeScript REST API ، الجزء 3: MongoDB والمصادقة والاختبارات الآلية
نشرت: 2022-03-11في هذه المرحلة من سلسلتنا حول كيفية إنشاء واجهة برمجة تطبيقات Node.js REST باستخدام Express.js و TypeScript ، قمنا ببناء نهاية خلفية للعمل وفصلنا الكود إلى تكوين المسار والخدمات والبرمجيات الوسيطة ووحدات التحكم والنماذج. إذا كنت مستعدًا للمتابعة من هناك ، فاستنسخ مثال الريبو وقم بتشغيل git checkout toptal-article-02 .
واجهة برمجة تطبيقات REST مع النمس والمصادقة والاختبار الآلي
في هذه المقالة الثالثة والأخيرة ، سنواصل تطوير واجهة برمجة تطبيقات REST الخاصة بنا عن طريق إضافة:
- Mongoose للسماح لنا بالعمل مع MongoDB واستبدال DAO الموجود في الذاكرة بقاعدة بيانات حقيقية.
- إمكانات المصادقة والأذونات بحيث يمكن لمستخدمي واجهة برمجة التطبيقات استخدام رمز ويب JSON (JWT) للوصول إلى نقاط النهاية الخاصة بنا بشكل آمن.
- اختبار آلي باستخدام Mocha (إطار اختبار) ، و Chai (مكتبة تأكيد) ، و SuperTest (وحدة تجريد HTTP) للمساعدة في التحقق من الانحدارات مع نمو قاعدة التعليمات البرمجية وتغيرها.
على طول الطريق ، سنضيف مكتبات التحقق من الصحة والأمان ، واكتساب بعض الخبرة مع Docker ، ونقترح العديد من الموضوعات والمكتبات والمهارات التي من الأفضل للقراء استكشافها في بناء وتوسيع REST APIs الخاصة بهم.
تثبيت MongoDB كحاوية
لنبدأ باستبدال قاعدة البيانات الموجودة في الذاكرة الخاصة بنا من المقالة السابقة بأخرى حقيقية.
لإنشاء قاعدة بيانات محلية للتطوير ، يمكننا تثبيت MongoDB محليًا. لكن الاختلافات بين البيئات (توزيعات وإصدارات نظام التشغيل ، على سبيل المثال) يمكن أن تسبب مشاكل. لتجنب ذلك ، سنستخدم هذه الفرصة للاستفادة من أداة متوافقة مع معايير الصناعة: حاوية Docker.
الشيء الوحيد الذي يحتاجه القراء هو تثبيت Docker ثم تثبيت Docker Compose. بمجرد التثبيت ، يجب أن ينتج عن تشغيل docker -v في المحطة رقم إصدار Docker.
الآن ، لتشغيل MongoDB ، في جذر مشروعنا ، سننشئ ملف YAML يسمى docker-compose.yml يحتوي على ما يلي:
version: '3' services: mongo: image: mongo volumes: - ./data:/data/db ports: - "27017:27017"يتيح لنا Docker Compose تشغيل عدة حاويات في وقت واحد باستخدام ملف تكوين واحد. في نهاية هذه المقالة ، سننظر في تشغيل الواجهة الخلفية لـ REST API في Docker أيضًا ، ولكن في الوقت الحالي ، سنستخدمها فقط لتشغيل MongoDB دون الحاجة إلى تثبيته محليًا:
sudo docker-compose up -d سيبدأ الأمر up في الحاوية المحددة ، ويستمع إلى منفذ MongoDB القياسي من 27017. وسيفصل المحول -d الأمر عن الجهاز. إذا كان كل شيء يعمل بدون مشكلة ، فسنرى رسالة مثل هذه:
Creating network "toptal-rest-series_default" with the default driver Creating toptal-rest-series_mongo_1 ... done سيؤدي أيضًا إلى إنشاء دليل data جديد في جذر المشروع ، لذلك يجب علينا إضافة سطر data في .gitignore .
الآن ، إذا احتجنا إلى إغلاق حاوية MongoDB Docker الخاصة بنا ، فنحن نحتاج فقط إلى تشغيل sudo docker-compose down وسنرى الناتج التالي:
Stopping toptal-rest-series_mongo_1 ... done Removing toptal-rest-series_mongo_1 ... done Removing network toptal-rest-series_default هذا كل ما نحتاج إلى معرفته لبدء تشغيل الواجهة الخلفية لـ Node.js / MongoDB REST API. دعونا نتأكد من أننا استخدمنا sudo docker-compose up -d حتى يكون MongoDB جاهزًا لاستخدام تطبيقنا.
استخدام النمس للوصول إلى MongoDB
للتواصل مع MongoDB ، ستستفيد نهايتنا الخلفية من مكتبة نمذجة بيانات الكائن (ODM) تسمى Mongoose. في حين أن Mongoose سهل الاستخدام للغاية ، إلا أنه يستحق التحقق من الوثائق لمعرفة جميع الاحتمالات المتقدمة التي يوفرها لمشاريع العالم الحقيقي.
لتثبيت النمس نستخدم ما يلي:
npm i mongoose لنقم بتكوين خدمة Mongoose لإدارة الاتصال بمثيل MongoDB الخاص بنا. نظرًا لأنه يمكن مشاركة هذه الخدمة بين موارد متعددة ، فسنضيفها إلى المجلد common لمشروعنا.
التكوين واضح ومباشر. في حين أنه ليس مطلوبًا تمامًا ، سيكون لدينا كائن mongooseOptions لتخصيص خيارات اتصال Mongoose التالية:
-
useNewUrlParser: بدون هذه الضبط علىtrue، يطبع Mongoose تحذيرًا من الإهمال. -
useUnifiedTopology: توصي وثائق Mongoose بضبط هذا علىtrueلاستخدام محرك إدارة اتصال أحدث. -
serverSelectionTimeoutMS: لغرض تجربة المستخدم لهذا المشروع التجريبي ، يعني وقت أقصر من الافتراضي البالغ 30 ثانية أن أي قارئ ينسى بدء تشغيل MongoDB قبل Node.js سيرى ملاحظات مفيدة حوله في وقت أقرب ، بدلاً من النهاية الخلفية غير المستجيبة على ما يبدو . -
useFindAndModify: يؤدي تعيين هذا إلىfalseأيضًا إلى تجنب تحذير الإهمال ، ولكنه مذكور في قسم الإهمالات في الوثائق ، وليس بين خيارات اتصال Mongoose. وبشكل أكثر تحديدًا ، يتسبب هذا في استخدام Mongoose لميزة MongoDB الأصلية الأحدث بدلاً من طبقة Mongoose القديمة.
بدمج هذه الخيارات مع بعض منطق التهيئة وإعادة المحاولة ، إليك الملف common/services/mongoose.service.ts النهائي:
import mongoose from 'mongoose'; import debug from 'debug'; const log: debug.IDebugger = debug('app:mongoose-service'); class MongooseService { private count = 0; private mongooseOptions = { useNewUrlParser: true, useUnifiedTopology: true, serverSelectionTimeoutMS: 5000, useFindAndModify: false, }; constructor() { this.connectWithRetry(); } getMongoose() { return mongoose; } connectWithRetry = () => { log('Attempting MongoDB connection (will retry if needed)'); mongoose .connect('mongodb://localhost:27017/api-db', this.mongooseOptions) .then(() => { log('MongoDB is connected'); }) .catch((err) => { const retrySeconds = 5; log( `MongoDB connection unsuccessful (will retry #${++this .count} after ${retrySeconds} seconds):`, err ); setTimeout(this.connectWithRetry, retrySeconds * 1000); }); }; } export default new MongooseService(); تأكد من الحفاظ على الفرق بين وظيفة connect() من Mongoose ووظيفة خدمة connectWithRetry() الخاصة بنا:
- يحاول
mongoose.connect()الاتصال بخدمة MongoDB المحلية الخاصة بنا (التي تعمل معdocker-composeالإرساء) وستنتهي المهلة بعد مللي ثانيةserverSelectionTimeoutMS. -
MongooseService.connectWithRetry()محاولة إجراء ما سبق في حالة بدء تطبيقنا ولكن خدمة MongoDB لم تعمل بعد. نظرًا لأنه في مُنشئ مفرد ، سيتم تشغيلconnectWithRetry()مرة واحدة فقط ، لكنه سيعيد محاولة استدعاءconnect()إلى أجل غير مسمى ، مع توقف مؤقتretrySecondsثانية ثانية كلما حدثت مهلة.
خطوتنا التالية هي استبدال قاعدة البيانات السابقة في الذاكرة بـ MongoDB!
إزالة قاعدة البيانات الخاصة بنا في الذاكرة وإضافة MongoDB
في السابق ، استخدمنا قاعدة بيانات في الذاكرة للسماح لنا بالتركيز على الوحدات الأخرى التي كنا نبنيها. لاستخدام Mongoose بدلاً من ذلك ، سيتعين علينا إعادة تشكيل المستخدمين بالكامل. users.dao.ts سنحتاج إلى بيان import آخر ، للبدء:
import mongooseService from '../../common/services/mongoose.service'; لنقم الآن بإزالة كل شيء من تعريف فئة UsersDao باستثناء المُنشئ. يمكننا البدء في ملئه من خلال إنشاء Schema المستخدم لـ Mongoose قبل المُنشئ:
Schema = mongooseService.getMongoose().Schema; userSchema = new this.Schema({ _id: String, email: String, password: { type: String, select: false }, firstName: String, lastName: String, permissionFlags: Number, }, { id: false }); User = mongooseService.getMongoose().model('Users', this.userSchema); يحدد هذا مجموعة MongoDB الخاصة بنا ويضيف ميزة خاصة لم تكن قاعدة بياناتنا في الذاكرة بها: سيؤدي select: false في حقل password إلى إخفاء هذا الحقل كلما حصلنا على مستخدم أو سرد جميع المستخدمين.
ربما يبدو مخطط المستخدم الخاص بنا مألوفًا لأنه مشابه لكيانات DTO الخاصة بنا. يتمثل الاختلاف الرئيسي في أننا نحدد الحقول التي يجب أن توجد في مجموعة MongoDB الخاصة بنا والتي تسمى Users ، بينما تحدد كيانات DTO الحقول التي يجب قبولها في طلب HTTP.
هذا الجزء من نهجنا لا يتغير ، وبالتالي لا يزال استيراد DTOs الثلاثة لدينا في الجزء العلوي من المستخدمين. users.dao.ts ولكن قبل تنفيذ عمليات طريقة CRUD الخاصة بنا ، سنقوم بتحديث DTOs بطريقتين.
تغيير DTO رقم 1: id مقابل _id
نظرًا لأن Mongoose يوفر تلقائيًا حقل _id ، فسنزيل حقل id من DTOs. سيأتي من المعلمات من طلب المسار على أي حال.
احذر من أن نماذج Mongoose توفر أداة الحصول على id افتراضي افتراضيًا ، لذلك قمنا بتعطيل هذا الخيار أعلاه باستخدام { id: false } لتجنب الالتباس. لكن هذا كسر مرجعنا إلى user.id في البرنامج الوسيط للمستخدم validateSameEmailBelongToSameUser() نحتاج إلى user._id هناك بدلاً من ذلك.
تستخدم بعض قواعد البيانات id الاتفاقية ، والبعض الآخر يستخدم _id ، لذلك لا توجد واجهة مثالية. بالنسبة لمشروعنا المثال الذي يستخدم Mongoose ، لقد أولينا اهتمامًا ببساطة للمشروع الذي نستخدمه في أي نقطة في الكود ، ولكن سيظل عدم التطابق معرضًا لمستهلكي واجهة برمجة التطبيقات:
نتركه كتمرين للقراء لتنفيذ أحد الحلول الواقعية العديدة المتاحة في نهاية المشروع.
تغيير DTO رقم 2: التحضير للأذونات المستندة إلى الإشارات
سنقوم أيضًا بإعادة تسمية permissionLevel إلى permissionFlags في DTOs لتعكس نظام الأذونات الأكثر تعقيدًا الذي سنقوم بتنفيذه ، بالإضافة إلى تعريف userSchema أعلاه.
DTOs: ماذا عن مبدأ الجفاف؟
تذكر أن DTO يحتوي فقط على الحقول التي نريد تمريرها بين عميل API وقاعدة البيانات الخاصة بنا. قد يبدو هذا مؤسفًا لأن هناك بعض التداخل بين النموذج و DTOs ولكن احذر من الضغط كثيرًا على DRY على حساب "الأمان افتراضيًا". إذا كانت إضافة حقل لا يتطلب سوى إضافته في مكان واحد ، فقد يعرضه المطورون عن غير قصد في واجهة برمجة التطبيقات عندما كان من المفترض أن يكون داخليًا فقط. ذلك لأن العملية لا تجبرهم على التفكير في تخزين البيانات ونقلها كسياقين منفصلين مع مجموعتين مختلفتين من المتطلبات.
بعد إجراء تغييرات DTO ، يمكننا تنفيذ عمليات طريقة CRUD الخاصة بنا (بعد مُنشئ UsersDao ) ، بدءًا من الإنشاء :
async addUser(userFields: CreateUserDto) { const userId = shortid.generate(); const user = new this.User({ _id: userId, ...userFields, permissionFlags: 1, }); await user.save(); return userId; } لاحظ أنه مهما كان ما يرسله مستهلك واجهة برمجة التطبيقات للحصول على permissionFlags إشارات عبر حقول المستخدم ، فإننا userFields بالقيمة 1 .
بعد ذلك قرأنا ، الوظائف الأساسية للحصول على مستخدم عن طريق المعرف ، والحصول على مستخدم عبر البريد الإلكتروني ، وقائمة المستخدمين بترقيم الصفحات:
async getUserByEmail(email: string) { return this.User.findOne({ email: email }).exec(); } async getUserById(userId: string) { return this.User.findOne({ _id: userId }).populate('User').exec(); } async getUsers(limit = 25, page = 0) { return this.User.find() .limit(limit) .skip(limit * page) .exec(); } لتحديث مستخدم ، تكفي وظيفة DAO واحدة لأن وظيفة findOneAndUpdate() الأساسية يمكنها تحديث المستند بأكمله أو جزء منه فقط. لاحظ أن وظيفتنا الخاصة ستأخذ userFields إما على أنها PatchUserDto أو PutUserDto ، باستخدام نوع اتحاد TypeScript (يُشار إليه بـ | ):
async updateUserById( userId: string, userFields: PatchUserDto | PutUserDto ) { const existingUser = await this.User.findOneAndUpdate( { _id: userId }, { $set: userFields }, { new: true } ).exec(); return existingUser; } يخبر الخيار new: true النمس أن يعيد الكائن كما هو بعد التحديث ، وليس كيف كان في الأصل.
الحذف موجز مع النمس:
async removeUserById(userId: string) { return this.User.deleteOne({ _id: userId }).exec(); } قد يلاحظ القراء أن كل استدعاءات لوظائف عضو User مرتبطة باستدعاء exec() . هذا اختياري ، لكن مطوري Mongoose يوصون به لأنه يوفر تتبعات مكدس أفضل عند تصحيح الأخطاء.
بعد ترميز DAO الخاص بنا ، نحتاج إلى تحديث users.service.ts بنا قليلاً من مقالتنا الأخيرة لمطابقة الوظائف الجديدة. ليست هناك حاجة لإعادة هيكلة كبيرة ، فقط ثلاث عمليات لمسات نهائية:
@@ -16,3 +16,3 @@ class UsersService implements CRUD { async list(limit: number, page: number) { - return UsersDao.getUsers(); + return UsersDao.getUsers(limit, page); } @@ -20,3 +20,3 @@ class UsersService implements CRUD { async patchById(id: string, resource: PatchUserDto): Promise<any> { - return UsersDao.patchUserById(id, resource); + return UsersDao.updateUserById(id, resource); } @@ -24,3 +24,3 @@ class UsersService implements CRUD { async putById(id: string, resource: PutUserDto): Promise<any> { - return UsersDao.putUserById(id, resource); + return UsersDao.updateUserById(id, resource); } تظل معظم استدعاءات الوظائف كما هي تمامًا ، لأنه عندما أعدنا بناء UsersDao ، حافظنا على الهيكل الذي أنشأناه في المقالة السابقة. لكن لماذا الاستثناءات؟
- نحن نستخدم
updateUserById()لكل منPUTوPATCHكما أشرنا أعلاه. (كما هو مذكور في الجزء 2 ، نحن نتبع تطبيقات REST API النموذجية بدلاً من محاولة الالتزام بـ RFCs معينة بالحرف. من بين أمور أخرى ، هذا يعني عدم وجود طلباتPUTتنشئ كيانات جديدة إذا لم تكن موجودة ؛ بهذه الطريقة ، لا تسلم نهايتنا الخلفية التحكم في إنشاء المعرفات إلى مستهلكي واجهة برمجة التطبيقات.) - لقد تجاوزنا
limitومعلماتpageإلىgetUsers()نظرًا لأن تطبيق DAO الجديد الخاص بنا سيستفيد منها.
الهيكل الرئيسي هنا هو نمط قوي إلى حد ما. على سبيل المثال ، يمكن إعادة استخدامه إذا أراد المطورون تبديل Mongoose و MongoDB بشيء مثل TypeORM و PostgreSQL. كما هو مذكور أعلاه ، سيتطلب هذا الاستبدال ببساطة إعادة بناء الوظائف الفردية لـ DAO مع الحفاظ على توقيعاتهم لتتناسب مع بقية الكود.
اختبار واجهة برمجة تطبيقات REST المدعومة من Mongoose
دعنا نطلق واجهة API الخلفية مع npm start . سنحاول بعد ذلك إنشاء مستخدم:
curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'يحتوي كائن الاستجابة على معرف مستخدم جديد:
{ "id": "7WYQoVZ3E" }كما في المقالة السابقة ، ستكون الاختبارات اليدوية المتبقية أسهل باستخدام متغيرات البيئة:
REST_API_EXAMPLE_يبدو تحديث المستخدم كالتالي:
curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }' يجب أن تبدأ الاستجابة بـ HTTP/1.1 204 No Content . (بدون رمز التبديل --include ، لن تتم طباعة أي استجابة ، وهو ما يتماشى مع تنفيذنا.)
إذا حصلنا الآن على المستخدم للتحقق من التحديثات المذكورة أعلاه ...:
curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }' ... يُظهر الرد الحقول المتوقعة ، بما في ذلك الحقل _id الذي تمت مناقشته أعلاه:
{ "_id": "7WYQoVZ3E", "email": "[email protected]", "permissionFlags": 1, "__v": 0, "firstName": "Marcos", "lastName": "Silva" } هناك أيضًا حقل خاص ، __v ، يستخدمه Mongoose للإصدار ؛ ستتم زيادته في كل مرة يتم فيها تحديث هذا السجل.
بعد ذلك ، دعنا نسرد المستخدمين:
curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' الاستجابة المتوقعة هي نفسها ، ملفوفة للتو في [] .
الآن بعد أن تم تخزين كلمة المرور الخاصة بنا بأمان ، فلنتأكد من أنه يمكننا إزالة المستخدم:
curl --include --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'نتوقع استجابة 204 مرة أخرى.
قد يتساءل القراء عما إذا كان حقل كلمة المرور يعمل بشكل صحيح ، حيث إن اختيارنا select: false في تعريف مخطط Mongoose Schema أخفيه من إخراج GET النحو المنشود. دعنا نكرر POST الأولي الخاص بنا لإنشاء مستخدم مرة أخرى ، ثم تحقق. (لا تنس تخزين المعرّف الجديد لوقت لاحق.)
كلمات المرور المخفية وتصحيح البيانات المباشر باستخدام حاويات MongoDB
للتحقق من تخزين كلمات المرور بأمان (على سبيل المثال ، مجزأة ، بدلاً من نص عادي) ، يمكن للمطورين فحص بيانات MongoDB مباشرةً. تتمثل إحدى الطرق في الوصول إلى عميل mongo CLI القياسي من داخل حاوية Docker قيد التشغيل:
sudo docker exec -it toptal-rest-series_mongo_1 mongo من هناك ، سيؤدي تنفيذ use api-db متبوعًا بـ db.users.find().pretty() إلى سرد جميع بيانات المستخدم ، بما في ذلك كلمات المرور.
أولئك الذين يفضلون واجهة المستخدم الرسومية يمكنهم تثبيت عميل MongoDB منفصل مثل Robo 3T:
بادئة كلمة المرور ( $argon2... ) هي جزء من تنسيق سلسلة PHC ، ويتم تخزينها عن قصد دون تعديل: حقيقة أن Argon2 ومعلماتها العامة مذكورة لن تساعد المخترق في تحديد كلمات المرور الأصلية إذا تمكن من سرقة قاعدة البيانات. يمكن تعزيز كلمة المرور المخزنة بشكل أكبر باستخدام التمليح ، وهي تقنية سنستخدمها أدناه مع JWTs. نتركه كتدريب للقارئ لتطبيق التمليح أعلاه وفحص الفرق بين القيم المخزنة عندما يقوم مستخدمان بإدخال نفس كلمة المرور.
نحن نعلم الآن أن Mongoose يرسل البيانات بنجاح إلى قاعدة بيانات MongoDB الخاصة بنا. ولكن كيف نعرف أن مستهلكي واجهة برمجة التطبيقات (API) لدينا سيرسلون البيانات المناسبة في طلباتهم إلى مسارات المستخدم لدينا؟
مضيفا صريح المدقق
هناك عدة طرق لإنجاز التحقق الميداني. في هذه المقالة سنستخدم المدقق السريع ، وهو ثابت تمامًا وسهل الاستخدام وموثق بشكل لائق. بينما يمكننا استخدام وظيفة التحقق التي تأتي مع Mongoose ، يوفر المدقق السريع ميزات إضافية. على سبيل المثال ، يأتي مع مدقق خارج الصندوق لعناوين البريد الإلكتروني ، والذي يتطلب منا في Mongoose ترميز مدقق مخصص.
لنقم بتثبيته:
npm i express-validator لتعيين الحقول التي نريد التحقق من صحتها ، سنستخدم طريقة body() التي سنستوردها في users.routes.config.ts . ستقوم طريقة body() بالتحقق من صحة الحقول وإنشاء قائمة أخطاء - مخزنة في كائن express.Request - في حالة الفشل.
نحتاج بعد ذلك إلى البرامج الوسيطة الخاصة بنا للتحقق من قائمة الأخطاء والاستفادة منها. نظرًا لأنه من المحتمل أن يعمل هذا المنطق بنفس الطريقة مع المسارات المختلفة ، فلنقم بإنشاء common/middleware/body.validation.middleware.ts بما يلي:
import express from 'express'; import { validationResult } from 'express-validator'; class BodyValidationMiddleware { verifyBodyFieldsErrors( req: express.Request, res: express.Response, next: express.NextFunction ) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).send({ errors: errors.array() }); } next(); } } export default new BodyValidationMiddleware(); وبذلك نكون جاهزين للتعامل مع أي أخطاء ناتجة عن وظيفة body() . دعنا نضيف ما يلي مرة أخرى في users.routes.config.ts :
import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator';يمكننا الآن تحديث مساراتنا بما يلي:
@@ -15,3 +17,6 @@ export class UsersRoutes extends CommonRoutesConfig { .post( - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailDoesntExist, @@ -28,3 +33,10 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.put(`/users/:userId`, [ - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + body('firstName').isString(), + body('lastName').isString(), + body('permissionFlags').isInt(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailBelongToSameUser, @@ -34,2 +46,11 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.patch(`/users/:userId`, [ + body('email').isEmail().optional(), + body('password') + .isLength({ min: 5 }) + .withMessage('Password must be 5+ characters') + .optional(), + body('firstName').isString().optional(), + body('lastName').isString().optional(), + body('permissionFlags').isInt().optional(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validatePatchEmail, تأكد من إضافة BodyValidationMiddleware.verifyBodyFieldsErrors في كل مسار بعد أي خطوط body() موجودة ، وإلا فلن يكون لأي منها تأثير.
لاحظ كيف قمنا بتحديث مسارات POST و PUT لاستخدام أداة validateRequiredUserBodyFields السريع بدلاً من وظيفة ValidateRequiredUserBodyFields الخاصة بنا. نظرًا لأن هذه المسارات هي الوحيدة التي تستخدم هذه الوظيفة ، يمكن حذف تنفيذها من users.middleware.ts .
هذا هو! يمكن للقراء إعادة تشغيل Node.js وتجربة النتيجة باستخدام عملاء REST المفضلين لديهم لمعرفة كيف يتعامل مع المدخلات المختلفة. لا تنسَ استكشاف توثيق المدقق السريع لمزيد من الاحتمالات ؛ مثالنا هو مجرد نقطة انطلاق للتحقق من صحة الطلب.
البيانات الصالحة هي أحد الجوانب التي يجب ضمانها ؛ المستخدمين الصالحين والإجراءات هي أخرى.
المصادقة مقابل تدفق الأذونات (أو "التفويض")
يعرض تطبيق Node.js لدينا مجموعة كاملة من users/ نقاط النهاية ، مما يسمح لمستهلكي واجهة برمجة التطبيقات بإنشاء المستخدمين وتحديثهم وإدراجهم في القائمة. لكن كل نقطة نهاية تسمح بالوصول العام غير المحدود. إنه نمط شائع لمنع المستخدمين من تغيير بيانات بعضهم البعض والأشخاص الخارجيين من الوصول إلى أي نقطة نهاية لا نريد أن نكون علنيين.
هناك جانبان أساسيان في هذه القيود وكلاهما يختصر إلى "المصادقة". تتعلق المصادقة بمن هو الطلب والتفويض يتعلق بما إذا كان مسموحًا لهم بالقيام بما يطلبونه. من المهم أن تظل على دراية بما تتم مناقشته. حتى بدون النماذج القصيرة ، تعمل رموز استجابة HTTP القياسية على إرباك المشكلة: 401 Unauthorized يتعلق بالمصادقة و 403 Forbidden يتعلق بالتفويض. سنخطئ في جانب "المصادقة" التي تعني "المصادقة" في أسماء الوحدات النمطية ، ونستخدم "الأذونات" في مسائل التفويض.
حتى بدون النماذج القصيرة ، تعمل رموز استجابة HTTP القياسية على إرباك المشكلة:
401 Unauthorizedيتعلق بالمصادقة و403 Forbiddenيتعلق بالتفويض.
هناك الكثير من أساليب المصادقة التي يجب استكشافها ، بما في ذلك موفرو الهوية التابعون لجهات خارجية مثل Auth0. في هذه المقالة ، اخترنا تطبيقًا أساسيًا ولكنه قابل للتطوير. يعتمد على JWTs.
يتكون JWT من JSON المشفر مع بعض البيانات الوصفية غير المتعلقة بالمصادقة ، والتي تتضمن في حالتنا عنوان البريد الإلكتروني للمستخدم وعلامات الأذونات. سيحتوي JSON أيضًا على سر للتحقق من سلامة البيانات الوصفية.
الفكرة هي مطالبة العملاء بإرسال JWT صالح داخل كل طلب غير عام. يتيح لنا هذا التحقق من أن العميل لديه مؤخرًا بيانات اعتماد صالحة لنقطة النهاية التي يريد استخدامها ، دون الحاجة إلى إرسال بيانات الاعتماد بأنفسهم عبر السلك مع كل طلب.
ولكن أين سيتناسب هذا مع قاعدة بيانات API الخاصة بمثالنا؟ سهل: مع البرامج الوسيطة يمكننا استخدامها في تكوين المسار الخاص بنا!
إضافة وحدة المصادقة
لنقم أولاً بتهيئة ما سيكون في JWTs الخاصة بنا. إليك المكان الذي سنبدأ فيه في استخدام حقل permissionFlags من مورد المستخدم الخاص بنا ولكن فقط لأنه بيانات وصفية ملائمة للتشفير داخل JWTs - ليس لأن JWTs بطبيعتها لها أي علاقة بمنطق الأذونات الدقيق.
قبل إنشاء برمجية وسيطة لإنشاء JWT ، سنحتاج إلى إضافة وظيفة خاصة إلى users.dao.ts لاسترداد حقل كلمة المرور ، نظرًا لأننا قمنا بتعيين Mongoose على تجنب استرداده عادةً:
async getUserByEmailWithPassword(email: string) { return this.User.findOne({ email: email }) .select('_id email permissionFlags +password') .exec(); } وفي موقع users.service.ts :
async getUserByEmailWithPassword(email: string) { return UsersDao.getUserByEmailWithPassword(email); } الآن ، دعنا ننشئ مجلد auth في جذر مشروعنا - سنضيف نقطة نهاية للسماح لمستخدمي واجهة برمجة التطبيقات بإنشاء JWTs. أولاً ، لنقم بإنشاء برمجية وسيطة لها في auth/middleware/auth.middleware.ts ، كبرنامج مفرد يسمى AuthMiddleware .
سنحتاج بعض import :
import express from 'express'; import usersService from '../../users/services/users.service'; import * as argon2 from 'argon2'; في فئة AuthMiddleware ، سننشئ وظيفة وسيطة للتحقق مما إذا كان مستخدم واجهة برمجة التطبيقات قد قام بتضمين بيانات اعتماد تسجيل دخول صالحة مع طلبه:
async verifyUserPassword( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( req.body.email ); if (user) { const passwordHash = user.password; if (await argon2.verify(passwordHash, req.body.password)) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } } // Giving the same message in both cases // helps protect against cracking attempts: res.status(400).send({ errors: ['Invalid email and/or password'] }); } بالنسبة إلى البرامج الوسيطة للتأكد من وجود email password في req.body ، سنستخدم أداة التحقق السريع عندما نقوم فيما بعد بتكوين المسار لاستخدام وظيفة verifyUserPassword() أعلاه.
تخزين أسرار JWT
لإنشاء JWT ، سنحتاج إلى سر JWT ، والذي سنستخدمه للتوقيع على JWTs التي تم إنشاؤها وأيضًا للتحقق من صحة JWTs الواردة من طلبات العميل. بدلاً من كتابة التعليمات البرمجية الثابتة لقيمة سر JWT داخل ملف TypeScript ، سنقوم بتخزينها في ملف منفصل "متغير بيئة" ، .env ، والذي لا يجب دفعه أبدًا إلى مستودع الكود .
كالممارسة الشائعة ، قمنا بإضافة ملف .env.example إلى الريبو لمساعدة المطورين على فهم المتغيرات المطلوبة عند إنشاء .env الحقيقي. في حالتنا ، نريد متغيرًا يسمى JWT_SECRET يخزن سر JWT كسلسلة. يجب على القراء الذين ينتظرون حتى نهاية هذه المقالة ويستخدمون الفرع الأخير من الريبو أن يتذكروا تغيير هذه القيم محليًا .
ستحتاج مشاريع العالم الحقيقي بشكل خاص إلى اتباع أفضل ممارسات JWT من خلال التمييز بين أسرار JWT وفقًا للبيئة (التطوير ، التدريج ، الإنتاج ، إلخ).
يجب أن يستخدم ملف .env الخاص بنا (في جذر المشروع) التنسيق التالي ولكن لا يجب أن يحتفظ بنفس القيمة السرية:
JWT_SECRET=My!@!Se3cr8tH4sh3من الطرق السهلة لتحميل هذه المتغيرات في تطبيقنا استخدام مكتبة تسمى dotenv:
npm i dotenv التكوين الوحيد المطلوب هو استدعاء وظيفة dotenv.config() بمجرد إطلاق تطبيقنا. في الجزء العلوي من app.ts ، سنضيف:
import dotenv from 'dotenv'; const dotenvResult = dotenv.config(); if (dotenvResult.error) { throw dotenvResult.error; }مراقب المصادقة
آخر شرط أساسي لجيل JWT هو تثبيت مكتبة jsonwebtoken وأنواع TypeScript الخاصة بها:
npm i jsonwebtoken npm i --save-dev @types/jsonwebtoken الآن ، لنقم بإنشاء وحدة تحكم /auth auth في auth/controllers/auth.controller.ts . لا نحتاج إلى استيراد مكتبة dotenv هنا لأن استيرادها في app.ts يجعل محتويات ملف .env متاحًا في جميع أنحاء التطبيق عبر الكائن العام Node.js المسمى process :
import express from 'express'; import debug from 'debug'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; const log: debug.IDebugger = debug('app:auth-controller'); /** * This value is automatically populated from .env, a file which you will have * to create for yourself at the root of the project. * * See .env.example in the repo for the required format. */ // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; const tokenExpirationInSeconds = 36000; class AuthController { async createJWT(req: express.Request, res: express.Response) { try { const refreshId = req.body.userId + jwtSecret; const salt = crypto.createSecretKey(crypto.randomBytes(16)); const hash = crypto .createHmac('sha512', salt) .update(refreshId) .digest('base64'); req.body.refreshKey = salt.export(); const token = jwt.sign(req.body, jwtSecret, { expiresIn: tokenExpirationInSeconds, }); return res .status(201) .send({ accessToken: token, refreshToken: hash }); } catch (err) { log('createJWT error: %O', err); return res.status(500).send(); } } } export default new AuthController(); ستوقع مكتبة jsonwebtoken على رمز جديد باستخدام jwtSecret . سننشئ أيضًا ملحًا وعلامة تجزئة باستخدام وحدة crypto الأصلية Node.js ، ثم نستخدمها لإنشاء refreshToken حيث يمكن لمستخدمي واجهة برمجة التطبيقات تحديث JWT الحالي - وهو إعداد جيد بشكل خاص لتطبيق ما تكون قادرة على التوسع.
ما الفرق بين refreshKey و refreshToken و accessToken ؟ يتم إرسال الرموز *Token إلى مستهلكي واجهة برمجة التطبيقات لدينا مع فكرة أن accessToken يستخدم لأي طلب بخلاف ما هو متاح لعامة الناس ، ويتم استخدام refreshToken لطلب بديل لـ accessToken منتهي الصلاحية. من ناحية أخرى ، يتم استخدام refreshKey لتمرير متغير salt - المشفر داخل refreshToken إلى البرامج الوسيطة للتحديث ، والتي سنصل إليها أدناه.
لاحظ أن تطبيقنا يحتوي على انتهاء صلاحية الرمز المميز لمقبض jsonwebtoken بالنسبة لنا. إذا انتهت صلاحية JWT ، سيحتاج العميل إلى المصادقة مرة أخرى.
مسار مصادقة Node.js الأولي REST API
لنقم بتهيئة نقطة النهاية الآن على auth/auth.routes.config.ts :
import { CommonRoutesConfig } from '../common/common.routes.config'; import authController from './controllers/auth.controller'; import authMiddleware from './middleware/auth.middleware'; import express from 'express'; import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator'; export class AuthRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'AuthRoutes'); } configureRoutes(): express.Application { this.app.post(`/auth`, [ body('email').isEmail(), body('password').isString(), BodyValidationMiddleware.verifyBodyFieldsErrors, authMiddleware.verifyUserPassword, authController.createJWT, ]); return this.app; } } ولا تنس إضافته إلى ملف app.ts بنا:
// ... import { AuthRoutes } from './auth/auth.routes.config'; // ... routes.push(new AuthRoutes(app)); // independent: can go before or after UsersRoute // ...نحن جاهزون لإعادة تشغيل Node.js والاختبار الآن ، مع التأكد من مطابقة بيانات الاعتماد التي استخدمناها لإنشاء مستخدم الاختبار الخاص بنا مسبقًا:
curl --request POST 'localhost:3000/auth' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'سيكون الرد شيئًا مثل:
{ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJVZGdzUTBYMXciLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicHJvdmlkZXIiOiJlbWFpbCIsInBlcm1pc3Npb25MZXZlbCI6MSwicmVmcmVzaEtleSI6ImtDN3JFdDFHUmNsWTVXM0N4dE9nSFE9PSIsImlhdCI6MTYxMTM0NTYzNiwiZXhwIjoxNjExMzgxNjM2fQ.cfI_Ey4RHKbOKFdVGsowePZlMeX3fku6WHFu0EMjFP8", "refreshToken": "cXBHZ2tJdUhucERaTVpMWVNTckhNenQwcy9Bd0VIQ2RXRnA4bVBJbTBuQVorcS9Qb2xOUDVFS2xEM1RyNm1vTGdoWWJqb2xtQ0NHcXhlWERUcG81d0E9PQ==" }كما في السابق ، دعنا نضع بعض متغيرات البيئة للراحة باستخدام القيم المذكورة أعلاه:
REST_API_EXAMPLE_ACCESS="put_your_access_token_here" REST_API_EXAMPLE_REFRESH="put_your_refresh_token_here"رائعة! لدينا رمز وصول خاص بنا ورمز تحديث مميز ، لكننا نحتاج إلى بعض البرامج الوسيطة التي يمكنها فعل شيء مفيد معهم.
JWT الوسيطة
سنحتاج إلى نوع TypeScript جديد للتعامل مع بنية JWT في شكلها الذي تم فك ترميزه. إنشاء common/types/jwt.ts مع هذا فيه:
export type Jwt = { refreshKey: string; userId: string; permissionFlags: string; }; دعنا ننفذ وظائف البرامج الوسيطة للتحقق من وجود رمز التحديث ، وللتحقق من رمز التحديث ، وللتحقق من JWT. يمكن إدخال الثلاثة جميعًا في ملف جديد ، auth/middleware/jwt.middleware.ts :
import express from 'express'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { Jwt } from '../../common/types/jwt'; import usersService from '../../users/services/users.service'; // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; class JwtMiddleware { verifyRefreshBodyField( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.refreshToken) { return next(); } else { return res .status(400) .send({ errors: ['Missing required field: refreshToken'] }); } } async validRefreshNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( res.locals.jwt.email ); const salt = crypto.createSecretKey( Buffer.from(res.locals.jwt.refreshKey.data) ); const hash = crypto .createHmac('sha512', salt) .update(res.locals.jwt.userId + jwtSecret) .digest('base64'); if (hash === req.body.refreshToken) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } else { return res.status(400).send({ errors: ['Invalid refresh token'] }); } } validJWTNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.headers['authorization']) { try { const authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { res.locals.jwt = jwt.verify( authorization[1], jwtSecret ) as Jwt; next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } } } export default new JwtMiddleware(); The validRefreshNeeded() function also verifies if the refresh token is correct for a specific user ID. If it is, then below we'll reuse authController.createJWT to generate a new JWT for the user.

We also have validJWTNeeded() , which validates whether the API consumer sent a valid JWT in the HTTP headers respecting the convention Authorization: Bearer <token> . (Yes, that's another unfortunate “auth” conflation.)
Now to configure a new route for refreshing the token and the permission flags encoded within it.
JWT Refresh Route
In auth.routes.config.ts we'll import our new middleware:
import jwtMiddleware from './middleware/jwt.middleware';Then we'll add the following route:
this.app.post(`/auth/refresh-token`, [ jwtMiddleware.validJWTNeeded, jwtMiddleware.verifyRefreshBodyField, jwtMiddleware.validRefreshNeeded, authController.createJWT, ]); Now we can test if it is working properly with the accessToken and refreshToken we received earlier:
curl --request POST 'localhost:3000/auth/refresh-token' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw "{ \"refreshToken\": \"$REST_API_EXAMPLE_REFRESH\" }" We should expect to receive a new accessToken and a new refreshToken to be used later. We leave it as an exercise for the reader to ensure that the back end invalidates previous tokens and limits how often new ones can be requested.
Now our API consumers are able to create, validate, and refresh JWTs. Let's look at some permissions concepts, then implement one and integrate it with our JWT middleware in our user routes.
User Permissions
Once we know who an API client is, we want to know whether they're allowed to use the resource they're requesting. It's quite common to manage combinations of permissions for each user. Without adding much complexity, this allows for more flexibility than a traditional “access level” strategy. Regardless of the business logic we use for each permission, it's quite straightforward to create a generic way to handle it.
Bitwise AND ( & ) and Powers of Two
To manage permissions, we'll leverage JavaScript's built-in bitwise AND operator, & . This approach lets us store a whole set of permissions information as a single, per-user number, with each of its binary digits representing whether the user has permission to do something. But there's no need to worry about the math behind it too much—the point is that it's easy to use.
All we need to do is define each kind of permission (a permission flag ) as a power of 2 (1, 2, 4, 8, 16, 32, …). Then we can attach business logic to each flag, up to a maximum of 31 flags. For example, an audio-accessible, international blog might have these permissions:
- 1: Authors can edit text.
- 2: Illustrators can replace illustrations.
- 4: Narrators can replace the audio file corresponding to any paragraph.
- 8: Translators can edit translations.
This approach allows for all sorts of permission flag combinations for users:
- An author's (or editor's) permission flags value will be just the number 1.
- An illustrator's permission flags will be the number 2. But some authors are also illustrators. In that case, we sum the relevant permissions values: 1 + 2 = 3.
- A narrator's flags will be 4. In the case of an author who narrates their own work, it will be 1 + 4 = 5. If they also illustrate, it's 1 + 2 + 4 = 7.
- A translator will have a permission value of 8. Multilingual authors would then have flags of 1 + 8 = 9. A translator who also narrates (but is not an author) would have 4 + 8 = 12.
- If we want to have a sudo admin, having all combined permissions, we can simply use 2,147,483,647, which is the maximum safe value for a 32-bit integer.
Readers can test this logic as plain JavaScript:
- User with permission 5 trying to edit text (permission flag 1):
Input: 5 & 1
Output: 1
- User with permission 1 trying to narrate (permission flag 4):
Input: 1 & 4
Output: 0
- User with permission 12 trying to narrate:
Input: 12 & 4
Output: 4
When the output is 0, we block the user; otherwise, we let them access what they are trying to access.
Permission Flag Implementation
We'll store permissions flags inside the common folder since the business logic can be shared with future modules. Let's start by adding an enum to hold some permission flags at common/middleware/common.permissionflag.enum.ts :
export enum PermissionFlag { FREE_PERMISSION = 1, PAID_PERMISSION = 2, ANOTHER_PAID_PERMISSION = 4, ADMIN_PERMISSION = 8, ALL_PERMISSIONS = 2147483647, }Note: Since this is an example project, we kept the flag names fairly generic.
Before we forget, now's a good time for a quick return to the addUser() function in our user DAO to replace our temporary magic number 1 with PermissionFlag.FREE_PERMISSION . We'll also need a corresponding import statement.
We can also import it into a new middleware file at common/middleware/common.permission.middleware.ts with a singleton class named CommonPermissionMiddleware :
import express from 'express'; import { PermissionFlag } from './common.permissionflag.enum'; import debug from 'debug'; const log: debug.IDebugger = debug('app:common-permission-middleware');Instead of creating several similar middleware functions, we'll use the factory pattern to create a special factory method (or factory function or simply factory ). Our factory function will allow us to generate—at the time of route configuration—middleware functions to check for any permission flag needed. With that, we avoid having to manually duplicate our middleware function whenever we add a new permission flag.
Here's the factory that will generate a middleware function that checks for whatever permission flag we pass it:
permissionFlagRequired(requiredPermissionFlag: PermissionFlag) { return ( req: express.Request, res: express.Response, next: express.NextFunction ) => { try { const userPermissionFlags = parseInt( res.locals.jwt.permissionFlags ); if (userPermissionFlags & requiredPermissionFlag) { next(); } else { res.status(403).send(); } } catch (e) { log(e); } }; }الحالة الأكثر تخصيصًا هي أن المستخدمين الوحيدين الذين يجب أن يكونوا قادرين على الوصول إلى سجل مستخدم معين هم نفس المستخدم أو المسؤول:
async onlySameUserOrAdminCanDoThisAction( req: express.Request, res: express.Response, next: express.NextFunction ) { const userPermissionFlags = parseInt(res.locals.jwt.permissionFlags); if ( req.params && req.params.userId && req.params.userId === res.locals.jwt.userId ) { return next(); } else { if (userPermissionFlags & PermissionFlag.ADMIN_PERMISSION) { return next(); } else { return res.status(403).send(); } } } سنضيف قطعة أخيرة من البرامج الوسيطة ، هذه المرة في users.middleware.ts :
async userCantChangePermission( req: express.Request, res: express.Response, next: express.NextFunction ) { if ( 'permissionFlags' in req.body && req.body.permissionFlags !== res.locals.user.permissionFlags ) { res.status(400).send({ errors: ['User cannot change permission flags'], }); } else { next(); } } ونظرًا لأن الوظيفة المذكورة أعلاه تعتمد على res.locals.user ، يمكننا ملء هذه القيمة في validateUserExists() قبل استدعاء next() :
// ... if (user) { res.locals.user = user; next(); } else { // ... في الواقع ، القيام بذلك في validateUserExists() سيجعله غير ضروري في validateSameEmailBelongToSameUser() . يمكننا إلغاء استدعاء قاعدة البيانات الخاصة بنا هناك ، واستبدالها بالقيمة التي يمكننا الاعتماد عليها في التخزين المؤقت في res.locals :
- const user = await userService.getUserByEmail(req.body.email); - if (user && user.id === req.params.userId) { + if (res.locals.user._id === req.params.userId) { نحن الآن جاهزون لدمج منطق الأذونات الخاص بنا في users.routes.config.ts .
طلب أذونات
أولاً ، سنقوم باستيراد enum الوسيطة الجديدة والتعداد:
import jwtMiddleware from '../auth/middleware/jwt.middleware'; import permissionMiddleware from '../common/middleware/common.permission.middleware'; import { PermissionFlag } from '../common/middleware/common.permissionflag.enum';نريد أن يكون الوصول إلى قائمة المستخدمين متاحًا فقط من خلال الطلبات المقدمة من شخص لديه أذونات المسؤول ، لكننا ما زلنا نريد القدرة على إنشاء مستخدم جديد ليكون عامًا ، كتدفق توقعات تجربة المستخدم العادية. دعنا نقيد قائمة المستخدمين أولاً باستخدام وظيفة المصنع الخاصة بنا قبل وحدة التحكم الخاصة بنا:
this.app .route(`/users`) .get( jwtMiddleware.validJWTNeeded, permissionMiddleware.permissionFlagRequired( PermissionFlag.ADMIN_PERMISSION ), UsersController.listUsers ) // ... تذكر أن استدعاء المصنع هنا ( (...) ) يعرض دالة وسيطة - ومن ثم تتم الإشارة إلى جميع البرامج الوسيطة العادية غير التابعة للمصنع دون استدعاء ( () ).
هناك قيد شائع آخر وهو أنه بالنسبة لجميع المسارات التي تتضمن userId ، نريد فقط أن يكون للمستخدم نفسه أو المسؤول حق الوصول:
.route(`/users/:userId`) - .all(UsersMiddleware.validateUserExists) + .all( + UsersMiddleware.validateUserExists, + jwtMiddleware.validJWTNeeded, + permissionMiddleware.onlySameUserOrAdminCanDoThisAction + ) .get(UsersController.getUserById) سنمنع المستخدمين أيضًا من تصعيد امتيازاتهم عن طريق إضافة UsersMiddleware.userCantChangePermission ، مباشرةً قبل مرجع وظيفة UsersController في نهاية كل مسار من مسارات PUT و PATCH .
ولكن لنفترض كذلك أن منطق عمل REST API الخاص بنا يسمح فقط للمستخدمين الذين لديهم PAID_PERMISSION بتحديث معلوماتهم على الإطلاق. قد يتوافق هذا أو لا يتوافق مع احتياجات العمل للمشاريع الأخرى: إنه فقط لاختبار الفرق بين الإذن المدفوع والإذن المجاني.
يمكن القيام بذلك عن طريق إضافة مكالمة مولد أخرى بعد كل مراجع userCantChangePermission للتو:
permissionMiddleware.permissionFlagRequired( PermissionFlag.PAID_PERMISSION ),مع ذلك ، نحن على استعداد لإعادة تشغيل Node.js وتجربته.
اختبار الأذونات اليدوي
لاختبار المسارات ، دعنا نحاول GET على قائمة المستخدمين بدون رمز وصول:
curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'نتلقى استجابة HTTP 401 لأننا نحتاج إلى استخدام JWT صالح. لنجرب رمز وصول من المصادقة السابقة لدينا:
curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" هذه المرة نحصل على HTTP 403. رمزنا المميز صالح ، لكننا ممنوعون من استخدام نقطة النهاية هذه لأنه ليس لدينا ADMIN_PERMISSION .
لا نحتاج إليه للحصول GET سجل المستخدم الخاص بنا ، على الرغم من:
curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"الإجابة:
{ "_id": "UdgsQ0X1w", "email": "[email protected]", "permissionFlags": 1, "__v": 0 } في المقابل ، يجب أن تفشل محاولة تحديث سجل المستخدم الخاص بنا ، نظرًا لأن قيمة إذننا هي 1 ( FREE_PERMISSION فقط):
curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw '{ "firstName": "Marcos" }'الاستجابة 403 كما هو متوقع.
كتمرين للقارئ ، أوصي بتغيير إشارات permissionFlags المستخدم في قاعدة البيانات المحلية وإجراء منشور جديد /auth (لإنشاء رمز مميز باستخدام permissionFlags الجديد) ، ثم محاولة PATCH المستخدم مرة أخرى. تذكر أنك ستحتاج إلى تعيين العلامات على القيمة الرقمية إما PAID_PERMISSION أو ALL_PERMISSIONS ، نظرًا لأن منطق العمل الخاص بنا يحدد أن ADMIN_PERMISSION حد ذاته لا يتيح لك تصحيح المستخدمين الآخرين أو حتى لنفسك.
يُظهر مطلب /auth الجديد سيناريو أمان يستحق أن يؤخذ في الاعتبار. عندما يغير مالك الموقع أذونات المستخدم - على سبيل المثال ، لمحاولة حظر مستخدم يسيء التصرف - فلن يرى المستخدم أن هذا ساري المفعول حتى تحديث JWT التالي. ذلك لأن التحقق من الأذونات يستخدم بيانات JWT نفسها لتجنب إصابة قاعدة بيانات إضافية.
يمكن أن تساعد خدمات مثل Auth0 من خلال تقديم تدوير تلقائي للرمز المميز ، ولكن سيظل المستخدمون يواجهون سلوكًا غير متوقع للتطبيق خلال الفترة بين الدورات ، مهما كان ذلك قصيرًا في العادة. للتخفيف من ذلك ، يجب على المطورين الحرص على إبطال رموز التحديث بشكل فعال استجابة لتغييرات الأذونات.
أثناء العمل على REST API ، يمكن للمطورين الحماية من الأخطاء المحتملة عن طريق إعادة تشغيل كومة من أوامر cURL بشكل دوري. لكن هذا بطيء وعرضة للخطأ ، وسرعان ما يصبح مملاً.
الاختبار الآلي
مع نمو API ، يصبح من الصعب الحفاظ على جودة البرامج ، خاصة مع تغيير منطق الأعمال بشكل متكرر. لتقليل أخطاء واجهة برمجة التطبيقات قدر الإمكان ونشر التغييرات الجديدة بثقة ، من الشائع جدًا وجود مجموعة اختبار للواجهة الأمامية و / أو النهاية الخلفية للتطبيق.
بدلاً من الغوص في الاختبارات الكتابية والكود القابل للاختبار ، سنعرض بعض الآليات الأساسية ونوفر مجموعة اختبار عملية للقراء للبناء عليها.
التعامل مع بقايا بيانات الاختبار
قبل الأتمتة ، يجدر بنا التفكير فيما يحدث لبيانات الاختبار.
نحن نستخدم Docker Compose لتشغيل قاعدة البيانات المحلية الخاصة بنا ، ونتوقع استخدام قاعدة البيانات هذه للتطوير ، وليس كمصدر مباشر لبيانات الإنتاج. ستؤثر الاختبارات التي سنجريها هنا على قاعدة البيانات المحلية من خلال ترك مجموعة جديدة من بيانات الاختبار في كل مرة نقوم بتشغيلها. لا ينبغي أن تكون هذه مشكلة في معظم الحالات ، ولكن إذا كان الأمر كذلك ، فإننا نترك للقراء تمرين تغيير docker-compose.yml لإنشاء قاعدة بيانات جديدة لأغراض الاختبار.
في العالم الحقيقي ، غالبًا ما يُجري المطورون اختبارات آلية كجزء من خط أنابيب تكامل مستمر. للقيام بذلك ، سيكون من المنطقي تكوين طريقة - على مستوى خط الأنابيب - لإنشاء قاعدة بيانات مؤقتة لكل تشغيل اختباري.
سنستخدم Mocha و Chai و SuperTest لإنشاء اختباراتنا:
npm i --save-dev chai mocha supertest @types/chai @types/express @types/mocha @types/supertest ts-nodeستدير Mocha تطبيقنا وتجري الاختبارات ، وسيسمح Chai بتعبير اختبار أكثر قابلية للقراءة ، وسيسهل SuperTest الاختبار الشامل (E2E) عن طريق استدعاء API الخاص بنا كعميل REST.
سنحتاج إلى تحديث البرامج النصية الخاصة بنا في package.json :
"scripts": { // ... "test": "mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict", "test-debug": "export DEBUG=* && npm test" }, سيسمح لنا ذلك بإجراء الاختبارات في مجلد سننشئه ، يسمى test .
اختبار ميتا
لتجربة البنية التحتية للاختبار ، دعنا ننشئ ملفًا ، test/app.test.ts :
import { expect } from 'chai'; describe('Index Test', function () { it('should always pass', function () { expect(true).to.equal(true); }); }); قد تبدو البنية هنا غير عادية ، لكنها صحيحة. نحدد الاختبارات من خلال expect() السلوك داخلها it() الكتل - التي نعني بها جسم الوظيفة التي سنمررها it() - والتي تسمى داخل كتل describe() .
الآن ، في المحطة ، سنجري:
npm run testيجب أن نرى هذا:
> mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict Index Test ✓ should always pass 1 passing (6ms)رائعة! مكتبات الاختبار الخاصة بنا مثبتة وجاهزة للاستخدام.
تبسيط الاختبار
للحفاظ على نتائج الاختبار نظيفة ، سنرغب في إسكات تسجيل طلب Winston تمامًا أثناء عمليات التشغيل التجريبية العادية. يعد هذا سهلاً مثل إجراء تغيير سريع على فرع else غير المصحح في app.ts لاكتشاف ما إذا كانت وظيفة it() من Mocha موجودة أم لا:
if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, make terse + if (typeof global.it === 'function') { + loggerOptions.level = 'http'; // for non-debug test runs, squelch entirely + } } اللمسة الأخيرة التي نحتاج إلى إضافتها هي تصدير app.ts بنا ليتم استهلاكه في اختباراتنا. في نهاية app.ts ، سنضيف خيار export default قبل server.listen() مباشرةً ، لأن listen() يُعيد كائن Node.js http.Server .
من خلال npm run test سريع للتحقق من أننا لم نكسر المكدس ، نحن الآن جاهزون لاختبار API الخاص بنا.
أول اختبار آلي لـ Real REST API
لبدء تكوين اختبارات المستخدمين لدينا ، دعنا ننشئ test/users/users.test.ts ، بدءًا من عمليات الاستيراد المطلوبة ومتغيرات الاختبار:
import app from '../../app'; import supertest from 'supertest'; import { expect } from 'chai'; import shortid from 'shortid'; import mongoose from 'mongoose'; let firstUserIdTest = ''; // will later hold a value returned by our API const firstUserBody = { email: `marcos.henrique+${shortid.generate()}@toptal.com`, password: 'Sup3rSecret!23', }; let accessToken = ''; let refreshToken = ''; const newFirstName = 'Jose'; const newFirstName2 = 'Paulo'; const newLastName2 = 'Faraco'; بعد ذلك سننشئ كتلة describe() مع بعض تعريفات الإعداد والتفكيك:
describe('users and auth endpoints', function () { let request: supertest.SuperAgentTest; before(function () { request = supertest.agent(app); }); after(function (done) { // shut down the Express.js server, close our MongoDB connection, then // tell Mocha we're done: app.close(() => { mongoose.connection.close(done); }); }); }); يتم استدعاء الوظائف التي ننتقل إليها before() after() قبل وبعد كل الاختبارات التي سنحددها من خلال تسميتها it() داخل نفس كتلة describe() . الوظيفة التي تم تمريرها إلى after() تأخذ رد اتصال ، done ، والذي نضمن أنه يتم استدعاؤه فقط بمجرد تنظيف كل من التطبيق واتصال قاعدة البيانات الخاصة به.
ملاحظة: بدون تكتيك after() الخاص بنا ، سيتم تعليق Mocha حتى بعد إكمال الاختبار بنجاح. غالبًا ما تكون النصيحة هي الاتصال دائمًا بـ Mocha مع --exit لتجنب ذلك ، ولكن هناك تحذير (غالبًا ما يكون مذكورًا). إذا توقفت مجموعة الاختبار عن أسباب أخرى - مثل الوعد الخاطئ في مجموعة الاختبار أو التطبيق نفسه - فعندئذٍ مع - --exit ، لن تنتظر Mocha وستبلغ عن النجاح على أي حال ، مما يضيف تعقيدًا خفيًا إلى تصحيح الأخطاء.
نحن الآن جاهزون لإضافة اختبارات E2E فردية داخل كتلة describe() :
it('should allow a POST to /users', async function () { const res = await request.post('/users').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.id).to.be.a('string'); firstUserIdTest = res.body.id; }); ستعمل هذه الوظيفة الأولى على إنشاء مستخدم جديد لنا - مستخدم فريد ، حيث تم إنشاء البريد الإلكتروني للمستخدم في وقت سابق باستخدام shortid . يحتوي متغير request على وكيل SuperTest ، مما يسمح لنا بتقديم طلبات HTTP إلى واجهة برمجة التطبيقات الخاصة بنا. نجعلهم يستخدمون await ، ولهذا السبب يجب أن تكون الوظيفة التي async it() غير متزامنة. ثم نستخدم expect() من تشاي لاختبار جوانب مختلفة من النتيجة.
يجب أن npm run test في هذه المرحلة نجاح الاختبار الجديد.
سلسلة من الاختبارات
سنضيف كل الكتل التالية it() داخل كتلة describe() الخاصة بنا. يتعين علينا إضافتها بالترتيب المقدم حتى تعمل مع المتغيرات التي نغيرها ، مثل firstUserIdTest .
it('should allow a POST to /auth', async function () { const res = await request.post('/auth').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.accessToken).to.be.a('string'); accessToken = res.body.accessToken; refreshToken = res.body.refreshToken; });هنا نقوم بإحضار رمز وصول جديد وتحديث مميز للمستخدم الذي تم إنشاؤه حديثًا.
it('should allow a GET from /users/:userId with an access token', async function () { const res = await request .get(`/users/${firstUserIdTest}`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(200); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body._id).to.be.a('string'); expect(res.body._id).to.equal(firstUserIdTest); expect(res.body.email).to.equal(firstUserBody.email); }); يؤدي ذلك إلى تقديم طلب GET يحمل رمزًا إلى مسار :userId للتحقق من أن استجابة بيانات المستخدم تتطابق مع ما أرسلناه في البداية.
التعشيش والتخطي والعزل والإنقاذ في الاختبارات
في Mocha ، يمكن أن تحتوي كتل it() أيضًا على كتل describe() خاصة بها ، لذلك سنقوم بتداخل اختبارنا التالي داخل كتلة describe() أخرى. سيؤدي ذلك إلى جعل سلسلة التبعيات الخاصة بنا أكثر وضوحًا في إخراج الاختبار ، كما سنبين في النهاية.
describe('with a valid access token', function () { it('should allow a GET from /users', async function () { const res = await request .get(`/users`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(403); }); });لا يغطي الاختبار الفعال ما نتوقع أن ينجح فحسب ، بل يشمل أيضًا ما نتوقع فشله. نحاول هنا سرد جميع المستخدمين ونتوقع استجابة 403 نظرًا لأن مستخدمنا (الذي لديه أذونات افتراضية) غير مسموح له باستخدام نقطة النهاية هذه.
ضمن كتلة describe() الجديدة هذه ، يمكننا متابعة كتابة الاختبارات. نظرًا لأننا ناقشنا بالفعل الميزات المستخدمة في باقي كود الاختبار ، يمكن العثور عليها بدءًا من هذا السطر في الريبو.
يوفر Mocha بعض الميزات التي يمكن أن تكون ملائمة للاستخدام أثناء تطوير الاختبارات وتصحيحها:
- يمكن استخدام طريقة
.skip()لتجنب تشغيل اختبار واحد أو مجموعة كاملة من الاختبارات. عندما يتم استبدالهit()بـit.skip()(وبالمثلdescribe()) ، لن يتم تشغيل الاختبار أو الاختبارات المعنية ولكن سيتم تسجيلها على أنها "معلقة" في الإخراج النهائي لـ Mocha. - لمزيد من الاستخدام المؤقت ، تتسبب وظيفة .only
.only().only()في تجاهل جميع الاختبارات التي لا تحمل علامة () بالكامل ولا ينتج عنها وضع علامة "معلق" على أي شيء. - يمكن لاستدعاء
mochaكما هو محدد فيpackage.jsonاستخدام--bailسطر أوامر. عند تعيين هذا ، يتوقف Mocha عن إجراء الاختبارات بمجرد فشل اختبار واحد. هذا مفيد بشكل خاص في مشروع مثال REST API الخاص بنا ، حيث تم إعداد الاختبارات للتتالي ؛ إذا تم كسر الاختبار الأول فقط ، فإن Mocha يبلغ عن ذلك بالضبط ، بدلاً من الشكوى من جميع الاختبارات التابعة (ولكن غير المكسورة) التي فشلت الآن بسببها.
إذا أجرينا مجموعة كاملة من الاختبارات في هذه المرحلة باستخدام npm run test ، فسنرى ثلاثة اختبارات فاشلة. (إذا كنا سنغادر الوظائف التي يعتمدون عليها دون تنفيذ في الوقت الحالي ، فستكون هذه الاختبارات الثلاثة مرشحين جيدين لـ .skip() .)
تعتمد الاختبارات الفاشلة على قطعتين مفقودتين حاليًا من تطبيقنا. الأول في users.routes.config.ts :
this.app.put(`/users/:userId/permissionFlags/:permissionFlags`, [ jwtMiddleware.validJWTNeeded, permissionMiddleware.onlySameUserOrAdminCanDoThisAction, // Note: The above two pieces of middleware are needed despite // the reference to them in the .all() call, because that only covers // /users/:userId, not anything beneath it in the hierarchy permissionMiddleware.permissionFlagRequired( PermissionFlag.FREE_PERMISSION ), UsersController.updatePermissionFlags, ]); الملف الثاني الذي نحتاج إلى تحديثه هو users.controller.ts ، لأننا أشرنا للتو إلى وظيفة غير موجودة هناك. سنحتاج إلى إضافة import { PatchUserDto } from '../dto/patch.user.dto'; بالقرب من القمة ، والوظيفة المفقودة للفصل:
async updatePermissionFlags(req: express.Request, res: express.Response) { const patchUserDto: PatchUserDto = { permissionFlags: parseInt(req.params.permissionFlags), }; log(await usersService.patchById(req.body.id, patchUserDto)); res.status(204).send(); }تعد إضافة قدرات تصعيد الامتياز هذه مفيدة للاختبار ولكنها لا تلائم معظم متطلبات العالم الحقيقي. يوجد تمرينان للقارئ هنا:
- ضع في اعتبارك طرقًا لجعل الكود لا يسمح مرة أخرى للمستخدمين بتغيير علامات
permissionFlagsالخاصة بهم مع الاستمرار في السماح باختبار نقاط النهاية المقيدة بالأذونات. - قم بإنشاء وتنفيذ منطق الأعمال (والاختبارات المقابلة) لكيفية تغيير
permissionFlagsالعلامات عبر واجهة برمجة التطبيقات. (هناك لغز دجاجة وبيضة هنا: كيف يحصل مستخدم معين على إذن لتغيير الأذونات في المقام الأول؟)
مع ذلك ، يجب أن ينتهي npm run test الآن بنجاح بإخراج منسق بشكل جيد مثل هذا:
Index Test ✓ should always pass users and auth endpoints ✓ should allow a POST to /users (76ms) ✓ should allow a POST to /auth ✓ should allow a GET from /users/:userId with an access token with a valid access token ✓ should allow a GET from /users ✓ should disallow a PATCH to /users/:userId ✓ should disallow a PUT to /users/:userId with an nonexistent ID ✓ should disallow a PUT to /users/:userId trying to change the permission flags ✓ should allow a PUT to /users/:userId/permissionFlags/2 for testing with a new permission level ✓ should allow a POST to /auth/refresh-token ✓ should allow a PUT to /users/:userId to change first and last names ✓ should allow a GET from /users/:userId and should have a new full name ✓ should allow a DELETE from /users/:userId 13 passing (231ms)لدينا الآن طريقة للتحقق بسرعة من أن REST API تعمل كما هو متوقع.
التصحيح (مع) الاختبارات
يمكن للمطورين الذين يواجهون إخفاقات غير متوقعة في الاختبار الاستفادة بسهولة من وحدة تصحيح أخطاء Winston و Node.js عند تشغيل مجموعة الاختبار.
على سبيل المثال ، من السهل التركيز على استعلامات Mongoose التي يتم تنفيذها عن طريق استدعاء DEBUG=mquery npm run test . (لاحظ كيف يفتقر هذا الأمر إلى بادئة export و && في المنتصف ، مما يجعل البيئة مستمرة في الأوامر اللاحقة.)
من الممكن أيضًا إظهار جميع مخرجات التصحيح باستخدام npm run test-debug ، وذلك بفضل الإضافة السابقة إلى package.json .
مع ذلك ، لدينا واجهة برمجة تطبيقات REST عاملة وقابلة للتطوير ومدعومة من MongoDB ، مع مجموعة اختبار مؤتمتة مريحة. لكنها لا تزال تفتقد بعض الأساسيات.
الأمان (يجب أن ترتدي جميع المشاريع خوذة)
عند العمل مع Express.js ، يجب قراءة الوثائق ، لا سيما أفضل ممارسات الأمان الخاصة بها. على الأقل ، الأمر يستحق المتابعة:
- تكوين دعم TLS
- إضافة برمجيات وسيطة لتحديد المعدل
- التأكد من أن تبعيات npm آمنة (قد يرغب القراء في البدء
npm auditأو التعمق أكثر مع snyk) - استخدام مكتبة Helmet للمساعدة في الحماية من الثغرات الأمنية الشائعة
هذه النقطة الأخيرة واضحة ومباشرة لإضافتها إلى مشروعنا كمثال:
npm i --save helmet بعد ذلك ، في app.ts ، نحتاج فقط إلى استيراده وإضافة app.use() :
import helmet from 'helmet'; // ... app.use(helmet());كما تشير مستنداتها ، فإن Helmet (مثل أي إضافة أمنية) ليست حل سحري ، ولكن كل جزء من الوقاية يساعد.
تحتوي على مشروع REST API الخاص بنا مع Docker
في هذه السلسلة ، لم نتعمق في حاويات Docker ، لكننا استخدمنا MongoDB في حاوية مع Docker Compose. يمكن للقراء الذين ليسوا على دراية بـ Docker ولكنهم يرغبون في تجربة خطوة أخرى إنشاء ملف يسمى Dockerfile (بدون امتداد) في جذر المشروع:
FROM node:14-slim RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY . . RUN npm install EXPOSE 3000 CMD ["node", "./dist/app.js"] يبدأ هذا التكوين node:14-slim من Docker ، ويبني ويدير مثالنا REST API في حاوية. يمكن أن يتغير التكوين من حالة إلى أخرى ، ولكن هذه الإعدادات الافتراضية ذات المظهر العام تعمل في مشروعنا.
لإنشاء الصورة ، نقوم فقط بتشغيل هذا في جذر المشروع (استبدال tag_your_image_here حسب الرغبة):
docker build . -t tag_your_image_hereبعد ذلك ، تتمثل إحدى طرق تشغيل نهايتنا الخلفية - بافتراض استبدال النص نفسه - في:
docker run -p 3000:3000 tag_your_image_here في هذه المرحلة ، يمكن لكل من MongoDB و Node.js استخدام Docker ، لكن علينا أن نبدأهما بطريقتين مختلفتين. نتركه كتمرين للقارئ لإضافة تطبيق Node.js الرئيسي إلى docker-compose.yml بحيث يمكن تشغيل التطبيق بالكامل باستخدام أمر docker-compose واحد.
المزيد من مهارات REST API للاستكشاف
في هذه المقالة ، أجرينا تحسينات واسعة النطاق على واجهة برمجة تطبيقات REST الخاصة بنا: أضفنا MongoDB في حاويات ، وقمنا بتكوين Mongoose والتحقق السريع ، وإضافة مصادقة تستند إلى JWT ونظام أذونات مرن ، وكتبنا مجموعة من الاختبارات الآلية.
هذه نقطة انطلاق قوية لكل من مطوري الواجهة الخلفية الجدد والمتقدمين. ومع ذلك ، قد لا يكون مشروعنا من بعض النواحي مثاليًا لاستخدام الإنتاج والتوسع والصيانة. بصرف النظر عن تمارين القارئ التي رشناها في جميع أنحاء هذا المقال ، ماذا هناك لتتعلمه أيضًا؟
على مستوى API ، نوصي بقراءة إنشاء مواصفات متوافقة مع OpenAPI. القراء المهتمون بشكل خاص بمتابعة تطوير المشاريع سيرغبون أيضًا في تجربة NestJS. إنه إطار عمل آخر تم إنشاؤه أعلى Express.js ، لكنه أكثر قوة وتجريدًا - ولهذا السبب من الجيد استخدام مثال مشروعنا للاعتياد على أساسيات Express.js أولاً. لا يقل أهمية عن ذلك ، أن نهج GraphQL في واجهات برمجة التطبيقات لديه قوة جذب واسعة كبديل لـ REST.
عندما يتعلق الأمر بالأذونات ، فقد قمنا بتغطية نهج العلامات قليلاً باستخدام منشئ البرامج الوسيطة للعلامات المحددة يدويًا. لمزيد من الراحة عند القياس ، يجدر النظر في مكتبة CASL ، التي تتكامل مع Mongoose. إنه يوسع مرونة نهجنا ، مما يسمح بتعريفات موجزة للقدرات يجب أن تسمح بها علامة معينة ، مثل can(['update', 'delete'], '(model name here)', { creator: 'me' }); بدلا من وظيفة وسيطة مخصصة كاملة.
لقد قدمنا نقطة انطلاق للاختبار الآلي العملي في هذا المشروع ، ولكن بعض الموضوعات المهمة كانت خارج نطاقنا. نوصي القراء بما يلي:
- استكشف اختبار الوحدة لاختبار المكونات بشكل منفصل - يمكن استخدام الموكا والتشاي لهذا أيضًا.
- ابحث في أدوات تغطية التعليمات البرمجية ، والتي تساعد في تحديد الفجوات في مجموعات الاختبار من خلال إظهار سطور من التعليمات البرمجية التي لا يتم تشغيلها أثناء الاختبار. باستخدام هذه الأدوات ، يمكن للقراء استكمال نماذج الاختبارات ، حسب الحاجة - لكنهم قد لا يكشفون عن كل سيناريو مفقود ، مثل ما إذا كان يمكن للمستخدمين تعديل أذوناتهم عبر
PATCHإلى/users/:userId. - جرب طرقًا أخرى للاختبار الآلي. لقد استخدمنا الواجهة
expectعلى غرار التطوير المستند إلى السلوك (BDD) من Chai ، ولكنها تدعم أيضًاshould()assert. من الجدير أيضًا تعلم مكتبات اختبار أخرى ، مثل Jest.
بصرف النظر عن هذه الموضوعات ، فإن واجهة برمجة تطبيقات Node.js / TypeScript REST جاهزة للبناء عليها. على وجه الخصوص ، قد يرغب القراء في تنفيذ المزيد من البرامج الوسيطة لفرض منطق الأعمال المشترك حول مورد المستخدم القياسي. لن أتعمق في ذلك هنا ، ولكن سأكون سعيدًا بتقديم إرشادات ونصائح للقراء الذين يجدون أنفسهم محجوبين - فقط اترك تعليقًا أدناه.
الكود الكامل لهذا المشروع متاح كمستودع GitHub مفتوح المصدر.
مزيد من القراءة على مدونة Toptal Engineering:
- استخدام مسارات Express.js لمعالجة الأخطاء المستندة إلى الوعد
