> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# كيف تصمم API لا تضطر إلى كسره بعد سنة
- URL: https://arabroot.io/articles/تصميم-واجهات-api-rest-و-grpc-و-graphql/
- Published: 2026-10-07T15:45:00.000Z
- Updated: 2026-10-09T13:02:31.000Z
- Description: مبادئ تصميم API يتطور دون أن يكسر التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, DevOps وCI/CD, الاستضافة الذاتية

عندما يكبر تطبيقك من صفحات يبنيها السيرفر إلى تطبيق جوال وواجهة SPA وشريك يربط نظامه بنظامك، يصبح ال API هو المنتج الحقيقي الذي يتعامل معه الآخرون، وكل قرار صغير فيه يبقى معك سنوات: اسم المسار، وشكل رسالة الخطأ، وطريقة التقسيم إلى صفحات، وما يحدث عندما يعيد الكلاينت نفس الطلب بعد انقطاع الشبكة، والسبب أن تغيير أي شيء بعد أن يعتمد عليه الآخرون يكسر أنظمة لا تملكها ولا تراها. وفي [مدخل تصميم الأنظمة](https://arabroot.io/articles/%D9%85%D8%AF%D8%AE%D9%84-%D8%A7%D9%84%D9%89-%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D9%85%D9%86-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1-%D9%88%D8%A7%D8%AD%D8%AF-%D8%A7%D9%84%D9%89-%D9%85%D9%84%D8%A7%D9%8A%D9%8A%D9%86/) رأينا كيف ينمو النظام من سيرفر واحد إلى عدة خدمات، وفي هذا المقال سوف نركز على العقد Contract الذي تتكلم به هذه الخدمات مع العالم ومع بعضها.

وفي [مقال HTTP وHTTPS ببساطة](https://arabroot.io/articles/http-%D9%88-https-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) شرحنا الطلب والرد والطرق Methods ورموز الحالة Status Codes، فلن نعيد شرحها هنا، وإنما سوف نبني عليها. وسوف نناقش في هذا المقال ما يلي:

- الأشكال الثلاثة الأشهر لواجهات API: REST وgRPC وGraphQL، وما الذي يختلف بينها فعلاً.
- REST بشكل صحيح: الموارد Resources والطرق، ورموز الحالة الصحيحة، ورسائل الخطأ بصيغة Problem Details من RFC 9457، والتقسيم إلى صفحات Pagination، والتصفية Filtering، والعمليات الآمنة عند تكرارها Idempotency.
- الإصدارات Versioning والتوافق مع الإصدارات السابقة Backward Compatibility، وكيف تغلق إصداراً قديماً.
- OpenAPI لوصف العقد، والفرق بين كتابة العقد أولاً وتوليده من الكود.
- gRPC وprotobuf وHTTP/2، ومتى تستخدمه.
- GraphQL، ومتى يفيد، ومشكلة N+1 وجلب بيانات أكثر من اللازم Over-fetching، وملاحظات الأمان.
- جدول مقارنة، ثم تجربة عملية تعرض نفس العملية عبر REST وgRPC باستدعاءات حقيقية.

## ثلاثة أشكال لنفس العملية

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

![ثلاثة أعمدة: REST باللون البرتقالي فيه متصفح يرسل GET /v1/orders/ord_002 ويستقبل JSON، وgRPC باللون الأزرق فيه سيرفر يرسل POST /shop.v1.OrderService/GetOrder مع content-type application/grpc ويستقبل protobuf عبر HTTP/2، وGraphQL باللون البنفسجي فيه جوال يرسل POST /graphql مع استعلام يحدد الحقول ويستقبل JSON بنفس شكل الاستعلام](https://arabroot.io/content/images/2026/10/api-design-01-api-styles.webp)

نفس العملية في REST وgRPC وGraphQL، والفرق في شكل العقد والرسالة

- **REST**: الأساس هو المورد Resource، أي شيء له اسم وعنوان مثل `/v1/orders/ord_002`، وتتعامل معه بطرق HTTP المعروفة، ومعنى النتيجة في رمز الحالة. وهو أسلوب معماري وليس مواصفة رسمية واحدة، لذلك تجد تطبيقات مختلفة كثيراً تحت نفس الاسم.
- **gRPC**: الأساس هو الدالة Method، فأنت تستدعي `GetOrder` في خدمة `OrderService` كأنها دالة محلية، والعقد مكتوب في ملف `.proto` ويولد منه كود الكلاينت والسيرفر.
- **GraphQL**: الأساس هو المخطط Schema والاستعلام Query، فهناك نقطة واحدة `/graphql`، والكلاينت يكتب استعلاماً يحدد فيه الحقول التي يريدها بالضبط، والرد يأتي بنفس شكل الاستعلام.

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

## REST بشكل صحيح

### الموارد Resources والطرق Methods

أول قاعدة في REST أن المسار اسم Noun والطريقة هي الفعل، فلا تكتب `/getOrder?id=2` أو `/createOrder` أو `/orders/2/delete`، وإنما `GET /v1/orders/2` و`POST /v1/orders` و`DELETE /v1/orders/2`. وتصف Google هذا الأسلوب في [AIP-121 Resource-oriented design](https://google.aip.dev/121?ref=arabroot.io) بأن تبدأ التصميم بالموارد التي يقدمها ال API والعلاقات والتسلسل بينها، فالمجموعة Collection تحتوي موارد من نفس النوع، والمورد قد يحتوي مجموعات فرعية، مثل `/v1/customers/7/orders`. والصورة التالية تبين الطرق على مورد الطلبات، وأي منها آمن Safe وأي منها متكرر الأثر Idempotent كما يعرفهما [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html?ref=arabroot.io#name-idempotent-methods):

![جدول بستة أسطر: GET /v1/orders للقائمة برمز 200 آمن ومتكرر الأثر، وPOST /v1/orders للإنشاء برمز 201 مع Location ليس آمناً ولا متكرر الأثر، وGET /v1/orders/{id} برمز 200 أو 404 آمن ومتكرر الأثر، وPUT للاستبدال برمز 200 أو 204 متكرر الأثر وليس آمناً، وPATCH للتعديل الجزئي برمز 200 ليس آمناً ولا متكرر الأثر، وDELETE برمز 204 متكرر الأثر وليس آمناً. وفي اليسار مسارات فيها أفعال يجب تجنبها مثل /getOrder?id=2 و/createOrder](https://arabroot.io/content/images/2026/10/api-design-02-rest-resources.webp)

المسار للمورد والطريقة للفعل، وخصائص كل طريقة من RFC 9110

في الجدول أعلاه لاحظ التالي:

- الطريقة الآمنة Safe لا تغير شيئاً في السيرفر، لذلك تستطيع المتصفحات وال Proxies إعادتها وتخزينها مؤقتاً دون خوف، فلا تضع أبداً عملية تغير البيانات خلف `GET`.
- متكرر الأثر Idempotent يعني أن أثر تنفيذ الطلب مرتين هو نفس أثر تنفيذه مرة واحدة، فحذف الطلب 2 مرتين يتركه محذوفاً، أما `POST` مرتين فقد ينشئ طلبين، وهذه هي المشكلة التي سوف نحلها بمفتاح Idempotency-Key.
- `PATCH` المعرف في [RFC 5789](https://www.rfc-editor.org/rfc/rfc5789.html?ref=arabroot.io) للتعديل الجزئي ليس متكرر الأثر بالتعريف، وأبسط صيغة لجسمه هي [JSON Merge Patch في RFC 7396](https://www.rfc-editor.org/rfc/rfc7396.html?ref=arabroot.io) حيث ترسل الحقول التي تغيرت فقط وقيمة `null` تعني الحذف.

وإذا كان نظامك يخدم عدة عملاء كل منهم مستأجر Tenant مستقل، فاحرص على أن يأتي المستأجر من التوكن أو النطاق الفرعي وليس من معامل يرسله الكلاينت بحرية، وقد شرحنا الخيارات في قسم [المسار والترويسة والتوكن في مقال تعدد المستأجرين](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#path-header-token).

### رمز الحالة الصحيح لكل حالة

شرحنا معنى رموز الحالة في [قسم رموز الحالة في مقال HTTP](https://arabroot.io/articles/http-%D9%88-https-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#status-codes)، والذي يهمنا هنا هو الاختيار بينها في API:

- `201 Created` بعد الإنشاء، مع ترويسة `Location` فيها عنوان المورد الجديد، و`204 No Content` بعد الحذف أو التعديل إذا لم يكن هناك جسم للرد.
- `400 Bad Request` لطلب لا يمكن فهمه أو معامل خاطئ، و`422 Unprocessable Content` لجسم مفهوم الصيغة لكن قيمه غير صالحة، والمهم أن تختار قاعدة واحدة وتلتزم بها في كل المسارات.
- `401` عندما لا يعرف السيرفر من أنت، و`403` عندما يعرفك ولا يسمح لك، و`404` للمورد غير الموجود، ويفضل كثيرون إرجاع `404` أيضاً لمورد موجود لا يملكه المستخدم حتى لا يكشفوا وجوده.
- `409 Conflict` عندما يتعارض الطلب مع حالة المورد، و`429 Too Many Requests` عند تجاوز حد الطلبات.
- ولا ترجع أبداً `200` وفي الجسم `"error": true`، والسبب أن كل أداة في الطريق من ال Proxy إلى المراقبة إلى مكتبة الكلاينت تعتمد على رمز الحالة لتعرف أن الطلب فشل.

### رسائل الخطأ بصيغة Problem Details

الحل البسيط أن يكتب كل مطور رسالة الخطأ بالشكل الذي يحبه: مرة `{"error": "not found"}` ومرة `{"message": "...", "code": 17}`، والنتيجة أن الكلاينت يكتب كوداً خاصاً لكل مسار. والحل الأنسب هو المعيار [RFC 9457 Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457.html?ref=arabroot.io) الصادر في يوليو 2023، والذي حل محل RFC 7807، ويعرف نوع المحتوى `application/problem+json` وخمسة حقول قياسية:

- `type`: رابط URI يعرف نوع المشكلة، مثل `https://example.com/problems/order-not-found`، والقيمة الافتراضية `about:blank` عندما لا يضيف النوع معنى أكثر من رمز الحالة.
- `title`: ملخص قصير يقرؤه الإنسان لنوع المشكلة.
- `status`: رمز حالة HTTP لهذا الحدوث.
- `detail`: شرح خاص بهذا الحدوث بالذات.
- `instance`: رابط يعرف هذا الحدوث، مثل مسار الطلب.

ويسمح المعيار بحقول إضافية Extensions، مثل قائمة `errors` بأخطاء كل حقل كما سوف ترى في التجربة العملية. ولا تضع في `detail` نص الاستثناء Exception أو Stack Trace أو استعلام SQL، لأن هذا ما يبحث عنه المخترق، وقد شرحنا ذلك في [خطر أخطاء الإعداد الأمني في مقال أمن واجهات API](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/#api8).

### التقسيم إلى صفحات Pagination

لا ترجع أبداً كل السجلات في رد واحد، فالقائمة التي فيها 50 طلباً اليوم فيها 500 ألف بعد سنتين، والطريقة الأولى التي يكتبها الجميع هي الإزاحة Offset: `?limit=20&offset=40`، وهي سهلة لكن فيها مشكلتين تبينهما الصورة التالية:

![في اليمين لوحة OFFSET: الصفحة 1 فيها ord_009 وord_008 وord_007، ثم يصل طلب جديد ord_010، فتأتي الصفحة 2 بـ LIMIT 3 OFFSET 3 وفيها ord_007 مرة ثانية باللون الأحمر ثم ord_006 وord_005، وعلامة خطأ. وفي اليسار لوحة CURSOR: الصفحة 1 تنتهي بـ next_cursor = ord_007، ويصل نفس الطلب الجديد، والصفحة 2 تبدأ بعد ord_007 بـ ord_006 وord_005 وord_004 دون تكرار، وعلامة صح](https://arabroot.io/content/images/2026/10/api-design-03-pagination.webp)

الإزاحة تكرر أو تتخطى صفوفاً عندما تتغير البيانات، والمؤشر يكمل من آخر مفتاح

المشكلة الأولى أن البيانات تتغير بين الصفحتين، فإذا أضيف طلب جديد في أول القائمة تنزاح كل الصفوف ويظهر صف مرتين أو يختفي صف، والثانية أن قاعدة البيانات تقرأ كل الصفوف قبل الإزاحة ثم ترميها، فالصفحة رقم 10000 بطيئة. والحل هو المؤشر Cursor ويسمى أيضاً Keyset Pagination: الرد يحمل مؤشراً معتماً Opaque فيه آخر مفتاح رآه الكلاينت، والصفحة التالية تطلب ما بعده، ومع فهرس Index على المفتاح تذهب قاعدة البيانات إليه مباشرة. وهذا ما تفعله Google في [AIP-158](https://google.aip.dev/158?ref=arabroot.io) بحقلي `page_size` و`page_token` في الطلب و`next_page_token` في الرد، وهي نفس الأسماء التي استخدمناها في ملف `.proto` في التجربة. وفي REST يمكن أن ترسل رابط الصفحة التالية أيضاً في ترويسة `Link` المعرفة في [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288.html?ref=arabroot.io)، وهذا ما يفعله GitHub كما في [توثيق التقسيم إلى صفحات في GitHub REST API](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api?ref=arabroot.io).

وضع دائماً حداً أعلى لحجم الصفحة، مثل 50 أو 100، وارفض ما فوقه بخطأ واضح، والسبب أن `?limit=1000000` طريقة سهلة لإيقاف السيرفر، وهذا هو الخطر الرابع في [قائمة OWASP API Security Top 10](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/#api4).

### التصفية Filtering والترتيب Sorting واختيار الحقول

التصفية تكون بمعاملات في الرابط Query Parameters على المجموعة، مثل `GET /v1/orders?status=paid&customer=7&sort=-created_at`، وفيما يلي القواعد التي توفر عليك مشكلات لاحقة:

- اسمح فقط بقائمة معروفة من حقول التصفية والترتيب، ولا تمرر اسم الحقل من الرابط إلى استعلام SQL مباشرة، والسبب أنه يفتح باب SQL Injection ويسمح بالترتيب على أعمدة بلا فهرس.
- إذا كان الترتيب جزءاً من المؤشر فاجعله ثابتاً، أي أضف المعرف كعمود أخير في الترتيب حتى لا يتساوى صفان.
- اختيار الحقول مثل `?fields=id,total` مفيد للجوال، لكن لا تجعله الطريقة الوحيدة لإخفاء حقول حساسة، فالحقل الذي لا يجب أن يراه المستخدم لا يرسل أبداً، وهذا هو الخطر الثالث BOPLA في [مقال أمن واجهات API](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/#api3).

### إعادة المحاولة الآمنة Idempotency

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

![في الأعلى لوحة حمراء بدون مفتاح: الكلاينت يرسل POST /v1/orders، ثم timeout، ثم يعيد نفس الطلب، فينشئ ال API طلبين ord_a1 وord_b2 مدفوعين، ومكتوب دفع مرتين. وفي الأسفل لوحة خضراء مع Idempotency-Key: الطلب الأول بمفتاح 8e03، ثم ضياع الرد 201، ثم نفس الطلب بنفس المفتاح، فيعيد ال API الرد المحفوظ 200 ord_e866fc من جدول key إلى ord_e866fc، وطلب واحد فقط، ونفس المفتاح بجسم مختلف يرد 422](https://arabroot.io/content/images/2026/10/api-design-04-idempotency.webp)

الكلاينت يرسل مفتاحاً فريداً، والسيرفر يحفظ النتيجة ويعيدها لنفس المفتاح

والفكرة أن الكلاينت يولد مفتاحاً عشوائياً لكل عملية، ويرسله في ترويسة `Idempotency-Key`، ويعيد نفس المفتاح في كل محاولة لنفس العملية، والسيرفر يحفظ المفتاح مع نتيجة التنفيذ الأول، فإذا جاء المفتاح مرة أخرى يرجع النتيجة المحفوظة بدل التنفيذ. وهذه الترويسة موصوفة في مسودة IETF [draft-ietf-httpapi-idempotency-key-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/?ref=arabroot.io)، وانتبه إلى أنها مسودة Internet-Draft وصلت إلى الإصدار 07 ولم تصبح RFC، وحالتها اليوم منتهية Expired، لكنها الأساس الذي تبني عليه واجهات كثيرة، وتوصي بأن يكون المفتاح UUID أو قيمة عشوائية مشابهة، وبأن يرد السيرفر بـ `400` إذا كان المفتاح مطلوباً وغاب، وبـ `422` إذا أعيد استخدام المفتاح بجسم مختلف، وبـ `409` إذا وصلت إعادة المحاولة والطلب الأصلي ما زال قيد التنفيذ.

وأشهر تطبيق لها هو Stripe، وبحسب [توثيق Idempotent requests](https://docs.stripe.com/api/idempotent%5Frequests?ref=arabroot.io) فإن كل طلبات `POST` تقبل المفتاح، ويحفظ Stripe رمز الحالة وجسم الرد للطلب الأول سواء نجح أو فشل، حتى رد `500`، ويمكن حذف المفاتيح بعد 24 ساعة على الأقل، ويقارن معاملات الطلب الجديد بالأصلي ويرجع خطأ إذا اختلفت، ولا معنى لإرسال المفتاح مع `GET` و`DELETE` لأنها متكررة الأثر أصلاً.

⚠️

في التجربة العملية نحفظ المفاتيح في الذاكرة لتبسيط المثال، أما في الإنتاج فاحفظ المفتاح مع النتيجة في قاعدة البيانات داخل نفس المعاملة Transaction التي تنشئ الطلب، والسبب أن السيرفر قد يتوقف بين الإنشاء وحفظ المفتاح، فيعود الطلب المكرر وينشئ نسخة ثانية.

## الإصدارات Versioning والتوافق مع الإصدارات السابقة

القاعدة الأولى أن أفضل إصدار جديد هو الذي لا تحتاجه، فأغلب التغييرات يمكن أن تكون إضافية Additive لا تكسر أحداً: حقل جديد اختياري في الرد، أو مسار جديد، أو معامل اختياري جديد. ويعدد توثيق GitHub في صفحة [Breaking changes](https://docs.github.com/en/rest/about-the-rest-api/breaking-changes?ref=arabroot.io) التغييرات الكاسرة التي تحتاج إصداراً جديداً: حذف عملية كاملة، وحذف أو إعادة تسمية معامل أو حقل في الرد، وإضافة معامل إجباري جديد، وجعل معامل اختياري إجبارياً، وتغيير نوع معامل أو حقل، وحذف قيم من Enum، وإضافة قاعدة تحقق Validation جديدة على معامل موجود، وتغيير متطلبات المصادقة Authentication أو الصلاحيات Authorization. ويحتاج الكلاينت من جهته أن يكون متسامحاً، أي أن يتجاهل الحقول التي لا يعرفها بدل أن يفشل، وإلا أصبح حتى الحقل الإضافي كاسراً.

وعندما تحتاج إصداراً جديداً فعلاً فهناك ثلاث طرق شائعة تبينها الصورة التالية:

![ثلاث بطاقات: URL path فيها GET /v1/orders/ord_002 وGET /v2/orders/ord_002 من Google AIP-185، وHeader فيها X-GitHub-Api-Version: 2026-03-10 من GitHub REST API، وPinned per account فيها Stripe-Version: 2026-09-30.endive من Stripe API. وفي الأسفل قائمة متى تحتاج إصداراً جديداً: إضافة حقل اختياري أو مسار جديد لا تحتاج، وحذف حقل أو تغيير اسمه أو نوعه ومعامل جديد إجباري وتغيير المصادقة تحتاج. وخط زمني: v2 released ثم ترويسة Deprecation من RFC 9745 ثم ترويسة Sunset من RFC 8594 ثم 410 Gone](https://arabroot.io/content/images/2026/10/api-design-05-versioning.webp)

طرق الإصدارات الثلاث، وما يكسر التوافق، وكيف تغلق الإصدار القديم

- **الإصدار الرئيسي في المسار**: `/v1/orders` ثم `/v2/orders`، وهو الأوضح في السجلات Logs والتوثيق، وتطلب Google في [AIP-185](https://google.aip.dev/185?ref=arabroot.io) أن يكون الإصدار رئيسياً فقط مثل `v1` وليس `v1.4.2`، وأن تعمل الإصدارات المختلفة معاً في نفس الوقت أثناء الانتقال، وأن يكون للإصدار القديم فترة إيقاف معلنة قبل إغلاقه.
- **الإصدار في ترويسة**: GitHub يستخدم `X-GitHub-Api-Version` بإصدارات بالتاريخ، وبحسب [توثيق إصدارات GitHub REST API](https://docs.github.com/en/rest/about-the-rest-api/api-versions?ref=arabroot.io) فإن الطلب بدون الترويسة يستخدم الإصدار `2022-11-28`، والإصدار يبقى مدعوماً 24 شهراً بعد صدور إصدار أحدث، وبعد انتهاء الدعم يرجع `410 Gone`.
- **إصدار مثبت لكل حساب**: في Stripe يستخدم الحساب إصداره الافتراضي ما لم ترسل ترويسة `Stripe-Version`، وبحسب [توثيق Versioning في Stripe](https://docs.stripe.com/api/versioning?ref=arabroot.io) تصدر منذ `2024-09-30.acacia` إصدارات شهرية بلا تغييرات كاسرة، وإصدار رئيسي مرتين في السنة يبدأ بالتغييرات الكاسرة.

وإغلاق الإصدار القديم له الآن ترويستان قياسيتان: ترويسة `Deprecation` في [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745.html?ref=arabroot.io) الصادر في مارس 2025 لتعلن تاريخ إيقاف الدعم، وترويسة `Sunset` في [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594.html?ref=arabroot.io) لتعلن تاريخ توقف المورد عن الاستجابة، ويشترط RFC 9745 ألا يكون تاريخ `Sunset` قبل تاريخ `Deprecation`. وبينهما أهم شيء عملي: سجلات تريك من ما زال يستدعي الإصدار القديم، حتى تتواصل معه قبل الإغلاق بدل أن تكتشفه من شكواه بعده.

## OpenAPI: العقد مكتوب

بحسب [مواصفة OpenAPI 3.2.0](https://spec.openapis.org/oas/v3.2.0.html?ref=arabroot.io) المنشورة في 19 سبتمبر 2025 فإن OpenAPI وصف قياسي لا يرتبط بلغة برمجة لواجهات HTTP، يسمح للإنسان والحاسوب بمعرفة قدرات الخدمة دون الوصول إلى الكود أو توثيق إضافي أو مراقبة حركة الشبكة. وفي الممارسة هو ملف YAML أو JSON فيه المسارات والمعاملات وأشكال الطلب والرد ورموز الحالة والمصادقة، ومنه تولد صفحات التوثيق ومكتبات الكلاينت بأدوات مثل [OpenAPI Generator](https://openapi-generator.tech/?ref=arabroot.io)، ويستخدمه الفحص الأمني الديناميكي واختبارات العقد Contract Tests التي شرحناها في [مقال أنواع الاختبارات](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-unit-%D9%88-integration-%D9%88-e2e/#contract).

وهناك طريقتان للحصول على الملف: توليده من الكود Code-first كما تفعل أطر مثل [FastAPI](https://fastapi.tiangolo.com/?ref=arabroot.io)، أو كتابته أولاً Contract-first ثم كتابة الكود ليطابقه. والتوليد من الكود أسرع في البداية، لكنه يصف ما كتبه الكود وليس ما قصده المصمم، وسوف ترى في التجربة العملية أن الملف المولد يقول إن ترويسة `Idempotency-Key` اختيارية مع أن الكود يرفض الطلب بدونها، ولا يذكر ردود الخطأ بصيغة `application/problem+json` إطلاقاً. لذلك إذا كان ال API عاماً أو يستهلكه فريق آخر فاكتب العقد أولاً وراجعه كما تراجع الكود، أو على الأقل راجع الملف المولد وأكمله قبل أن تنشره.

## gRPC: دوال عن بعد فوق HTTP/2

بحسب [مقدمة gRPC](https://grpc.io/docs/what-is-grpc/introduction/?ref=arabroot.io) فإن الكلاينت في gRPC يستدعي دالة على سيرفر في جهاز آخر كأنها كائن محلي، والعقد يكتب بلغة Protocol Buffers في ملف `.proto`، ثم يولد المترجم `protoc` مع إضافة gRPC كود الكلاينت والسيرفر بلغات مختلفة، فيمكن أن يكون السيرفر بـ Java والكلاينت بـ Python أو Go. ويعمل gRPC فوق HTTP/2، والتفاصيل في [وصف بروتوكول gRPC over HTTP2](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md?ref=arabroot.io): الطريقة دائماً `POST`، والمسار بالشكل `/Service/Method`، ونوع المحتوى `application/grpc`، وكل رسالة مسبوقة ببايت للضغط Compressed-Flag وأربعة بايتات لطول الرسالة، وحالة النتيجة في ترويسة `grpc-status` التي تأتي في الترويسات الختامية Trailers. والصورة التالية تبين البايتات الحقيقية لاستدعاء واحد من التجربة العملية:

![في اليمين CLIENT إلى SERVER: إطار HEADERS فيه :method POST و:path /shop.v1.OrderService/GetOrder وcontent-type: application/grpc وte: trailers، ثم إطار DATA فيه بايت 00 للعلم flag، وأربعة بايتات 00 00 00 09 للطول 9، ثم 0a 07 6f 72 64 5f 30 30 32 وهي رسالة protobuf للحقل 1 بقيمة ord_002. وفي اليسار SERVER إلى CLIENT: HEADERS فيها :status 200، وDATA فيها 31 بايتاً للطلب، وTRAILERS فيها grpc-status: 0 أو grpc-status: 5 مع :status 200 في الحالتين. وفي الأسفل نفس الطلب ord_002 بحجم 78 بايتاً في REST JSON و31 بايتاً في protobuf](https://arabroot.io/content/images/2026/10/api-design-06-grpc-frames.webp)

استدعاء gRPC واحد: ترويسات HTTP/2 ثم رسالة protobuf بطولها ثم الحالة في الترويسات الختامية

وفيما يلي أهم ما يميز gRPC:

- **أربعة أنواع من الاستدعاء**، كما في [المفاهيم الأساسية في gRPC](https://grpc.io/docs/what-is-grpc/core-concepts/?ref=arabroot.io): طلب ورد واحد Unary، وتدفق من السيرفر Server Streaming، وتدفق من الكلاينت Client Streaming، وتدفق في الاتجاهين Bidirectional Streaming، وهذا أصعب كثيراً في REST.
- **المهلة Deadline جزء من الاستدعاء**: الكلاينت يحدد إلى متى ينتظر، وإذا انتهت ينتهي الاستدعاء بـ `DEADLINE_EXCEEDED`، ويستطيع السيرفر أن يعرف الوقت الباقي.
- **رموز حالة خاصة به**: مثل `NOT_FOUND` و`ALREADY_EXISTS` و`UNAVAILABLE`، والقائمة في [صفحة رموز الحالة](https://grpc.io/docs/guides/status-codes/?ref=arabroot.io)، ولاحظ في الصورة أن رد HTTP يبقى `200` حتى عند الخطأ، فأي أداة مراقبة تقرأ رمز HTTP فقط لن ترى الأخطاء.
- **تطور العقد له قواعد**: في [دليل proto3](https://protobuf.dev/programming-guides/proto3/?ref=arabroot.io) إضافة حقل جديد آمنة، وحذف حقل آمن بشرط أن تحجز رقمه واسمه بـ `reserved` حتى لا يعيد أحد استخدامه، أما تغيير رقم حقل موجود فغير آمن، والسبب أن الرسالة على الشبكة تحمل أرقام الحقول وليس أسماءها، فإعادة استخدام رقم قديم بمعنى جديد تجعل الكلاينت القديم يقرأ بيانات خاطئة، والأرقام من 1 إلى 15 تأخذ بايتاً واحداً لذلك تعطى للحقول الأكثر استخداماً.

ومتى تستخدمه؟ gRPC مناسب للاتصال الداخلي بين الخدمات Service-to-service، وعندما تحتاج تدفقاً Streaming، وعندما تكتب الخدمات بلغات مختلفة وتريد عقداً صارماً وكوداً مولداً. وهو غير مناسب لواجهة عامة يستدعيها المتصفح مباشرة، والسبب أن المتصفح لا يستطيع التحكم في إطارات HTTP/2 بالشكل الذي يحتاجه gRPC، لذلك يحتاج [gRPC-Web](https://grpc.io/docs/platforms/web/basics/?ref=arabroot.io) إلى Proxy مثل Envoy يترجم بين المتصفح والسيرفر، ولا يناسب أيضاً شريكاً يريد أن يجرب ال API بأداة curl في دقيقة.

## GraphQL: الكلاينت يحدد الشكل

بحسب [مواصفة GraphQL إصدار سبتمبر 2025](https://spec.graphql.org/September2025/?ref=arabroot.io) فإن GraphQL أنشئ عام 2012 وبدأ تطويره كمعيار مفتوح عام 2015\. وفكرته أن السيرفر ينشر مخططاً Schema فيه الأنواع والعلاقات، والكلاينت يكتب استعلاماً فيه الحقول التي يريدها فقط، فيحصل عليها في طلب واحد بنفس شكل الاستعلام. وهذا يحل مشكلتين في REST: جلب بيانات أكثر من اللازم Over-fetching عندما يرجع المسار حقولاً كثيرة لا تحتاجها شاشة الجوال، وجلب بيانات أقل من اللازم Under-fetching عندما تحتاج الشاشة ثلاثة طلبات REST متتالية لتجمع الطلب والعميل والمنتجات.

لكن هذه المرونة تنقل التعقيد إلى السيرفر، وأشهر مشكلة فيه هي N+1 التي يشرحها [دليل الأداء في GraphQL](https://graphql.org/learn/performance/?ref=arabroot.io): كل حقل يحله Resolver مستقل، فاستعلام يطلب الطلبات ومع كل طلب عميله يرسل استعلاماً للطلبات ثم استعلاماً لكل عميل، والصورة التالية تبين ذلك والحل:

![استعلام GraphQL يطلب orders(first: 3) مع id وtotal وcustomer { name }. في اليمين لوحة N+1 حمراء: استعلام SELECT من orders ثم ثلاثة استعلامات من customers لكل id 7 و9 و12، ومكتوب مع 100 طلب تصبح 101 استعلام. وفي اليسار لوحة BATCHING خضراء: DataLoader يجمع المفاتيح، فيصبح استعلام orders واستعلام customers WHERE id IN (7, 9, 12) فقط. وفي الأسفل ضوابط: حد للعمق Depth، وحد للتكلفة Cost، وإيقاف Introspection في الإنتاج، وصلاحيات لكل حقل](https://arabroot.io/content/images/2026/10/api-design-07-graphql-n1.webp)

كل Resolver يسأل وحده فتتضاعف الاستعلامات، والتجميع Batching يعيدها إلى استعلامين

والحل المعروف هو التجميع Batching بمكتبة مثل [DataLoader](https://github.com/graphql/dataloader?ref=arabroot.io) التي تجمع المفاتيح المطلوبة خلال الطلب ثم تسأل قاعدة البيانات مرة واحدة. وهناك ثمن آخر: التخزين المؤقت Caching في HTTP أصعب لأن أغلب الاستعلامات تذهب بـ `POST` إلى نفس المسار، والمراقبة أصعب لأن كل الطلبات على مسار واحد.

ومن جهة الأمان فإن الكلاينت هو الذي يكتب الاستعلام، فيستطيع أن يكتب استعلاماً متداخلاً بعمق كبير أو يرسل مئات العمليات في طلب واحد، لذلك يوصي [OWASP GraphQL Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/GraphQL%5FCheat%5FSheet.html?ref=arabroot.io) بحد للعمق وحد للكمية وتحليل تكلفة الاستعلام ومهلة زمنية، وبإيقاف الاستكشاف Introspection في الإنتاج وعدم إرجاع Stack Traces، وبالتحقق من الصلاحيات على كل حقل وكل علاقة وليس على نقطة الدخول فقط. وهذه نفس مخاطر ال API العادية لكن بأبواب أكثر، وقد شرحنا أصلها في [مقال أمن واجهات API مع OWASP API Security Top 10](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/)، وفي [مقال ما هي OWASP](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%8A-owasp-%D9%85%D8%B4%D8%A7%D8%B1%D9%8A%D8%B9%D9%87%D8%A7-%D9%88%D9%88%D8%AB%D8%A7%D8%A6%D9%82%D9%87%D8%A7-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/) تجد بقية مشاريعها.

ومتى يفيد GraphQL؟ عندما تخدم عدة واجهات بحاجات مختلفة جداً من نفس البيانات، مثل تطبيق جوال وموقع ولوحة إدارة، أو عندما تجمع بيانات من عدة خدمات خلف نقطة واحدة. أما لواجهة صغيرة بشاشات معروفة، أو لاتصال بين خدمتين، فإن REST أو gRPC أبسط في التشغيل والمراقبة والتأمين.

## المقارنة في جدول واحد

| المعيار                | REST                                  | gRPC                               | GraphQL                                    |
| ---------------------- | ------------------------------------- | ---------------------------------- | ------------------------------------------ |
| وحدة التصميم           | المورد Resource والمسار               | الخدمة والدالة Method              | المخطط Schema والحقل                       |
| العقد                  | OpenAPI، اختياري                      | ملف .proto، إجباري                 | المخطط، إجباري                             |
| الصيغة على الشبكة      | JSON نصي غالباً                       | protobuf ثنائي Binary              | JSON                                       |
| النقل                  | HTTP/1.1 أو HTTP/2 أو HTTP/3          | HTTP/2                             | HTTP، غالباً POST على مسار واحد            |
| المتصفح مباشرة         | نعم                                   | عبر gRPC-Web وProxy                | نعم                                        |
| التدفق Streaming       | محدود                                 | أربعة أنواع مدمجة                  | عبر Subscriptions                          |
| التخزين المؤقت في HTTP | سهل مع GET                            | غير متاح عملياً                    | صعب                                        |
| الأخطاء                | رموز HTTP مع Problem Details          | grpc-status في Trailers            | غالباً 200 مع قائمة errors                 |
| نقاط القوة             | بسيط ومعروف، يجرب بـ curl، أدوات ضخمة | عقد صارم، كود مولد، سريع، تدفق     | الكلاينت يحدد الحقول، طلب واحد لشاشة كاملة |
| نقاط الضعف             | لا يوجد معيار واحد، Over-fetching     | صعب في المتصفح وفي التجربة اليدوية | N+1، التخزين المؤقت، سطح هجوم أوسع         |
| الأنسب له              | الواجهات العامة وأغلب التطبيقات       | الاتصال الداخلي بين الخدمات        | عدة واجهات بحاجات مختلفة من نفس البيانات   |

ومكان ال API في المعمارية مهم أيضاً: في تطبيق SPA تكون الواجهة في المتصفح وال API هو نقطة الدخول إلى مستوى التطبيق، وأي API داخلي بين الخدمات يجب ألا يكون مكشوفاً للإنترنت، وقد شرحنا الفصل بين المستويات Tiers في [مقال المعمارية متعددة الطبقات](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D9%85%D8%A7%D8%B1%D9%8A%D8%A9-%D9%85%D8%AA%D8%B9%D8%AF%D8%AF%D8%A9-%D8%A7%D9%84%D8%B7%D8%A8%D9%82%D8%A7%D8%AA-tiers-%D9%88-layers/#spa-api).

## تجربة عملية: نفس العملية عبر REST وgRPC

الآن سوف نبني خدمة صغيرة بلغة Python تعرض نفس مخزن الطلبات عبر REST بإطار FastAPI وعبر gRPC، في Container واحد، ثم نستدعيها بأداة curl وأداة grpcurl، ونرى رسائل الخطأ والتقسيم إلى صفحات ومفتاح Idempotency-Key وملف OpenAPI والبايتات الفعلية على الشبكة. والخدمة تستمع على 127.0.0.1 فقط، وgRPC هنا بدون TLS لتبسيط التجربة، أما في الإنتاج فاستخدم TLS دائماً.

### الملفات

العقد في الملف `orders.proto`:

```protobuf
syntax = "proto3";

package shop.v1;

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);
  rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
}

message Order {
  string id = 1;
  string customer = 2;
  int64 total_halalas = 3;
  string status = 4;
}

message GetOrderRequest {
  string id = 1;
}

message ListOrdersRequest {
  int32 page_size = 1;
  string page_token = 2;
}

message ListOrdersResponse {
  repeated Order orders = 1;
  string next_page_token = 2;
}
```

ونسخ المكتبات في `requirements.txt`:

```text
fastapi==0.142.2
uvicorn==0.54.0
grpcio==1.84.0
grpcio-tools==1.84.0
grpcio-reflection==1.84.0
```

وملف `Dockerfile` الذي يولد كود gRPC من العقد أثناء بناء ال Image:

```dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY orders.proto .
RUN python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. orders.proto
COPY app.py .
CMD ["python", "app.py"]
```

والتطبيق في الملف `app.py`:

```python
import base64, hashlib, json, threading, uuid
from concurrent import futures

import grpc
import uvicorn
from fastapi import FastAPI, Header, Request, Response
from fastapi.responses import JSONResponse
from grpc_reflection.v1alpha import reflection
from pydantic import BaseModel, Field

import orders_pb2, orders_pb2_grpc

# one in-memory store shared by both interfaces
ORDERS = {f"ord_{i:03d}": {"id": f"ord_{i:03d}", "customer": f"customer-{i}",
                          "total_halalas": 11500 * i, "status": "paid"} for i in range(1, 6)}
IDEMPOTENCY = {}  # key -> (request hash, order id)

def page(size, token):
    # keyset pagination: the cursor carries the last id the client saw, not a row offset
    after = base64.urlsafe_b64decode(token).decode() if token else ""
    ids = [i for i in sorted(ORDERS) if i > after]
    chunk = ids[:size]
    nxt = base64.urlsafe_b64encode(chunk[-1].encode()).decode() if len(ids) > size else ""
    return [ORDERS[i] for i in chunk], nxt

# ---------- REST ----------
api = FastAPI(title="Shop API", version="1.0.0")

def problem(status, title, detail, type_="about:blank", **extra):
    body = {"type": type_, "title": title, "status": status, "detail": detail, **extra}
    return JSONResponse(body, status_code=status, media_type="application/problem+json")

class NewOrder(BaseModel):
    customer: str = Field(min_length=1)
    total_halalas: int = Field(gt=0)

@api.get("/v1/orders")
def list_orders(request: Request, response: Response, limit: int = 2, cursor: str = ""):
    if not 1 <= limit <= 50:
        return problem(400, "Invalid limit", "limit must be between 1 and 50", instance=str(request.url.path))
    items, nxt = page(limit, cursor)
    if nxt:
        response.headers["Link"] = f'</v1/orders?limit={limit}&cursor={nxt}>; rel="next"'
    return {"items": items, "next_cursor": nxt or None}

@api.get("/v1/orders/{order_id}")
def get_order(order_id: str, request: Request):
    if order_id not in ORDERS:
        return problem(404, "Order not found", f"No order with id {order_id}",
                       type_="https://example.com/problems/order-not-found", instance=str(request.url.path))
    return ORDERS[order_id]

@api.post("/v1/orders", status_code=201)
def create_order(order: NewOrder, request: Request, response: Response,
                 idempotency_key: str | None = Header(default=None)):
    if not idempotency_key:
        return problem(400, "Idempotency-Key required", "Send a unique Idempotency-Key header with every POST")
    digest = hashlib.sha256(order.model_dump_json().encode()).hexdigest()
    if idempotency_key in IDEMPOTENCY:
        old_digest, order_id = IDEMPOTENCY[idempotency_key]
        if old_digest != digest:
            return problem(422, "Idempotency-Key reused", "This key was already used with a different body")
        response.status_code = 200
        response.headers["Location"] = f"/v1/orders/{order_id}"
        return ORDERS[order_id]
    order_id = f"ord_{uuid.uuid4().hex[:6]}"
    ORDERS[order_id] = {"id": order_id, **order.model_dump(), "status": "pending"}
    IDEMPOTENCY[idempotency_key] = (digest, order_id)
    response.headers["Location"] = f"/v1/orders/{order_id}"
    return ORDERS[order_id]

# ---------- gRPC ----------
class OrderService(orders_pb2_grpc.OrderServiceServicer):
    def GetOrder(self, request, context):
        if request.id not in ORDERS:
            context.abort(grpc.StatusCode.NOT_FOUND, f"No order with id {request.id}")
        return orders_pb2.Order(**ORDERS[request.id])

    def ListOrders(self, request, context):
        items, nxt = page(request.page_size or 2, request.page_token)
        return orders_pb2.ListOrdersResponse(orders=[orders_pb2.Order(**o) for o in items], next_page_token=nxt)

def serve_grpc():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
    orders_pb2_grpc.add_OrderServiceServicer_to_server(OrderService(), server)
    names = (orders_pb2.DESCRIPTOR.services_by_name["OrderService"].full_name, reflection.SERVICE_NAME)
    reflection.enable_server_reflection(names, server)
    server.add_insecure_port("0.0.0.0:50051")
    server.start()
    return server

if __name__ == "__main__":
    grpc_server = serve_grpc()
    uvicorn.run(api, host="0.0.0.0", port=8000, log_level="warning")
```

في الإعداد أعلاه لاحظ التالي:

- المخزن `ORDERS` واحد، وكل من مسارات REST ودوال gRPC تقرأ منه، فالفرق الوحيد هو العقد والنقل.
- الدالة `page` تقسم بالمؤشر: المؤشر هو آخر معرف رآه الكلاينت مشفراً بـ base64 حتى يبقى معتماً، والصفحة التالية تبدأ بعده.
- الدالة `problem` ترجع كل الأخطاء بصيغة RFC 9457 ونوع المحتوى `application/problem+json`.
- `create_order` يرفض الطلب بدون `Idempotency-Key`، ويحفظ هاش الجسم مع المفتاح حتى يكتشف إعادة استخدام المفتاح بجسم مختلف.
- تفعيل الاستكشاف Reflection في gRPC يسمح لأداة grpcurl بمعرفة الخدمات دون ملف `.proto`، وهو مفيد في التجربة لكنه يكشف العقد كاملاً، فقرر بوعي هل تفعله في الإنتاج كما يشرح [دليل Reflection](https://grpc.io/docs/guides/reflection/?ref=arabroot.io).

وملف `compose.yaml`:

```yaml
name: lab-api

services:
  app:
    build: .
    image: lab-api-app:1
    container_name: lab-api-app
    ports:
      - "127.0.0.1:21580:8000"    # REST
      - "127.0.0.1:21581:50051"   # gRPC
    networks: [net]

networks:
  net:
    name: lab-api-net
```

### التشغيل والقراءة عبر REST

```bash
docker compose up -d --build
docker compose ps
```

```text
NAME          IMAGE           COMMAND           SERVICE   CREATED         STATUS         PORTS
lab-api-app   lab-api-app:1   "python app.py"   app       6 seconds ago   Up 3 seconds   127.0.0.1:21580->8000/tcp, 127.0.0.1:21581->50051/tcp
```

الآن سوف نطلب الصفحة الأولى من الطلبات ونطبع الترويسات، ثم نتبع المؤشر إلى الصفحتين التاليتين:

```bash
curl -s -i "http://127.0.0.1:21580/v1/orders?limit=2"
curl -s "http://127.0.0.1:21580/v1/orders?limit=2&cursor=b3JkXzAwMg=="
curl -s "http://127.0.0.1:21580/v1/orders?limit=2&cursor=b3JkXzAwNA=="
```

والمخرج سوف يكون كما يلي (حذفنا ترويستي `date` و`server` من كل رد في هذا القسم):

```text
HTTP/1.1 200 OK
content-length: 198
content-type: application/json
link: </v1/orders?limit=2&cursor=b3JkXzAwMg==>; rel="next"

{"items":[{"id":"ord_001","customer":"customer-1","total_halalas":11500,"status":"paid"},{"id":"ord_002","customer":"customer-2","total_halalas":23000,"status":"paid"}],"next_cursor":"b3JkXzAwMg=="}

{"items":[{"id":"ord_003","customer":"customer-3","total_halalas":34500,"status":"paid"},{"id":"ord_004","customer":"customer-4","total_halalas":46000,"status":"paid"}],"next_cursor":"b3JkXzAwNA=="}
{"items":[{"id":"ord_005","customer":"customer-5","total_halalas":57500,"status":"paid"}],"next_cursor":null}
```

لاحظ أن رابط الصفحة التالية موجود في الجسم `next_cursor` وفي ترويسة `link` معاً، وأن الصفحة الأخيرة ترجع `null` فيعرف الكلاينت أن القائمة انتهت. والمؤشر `b3JkXzAwMg==` هو `ord_002` بترميز base64، والكلاينت لا يجب أن يبني عليه أي افتراض، لأنك قد تغير محتواه لاحقاً.

ثم طلب واحد، وطلب غير موجود، وحجم صفحة خارج الحد:

```bash
curl -s http://127.0.0.1:21580/v1/orders/ord_002
curl -s -i http://127.0.0.1:21580/v1/orders/ord_999
curl -s -i "http://127.0.0.1:21580/v1/orders?limit=500"
```

```text
{"id":"ord_002","customer":"customer-2","total_halalas":23000,"status":"paid"}

HTTP/1.1 404 Not Found
content-length: 162
content-type: application/problem+json

{"type":"https://example.com/problems/order-not-found","title":"Order not found","status":404,"detail":"No order with id ord_999","instance":"/v1/orders/ord_999"}

HTTP/1.1 400 Bad Request
content-length: 125
content-type: application/problem+json

{"type":"about:blank","title":"Invalid limit","status":400,"detail":"limit must be between 1 and 50","instance":"/v1/orders"}
```

### الإنشاء مع Idempotency-Key

الآن سوف نرسل نفس طلب الإنشاء مرتين بنفس المفتاح كأن الرد الأول ضاع، ثم نفس المفتاح بجسم مختلف، ثم طلباً بدون مفتاح:

```bash
U=http://127.0.0.1:21580/v1/orders; H='content-type: application/json'
curl -s -i -X POST $U -H "$H" -H 'Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324' -d '{"customer":"customer-9","total_halalas":11500}'
curl -s -i -X POST $U -H "$H" -H 'Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324' -d '{"customer":"customer-9","total_halalas":11500}'
curl -s -i -X POST $U -H "$H" -H 'Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324' -d '{"customer":"customer-9","total_halalas":99900}'
curl -s -X POST $U -H "$H" -d '{"customer":"customer-9","total_halalas":11500}'
```

```text
HTTP/1.1 201 Created
content-length: 84
content-type: application/json
location: /v1/orders/ord_e866fc

{"id":"ord_e866fc","customer":"customer-9","total_halalas":11500,"status":"pending"}

HTTP/1.1 200 OK
content-length: 84
content-type: application/json
location: /v1/orders/ord_e866fc

{"id":"ord_e866fc","customer":"customer-9","total_halalas":11500,"status":"pending"}

HTTP/1.1 422 Unprocessable Content
content-length: 127
content-type: application/problem+json

{"type":"about:blank","title":"Idempotency-Key reused","status":422,"detail":"This key was already used with a different body"}

{"type":"about:blank","title":"Idempotency-Key required","status":400,"detail":"Send a unique Idempotency-Key header with every POST"}
```

في المخرج أعلاه لاحظ التالي:

- الطلب الأول أنشأ `ord_e866fc` برمز `201` وترويسة `location`، والثاني بنفس المفتاح رجع نفس الطلب ولم ينشئ طلباً جديداً.
- اخترنا أن يرجع التكرار `200` حتى يميزه الكلاينت عن الإنشاء، أما Stripe فيعيد نفس الرد الأول حرفياً، وكلا الخيارين صحيح إذا وثقته.
- نفس المفتاح بمبلغ مختلف رجع `422` كما توصي مسودة IETF، والسبب أن هذا غالباً خطأ في الكلاينت أعاد استخدام مفتاح قديم لعملية جديدة.

### نكسر العقد: خطأ التحقق بصيغة الإطار

الآن سوف نرسل جسماً قيمه غير صالحة: اسم عميل فارغ ومبلغ سالب:

```bash
curl -s -i -X POST http://127.0.0.1:21580/v1/orders -H 'content-type: application/json' -H 'Idempotency-Key: 1f0c2b9e-0d7a-4c8e-9a51-3b2f4e6d7a10' -d '{"customer":"","total_halalas":-5}'
```

```text
HTTP/1.1 422 Unprocessable Content
content-length: 268
content-type: application/json

{"detail":[{"type":"string_too_short","loc":["body","customer"],"msg":"String should have at least 1 character","input":"","ctx":{"min_length":1}},{"type":"greater_than","loc":["body","total_halalas"],"msg":"Input should be greater than 0","input":-5,"ctx":{"gt":0}}]}
```

لاحظ أن هذا الخطأ لم يمر عبر الدالة `problem`، لأن FastAPI يتحقق من الجسم قبل أن يصل إلى الكود ويرجع شكله الخاص `{"detail": [...]}` بنوع `application/json`، فأصبح لدينا شكلان مختلفان للأخطاء في نفس ال API، وهو الخطأ الشائع الذي يقع فيه كثيرون، لأن كل إطار له شكله الافتراضي. والحل أن تلتقط أخطاء التحقق وتحولها إلى نفس الصيغة، لذلك أضفنا قبل قسم gRPC في `app.py` ما يلي:

```python
from fastapi.exceptions import RequestValidationError

@api.exception_handler(RequestValidationError)
def validation_problem(request: Request, exc: RequestValidationError):
    errors = [{"field": ".".join(map(str, e["loc"][1:])), "message": e["msg"]} for e in exc.errors()]
    return problem(422, "Validation failed", "The request body has invalid fields",
                   type_="https://example.com/problems/validation", errors=errors)
```

ثم أعدنا البناء بـ `docker compose up -d --build` وأرسلنا نفس الطلب:

```text
HTTP/1.1 422 Unprocessable Content
content-length: 291
content-type: application/problem+json

{"type":"https://example.com/problems/validation","title":"Validation failed","status":422,"detail":"The request body has invalid fields","errors":[{"field":"customer","message":"String should have at least 1 character"},{"field":"total_halalas","message":"Input should be greater than 0"}]}
```

والآن كل أخطاء ال API بنفس الصيغة، والحقل الإضافي `errors` يحمل خطأ كل حقل كما يسمح RFC 9457\. ولاحظ أن المخزن في الذاكرة بدأ من جديد بعد إعادة التشغيل، وهذا سبب آخر لحفظ المفاتيح في قاعدة البيانات في الإنتاج.

### ماذا يقول ملف OpenAPI المولد؟

FastAPI يولد الملف تلقائياً على المسار `/openapi.json`، والأمر التالي يطبع النسخة والمسارات ومعاملات الإنشاء ومخطط الجسم:

```bash
curl -s http://127.0.0.1:21580/openapi.json | python -c "import json,sys; d=json.load(sys.stdin); print(d['openapi'], d['info']); print(list(d['paths'])); print(json.dumps(d['paths']['/v1/orders']['post']['parameters'], indent=2)); print(json.dumps(d['components']['schemas']['NewOrder'], indent=2))"
```

```text
3.1.0 {'title': 'Shop API', 'version': '1.0.0'}
['/v1/orders', '/v1/orders/{order_id}']
[
  {
    "name": "idempotency-key",
    "in": "header",
    "required": false,
    "schema": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Idempotency-Key"
    }
  }
]
{
  "properties": {
    "customer": {
      "type": "string",
      "minLength": 1,
      "title": "Customer"
    },
    "total_halalas": {
      "type": "integer",
      "exclusiveMinimum": 0.0,
      "title": "Total Halalas"
    }
  },
  "type": "object",
  "required": [
    "customer",
    "total_halalas"
  ],
  "title": "NewOrder"
}
```

لاحظ ثلاثة أمور: الملف بنسخة OpenAPI 3.1.0 وليس 3.2.0 الأحدث، لأن الإطار هو الذي يقرر، وترويسة `idempotency-key` مكتوبة `"required": false` مع أن الكود يرفض الطلب بدونها، ولا يوجد في الملف أي ذكر لردود `application/problem+json`. فالملف المولد يصف التوقيع البرمجي للدالة، وليس العقد الذي قصدناه، وهذا ما ذكرناه في قسم OpenAPI.

### نفس العملية عبر gRPC

الآن سوف نستدعي نفس الخدمة عبر gRPC بأداة [grpcurl](https://github.com/fullstorydev/grpcurl?ref=arabroot.io) من Container على نفس الشبكة:

```bash
G="docker run --rm --name lab-api-grpcurl --network lab-api-net fullstorydev/grpcurl:v1.9.3"
$G -plaintext lab-api-app:50051 list
$G -plaintext lab-api-app:50051 describe shop.v1.OrderService
$G -plaintext -d '{"id":"ord_002"}' lab-api-app:50051 shop.v1.OrderService/GetOrder
$G -plaintext -d '{"id":"ord_999"}' lab-api-app:50051 shop.v1.OrderService/GetOrder; echo "exit=$?"
$G -plaintext -d '{"page_size":2}' lab-api-app:50051 shop.v1.OrderService/ListOrders
```

```text
grpc.reflection.v1alpha.ServerReflection
shop.v1.OrderService
shop.v1.OrderService is a service:
service OrderService {
  rpc GetOrder ( .shop.v1.GetOrderRequest ) returns ( .shop.v1.Order );
  rpc ListOrders ( .shop.v1.ListOrdersRequest ) returns ( .shop.v1.ListOrdersResponse );
}
{
  "id": "ord_002",
  "customer": "customer-2",
  "total_halalas": "23000",
  "status": "paid"
}
ERROR:
  Code: NotFound
  Message: No order with id ord_999
exit=69
{
  "orders": [
    {
      "id": "ord_001",
      "customer": "customer-1",
      "total_halalas": "11500",
      "status": "paid"
    },
    {
      "id": "ord_002",
      "customer": "customer-2",
      "total_halalas": "23000",
      "status": "paid"
    }
  ],
  "next_page_token": "b3JkXzAwMg=="
}
```

في المخرج أعلاه لاحظ التالي:

- نفس البيانات ونفس المؤشر `b3JkXzAwMg==` الذي رأيناه في REST، لأن الدالة `page` مشتركة.
- الحقل `total_halalas` ظهر كنص `"23000"` وليس كرقم، والسبب أن grpcurl يحول protobuf إلى JSON بقواعد [ProtoJSON](https://protobuf.dev/programming-guides/json/?ref=arabroot.io) التي تكتب قيم `int64` كنص عشري، لأن JavaScript لا تستطيع تمثيل كل أرقام 64 بت بدقة.
- الخطأ جاء برمز gRPC `NotFound` وليس برمز HTTP `404`.

### البايتات على الشبكة

حتى نرى ما تقوله صورة إطارات gRPC، سوف نكتب رسالة protobuf يدوياً ونرسلها بأداة curl عبر HTTP/2\. والرسالة هي بايت الضغط `00` ثم الطول `00 00 00 09` ثم تسعة بايتات: `0a` وهو الحقل رقم 1 من نوع نص، و`07` طول النص، ثم `ord_002`:

```bash
docker run --rm --name lab-api-curl --network lab-api-net --entrypoint sh curlimages/curl:8.22.0 -c "printf '\000\000\000\000\011\012\007ord_002' > /tmp/req; od -An -tx1 /tmp/req; curl -s -v --http2-prior-knowledge -H 'content-type: application/grpc' -H 'te: trailers' --data-binary @/tmp/req -o /tmp/resp http://lab-api-app:50051/shop.v1.OrderService/GetOrder 2>&1 | grep -E '^[<>] ' | grep -v -i 'user-agent\|^> accept'; od -An -tx1 /tmp/resp"
```

```text
 00 00 00 00 09 0a 07 6f 72 64 5f 30 30 32
> POST /shop.v1.OrderService/GetOrder HTTP/2
> Host: lab-api-app:50051
> content-type: application/grpc
> te: trailers
> Content-Length: 14
>
< HTTP/2 200
< content-type: application/grpc
< grpc-accept-encoding: identity, deflate, gzip
<
< grpc-status: 0
< grpc-message:
 00 00 00 00 1f 0a 07 6f 72 64 5f 30 30 32 12 0a
 63 75 73 74 6f 6d 65 72 2d 32 18 d8 b3 01 22 04
 70 61 69 64
```

وبنفس الطريقة مع `ord_999`:

```text
< HTTP/2 200
< content-type: application/grpc
< grpc-status: 5
< grpc-message: No order with id ord_999
< 
```

في المخرج أعلاه لاحظ التالي:

- الطلب `POST` على المسار `/shop.v1.OrderService/GetOrder` بنوع `application/grpc`، تماماً كما يصفه بروتوكول gRPC over HTTP2، وجسمه 14 بايتاً فقط.
- الرد يبدأ بـ `00 00 00 00 1f`، أي بدون ضغط وطول 31 بايتاً، ثم الحقول بأرقامها: `0a` للحقل 1 (`id`)، و`12` للحقل 2 (`customer`)، و`18` للحقل 3 (`total_halalas`) وقيمته `d8 b3 01` هي 23000 بترميز Varint، و`22` للحقل 4 (`status`)، ولا توجد أسماء حقول على الشبكة إطلاقاً، ولهذا تغيير رقم الحقل يكسر العقد وتغيير اسمه لا يكسر الرسالة الثنائية.
- `grpc-status` جاء بعد الجسم في الترويسات الختامية، وفي حالة الخطأ جاء `grpc-status: 5` وهو `NOT_FOUND` مع رد HTTP بحالة `200`.
- نفس الطلب في REST كان 78 بايتاً من JSON (`content-length: 78` في رد `GET /v1/orders/ord_002`)، وفي protobuf 31 بايتاً، وهذا فرق حقيقي في الحجم لكنه ليس السبب الأول لاختيار gRPC كما ذكرنا.

### التنظيف

```bash
docker compose down
docker image rm lab-api-app:1 fullstorydev/grpcurl:v1.9.3 curlimages/curl:8.22.0
```

## أخطاء شائعة

- **أفعال في المسارات** و`GET` يغير البيانات: يكسر التخزين المؤقت ويجعل أي زاحف Crawler يحذف بياناتك.
- **`200` مع خطأ في الجسم**: المراقبة لا ترى الفشل، ومكتبات الكلاينت لا تعيد المحاولة.
- **شكلان للأخطاء**: شكلك وشكل الإطار الافتراضي كما رأينا في التجربة، والحل صيغة RFC 9457 في كل مكان.
- **قوائم بلا حد أعلى** أو تقسيم بالإزاحة على جداول كبيرة تتغير.
- **`POST` دون Idempotency-Key** في عمليات الدفع والطلبات، ومفاتيح محفوظة خارج معاملة قاعدة البيانات.
- **تغيير كاسر بلا إصدار**: حذف حقل أو جعل معامل إجبارياً «لأن لا أحد يستخدمه»، ثم تكتشف من يستخدمه.
- **إعادة استخدام رقم حقل في protobuf** بدل حجزه بـ `reserved`.
- **GraphQL بلا حدود** للعمق والتكلفة، مع Introspection مفتوح في الإنتاج.
- **ملف OpenAPI مولد لا يراجعه أحد**، فيصف شيئاً غير ما يفعله ال API.

## قائمة تحقق قبل نشر API

1. هل المسارات أسماء موارد، والطرق تتبع معانيها في RFC 9110؟
2. هل كل نجاح وكل فشل له رمز حالة صحيح، و`201` مع `Location` بعد الإنشاء؟
3. هل كل الأخطاء بصيغة `application/problem+json`، بما فيها أخطاء التحقق التي يرجعها الإطار؟
4. هل كل قائمة مقسمة إلى صفحات بمؤشر وحد أعلى للحجم؟
5. هل حقول التصفية والترتيب من قائمة مسموحة فقط؟
6. هل عمليات `POST` الحساسة تقبل `Idempotency-Key`، والمفاتيح محفوظة مع النتيجة في نفس المعاملة؟
7. هل تعرف أي تغيير كاسر، ولديك طريقة إصدارات وسياسة إغلاق بترويستي `Deprecation` و`Sunset`؟
8. هل ملف OpenAPI أو `.proto` أو المخطط مراجع ومحفوظ في Git ومطابق للسلوك؟
9. هل راجعت ال API مقابل OWASP API Security Top 10، وخصوصاً الصلاحيات على كل كائن وكل حقل؟

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- REST للموارد وgRPC للدوال وGraphQL للمخطط، والاختيار يبدأ من سؤال من سوف يستدعي ال API: الواجهات العامة غالباً REST، والاتصال الداخلي بين الخدمات يناسبه gRPC، وGraphQL يفيد عندما تخدم واجهات كثيرة بحاجات مختلفة.
- REST الصحيح: أسماء موارد، وطرق بمعانيها في RFC 9110، ورموز حالة صادقة، وأخطاء بصيغة RFC 9457، وصفحات بمؤشر وحد أعلى، وتصفية من قائمة مسموحة.
- `POST` ليس متكرر الأثر، ومفتاح `Idempotency-Key` من مسودة IETF وكما يطبقه Stripe يجعل إعادة المحاولة آمنة.
- أغلب التغييرات يمكن أن تكون إضافية بلا إصدار جديد، وعند الحاجة إصدار رئيسي في المسار أو ترويسة، ثم `Deprecation` من RFC 9745 و`Sunset` من RFC 8594 قبل الإغلاق.
- OpenAPI 3.2.0 يصف العقد، والملف المولد من الكود يحتاج مراجعة كما رأينا في التجربة.
- gRPC يرسل protobuf بأرقام الحقول فوق HTTP/2 والحالة في الترويسات الختامية، وGraphQL يحتاج تجميعاً ضد N+1 وحدوداً للعمق والتكلفة وصلاحيات لكل حقل.

وأقرب المقالات إلى هذا الموضوع على الموقع:

[![](https://arabroot.io/content/images/2026/10/http-basics-cover-cover.webp)دليلHTTP وHTTPS ببساطة: ماذا يحدث بين الرابط والصفحةماذا يحدث بين كتابة الرابط وظهور الصفحة، وكيف تقرأ الطلب والرد ورموز مثل 502 و504، ولماذا يكلف الاتصال الجديد أكثر.](https://arabroot.io/articles/http-%D9%88-https-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/api-security-cover-cover.webp)دليلأمن ال API بالأمثلة مع OWASP API Security Top 10أشهر عشرة مخاطر في واجهات API كما ترتبها OWASP، ولكل خطر مثال لكود ضعيف وكيف تصلحه وتختبره.](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-%D9%85%D8%B9-owasp-api-security-top-10/)[![](https://arabroot.io/content/images/2026/10/system-design-intro-cover-cover.webp)دليلمدخل إلى تصميم الأنظمة: من سيرفر واحد إلى ملايين المستخدمينكيف يكبر متجر إلكتروني من سيرفر واحد إلى نظام يخدم ملايين المستخدمين، وما الثمن الذي تدفعه في كل خطوة.](https://arabroot.io/articles/%D9%85%D8%AF%D8%AE%D9%84-%D8%A7%D9%84%D9%89-%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D9%85%D9%86-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1-%D9%88%D8%A7%D8%AD%D8%AF-%D8%A7%D9%84%D9%89-%D9%85%D9%84%D8%A7%D9%8A%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/tiers-layers-cover-cover.webp)دليلالفرق بين Tiers وLayers: متى يكون نظامك ثلاثي الطبقات فعلاً؟ماذا يعني أن يكون نظامك ثلاثي الطبقات 3-tier فعلاً، والفرق بين الطبقة داخل الكود والمستوى في الشبكة، وما تطلبه الضوابط بالنص.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D9%85%D8%A7%D8%B1%D9%8A%D8%A9-%D9%85%D8%AA%D8%B9%D8%AF%D8%AF%D8%A9-%D8%A7%D9%84%D8%B7%D8%A8%D9%82%D8%A7%D8%AA-tiers-%D9%88-layers/)[![](https://arabroot.io/content/images/2026/10/multi-tenancy-cover-cover.webp)دليلتعدد المستأجرين Multi-Tenancy في SaaS للمبتدئينكيف يخدم تطبيق SaaS واحد مئات العملاء دون أن يرى أحدهم بيانات غيره، وأي نموذج عزل يناسب تطبيقك.](https://arabroot.io/articles/%D8%AA%D8%B9%D8%AF%D8%AF-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%A7%D8%AC%D8%B1%D9%8A%D9%86-multi-tenancy-%D9%81%D9%8A-saas-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/owasp-overview-cover-cover.webp)دليلما هي OWASP؟ مشاريعها ووثائقها ومتى تستخدم كلاً منهامن يدير OWASP ولماذا كل ما تنشره مجاني، وأي مورد من مواردها تفتح في كل مرحلة من بناء تطبيقك.](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%8A-owasp-%D9%85%D8%B4%D8%A7%D8%B1%D9%8A%D8%B9%D9%87%D8%A7-%D9%88%D9%88%D8%AB%D8%A7%D8%A6%D9%82%D9%87%D8%A7-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 وDocker Compose 5.5.1 مع Python 3.13.16 وFastAPI 0.142.2 وuvicorn 0.54.0 وgrpcio 1.84.0 وprotobuf 7.36.2، وgrpcurl 1.9.3 وcurl 8.22.0.