إنشاء Node.js / TypeScript REST API ، الجزء 2: النماذج والبرمجيات الوسيطة والخدمات

نشرت: 2022-03-11

في المقالة الأولى من سلسلة REST API الخاصة بنا ، قمنا بتغطية كيفية استخدام npm لإنشاء نهاية خلفية من البداية ، وإضافة تبعيات مثل TypeScript ، واستخدام وحدة debug المضمنة في Node.js ، وإنشاء بنية مشروع Express.js ، ووقت تشغيل السجل الأحداث بمرونة مع ونستون. إذا كنت مرتاحًا لهذه المفاهيم بالفعل ، فما عليك سوى استنساخ هذا ، والتبديل إلى toptal-article-01 باستخدام git checkout ، ثم تابع القراءة.

خدمات REST API والبرمجيات الوسيطة ووحدات التحكم والنماذج

كما وعدنا ، سوف ندخل الآن في تفاصيل حول هذه الوحدات:

  • الخدمات التي تجعل الكود الخاص بنا أكثر وضوحًا من خلال تغليف عمليات منطق الأعمال في وظائف يمكن أن تستدعيها البرامج الوسيطة ووحدات التحكم.
  • البرمجيات الوسيطة التي ستتحقق من الشروط المسبقة قبل أن يستدعي Express.js وظيفة وحدة التحكم المناسبة.
  • المتحكمون الذين يستخدمون الخدمات لمعالجة الطلب قبل إرسال رد أخيرًا إلى مقدم الطلب.
  • النماذج التي تصف بياناتنا وتساعد في عمليات فحص وقت الترجمة.

سنضيف أيضًا قاعدة بيانات بدائية جدًا وليست مناسبة للإنتاج بأي حال من الأحوال. (الغرض الوحيد منه هو تسهيل متابعة هذا البرنامج التعليمي ، مما يمهد الطريق لمقالنا التالي للتعمق في اتصال قاعدة البيانات والتكامل مع MongoDB و Mongoose.)

التدريب العملي: الخطوات الأولى مع DAOs و DTOs وقاعدة البيانات المؤقتة الخاصة بنا

في هذا الجزء من برنامجنا التعليمي ، لن تستخدم قاعدة بياناتنا الملفات حتى. سيحافظ ببساطة على بيانات المستخدم في مصفوفة ، مما يعني أن البيانات تتبخر كلما خرجنا من Node.js. سوف يدعم فقط أبسط عمليات الإنشاء والقراءة والتحديث والحذف (CRUD).

سنستخدم مفهومين هنا:

  • كائنات الوصول إلى البيانات (DAOs)
  • كائنات نقل البيانات (DTOs)

هذا الاختلاف المكون من حرف واحد بين الاختصارات ضروري: DAO مسؤول عن الاتصال بقاعدة بيانات محددة وتنفيذ عمليات CRUD ؛ DTO هو كائن يحتفظ بالبيانات الأولية التي سيرسلها DAO إلى قاعدة البيانات ويستقبلها منها.

بمعنى آخر ، DTOs هي كائنات تتوافق مع أنواع نماذج البيانات ، و DAOs هي الخدمات التي تستخدمها.

بينما يمكن أن تصبح DTOs أكثر تعقيدًا - تمثل كيانات قاعدة البيانات المتداخلة ، على سبيل المثال - في هذه المقالة ، سيتوافق مثيل DTO الفردي مع إجراء محدد في صف قاعدة بيانات واحد.

لماذا DTOs؟

يساعد استخدام DTOs لجعل كائنات TypeScript الخاصة بنا متوافقة مع نماذج البيانات لدينا في الحفاظ على التناسق المعماري ، كما سنرى في القسم الخاص بالخدمات أدناه. ولكن هناك تحذير حاسم: لا تعد DTOs ولا TypeScript نفسها بأي نوع من التحقق التلقائي من صحة إدخال المستخدم ، حيث يجب أن يحدث ذلك في وقت التشغيل. عندما يتلقى الكود الخاص بنا إدخال المستخدم في نقطة نهاية في واجهة برمجة التطبيقات الخاصة بنا ، فقد:

  • امتلك حقولاً إضافية
  • تفتقد الحقول المطلوبة (على سبيل المثال ، تلك غير الملحقة بـ ? )
  • تحتوي على حقول لا تكون البيانات فيها من النوع الذي حددناه في نموذجنا باستخدام TypeScript

لن يتحقق TypeScript (وجافا سكريبت الذي يتم تحويله إليه) بطريقة سحرية من هذا الأمر بالنسبة لنا ، لذلك من المهم ألا ننسى عمليات التحقق هذه ، خاصة عند فتح API الخاص بك للجمهور. يمكن أن تساعد الحزم مثل ajv في ذلك ولكنها تعمل عادةً عن طريق تحديد النماذج في كائن مخطط خاص بمكتبة بدلاً من TypeScript الأصلي. (النمس ، الذي تمت مناقشته في المقالة التالية ، سيلعب دورًا مشابهًا في هذا المشروع.)

قد تفكر ، "هل من الأفضل حقًا استخدام DAOs و DTOs ، بدلاً من استخدام شيء أبسط؟" يقدم مطور المشاريع Gunther Popp إجابة ؛ سترغب في تجنب DTOs في معظم مشاريع Express.js / TypeScript الأصغر في العالم الحقيقي ما لم تتوقع بشكل معقول التوسع في المدى المتوسط.

ولكن حتى إذا لم تكن على وشك استخدامها في الإنتاج ، فإن مثال المشروع هذا يمثل فرصة جديرة بالاهتمام في طريق إتقان بنية TypeScript API. إنها طريقة رائعة لممارسة الاستفادة من أنواع TypeScript بطرق إضافية والعمل مع DTOs لمعرفة كيفية مقارنتها بنهج أكثر أساسية عند إضافة المكونات والنماذج.

نموذج واجهة برمجة تطبيقات REST للمستخدم الخاص بنا على مستوى TypeScript

أولاً سنحدد ثلاثة DTOs لمستخدمنا. لنقم بإنشاء مجلد يسمى dto داخل مجلد users ، وننشئ ملفًا هناك يسمى create.user.dto.ts يحتوي على ما يلي:

 export interface CreateUserDto { id: string; email: string; password: string; firstName?: string; lastName?: string; permissionLevel?: number; }

نقول أنه في كل مرة نقوم فيها بإنشاء مستخدم ، بغض النظر عن قاعدة البيانات ، يجب أن يكون له معرّف وكلمة مرور وبريد إلكتروني ، واختيارياً الاسم الأول والأخير. يمكن أن تتغير هذه المتطلبات بناءً على متطلبات العمل لمشروع معين.

بالنسبة لطلبات PUT ، نريد تحديث الكائن بالكامل ، لذا فإن الحقول الاختيارية مطلوبة الآن. في نفس المجلد ، قم بإنشاء ملف يسمى put.user.dto.ts بهذا الكود:

 export interface PutUserDto { id: string; email: string; password: string; firstName: string; lastName: string; permissionLevel: number; }

بالنسبة لطلبات PATCH ، يمكننا استخدام الميزة Partial من TypeScript ، والتي تنشئ نوعًا جديدًا عن طريق نسخ نوع آخر وجعل جميع حقوله اختيارية. بهذه الطريقة ، يجب أن يحتوي الملف patch.user.dto.ts على الكود التالي فقط:

 import { PutUserDto } from './put.user.dto'; export interface PatchUserDto extends Partial<PutUserDto> {}

الآن ، دعنا ننشئ قاعدة البيانات المؤقتة في الذاكرة. لنقم بإنشاء مجلد يسمى daos داخل مجلد users ، ونضيف ملفًا باسم users.dao.ts .

أولاً ، نريد استيراد DTOs التي أنشأناها:

 import { CreateUserDto } from '../dto/create.user.dto'; import { PatchUserDto } from '../dto/patch.user.dto'; import { PutUserDto } from '../dto/put.user.dto';

الآن ، للتعامل مع معرفات المستخدم الخاصة بنا ، دعنا نضيف مكتبة Shortid (باستخدام المحطة):

 npm i shortid npm i --save-dev @types/shortid

مرة أخرى في users.dao.ts ، سنستورد shortid:

 import shortid from 'shortid'; import debug from 'debug'; const log: debug.IDebugger = debug('app:in-memory-dao');

يمكننا الآن إنشاء فصل دراسي يسمى UsersDao ، والذي سيبدو كالتالي:

 class UsersDao { users: Array<CreateUserDto> = []; constructor() { log('Created new instance of UsersDao'); } } export default new UsersDao();

باستخدام النمط الفردي ، ستوفر هذه الفئة دائمًا نفس المثيل - وبشكل حاسم ، مصفوفة users نفسها - عندما نقوم باستيرادها في ملفات أخرى. ذلك لأن Node.js يخزن هذا الملف مؤقتًا أينما تم استيراده ، وتحدث جميع عمليات الاستيراد عند بدء التشغيل. أي أن أي ملف يشير إلى users.dao.ts سيتم تسليمه مرجعًا إلى نفس new UsersDao() الذي يتم تصديره في المرة الأولى التي يعالج فيها Node.js هذا الملف.

سنرى هذا يعمل عندما نستخدم هذه الفئة بشكل أكبر في هذه المقالة ، ونستخدم نمط TypeScript / Express.js الشائع لمعظم الفئات في جميع أنحاء المشروع.

ملاحظة: من العيوب التي يتم الاستشهاد بها في كثير من الأحيان أنه يصعب عليهم كتابة اختبارات الوحدة. في حالة العديد من فصولنا الدراسية ، لن يتم تطبيق هذا العيب ، نظرًا لعدم وجود أي متغيرات لأعضاء الفصل تحتاج إلى إعادة تعيين. ولكن بالنسبة لأولئك الذين يكونون كذلك ، فإننا نترك الأمر كتمرين للقارئ للنظر في مقاربة هذه المشكلة باستخدام حقن التبعية.

سنقوم الآن بإضافة عمليات CRUD الأساسية إلى الفصل كوظائف. ستبدو وظيفة الإنشاء كما يلي:

 async addUser(user: CreateUserDto) { user.id = shortid.generate(); this.users.push(user); return user.id; }

ستأتي القراءة في نسختين ، "اقرأ جميع الموارد" و "اقرأ واحدة بمعرف". تم ترميزها على النحو التالي:

 async getUsers() { return this.users; } async getUserById(userId: string) { return this.users.find((user: { id: string }) => user.id === userId); }

وبالمثل ، فإن التحديث يعني إما الكتابة فوق الكائن الكامل (مثل PUT ) أو مجرد أجزاء من الكائن (مثل PATCH ):

 async putUserById(userId: string, user: PutUserDto) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); this.users.splice(objIndex, 1, user); return `${user.id} updated via put`; } async patchUserById(userId: string, user: PatchUserDto) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); let currentUser = this.users[objIndex]; const allowedPatchFields = [ 'password', 'firstName', 'lastName', 'permissionLevel', ]; for (let field of allowedPatchFields) { if (field in user) { // @ts-ignore currentUser[field] = user[field]; } } this.users.splice(objIndex, 1, currentUser); return `${user.id} patched`; }

كما ذكرنا سابقًا ، على الرغم من إعلان UserDto في توقيعات الوظائف هذه ، لا يوفر TypeScript فحصًا لنوع وقت التشغيل. هذا يعني ذاك:

  • putUserById() بها خطأ. سيتيح لمستهلكي واجهة برمجة التطبيقات (API) تخزين قيم الحقول التي ليست جزءًا من النموذج المحدد بواسطة DTO الخاص بنا.
  • patchUserById() تعتمد على قائمة مكررة بأسماء الحقول التي يجب أن تبقى متزامنة مع النموذج. بدون ذلك ، سيتعين عليه استخدام الكائن الذي يتم تحديثه لهذه القائمة. هذا يعني أنه سيتجاهل بصمت قيم الحقول التي تعد جزءًا من النموذج المحدد DTO ولكن لم يتم حفظها من قبل لمثيل الكائن المحدد هذا.

ولكن سيتم التعامل مع هذين السيناريوهين بشكل صحيح على مستوى قاعدة البيانات في المقالة التالية.

ستبدو العملية الأخيرة لحذف مورد كما يلي:

 async removeUserById(userId: string) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); this.users.splice(objIndex, 1); return `${userId} removed`; }

كمكافأة ، مع العلم أن الشرط المسبق لإنشاء مستخدم هو التحقق من عدم تكرار البريد الإلكتروني للمستخدم ، دعنا نضيف وظيفة "الحصول على مستخدم عبر البريد الإلكتروني" الآن:

 async getUserByEmail(email: string) { const objIndex = this.users.findIndex( (obj: { email: string }) => obj.email === email ); let currentUser = this.users[objIndex]; if (currentUser) { return currentUser; } else { return null; } }

ملاحظة: في سيناريو العالم الحقيقي ، من المحتمل أن تتصل بقاعدة بيانات باستخدام مكتبة موجودة مسبقًا ، مثل Mongoose أو Sequelize ، والتي ستجرد جميع العمليات الأساسية التي قد تحتاجها. لهذا السبب ، لن ندخل في تفاصيل الوظائف التي تم تنفيذها أعلاه.

طبقة خدمات REST API الخاصة بنا

الآن بعد أن أصبح لدينا DAO أساسي في الذاكرة ، يمكننا إنشاء خدمة تستدعي وظائف CRUD. نظرًا لأن وظائف CRUD هي شيء ستحتاج إليه كل خدمة ستتصل بقاعدة بيانات ، فسننشئ واجهة CRUD تحتوي على الأساليب التي نريد تنفيذها في كل مرة نريد فيها تنفيذ خدمة جديدة.

في الوقت الحاضر ، تمتلك IDEs التي نعمل معها ميزات إنشاء التعليمات البرمجية لإضافة الوظائف التي نقوم بتنفيذها ، مما يقلل من مقدار التعليمات البرمجية المتكررة التي نحتاج إلى كتابتها.

مثال سريع باستخدام WebStorm IDE:

لقطة شاشة لـ WebStorm تعرض تعريفًا فارغًا لفئة تسمى MyService تنفذ واجهة تسمى CRUD. تم وضع خط أحمر تحته اسم MyService باللون الأحمر بواسطة IDE.

يبرز IDE اسم فئة MyService ويقترح الخيارات التالية:

لقطة شاشة مشابهة للصورة السابقة ، ولكن مع قائمة سياق تسرد العديد من الخيارات ، أولها "تنفيذ جميع الأعضاء".

يعمل خيار "تنفيذ جميع الأعضاء" على دعم الوظائف اللازمة للتوافق مع واجهة CRUD :

لقطة شاشة لفئة MyService في WebStorm. لم يعد MyService مسطرًا باللون الأحمر ، ويحتوي تعريف الفئة الآن على جميع تواقيع دالة TypeScript (جنبًا إلى جنب مع نصوص الوظائف ، إما فارغة أو تحتوي على عبارة إرجاع) المحددة في واجهة CRUD.

بعد قولي هذا كله ، لنقم أولاً بإنشاء واجهة TypeScript الخاصة بنا ، والتي تسمى CRUD . في مجلدنا common ، دعنا ننشئ مجلدًا يسمى interfaces ونضيف crud.interface.ts بما يلي:

 export interface CRUD { list: (limit: number, page: number) => Promise<any>; create: (resource: any) => Promise<any>; putById: (id: string, resource: any) => Promise<string>; readById: (id: string) => Promise<any>; deleteById: (id: string) => Promise<string>; patchById: (id: string, resource: any) => Promise<string>; }

بعد القيام بذلك ، يتيح إنشاء مجلد services داخل مجلد users وإضافة ملف users.service.ts هناك ، والذي يحتوي على:

 import UsersDao from '../daos/users.dao'; import { CRUD } from '../../common/interfaces/crud.interface'; import { CreateUserDto } from '../dto/create.user.dto'; import { PutUserDto } from '../dto/put.user.dto'; import { PatchUserDto } from '../dto/patch.user.dto'; class UsersService implements CRUD { async create(resource: CreateUserDto) { return UsersDao.addUser(resource); } async deleteById(id: string) { return UsersDao.removeUserById(id); } async list(limit: number, page: number) { return UsersDao.getUsers(); } async patchById(id: string, resource: PatchUserDto) { return UsersDao.patchUserById(id, resource); } async readById(id: string) { return UsersDao.getUserById(id); } async putById(id: string, resource: PutUserDto) { return UsersDao.putUserById(id, resource); } async getUserByEmail(email: string) { return UsersDao.getUserByEmail(email); } } export default new UsersService();

كانت خطوتنا الأولى هنا هي استيراد DAO داخل الذاكرة ، وتبعية واجهتنا ، ونوع TypeScript لكل من DTOs لدينا ، فقد حان الوقت لتطبيق UsersService كخدمة فردية ، وهو نفس النمط الذي استخدمناه مع DAO.

جميع وظائف CRUD الآن فقط قم باستدعاء الوظائف الخاصة بـ UsersDao . عندما يحين وقت استبدال DAO ، لن نضطر إلى إجراء تغييرات في أي مكان آخر في المشروع ، باستثناء بعض التعديلات على هذا الملف حيث يتم استدعاء وظائف DAO ، كما سنرى في الجزء 3.

على سبيل المثال ، لن نضطر إلى تعقب كل مكالمة إلى list() والتحقق من سياقها قبل استبدالها. هذه هي ميزة وجود طبقة الفصل هذه ، على حساب الكمية الصغيرة من النموذج الأولي الذي تراه أعلاه.

عدم التزامن / انتظار و Node.js

قد يبدو استخدامنا غير async لوظائف الخدمة بلا فائدة. في الوقت الحالي ، يكون الأمر كذلك: تقوم كل هذه الوظائف بإرجاع قيمها على الفور ، دون أي استخدام داخلي لـ Promise s أو await . هذا فقط لإعداد قاعدة التعليمات البرمجية الخاصة بنا للخدمات التي ستستخدم غير async . وبالمثل ، أدناه ، سترى أن جميع استدعاءات هذه الوظائف تستخدم في await .

بنهاية هذه المقالة ، سيكون لديك مرة أخرى مشروع قابل للتشغيل لتجربته. ستكون هذه لحظة ممتازة لمحاولة إضافة أنواع مختلفة من الأخطاء في أماكن مختلفة في قاعدة الشفرة ، ومعرفة ما يحدث أثناء التجميع والاختبار. قد لا تتصرف الأخطاء في سياق غير async على وجه الخصوص بالشكل الذي تتوقعه. يجدر البحث في الحلول المختلفة واستكشافها ، والتي تتجاوز نطاق هذا المقال.


الآن ، بعد أن أصبحت DAO والخدمات جاهزة ، دعنا نعود إلى وحدة تحكم المستخدم.

بناء وحدة تحكم REST API الخاصة بنا

كما قلنا أعلاه ، فإن الفكرة وراء وحدات التحكم هي فصل تكوين المسار عن الكود الذي يعالج طلب المسار في النهاية. هذا يعني أنه يجب إجراء جميع عمليات التحقق من الصحة قبل أن يصل طلبنا إلى وحدة التحكم. يحتاج المتحكم فقط إلى معرفة ما يجب فعله بالطلب الفعلي لأنه إذا قدم الطلب إلى هذا الحد ، فإننا نعلم أنه تبين أنه صالح. ستتصل وحدة التحكم بعد ذلك بالخدمة المعنية لكل طلب سيتم التعامل معه.

قبل أن نبدأ ، سنحتاج إلى تثبيت مكتبة للتجزئة الآمنة لكلمة مرور المستخدم:

 npm i argon2

لنبدأ بإنشاء مجلد يسمى controllers داخل مجلد وحدة تحكم users وإنشاء ملف يسمى users.controller.ts فيه:

 // we import express to add types to the request/response objects from our controller functions import express from 'express'; // we import our newly created user services import usersService from '../services/users.service'; // we import the argon2 library for password hashing import argon2 from 'argon2'; // we use debug with a custom context as described in Part 1 import debug from 'debug'; const log: debug.IDebugger = debug('app:users-controller'); class UsersController { async listUsers(req: express.Request, res: express.Response) { const users = await usersService.list(100, 0); res.status(200).send(users); } async getUserById(req: express.Request, res: express.Response) { const user = await usersService.readById(req.body.id); res.status(200).send(user); } async createUser(req: express.Request, res: express.Response) { req.body.password = await argon2.hash(req.body.password); const userId = await usersService.create(req.body); res.status(201).send({ id: userId }); } async patch(req: express.Request, res: express.Response) { if (req.body.password) { req.body.password = await argon2.hash(req.body.password); } log(await usersService.patchById(req.body.id, req.body)); res.status(204).send(); } async put(req: express.Request, res: express.Response) { req.body.password = await argon2.hash(req.body.password); log(await usersService.putById(req.body.id, req.body)); res.status(204).send(); } async removeUser(req: express.Request, res: express.Response) { log(await usersService.deleteById(req.body.id)); res.status(204).send(); } } export default new UsersController();

ملاحظة: الأسطر أعلاه التي لا ترسل أي شيء مع استجابة HTTP 204 No Content تتماشى مع RFC 7231 حول الموضوع.

بعد الانتهاء من وحدة تحكم المستخدم الخاصة بنا ، نحن على استعداد لتشفير الوحدة الأخرى التي تعتمد على نموذج وخدمة كائن REST API: البرنامج الوسيط للمستخدم.

Node.js REST Middleware مع Express.js

ماذا يمكننا أن نفعل مع برمجية Express.js الوسيطة؟ تعتبر عمليات التحقق من الصحة مناسبة جدًا لشخص واحد. دعنا نضيف بعض عمليات التحقق الأساسية للعمل كحراس بوابات للطلبات قبل إرسالها إلى وحدة تحكم المستخدم الخاصة بنا:

  • تأكد من وجود حقول المستخدم مثل email password كما هو مطلوب لإنشاء مستخدم أو تحديثه
  • تأكد من أن بريدًا إلكترونيًا معينًا ليس قيد الاستخدام بالفعل
  • تحقق من أننا لا نغير حقل email بعد الإنشاء (نظرًا لأننا نستخدم ذلك كمعرف المستخدم الأساسي للبساطة)
  • التحقق من وجود مستخدم معين

لإجراء عمليات التحقق هذه للعمل مع Express.js ، سنحتاج إلى ترجمتها إلى وظائف تتبع نمط Express.js للتحكم في التدفق باستخدام next() ، كما هو موضح في المقالة السابقة. سنحتاج إلى ملف جديد ، users/middleware/users.middleware.ts :

 import express from 'express'; import userService from '../services/users.service'; import debug from 'debug'; const log: debug.IDebugger = debug('app:users-controller'); class UsersMiddleware { } export default new UsersMiddleware();

مع خروج الصيغة المعيارية المفردة المألوفة من الطريق ، دعنا نضيف بعض وظائف الوسيط إلى جسم الفصل:

 async validateRequiredUserBodyFields( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.email && req.body.password) { next(); } else { res.status(400).send({ error: `Missing required fields email and password`, }); } } async validateSameEmailDoesntExist( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.getUserByEmail(req.body.email); if (user) { res.status(400).send({ error: `User email already exists` }); } else { next(); } } async validateSameEmailBelongToSameUser( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.getUserByEmail(req.body.email); if (user && user.id === req.params.userId) { next(); } else { res.status(400).send({ error: `Invalid email` }); } } // Here we need to use an arrow function to bind `this` correctly validatePatchEmail = async ( req: express.Request, res: express.Response, next: express.NextFunction ) => { if (req.body.email) { log('Validating email', req.body.email); this.validateSameEmailBelongToSameUser(req, res, next); } else { next(); } }; async validateUserExists( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.readById(req.params.userId); if (user) { next(); } else { res.status(404).send({ error: `User ${req.params.userId} not found`, }); } }

لتوفير طريقة سهلة لعملاء واجهة برمجة التطبيقات لدينا لتقديم المزيد من الطلبات حول مستخدم مضاف حديثًا ، سنضيف وظيفة مساعدة من شأنها استخراج userId من معلمات الطلب - الواردة من عنوان URL للطلب نفسه - وإضافته إلى نص الطلب ، حيث توجد بقية بيانات المستخدم.

الفكرة هنا هي أن تكون قادرًا ببساطة على استخدام طلب النص الكامل عندما نرغب في تحديث معلومات المستخدم ، دون القلق بشأن الحصول على المعرف من المعلمات في كل مرة. بدلاً من ذلك ، يتم الاعتناء به في مكان واحد فقط ، وهو البرامج الوسيطة. ستبدو الوظيفة كما يلي:

 async extractUserId( req: express.Request, res: express.Response, next: express.NextFunction ) { req.body.id = req.params.userId; next(); }

إلى جانب المنطق ، يتمثل الاختلاف الرئيسي بين البرنامج الوسيط ووحدة التحكم في أننا نستخدم الآن الوظيفة next() لتمرير التحكم عبر سلسلة من الوظائف المكونة حتى تصل إلى الوجهة النهائية ، والتي في حالتنا هي وحدة التحكم.

وضع كل ذلك معًا: إعادة هيكلة طرقنا

الآن بعد أن قمنا بتنفيذ جميع الجوانب الجديدة لبنية مشروعنا ، دعنا نعود إلى ملف users.routes.config.ts الذي حددناه في المقالة السابقة. سوف يستدعي البرنامج الوسيط الخاص بنا ووحدات التحكم الخاصة بنا ، وكلاهما يعتمد على خدمة المستخدم الخاصة بنا ، والتي تتطلب بدورها نموذج المستخدم الخاص بنا.

سيكون الملف النهائي بهذه البساطة:

 import { CommonRoutesConfig } from '../common/common.routes.config'; import UsersController from './controllers/users.controller'; import UsersMiddleware from './middleware/users.middleware'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes(): express.Application { this.app .route(`/users`) .get(UsersController.listUsers) .post( UsersMiddleware.validateRequiredUserBodyFields, UsersMiddleware.validateSameEmailDoesntExist, UsersController.createUser ); this.app.param(`userId`, UsersMiddleware.extractUserId); this.app .route(`/users/:userId`) .all(UsersMiddleware.validateUserExists) .get(UsersController.getUserById) .delete(UsersController.removeUser); this.app.put(`/users/:userId`, [ UsersMiddleware.validateRequiredUserBodyFields, UsersMiddleware.validateSameEmailBelongToSameUser, UsersController.put, ]); this.app.patch(`/users/:userId`, [ UsersMiddleware.validatePatchEmail, UsersController.patch, ]); return this.app; } }

هنا ، قمنا بإعادة تعريف مساراتنا من خلال إضافة برمجيات وسيطة للتحقق من صحة منطق أعمالنا ووظائف وحدة التحكم المناسبة لمعالجة الطلب إذا كان كل شيء صالحًا. لقد استخدمنا أيضًا الدالة userId .param() من Express.js لاستخراج معرف المستخدم.

في الوظيفة .all .all() ، نقوم بتمرير وظيفة UsersMiddleware validateUserExists استدعاؤها قبل أن يمر أي GET أو PUT أو PATCH أو DELETE في نقطة النهاية /users/:userId . هذا يعني أن validateUserExists لا يحتاج إلى أن يكون في مصفوفات الوظائف الإضافية التي نمررها إلى .put() أو .patch() - سيتم استدعاؤها قبل الوظائف المحددة هناك.

لقد استفدنا من إمكانية إعادة الاستخدام المتأصلة في البرامج الوسيطة بطريقة أخرى أيضًا. من خلال تمرير UsersMiddleware.validateRequiredUserBodyFields لاستخدامها في سياقي POST و PUT ، فإننا نعيد دمجها بأناقة مع وظائف البرامج الوسيطة الأخرى.

إخلاء المسؤولية: نحن نغطي فقط عمليات التحقق الأساسية في هذه المقالة. في مشروع حقيقي ، ستحتاج إلى التفكير والعثور على جميع القيود التي تحتاجها للتشفير. من أجل البساطة ، نفترض أيضًا أنه لا يمكن للمستخدم تغيير بريده الإلكتروني.

اختبار واجهة برمجة تطبيقات Express / TypeScript REST الخاصة بنا

يمكننا الآن تجميع تطبيق Node.js وتشغيله. بمجرد تشغيله ، نكون جاهزين لاختبار مسارات API الخاصة بنا باستخدام عميل REST مثل Postman أو cURL.

دعنا نحاول أولاً الحصول على مستخدمينا:

 curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'

في هذه المرحلة ، سيكون لدينا مصفوفة فارغة كاستجابة ، وهي دقيقة. يمكننا الآن محاولة إنشاء مورد المستخدم الأول باستخدام هذا:

 curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json'

لاحظ أن تطبيق Node.js الآن سيرسل خطأ من برمجتنا الوسيطة:

 { "error": "Missing required fields email and password" }

لإصلاحها ، دعنا نرسل طلبًا صالحًا للنشر إلى /users :

 curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "email": "[email protected]", "password": "sup3rS3cr3tPassw0rd!23" }'

هذه المرة ، يجب أن نرى شيئًا مثل ما يلي:

 { "id": "ksVnfnPVW" }

هذا id هو معرف المستخدم الذي تم إنشاؤه حديثًا وسيكون مختلفًا على جهازك. لتسهيل عبارات الاختبار المتبقية ، يمكنك تشغيل هذا الأمر مع الأمر الذي تحصل عليه (بافتراض أنك تستخدم بيئة تشبه Linux):

 REST_API_EXAMPLE_

يمكننا الآن رؤية الاستجابة التي نحصل عليها من تقديم طلب GET باستخدام المتغير أعلاه:

 curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'

يمكننا الآن أيضًا تحديث المورد بالكامل بطلب PUT التالي:

 curl --request PUT "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "email": "[email protected]", "password": "sup3rS3cr3tPassw0rd!23", "firstName": "Marcos", "lastName": "Silva", "permissionLevel": 8 }'

يمكننا أيضًا اختبار ما إذا كان التحقق من الصحة يعمل عن طريق تغيير عنوان البريد الإلكتروني ، مما قد يؤدي إلى حدوث خطأ.

لاحظ أنه عند استخدام PUT لمعرّف المورد ، فنحن ، كمستهلكين لواجهة برمجة التطبيقات ، نحتاج إلى إرسال الكائن بالكامل إذا أردنا التوافق مع نمط REST القياسي. هذا يعني أنه إذا أردنا تحديث حقل lastName فقط ، ولكن باستخدام نقطة نهاية PUT الخاصة بنا ، فسنضطر إلى إرسال الكائن بأكمله ليتم تحديثه. سيكون من الأسهل استخدام طلب PATCH لأنه لا يزال ضمن قيود REST القياسية لإرسال حقل lastName فقط:

 curl --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "lastName": "Faraco" }'

تذكر أنه في قاعدة التعليمات البرمجية الخاصة بنا ، فإن تكوين المسار الخاص بنا هو الذي يفرض هذا التمييز بين PUT و PATCH باستخدام وظائف البرامج الوسيطة التي أضفناها في هذه المقالة.

PUT أم PATCH أم كلاهما؟

قد يبدو أنه لا يوجد سبب كبير لدعم PUT نظرًا لمرونة PATCH ، وستتخذ بعض واجهات برمجة التطبيقات هذا النهج. قد يصر الآخرون على دعم PUT لجعل واجهة برمجة التطبيقات "متوافقة تمامًا مع REST" ، وفي هذه الحالة ، قد يكون إنشاء مسارات PUT لكل حقل أسلوبًا مناسبًا لحالات الاستخدام الشائع.

في الواقع ، تعد هذه النقاط جزءًا من مناقشة أكبر بكثير تتراوح من الاختلافات في الحياة الواقعية بين الاثنين إلى دلالات أكثر مرونة لـ PATCH وحدها. نقدم هنا دعم PUT ودلالات PATCH المستخدمة على نطاق واسع من أجل البساطة ، لكننا نشجع القراء على الخوض في مزيد من البحث عندما يشعرون بالاستعداد للقيام بذلك.

الحصول على قائمة المستخدمين مرة أخرى كما فعلنا أعلاه ، يجب أن نرى المستخدم الذي أنشأناه مع تحديث حقوله:

 [ { "id": "ksVnfnPVW", "email": "[email protected]", "password": "$argon2i$v=19$m=4096,t=3,p=1$ZWXdiTgb922OvkNAdh9acA$XUXsOHaRN4uVg5ltIwwO+SPLxvb9uhOKcxoLER1e/mM", "firstName": "Marcos", "lastName": "Faraco", "permissionLevel": 8 } ]

أخيرًا ، يمكننا اختبار حذف المستخدم من خلال هذا:

 curl --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'

الحصول على قائمة المستخدمين مرة أخرى ، يجب أن نرى أن المستخدم المحذوف لم يعد موجودًا.

مع ذلك ، لدينا جميع عمليات CRUD لموارد users التي تعمل.

Node.js / TypeScript REST API

في هذا الجزء من السلسلة ، استكشفنا أيضًا الخطوات الأساسية في إنشاء واجهة برمجة تطبيقات REST باستخدام Express.js. قمنا بتقسيم الكود الخاص بنا إلى خدمات الدعم والبرمجيات الوسيطة ووحدات التحكم والنماذج. كل وظيفة من وظائفها لها دور محدد ، سواء أكان ذلك التحقق من الصحة أو العمليات المنطقية أو معالجة الطلبات الصالحة والاستجابة لها.

لقد أنشأنا أيضًا طريقة بسيطة جدًا لتخزين البيانات ، مع الغرض الصريح (العفو عن التورية) من السماح ببعض الاختبارات في هذه المرحلة ، ثم استبدالها بشيء أكثر عملية في الجزء التالي من سلسلتنا.

إلى جانب إنشاء واجهة برمجة تطبيقات مع وضع البساطة في الاعتبار - باستخدام فئات فردية ، على سبيل المثال - هناك العديد من الخطوات التي يجب اتخاذها لتسهيل صيانتها وجعلها أكثر قابلية للتوسع وأمانًا. في المقال الأخير من السلسلة ، نغطي:

  • استبدال قاعدة البيانات في الذاكرة بـ MongoDB ، ثم استخدام Mongoose لتبسيط عملية الترميز
  • إضافة طبقة أمان والتحكم في الوصول بطريقة عديمة الحالة مع JWT
  • تكوين الاختبار الآلي للسماح لتطبيقنا بالتوسع

يمكنك تصفح الكود النهائي من هذه المقالة هنا.