عندما يكبر تطبيقك من صفحات يبنيها السيرفر إلى تطبيق جوال وواجهة SPA وشريك يربط نظامه بنظامك، يصبح ال API هو المنتج الحقيقي الذي يتعامل معه الآخرون، وكل قرار صغير فيه يبقى معك سنوات: اسم المسار، وشكل رسالة الخطأ، وطريقة التقسيم إلى صفحات، وما يحدث عندما يعيد الكلاينت نفس الطلب بعد انقطاع الشبكة، والسبب أن تغيير أي شيء بعد أن يعتمد عليه الآخرون يكسر أنظمة لا تملكها ولا تراها. وفي مدخل تصميم الأنظمة رأينا كيف ينمو النظام من سيرفر واحد إلى عدة خدمات، وفي هذا المقال سوف نركز على العقد Contract الذي تتكلم به هذه الخدمات مع العالم ومع بعضها.
وفي مقال HTTP وHTTPS ببساطة شرحنا الطلب والرد والطرق 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: الأساس هو المورد 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 بأن تبدأ التصميم بالموارد التي يقدمها ال API والعلاقات والتسلسل بينها، فالمجموعة Collection تحتوي موارد من نفس النوع، والمورد قد يحتوي مجموعات فرعية، مثل /v1/customers/7/orders. والصورة التالية تبين الطرق على مورد الطلبات، وأي منها آمن Safe وأي منها متكرر الأثر Idempotent كما يعرفهما RFC 9110:

في الجدول أعلاه لاحظ التالي:
- الطريقة الآمنة Safe لا تغير شيئاً في السيرفر، لذلك تستطيع المتصفحات وال Proxies إعادتها وتخزينها مؤقتاً دون خوف، فلا تضع أبداً عملية تغير البيانات خلف
GET. - متكرر الأثر Idempotent يعني أن أثر تنفيذ الطلب مرتين هو نفس أثر تنفيذه مرة واحدة، فحذف الطلب 2 مرتين يتركه محذوفاً، أما
POSTمرتين فقد ينشئ طلبين، وهذه هي المشكلة التي سوف نحلها بمفتاح Idempotency-Key. PATCHالمعرف في RFC 5789 للتعديل الجزئي ليس متكرر الأثر بالتعريف، وأبسط صيغة لجسمه هي JSON Merge Patch في RFC 7396 حيث ترسل الحقول التي تغيرت فقط وقيمةnullتعني الحذف.
وإذا كان نظامك يخدم عدة عملاء كل منهم مستأجر Tenant مستقل، فاحرص على أن يأتي المستأجر من التوكن أو النطاق الفرعي وليس من معامل يرسله الكلاينت بحرية، وقد شرحنا الخيارات في قسم المسار والترويسة والتوكن في مقال تعدد المستأجرين.
رمز الحالة الصحيح لكل حالة
شرحنا معنى رموز الحالة في قسم رموز الحالة في مقال HTTP، والذي يهمنا هنا هو الاختيار بينها في 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 الصادر في يوليو 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.
التقسيم إلى صفحات Pagination
لا ترجع أبداً كل السجلات في رد واحد، فالقائمة التي فيها 50 طلباً اليوم فيها 500 ألف بعد سنتين، والطريقة الأولى التي يكتبها الجميع هي الإزاحة Offset: ?limit=20&offset=40، وهي سهلة لكن فيها مشكلتين تبينهما الصورة التالية:

المشكلة الأولى أن البيانات تتغير بين الصفحتين، فإذا أضيف طلب جديد في أول القائمة تنزاح كل الصفوف ويظهر صف مرتين أو يختفي صف، والثانية أن قاعدة البيانات تقرأ كل الصفوف قبل الإزاحة ثم ترميها، فالصفحة رقم 10000 بطيئة. والحل هو المؤشر Cursor ويسمى أيضاً Keyset Pagination: الرد يحمل مؤشراً معتماً Opaque فيه آخر مفتاح رآه الكلاينت، والصفحة التالية تطلب ما بعده، ومع فهرس Index على المفتاح تذهب قاعدة البيانات إليه مباشرة. وهذا ما تفعله Google في AIP-158 بحقلي page_size وpage_token في الطلب وnext_page_token في الرد، وهي نفس الأسماء التي استخدمناها في ملف .proto في التجربة. وفي REST يمكن أن ترسل رابط الصفحة التالية أيضاً في ترويسة Link المعرفة في RFC 8288، وهذا ما يفعله GitHub كما في توثيق التقسيم إلى صفحات في GitHub REST API.
وضع دائماً حداً أعلى لحجم الصفحة، مثل 50 أو 100، وارفض ما فوقه بخطأ واضح، والسبب أن ?limit=1000000 طريقة سهلة لإيقاف السيرفر، وهذا هو الخطر الرابع في قائمة OWASP API Security Top 10.
التصفية Filtering والترتيب Sorting واختيار الحقول
التصفية تكون بمعاملات في الرابط Query Parameters على المجموعة، مثل GET /v1/orders?status=paid&customer=7&sort=-created_at، وفيما يلي القواعد التي توفر عليك مشكلات لاحقة:
- اسمح فقط بقائمة معروفة من حقول التصفية والترتيب، ولا تمرر اسم الحقل من الرابط إلى استعلام SQL مباشرة، والسبب أنه يفتح باب SQL Injection ويسمح بالترتيب على أعمدة بلا فهرس.
- إذا كان الترتيب جزءاً من المؤشر فاجعله ثابتاً، أي أضف المعرف كعمود أخير في الترتيب حتى لا يتساوى صفان.
- اختيار الحقول مثل
?fields=id,totalمفيد للجوال، لكن لا تجعله الطريقة الوحيدة لإخفاء حقول حساسة، فالحقل الذي لا يجب أن يراه المستخدم لا يرسل أبداً، وهذا هو الخطر الثالث BOPLA في مقال أمن واجهات API.
إعادة المحاولة الآمنة Idempotency
لنفرض أن تطبيق الجوال أرسل طلب دفع، ووصل الطلب إلى السيرفر ونفذ، ثم انقطعت الشبكة قبل أن يصل الرد، فالتطبيق لا يعرف هل نفذ الطلب أم لا، فإذا أعاد المحاولة قد يدفع العميل مرتين، وإذا لم يعدها قد لا يتم الطلب. والصورة التالية تبين المشكلة والحل:

والفكرة أن الكلاينت يولد مفتاحاً عشوائياً لكل عملية، ويرسله في ترويسة Idempotency-Key، ويعيد نفس المفتاح في كل محاولة لنفس العملية، والسيرفر يحفظ المفتاح مع نتيجة التنفيذ الأول، فإذا جاء المفتاح مرة أخرى يرجع النتيجة المحفوظة بدل التنفيذ. وهذه الترويسة موصوفة في مسودة IETF draft-ietf-httpapi-idempotency-key-header، وانتبه إلى أنها مسودة Internet-Draft وصلت إلى الإصدار 07 ولم تصبح RFC، وحالتها اليوم منتهية Expired، لكنها الأساس الذي تبني عليه واجهات كثيرة، وتوصي بأن يكون المفتاح UUID أو قيمة عشوائية مشابهة، وبأن يرد السيرفر بـ 400 إذا كان المفتاح مطلوباً وغاب، وبـ 422 إذا أعيد استخدام المفتاح بجسم مختلف، وبـ 409 إذا وصلت إعادة المحاولة والطلب الأصلي ما زال قيد التنفيذ.
وأشهر تطبيق لها هو Stripe، وبحسب توثيق Idempotent requests فإن كل طلبات POST تقبل المفتاح، ويحفظ Stripe رمز الحالة وجسم الرد للطلب الأول سواء نجح أو فشل، حتى رد 500، ويمكن حذف المفاتيح بعد 24 ساعة على الأقل، ويقارن معاملات الطلب الجديد بالأصلي ويرجع خطأ إذا اختلفت، ولا معنى لإرسال المفتاح مع GET وDELETE لأنها متكررة الأثر أصلاً.
الإصدارات Versioning والتوافق مع الإصدارات السابقة
القاعدة الأولى أن أفضل إصدار جديد هو الذي لا تحتاجه، فأغلب التغييرات يمكن أن تكون إضافية Additive لا تكسر أحداً: حقل جديد اختياري في الرد، أو مسار جديد، أو معامل اختياري جديد. ويعدد توثيق GitHub في صفحة Breaking changes التغييرات الكاسرة التي تحتاج إصداراً جديداً: حذف عملية كاملة، وحذف أو إعادة تسمية معامل أو حقل في الرد، وإضافة معامل إجباري جديد، وجعل معامل اختياري إجبارياً، وتغيير نوع معامل أو حقل، وحذف قيم من Enum، وإضافة قاعدة تحقق Validation جديدة على معامل موجود، وتغيير متطلبات المصادقة Authentication أو الصلاحيات Authorization. ويحتاج الكلاينت من جهته أن يكون متسامحاً، أي أن يتجاهل الحقول التي لا يعرفها بدل أن يفشل، وإلا أصبح حتى الحقل الإضافي كاسراً.
وعندما تحتاج إصداراً جديداً فعلاً فهناك ثلاث طرق شائعة تبينها الصورة التالية:

- الإصدار الرئيسي في المسار:
/v1/ordersثم/v2/orders، وهو الأوضح في السجلات Logs والتوثيق، وتطلب Google في AIP-185 أن يكون الإصدار رئيسياً فقط مثلv1وليسv1.4.2، وأن تعمل الإصدارات المختلفة معاً في نفس الوقت أثناء الانتقال، وأن يكون للإصدار القديم فترة إيقاف معلنة قبل إغلاقه. - الإصدار في ترويسة: GitHub يستخدم
X-GitHub-Api-Versionبإصدارات بالتاريخ، وبحسب توثيق إصدارات GitHub REST API فإن الطلب بدون الترويسة يستخدم الإصدار2022-11-28، والإصدار يبقى مدعوماً 24 شهراً بعد صدور إصدار أحدث، وبعد انتهاء الدعم يرجع410 Gone. - إصدار مثبت لكل حساب: في Stripe يستخدم الحساب إصداره الافتراضي ما لم ترسل ترويسة
Stripe-Version، وبحسب توثيق Versioning في Stripe تصدر منذ2024-09-30.acaciaإصدارات شهرية بلا تغييرات كاسرة، وإصدار رئيسي مرتين في السنة يبدأ بالتغييرات الكاسرة.
وإغلاق الإصدار القديم له الآن ترويستان قياسيتان: ترويسة Deprecation في RFC 9745 الصادر في مارس 2025 لتعلن تاريخ إيقاف الدعم، وترويسة Sunset في RFC 8594 لتعلن تاريخ توقف المورد عن الاستجابة، ويشترط RFC 9745 ألا يكون تاريخ Sunset قبل تاريخ Deprecation. وبينهما أهم شيء عملي: سجلات تريك من ما زال يستدعي الإصدار القديم، حتى تتواصل معه قبل الإغلاق بدل أن تكتشفه من شكواه بعده.
OpenAPI: العقد مكتوب
بحسب مواصفة OpenAPI 3.2.0 المنشورة في 19 سبتمبر 2025 فإن OpenAPI وصف قياسي لا يرتبط بلغة برمجة لواجهات HTTP، يسمح للإنسان والحاسوب بمعرفة قدرات الخدمة دون الوصول إلى الكود أو توثيق إضافي أو مراقبة حركة الشبكة. وفي الممارسة هو ملف YAML أو JSON فيه المسارات والمعاملات وأشكال الطلب والرد ورموز الحالة والمصادقة، ومنه تولد صفحات التوثيق ومكتبات الكلاينت بأدوات مثل OpenAPI Generator، ويستخدمه الفحص الأمني الديناميكي واختبارات العقد Contract Tests التي شرحناها في مقال أنواع الاختبارات.
وهناك طريقتان للحصول على الملف: توليده من الكود Code-first كما تفعل أطر مثل FastAPI، أو كتابته أولاً Contract-first ثم كتابة الكود ليطابقه. والتوليد من الكود أسرع في البداية، لكنه يصف ما كتبه الكود وليس ما قصده المصمم، وسوف ترى في التجربة العملية أن الملف المولد يقول إن ترويسة Idempotency-Key اختيارية مع أن الكود يرفض الطلب بدونها، ولا يذكر ردود الخطأ بصيغة application/problem+json إطلاقاً. لذلك إذا كان ال API عاماً أو يستهلكه فريق آخر فاكتب العقد أولاً وراجعه كما تراجع الكود، أو على الأقل راجع الملف المولد وأكمله قبل أن تنشره.
gRPC: دوال عن بعد فوق HTTP/2
بحسب مقدمة gRPC فإن الكلاينت في gRPC يستدعي دالة على سيرفر في جهاز آخر كأنها كائن محلي، والعقد يكتب بلغة Protocol Buffers في ملف .proto، ثم يولد المترجم protoc مع إضافة gRPC كود الكلاينت والسيرفر بلغات مختلفة، فيمكن أن يكون السيرفر بـ Java والكلاينت بـ Python أو Go. ويعمل gRPC فوق HTTP/2، والتفاصيل في وصف بروتوكول gRPC over HTTP2: الطريقة دائماً POST، والمسار بالشكل /Service/Method، ونوع المحتوى application/grpc، وكل رسالة مسبوقة ببايت للضغط Compressed-Flag وأربعة بايتات لطول الرسالة، وحالة النتيجة في ترويسة grpc-status التي تأتي في الترويسات الختامية Trailers. والصورة التالية تبين البايتات الحقيقية لاستدعاء واحد من التجربة العملية:

وفيما يلي أهم ما يميز gRPC:
- أربعة أنواع من الاستدعاء، كما في المفاهيم الأساسية في gRPC: طلب ورد واحد Unary، وتدفق من السيرفر Server Streaming، وتدفق من الكلاينت Client Streaming، وتدفق في الاتجاهين Bidirectional Streaming، وهذا أصعب كثيراً في REST.
- المهلة Deadline جزء من الاستدعاء: الكلاينت يحدد إلى متى ينتظر، وإذا انتهت ينتهي الاستدعاء بـ
DEADLINE_EXCEEDED، ويستطيع السيرفر أن يعرف الوقت الباقي. - رموز حالة خاصة به: مثل
NOT_FOUNDوALREADY_EXISTSوUNAVAILABLE، والقائمة في صفحة رموز الحالة، ولاحظ في الصورة أن رد HTTP يبقى200حتى عند الخطأ، فأي أداة مراقبة تقرأ رمز HTTP فقط لن ترى الأخطاء. - تطور العقد له قواعد: في دليل proto3 إضافة حقل جديد آمنة، وحذف حقل آمن بشرط أن تحجز رقمه واسمه بـ
reservedحتى لا يعيد أحد استخدامه، أما تغيير رقم حقل موجود فغير آمن، والسبب أن الرسالة على الشبكة تحمل أرقام الحقول وليس أسماءها، فإعادة استخدام رقم قديم بمعنى جديد تجعل الكلاينت القديم يقرأ بيانات خاطئة، والأرقام من 1 إلى 15 تأخذ بايتاً واحداً لذلك تعطى للحقول الأكثر استخداماً.
ومتى تستخدمه؟ gRPC مناسب للاتصال الداخلي بين الخدمات Service-to-service، وعندما تحتاج تدفقاً Streaming، وعندما تكتب الخدمات بلغات مختلفة وتريد عقداً صارماً وكوداً مولداً. وهو غير مناسب لواجهة عامة يستدعيها المتصفح مباشرة، والسبب أن المتصفح لا يستطيع التحكم في إطارات HTTP/2 بالشكل الذي يحتاجه gRPC، لذلك يحتاج gRPC-Web إلى Proxy مثل Envoy يترجم بين المتصفح والسيرفر، ولا يناسب أيضاً شريكاً يريد أن يجرب ال API بأداة curl في دقيقة.
GraphQL: الكلاينت يحدد الشكل
بحسب مواصفة GraphQL إصدار سبتمبر 2025 فإن GraphQL أنشئ عام 2012 وبدأ تطويره كمعيار مفتوح عام 2015. وفكرته أن السيرفر ينشر مخططاً Schema فيه الأنواع والعلاقات، والكلاينت يكتب استعلاماً فيه الحقول التي يريدها فقط، فيحصل عليها في طلب واحد بنفس شكل الاستعلام. وهذا يحل مشكلتين في REST: جلب بيانات أكثر من اللازم Over-fetching عندما يرجع المسار حقولاً كثيرة لا تحتاجها شاشة الجوال، وجلب بيانات أقل من اللازم Under-fetching عندما تحتاج الشاشة ثلاثة طلبات REST متتالية لتجمع الطلب والعميل والمنتجات.
لكن هذه المرونة تنقل التعقيد إلى السيرفر، وأشهر مشكلة فيه هي N+1 التي يشرحها دليل الأداء في GraphQL: كل حقل يحله Resolver مستقل، فاستعلام يطلب الطلبات ومع كل طلب عميله يرسل استعلاماً للطلبات ثم استعلاماً لكل عميل، والصورة التالية تبين ذلك والحل:

والحل المعروف هو التجميع Batching بمكتبة مثل DataLoader التي تجمع المفاتيح المطلوبة خلال الطلب ثم تسأل قاعدة البيانات مرة واحدة. وهناك ثمن آخر: التخزين المؤقت Caching في HTTP أصعب لأن أغلب الاستعلامات تذهب بـ POST إلى نفس المسار، والمراقبة أصعب لأن كل الطلبات على مسار واحد.
ومن جهة الأمان فإن الكلاينت هو الذي يكتب الاستعلام، فيستطيع أن يكتب استعلاماً متداخلاً بعمق كبير أو يرسل مئات العمليات في طلب واحد، لذلك يوصي OWASP GraphQL Cheat Sheet بحد للعمق وحد للكمية وتحليل تكلفة الاستعلام ومهلة زمنية، وبإيقاف الاستكشاف Introspection في الإنتاج وعدم إرجاع Stack Traces، وبالتحقق من الصلاحيات على كل حقل وكل علاقة وليس على نقطة الدخول فقط. وهذه نفس مخاطر ال API العادية لكن بأبواب أكثر، وقد شرحنا أصلها في مقال أمن واجهات API مع OWASP API Security Top 10، وفي مقال ما هي OWASP تجد بقية مشاريعها.
ومتى يفيد 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 في مقال المعمارية متعددة الطبقات.
تجربة عملية: نفس العملية عبر REST وgRPC
الآن سوف نبني خدمة صغيرة بلغة Python تعرض نفس مخزن الطلبات عبر REST بإطار FastAPI وعبر gRPC، في Container واحد، ثم نستدعيها بأداة curl وأداة grpcurl، ونرى رسائل الخطأ والتقسيم إلى صفحات ومفتاح Idempotency-Key وملف OpenAPI والبايتات الفعلية على الشبكة. والخدمة تستمع على 127.0.0.1 فقط، وgRPC هنا بدون TLS لتبسيط التجربة، أما في الإنتاج فاستخدم TLS دائماً.
الملفات
العقد في الملف orders.proto:
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:
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:
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:
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.
وملف compose.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
docker compose up -d --build
docker compose psNAME 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الآن سوف نطلب الصفحة الأولى من الطلبات ونطبع الترويسات، ثم نتبع المؤشر إلى الصفحتين التاليتين:
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 من كل رد في هذا القسم):
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، والكلاينت لا يجب أن يبني عليه أي افتراض، لأنك قد تغير محتواه لاحقاً.
ثم طلب واحد، وطلب غير موجود، وحجم صفحة خارج الحد:
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"{"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
الآن سوف نرسل نفس طلب الإنشاء مرتين بنفس المفتاح كأن الرد الأول ضاع، ثم نفس المفتاح بجسم مختلف، ثم طلباً بدون مفتاح:
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}'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، والسبب أن هذا غالباً خطأ في الكلاينت أعاد استخدام مفتاح قديم لعملية جديدة.
نكسر العقد: خطأ التحقق بصيغة الإطار
الآن سوف نرسل جسماً قيمه غير صالحة: اسم عميل فارغ ومبلغ سالب:
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}'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 ما يلي:
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 وأرسلنا نفس الطلب:
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، والأمر التالي يطبع النسخة والمسارات ومعاملات الإنشاء ومخطط الجسم:
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))"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 من Container على نفس الشبكة:
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/ListOrdersgrpc.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 التي تكتب قيمint64كنص عشري، لأن JavaScript لا تستطيع تمثيل كل أرقام 64 بت بدقة. - الخطأ جاء برمز gRPC
NotFoundوليس برمز HTTP404.
البايتات على الشبكة
حتى نرى ما تقوله صورة إطارات gRPC، سوف نكتب رسالة protobuf يدوياً ونرسلها بأداة curl عبر HTTP/2. والرسالة هي بايت الضغط 00 ثم الطول 00 00 00 09 ثم تسعة بايتات: 0a وهو الحقل رقم 1 من نوع نص، و07 طول النص، ثم ord_002:
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" 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:
< 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 كما ذكرنا.
التنظيف
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
- هل المسارات أسماء موارد، والطرق تتبع معانيها في RFC 9110؟
- هل كل نجاح وكل فشل له رمز حالة صحيح، و
201معLocationبعد الإنشاء؟ - هل كل الأخطاء بصيغة
application/problem+json، بما فيها أخطاء التحقق التي يرجعها الإطار؟ - هل كل قائمة مقسمة إلى صفحات بمؤشر وحد أعلى للحجم؟
- هل حقول التصفية والترتيب من قائمة مسموحة فقط؟
- هل عمليات
POSTالحساسة تقبلIdempotency-Key، والمفاتيح محفوظة مع النتيجة في نفس المعاملة؟ - هل تعرف أي تغيير كاسر، ولديك طريقة إصدارات وسياسة إغلاق بترويستي
DeprecationوSunset؟ - هل ملف OpenAPI أو
.protoأو المخطط مراجع ومحفوظ في Git ومطابق للسلوك؟ - هل راجعت ال 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 وحدوداً للعمق والتكلفة وصلاحيات لكل حقل.
وأقرب المقالات إلى هذا الموضوع على الموقع:
دليلHTTP وHTTPS ببساطة: ماذا يحدث بين الرابط والصفحةماذا يحدث بين كتابة الرابط وظهور الصفحة، وكيف تقرأ الطلب والرد ورموز مثل 502 و504، ولماذا يكلف الاتصال الجديد أكثر.
دليلأمن ال API بالأمثلة مع OWASP API Security Top 10أشهر عشرة مخاطر في واجهات API كما ترتبها OWASP، ولكل خطر مثال لكود ضعيف وكيف تصلحه وتختبره.
دليلمدخل إلى تصميم الأنظمة: من سيرفر واحد إلى ملايين المستخدمينكيف يكبر متجر إلكتروني من سيرفر واحد إلى نظام يخدم ملايين المستخدمين، وما الثمن الذي تدفعه في كل خطوة.
دليلالفرق بين Tiers وLayers: متى يكون نظامك ثلاثي الطبقات فعلاً؟ماذا يعني أن يكون نظامك ثلاثي الطبقات 3-tier فعلاً، والفرق بين الطبقة داخل الكود والمستوى في الشبكة، وما تطلبه الضوابط بالنص.
دليلتعدد المستأجرين Multi-Tenancy في SaaS للمبتدئينكيف يخدم تطبيق SaaS واحد مئات العملاء دون أن يرى أحدهم بيانات غيره، وأي نموذج عزل يناسب تطبيقك.
دليلما هي OWASP؟ مشاريعها ووثائقها ومتى تستخدم كلاً منهامن يدير OWASP ولماذا كل ما تنشره مجاني، وأي مورد من مواردها تفتح في كل مرحلة من بناء تطبيقك.سجل التحديثات
- أكتوبر 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.