عقود API مُنمَّطة من NestJS إلى Next.js عبر OpenAPI: كيف نُبقي أنواع الواجهة صادقة
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗أكثر طريقة موثوقة وجدناها لإبقاء واجهة Next.js متزامنة مع واجهة NestJS البرمجية من نوع REST هي التعامل مع مواصفة OpenAPI على أنها العقد بين الطرفين. تولّد إضافة CLI الخاصة بـ @nestjs/swagger المواصفة وقت البناء، ويحوّلها openapi-typescript إلى أنواع TypeScript، ويجعل openapi-fetch كل استدعاء للـ API خاضعًا لفحص الأنواع. وخطوة CI تعيد توليد الملفين وتفشل عند أي فرق غير مُلتزَم به تحوّل أي تغيير غير مُعلن في العقد إلى بناء فاشل بدلًا من خطأ في الإنتاج.
لفترة طويلة كانت تطبيقات Next.js لدينا تتواصل مع خوادم NestJS عبر أنواع منسوخة يدويًا أو عبر حزمة أنواع مشتركة داخل الـ monorepo. وكلا الأسلوبين انحرف مع الوقت، فحقل أُعيدت تسميته كان يُترجَم بلا أخطاء في الواجهة بينما يظهر undefined في الإنتاج. هذه ملاحظاتنا الهندسية من استبدال ذلك بأنواع مولَّدة، بما في ذلك المواضع التي ما زالت الأنواع المولَّدة تكذب فيها.
أهم النقاط
OpenAPI وصف محايد لغويًا لواجهة HTTP برمجية، ويستطيع NestJS إخراجه من الـ controllers والـ DTOs الموجودة أصلًا لدى الفريق، لذلك لا يكلّف العقد تقريبًا أي كود إضافي.
إضافة CLI الخاصة بـ @nestjs/swagger تعمل فقط داخل nest build. والمواصفة المولَّدة عبر tsx أو esbuild تفقد خصائص الـ DTO بصمت.
يولّد openapi-typescript أنواعًا فقط، وopenapi-fetch غلاف رفيع مُنمَّط فوق fetch، لذا فإن تكلفة وقت التشغيل على الواجهة قريبة من الصفر.
الأنواع المولَّدة صادقة بقدر صدق المواصفة فقط. الـ interceptors التي تغلّف الاستجابات، والـ DTOs العامة (generics)، والحقول المخفية بـ @Exclude() هي المواضع التي تختلف فيها مواصفة NestJS عمّا يُرسَل فعليًا.
فحص الانحراف في CI هو الجزء الذي يمنع الأخطاء فعلًا: أعد توليد المواصفة والأنواع في كل pull request، ثم أفشل البناء عند أي فرق غير مُلتزَم به.
لماذا تنحرف أنواع TypeScript المشتركة بين NestJS وNext.js؟
تنحرف أنواع TypeScript المشتركة لأنها تصف ما يقصده كود الخادم، لا ما يُرسَل فعليًا عبر الشبكة. فـ DTO في NestJS يحتوي حقل createdAt من نوع Date يصل إلى المتصفح كنص بصيغة ISO 8601، لأن JSON لا يملك نوعًا للتاريخ.
والـ interceptor العام الذي يغلّف كل استجابة داخل حقل data يغيّر شكلها دون أن يلمس الـ DTO. كما أن حزمة الأنواع المشتركة لا تفيد إلا عملاء TypeScript في المستودع نفسه؛ أما تطبيق React Native في مستودع آخر فلا يحصل على شيء.
مواصفة OpenAPI مستند JSON أو YAML يسرد كل مسار ومعامل وجسم طلب ومخطط استجابة في واجهة HTTP برمجية. وتوليد أنواع الواجهة من المواصفة ينقل مصدر الحقيقة خطوة أقرب إلى ما يُرسَل فعليًا، ويمنح العملاء غير المكتوبين بـ TypeScript العقد نفسه.
كيف نستخرج مواصفة OpenAPI دقيقة من NestJS؟
تبدأ المواصفة الدقيقة في NestJS بإضافة CLI الخاصة بـ @nestjs/swagger، وهي محوّل (transformer) لمترجم TypeScript يضيف بيانات @ApiProperty() الوصفية إلى كل خاصية في الملفات المنتهية بـ .dto.ts أو .entity.ts، إضافة إلى بيانات الاستجابة بناءً على نوع الإرجاع لكل دالة في الـ controller. نفعّلها في nest-cli.json تحت compilerOptions.plugins. وبدون هذه الإضافة يحتاج كل حقل في الـ DTO إلى @ApiProperty() مكتوب يدويًا، وما ينساه المطوّر لا يظهر في المواصفة أصلًا.
نصدّر المواصفة عبر سكربت صغير src/export-openapi.ts يُترجَم مع بقية التطبيق. يستدعي NestFactory.create(AppModule) مع إيقاف السجلات، ويبني الإعدادات عبر DocumentBuilder، ويمرّر الاثنين إلى SwaggerModule.createDocument()، ثم يكتب openapi.json ويغلق التطبيق. نشغّله بـ nest build ثم node dist/export-openapi.js.
ثلاثة تفاصيل صنعت الفرق. إضافة Swagger محوّل TypeScript، لذا تعمل في nest build لكنها لا تعمل تحت tsx أو esbuild اللذين يحذفان الأنواع دون تشغيل المحوّلات؛ وقد خرجت أول مواصفة لدينا من tsx بمخططات DTO فارغة.
تقوم NestFactory.create() بإنشاء الـ providers، لذا فإن أي provider غير متزامن يفتح اتصالًا بقاعدة البيانات سيحاول الاتصال فعلًا. في CI نوجّه خطوة التصدير إلى قاعدة Postgres المؤقتة نفسها التي تستخدمها الاختبارات.
نلتزم بملف openapi.json في Git، فيرى المراجع كل تغيير في العقد كفرق بجوار الكود الذي تسبّب فيه.
كيف نولّد الأنواع وعميل fetch مُنمَّطًا لـ Next.js؟
يحوّل openapi-typescript مستند OpenAPI 3.x إلى ملف .d.ts واحد يصدّر النوع paths، ويستخدم openapi-fetch هذا النوع لفحص الرابط ومعاملات المسار والاستعلام وجسم الطلب والاستجابة في كل استدعاء. ولا تولّد أيّ من الأداتين كود تشغيل لكل endpoint، ولهذا يفضّلهما فريقنا على المولّدات التي تُنتج صنف عميل لكل controller.
في App Router الخاص بـ Next.js ننشئ عميل الـ API لكل طلب على حدة. الدالة getApi() لدينا تعمل على الخادم فقط، وتقرأ كوكي الجلسة عبر cookies() من next/headers، وتعيد createClient مع النوع paths ورابط API_URL وترويسة Authorization من نوع Bearer. أما العميل المعرَّف على مستوى الوحدة مع middleware للمصادقة فيتشاركه كل طلب يعالجه الخادم، وهكذا بالضبط ينتهي رمز مستخدم على طلب مستخدم آخر.
بعد ذلك تستدعي الصفحة api.GET('/orders/{id}') مع تمرير المعرّف داخل params.path، وتستقبل data وerror. أصبح الخطأ الإملائي في المسار، أو معامل المسار المفقود، أو حقل لم يعد موجودًا في الـ DTO خطأ ترجمة. وبعد إعادة توليد الأنواع، تؤدي إعادة تسمية حقل في DTO داخل NestJS إلى فشل next build في كل موضع تقرأه فيه الواجهة.
أين ما زالت أنواع OpenAPI المولَّدة تكذب؟
الأنواع المولَّدة صادقة بقدر صدق المواصفة فقط، ومواصفة NestJS تُبنى من الـ decorators والبيانات الوصفية للأصناف، لا من البايتات التي تعيدها الواجهة فعليًا. هذه الفجوات التي واجهناها.
الـ interceptors التي تغلّف الاستجابات: الـ TransformInterceptor العام الذي يعيد حقلي data وmeta يغيّر كل استجابة، بينما تظل المواصفة تصف الـ DTO المجرّد. إما أن نزيل الغلاف أو نصفه في المواصفة بصنف غلاف لكل استجابة.
الـ DTOs العامة: تختفي generics الخاصة بـ TypeScript وقت التشغيل، لذا فإن نوع إرجاع Paginated العام لا يوثّق شيئًا مفيدًا. والحل مصنع أصناف: دالة Paginated(item) تعيد صنفًا تحمل مصفوفة items فيه @ApiProperty({ type: [item] })، ثم يرث OrderPageDto من Paginated(OrderDto).
الحقول المحذوفة عند التسلسل: يحذف ClassSerializerInterceptor الخصائص المعلَّمة بـ @Exclude() من الاستجابات، لكن الإضافة تظل توثّقها ما لم تحمل أيضًا @ApiHideProperty(). فيَعِد نوع الواجهة بحقل passwordHash لا يصل أبدًا.
استجابات الخطأ غير المعلنة: يرمي ValidationPipe في NestJS خطأ 400 سواء ذكرته المواصفة أم لا. وما لم يعلن الـ controller عن @ApiBadRequestResponse مع DTO للخطأ، فلن يجد openapi-fetch نوعًا مفيدًا لـ error.
الـ interfaces وأسماء الأنواع المستعارة: لا وجود للـ interfaces وقت التشغيل، لذا تخرج الخصائص المنمّطة بها مبهمة. نجعل كل شكل استجابة متداخل صنفًا (class).
لا شيء من هذا يتحقق من الاستجابات وقت التشغيل، لأن openapi-fetch يثق بالخادم. اختبارات e2e لدينا في NestJS تتحقق من عدد محدود من الاستجابات الحرجة مقابل openapi.json المُلتزَم به، وهذا ما كشف غلاف الـ interceptor.
كيف نمنع انحراف عقد الـ API في CI؟
فحص انحراف العقد يعيد توليد مواصفة OpenAPI وأنواع الواجهة في كل pull request ويفشل إذا اختلف أيّ منهما عمّا هو مُلتزَم به. في monorepo يعتمد pnpm تكفي أربع خطوات: بناء الـ API، وتشغيل سكربت التصدير، وتشغيل openapi-typescript داخل حزمة الواجهة، ثم تشغيل git diff --exit-code على openapi.json وschema.d.ts.
يلتقط فحص الفرق نسيان إعادة التوليد، لكنه لا يحكم على ما إذا كان التغيير آمنًا. وعندما يكون للـ API عملاء لا يُنشَرون معه، مثل تطبيق جوال منشور بالفعل في المتاجر، نشغّل أيضًا oasdiff breaking مع --fail-on ERR مقابل مواصفة الفرع الرئيسي. وoasdiff أداة لمقارنة مواصفات OpenAPI تُفشل البناء عندما يحذف تغيير ما حقلًا من الاستجابة أو يجعل حقلًا اختياريًا في الطلب إلزاميًا.
متى يستخدم الفريق توليد OpenAPI بدلًا من tRPC أو ts-rest؟
توليد الكود من OpenAPI هو الخيار الصحيح عندما تكون واجهة NestJS موجودة أصلًا كـ REST controllers أو تخدم عملاء غير مكتوبين بـ TypeScript. أما tRPC وts-rest فأفضل عندما يتحكم فريق واحد في الطرفين ويريد كتابة العقد بـ TypeScript أولًا.
حزمة الأنواع المشتركة: مصدر الحقيقة أنواع TypeScript مكتوبة يدويًا؛ تناسب controllers الموجودة في NestJS لكنها لا تخدم أي عميل غير TypeScript، وتصف النية لا ما يُرسَل فعليًا.
tRPC: مصدر الحقيقة أنواع الـ router؛ يستبدل الـ controllers بدلًا من التوافق معها، ويحتاج محوّلًا إضافيًا للعملاء غير المكتوبين بـ TypeScript، ويربط العميل والخادم بـ TypeScript.
ts-rest: مصدر الحقيقة كائن عقد مكتوب بـ TypeScript، غالبًا مع Zod؛ يتوافق مع NestJS جزئيًا عبر معالجات @ts-rest/nest، ويصل إلى العملاء الآخرين عبر مولّد OpenAPI الخاص به.
توليد OpenAPI عبر openapi-typescript وopenapi-fetch: مصدر الحقيقة مواصفة مولَّدة من decorators الخاصة بـ NestJS؛ يناسب الـ controllers الموجودة ويخدم أي عميل، وعيبه الأساسي أن المواصفة قد تختلف عن سلوك وقت التشغيل.
معظم الخوادم التي نبنيها في Devya تعرض أصلًا REST controllers وواجهة Swagger UI، وكثير منها يخدم أيضًا تطبيق React Native، لذا فإن التوليد من OpenAPI يعني عدم إعادة الكتابة وعقدًا واحدًا لكل العملاء.
الأسئلة الشائعة
س: هل يضيف openapi-fetch حجمًا كبيرًا إلى حزمة Next.js؟
ج: قليل جدًا. openapi-fetch غلاف صغير حول fetch الأصلية، وناتج openapi-typescript ملف .d.ts يختفي بالكامل عند الترجمة.
س: هل يجب الالتزام بملفي openapi.json وschema.d.ts المولَّدين في Git؟
ج: نعم. الالتزام بالملفين يجعل تغييرات عقد الـ API ظاهرة في مراجعة الكود، ويمنح فحص الانحراف في CI شيئًا يقارن به.
س: لماذا تظهر حقول Date في NestJS كنصوص في الأنواع المولَّدة؟
ج: لا يملك JSON نوعًا للتاريخ، لذا يسلسل NestJS قيمة Date كنص بصيغة ISO 8601 وتصفها المواصفة كنص بصيغة date-time. ويُبقيها openapi-typescript من نوع string، وهذا صحيح؛ حوّلها إلى تاريخ في الموضع الذي تحتاجه فيه.
س: هل يمكن لتطبيق React Native استخدام العميل المولَّد نفسه؟
ج: نعم. يعتمد openapi-fetch فقط على fetch العامة التي يوفّرها React Native، لذا يستطيع تطبيق الجوال استيراد النوع paths نفسه من حزمة مشتركة أو من نسخة من schema.d.ts.
س: هل يجب تفعيل Swagger UI في الإنتاج لكي يعمل هذا؟
ج: لا. المواصفة يكتبها سكربت وقت البناء، والعميل المُنمَّط يحتاج فقط إلى openapi.json المُلتزَم به. أما عرض Swagger UI عبر SwaggerModule.setup() فقرار منفصل واختياري.
قراءات إضافية
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.
هندسة الواجهات
صور Open Graph الديناميكية في Next.js 16: كيف نبني opengraph-image.tsx ونتحقق من إصدار Satori الذي تضمّنه next/og ونحافظ على صحة معاينات الروابط
واجهت بطاقات المشاركة المولّدة لدينا ثلاث مفاجآت: خط WOFF2 لم يستطع المحرّك قراءته، وملف README لـ Satori يصف محرّكًا أحدث من المضمّن في Next.js، وصفحة ظلّت تعرض صورة الـ layout. هذه ملاحظاتنا الميدانية حول opengraph-image.tsx في Next.js 16 وأولوية البيانات الوصفية وفحص og:image قبل مشاركة أي رابط.