اختبار GraphQL

GraphQL لغة استعلام ونظام أنواع للواجهات البرمجية، تعرّفها مواصفة GraphQL (GraphQL Specification). وتعرض الواجهة عادةً نقطة نهاية واحدة، مثل POST /graphql. يرسل العميل مستنداً يسمّي الحقول التي يريدها بالضبط، فيتحقق الخادم من صحته وفق مخطط محدد الأنواع، ثم ينفّذه ويعيد JSON بالشكل نفسه.

مثال: { order(id: "1042") { status total } } يعيد {"data": {"order": {"status": "SHIPPED", "total": 249.5}}} ولا شيء غير ذلك. وما زال HTTP يحمل الطلب، لكن معظم ما يفحصه المختبِر ينتقل إلى جسمَي الطلب والاستجابة.

لماذا يهم

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

ويستطيع طلب واحد أيضاً أن يستدعي قوائم متداخلة تكلّف أكثر بكثير من أي طلب REST. وتضع قائمة OWASP API Security Top 10 لعام ⁦2023⁩ الاستهلاك غير المقيّد للموارد وكسر التفويض على مستوى خصائص الكائن ضمن مخاطرها، وGraphQL يجعل الخطأ في كليهما سهلاً.

المخطط والاستعلامات وعمليات التعديل

المخطط (Schema)، المكتوب بلغة تعريف المخطط (SDL)، هو العقد: كل نوع، وكل حقل مع نوعه، ونقاط الدخول. حقول Query تقرأ البيانات وحقول Mutation تغيّرها، وتدل ! على قيمة لا تكون null أبداً. والوسائط والمتغيرات محددة الأنواع: فالنوع Int عدد صحيح بإشارة من ⁦32⁩ بت، لذا تُرفض القيمة 2147483648 بخطأ طلب قبل التنفيذ.

مقارنةً بـREST، توجد عادةً نقطة نهاية واحدة، وتنتقل العملية في جسم الطلب، والعميل لا نقطة النهاية هو من يختار الحقول العائدة. ولا يكون لواجهة REST مستند OpenAPI إلا إذا وفّره الفريق، أما خادم GraphQL فيملك مخططه دائماً ويفرضه، فكل حقل ووسيط فيه شرط اختبار جاهز.

يوزّع REST الواجهة على عناوين URI كثيرة ويعلن النتيجة في رمز الحالة؛ أما GraphQL فيستخدم نقطة نهاية واحدة، ويختار فيه العميل الحقول، وتصل أخطاؤه في جسم الاستجابة، عادةً مع الرمز ⁦200 OK⁩.

REST

موارد كثيرة لكلٍّ منها عنوان URI خاص، والطريقة تحدد ما يُفعل

GET /v1/orders/1042

نقاط النهاية:
عنوان URI لكل مورد، وعدة طرق
الحقول العائدة:
تحددها نقطة النهاية
الأخطاء:
رمز حالة واحد يصف الطلب كله
العقد:
مستند OpenAPI إن وفّره الفريق
فحص الصلاحيات:
غالباً لكل نقطة نهاية وطريقة
تكلفة الطلب الواحد:
يحددها تصميم نقطة النهاية، وغالباً على صفحات

GraphQL

نقطة نهاية واحدة، وجسم الطلب يسمّي العملية وكل حقل

POST /graphql

نقاط النهاية:
نقطة نهاية واحدة لكل شيء عادةً
الحقول العائدة:
يختارها العميل في كل استعلام
الأخطاء:
في المصفوفة errors داخل الجسم، عادةً مع 200 OK؛ وقد تعود بيانات الحقول الأخرى
العقد:
المخطط: موجود دائماً ومفروض، ويُقرأ بالاستبطان إن كان مفعّلاً
فحص الصلاحيات:
لكل كائن وحقل، في كل مسار يصل إليه
تكلفة الطلب الواحد:
تزداد مع العمق وأحجام القوائم والأسماء المستعارة

سلوك شائع: تختلف الخوادم، خصوصاً في رموز الحالة التي تستخدمها، فراجع ما توثّقه واجهتك.

  • الاستعلام (Query): تطابق data التحديد (selection)، ففيها كل حقل مطلوب. وقد تعني null «لا قيمة» فعلاً أو حقلاً فشل، ولا يميّز بينهما إلا مدخل مطابق في errors.
  • عملية التعديل (Mutation): تغيّر البيانات وتعيد الحقول التي تختارها من النتيجة؛ وحقول التعديل المتعددة في عملية واحدة تُنفَّذ بالترتيب.
  • المتغيرات (Variables): تُرسل في كائن variables ويُتحقق من أنواعها؛ اختبرها كأي مدخل: مفقودة، وnull، ونوع خاطئ، وقيمة خارج النطاق.
# Schema (SDL), shortened
type Query {
  order(id: ID!): Order
  me: User!
}
type Mutation {
  cancelOrder(id: ID!, reason: String): Order
}
type Order {
  id: ID!
  status: OrderStatus!
  total: Float!
  trackingUrl: String
  internalNote: String
  customer: User!
  items: [OrderItem!]!
}
type User { id: ID! name: String! email: String! orders(first: Int): [Order!]! }
type OrderItem { sku: String! quantity: Int! }
enum OrderStatus { PLACED SHIPPED CANCELLED }

# An operation, sent over HTTP as a JSON body:
# {"query": "query OrderSummary($id: ID!) { ... }", "variables": {"id": "1042"}}
query OrderSummary($id: ID!) {
  order(id: $id) { status total items { sku quantity } }
}

جرّبها: مختبِر استعلامات GraphQL

أخطاء تصل مع رمز HTTP ⁦200⁩

تعرّف المواصفة الاستجابة لا رمز حالة HTTP: وهي عملياً كائن JSON فيه data أو errors أو كلاهما. في REST يصف رمز حالة واحد الطلب كله؛ أما خوادم GraphQL فكثير منها يجيب بـ200 OK على كل ما استطاع معالجته، وبعضها لا يستخدم 4xx إلا للطلبات التي يعجز عن تحليلها أو التحقق منها.

خطأ الطلب (request error)، كصياغة خاطئة أو حقل غير معروف أو متغير من نوع خاطئ، يوقف التنفيذ: errors بلا data. أما خطأ التنفيذ (execution error)، ويُسمّى خطأ الحقل (field error) في الإصدارات السابقة من المواصفة وفي كثير من الأدوات، فيجعل ذلك الحقل وحده null، ويضيف مدخلاً في errors يسمّيه في path، ويعيد باقي data: وهذا نجاح جزئي. وإذا كان الحقل لا يقبل null انتقلت null إلى أقرب أب يقبلها. وفي المثال أدناه انتهت مهلة خدمة الشحن التي يعتمد عليها trackingUrl.

  • تحقّق من الجسم: عند النجاح تغيب errors ويكون لكل حقل النوع الصحيح؛ وعند الفشل المتوقع افحص errors وpath وأي رمز خطأ موثّق. ومحتوى extensions يحدده خادمك لا المواصفة.
  • النجاح الجزئي: يجب ألا يُعرض رابط التتبع المفقود على أنه «لا يتوفر تتبع»، وألا تكشف أي message تتبع المكدس (stack trace) أو استعلامات SQL.
HTTP/1.1 200 OK
Content-Type: application/json

{
  "errors": [
    {
      "message": "Carrier service timed out",
      "locations": [{ "line": 4, "column": 5 }],
      "path": ["order", "trackingUrl"],
      "extensions": { "code": "UPSTREAM_TIMEOUT" }
    }
  ],
  "data": {
    "order": { "status": "SHIPPED", "trackingUrl": null }
  }
}

جرّبها: مختبِر JSON Path جرّبها: محلّل استجابات API

عمق الاستعلام وحجمه وتكلفته

للنوع User حقل orders، ولكل Order حقل customer، ولهذا الزبون orders من جديد، فيستطيع استعلام قصير أن يتداخل إلى أي عمق يريده العميل. وكل مستوى من القوائم يضاعف العمل: ⁦100⁩ طلبية، لكلٍّ منها زبون له ⁦100⁩ طلبية، تعني ⁦10,000⁩ كائن. ونقطة نهاية REST تعيد ما قرره مصمموها، وغالباً على صفحات؛ أما طلب GraphQL فيكلّف بقدر ما يطلبه العميل. والاسم المستعار (Alias)، مثل o1: قبل الحقل، يعيد تسمية ذلك الحقل في الاستجابة، فيستطيع طلب واحد أن يكرر الحقل نفسه مرات كثيرة.

توقّع حداً أقصى للعمق، وسقفاً لوسائط القوائم مثل first، وميزانية تكلفة، ومهلة زمنية (time-out)، وحدودَ معدل (rate limits) تحسب العمليات، وأن يفشل كل منها بمدخل واضح في errors لا بانهيار؛ واعثر على حد العمق بإضافة مستوى واحد في كل مرة. ولا تختبر إلا أنظمة تملكها أو لديك إذن مكتوب باختبارها، ويُفضَّل أن تكون بيئة اختبار، وبخطوات صغيرة.

  • إذا كان سقف first هو ⁦100⁩، فأرسل ⁦100⁩ و⁦101⁩ و⁦0⁩ و⁦-1⁩: لا صفحة أكبر بصمت أبداً.
  • أرسل في طلب واحد عملية تعديل محدودة المعدل، مثل استخدام قسيمة خصم، مكررةً بـ⁦10⁩ أسماء مستعارة: يجب أن يحسب الحدُّ كل اسم مستعار، لا طلب HTTP الواحد.
# Nesting: every list level multiplies the work
query Deep {
  me {
    orders(first: 100) {
      customer {
        orders(first: 100) {
          customer { orders(first: 100) { id } }
        }
      }
    }
  }
}

# Aliases: the same field many times in one request
query Aliases {
  o1: order(id: "1001") { total }
  o2: order(id: "1002") { total }
  # ... and so on, up to o500
}

جرّبها: مختبِر استعلامات GraphQL

مثال تطبيقي: التفويض على مستوى الحقل

في REST يُفحص الوصول غالباً لكل نقطة نهاية وطريقة. ومع نقطة نهاية GraphQL واحدة يجب أن يُطبَّق التفويض (Authorization) في كل مرة تُحسب فيها قيمة كائن أو حقل، وفي كل مسار يصل إليه، سواء وُضع الفحص في المُحلِّل (resolver)، وهو دالة الخادم التي تُنتج قيمة الحقل، أو في منطق الأعمال الذي يستدعيه المُحلِّل. المتطلب: «يرى الزبائن طلبياتهم فقط. والحقل internalNote لموظفي الدعم فقط. ولا يلغي الطلبية إلا صاحبها». الزبون A يملك الطلبية ⁦1042⁩ (PLACED)، والزبون B يملك طلبيات أخرى، ومعهما موظف دعم واحد؛ ويستخدم كل اختبار رمز (token) مستخدمه.

  • الاختباران ⁦1⁩ و⁦2⁩: استعلام واحد، ومستخدمان بدورين مختلفين، ونتيجتان متوقعتان.
  • الاختباران ⁦3⁩ و⁦4⁩ يفحصان التفويض على مستوى الكائن (OWASP API1:2023). اقرأ الطلبية مجدداً بحساب موظف الدعم بعد الاختبار ⁦4⁩، وقارن الاختبار ⁦3⁩ بمعرّف غير موجود: فاختلاف الاستجابة يخبر B أي الطلبيات موجودة.
  • الاختباران ⁦5⁩ و⁦6⁩ يصلان إلى internalNote عبر قائمة متداخلة ونتيجة عملية تعديل؛ والخادم الذي لا يفحص القاعدة إلا في الاستعلام order يفشل فيهما (OWASP API3:2023، التفويض على مستوى خصائص الكائن).
#  User        Operation, then expected result
1  Customer A  { order(id: "1042") { status internalNote } }
               -> status returned; internalNote null + error with its path
2  Agent       { order(id: "1042") { status internalNote } }
               -> both fields returned
3  Customer B  { order(id: "1042") { status } }
               -> order null; response identical to an unknown id
4  Customer B  mutation { cancelOrder(id: "1042") { status } }
               -> null + error; the agent still reads order 1042 as PLACED
5  Customer A  { me { orders(first: 5) { id internalNote } } }
               -> every internalNote null + error
6  Customer A  mutation { cancelOrder(id: "1042") { status internalNote } }
               -> status CANCELLED; internalNote null + error

جرّبها: مقارنة استجابات API

الاستبطان: المخطط يصف نفسه

الاستبطان (Introspection) جزء من المواصفة: حقول نظامية تبدأ بـ__ وتعيد المخطط نفسه. فالحقل __schema يسرد كل الأنواع وأنواع العمليات الجذرية، و__type(name: "Order") يصف نوعاً واحداً، و__typename يسمّي نوع أي كائن. استخدمه لسرد كل استعلام وعملية تعديل وحقل ووسيط يجب أن تغطيه، واطلب fields مع includeDeprecated: true، لأن الحقول المهملة (deprecated) تُستبعد افتراضياً لكنها ما زالت تعمل.

ولأنه يسلّم خريطة للواجهة، توصي ورقة OWASP الإرشادية لـGraphQL (OWASP GraphQL Cheat Sheet) بتعطيله في الإنتاج وفي كل بيئة متاحة للعموم. وقد تكون إتاحته مقصودةً في واجهة مبنية لعملاء خارجيين، وحتى عندئذٍ تقترح الورقة النظر في تعطيله للمستخدمين غير المصادَق عليهم أو غير المفوَّضين. وتعطيله لا يجعل المخطط سرياً: فالحقول المخمَّنة ما زالت تجيب، وبعض الخوادم يقترح الأسماء في رسائل الخطأ («Did you mean …?»)، وتنصح الورقة بإيقاف هذه الاقتراحات أيضاً، لذا يجب أن يصمد التفويض على أي حال.

  • احفظ النتيجة لقطةً (snapshot) في كل إصدار وقارن اللقطات: فحذف حقل أو إضافة وسيط إلزامي جديد يكسر العملاء.
  • في كل بيئة، أرسل __schema بلا رمز ثم برمز مستخدم عادي، وقارن النتيجة بالسياسة المتفق عليها.
query Inventory {
  __schema {
    queryType { name }
    mutationType { name }
    types {
      name
      kind
      fields(includeDeprecated: true) { name isDeprecated args { name } }
    }
  }
}

جرّبها: مختبِر استعلامات GraphQL جرّبها: مقارنة JSON

أخطاء شائعة

  • التحقق من 200 OK فقط بدلاً من فحص errors وdata.
  • وصف استجابة فيها data وerrors معاً بأنها نجاح أو فشل دون تحديد القيمة المتوقعة لكل حقل.
  • اختبار التفويض في الاستعلام الرئيسي فقط، لا في الكائنات المتداخلة والقوائم ونتائج عمليات التعديل.
  • افتراض أن استعلامات الواجهة الأمامية وحدها هي المهمة، مع أن أي رمز صالح يستطيع إرسال أي استعلام يسمح به المخطط.
  • إجراء اختبارات العمق أو الأسماء المستعارة على بيئة الإنتاج أو على بيئة مشتركة دون إذن.

من منظور ضمان الجودة

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

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

تمرين عملي

صمّم اختبارات لحقل توصيل جديد

المطلوب

يضيف فريقك الحقول أدناه إلى النوع Order في هذا الدرس. المتطلب: «يرى الزبائن عنوان التوصيل وأحداث التتبع لطلبياتهم، ويراها موظفو الدعم لأي طلبية. ويعيد trackingEvents ⁦50⁩ حدثاً على الأكثر في كل طلب».

اكتب حالات الاختبار، ولكل منها المستخدم والعملية وما يجب أن يظهر وما يجب ألا يظهر في data وerrors.

المادة
type Order {
  # ...existing fields from the lesson
  deliveryAddress: Address
  trackingEvents(first: Int): [TrackingEvent!]!
}
type Address { line1: String! city: String! postcode: String! }
type TrackingEvent { status: String! at: String! }
الخطوات
  1. اكتب الأدوار وبيانات الاختبار التي تحتاج إليها.
  2. اكتب الحالات الإيجابية.
  3. اكتب حالات تفويض تصل إلى العنوان عبر مسارين.
  4. طبّق تحليل القيم الحدّية الثنائي (2-value BVA) على first: اختبر كل قيمة حدّية وأقرب قيمة مجاورة لها في القسم المجاور.
إظهار الإجابة النموذجية
الإجابة النموذجية

البيانات: الزبون A يملك الطلبية ⁦1042⁩ ومعها ⁦60⁩ حدث تتبع، والزبون B يملك الطلبية ⁦2077⁩، ومعهما موظف دعم واحد؛ ويستخدم كل اختبار رمز مستخدمه.

الإيجابية: يطلب A وموظف الدعم deliveryAddress وtrackingEvents(first: 10) عبر order(id: "1042") فيتلقيان كليهما دون errors.

التفويض: يطلب B الحقول نفسها عبر order(id: "1042") فيتلقى order: null دون أي عنوان أو أحداث في أي مكان، والاستجابة نفسها التي يتلقاها لمعرّف غير معروف. ويرسل B العملية cancelOrder(id: "1042") { deliveryAddress } فيتلقى null مع خطأ، ويجد موظف الدعم حالة الطلبية دون تغيير. وعبر me { orders } لا يرى B إلا عناوينه.

الحد: first: 50 يعيد ⁦50⁩ حدثاً؛ وfirst: 51 يعيد خطأً واضحاً أو ⁦50⁩ حدثاً بالضبط كما هو موثّق، ولا يعيد ⁦51⁩ أبداً. وللحد الأدنى اسأل هل القيمة ⁦0⁩ صالحة: إن كان النطاق الصالح ⁦1–50⁩ فاختبر ⁦0⁩ (خطأ) و⁦1⁩ (حدث واحد)، وإن كان ⁦0–50⁩ فاختبر ⁦-1⁩ (خطأ) و⁦0⁩ (قائمة فارغة). ودون first اسأل ما القيمة الافتراضية: ⁦50⁩ على الأكثر، لا الأحداث الستون كلها.

أسئلة شائعة

لماذا تعيد واجهة GraphQL الرمز ⁦200⁩ مع أن شيئاً ما فشل؟

تصف مواصفة GraphQL الاستجابة لا رمز حالة HTTP، وخطأ التنفيذ في حقل واحد ما زال يُنتج استجابة مفيدة فيها بيانات جزئية. لذلك يجيب كثير من الخوادم بالرمز ⁦200⁩ على أي طلب استطاع معالجته، وبعضها لا يستخدم 4xx إلا للطلبات التي يعجز عن تحليلها أو التحقق منها. راجع توثيق واجهتك، وتحقّق من الجسم في كل الأحوال.

هل يجب تعطيل الاستبطان؟

هو مفيد في بيئات التطوير والاختبار. أما في الإنتاج وفي كل بيئة متاحة للعموم فتوصي ورقة OWASP الإرشادية لـGraphQL بتعطيله؛ وفي واجهة مُعدّة ليستكشفها عملاء خارجيون تقترح على الأقل النظر في تعطيله للمستخدمين غير المصادَق عليهم أو غير المفوَّضين. تعامل مع القرار كمتطلب واختبره في كل بيئة؛ فتعطيله يقلل الانكشاف لكنه لا يغني عن التفويض أبداً.

هل أستطيع اختبار GraphQL بأدوات REST التي أستخدمها؟

نعم. فطلب GraphQL عبر HTTP هو عادةً طلب POST بجسم JSON يحمل query وvariables، لذا يستطيع أي عميل HTTP إرساله. وتضيف أدوات GraphQL تحريراً وتحققاً يعرفان المخطط. وأداة GraphQL Query Tester في Mutqan تنفّذ الاستعلامات وعمليات التعديل مع المتغيرات والترويسات على نقطة نهاية وتعرض الاستجابة.

المراجع

  • GraphQL SpecificationGraphQL Foundationلغة استعلام GraphQL ونظام أنواعها وقواعد التحقق ودلالات التنفيذ.
  • OWASP Cheat Sheet Series: GraphQLOWASP Foundationمخاطر GraphQL الخاصة: حدود عمق الاستعلام وتعقيده، والاستبطان في الإنتاج، والتفويض على مستوى الحقل.
  • OWASP API Security Top 10 (2023)OWASP Foundation · 2023أكثر عشر نقاط ضعف شيوعاً في واجهات API: خلل التفويض على مستوى الكائن، وخلل التوثيق، والاستهلاك غير المقيد للموارد، وغيرها.

تدرّب بأدوات متقن

شغّل استعلامات GraphQL وتعديلاتها (mutations) مع المتغيرات والترويسات على أي نقطة نهاية وافحص الاستجابة.

اختبار واجهات API

تقييم تعبيرات JSONPath ($.store.book[*].author) على JSON وعرض النتائج المطابقة فوراً.

JSON والبيانات

قارن استجابتي API (مثل بيئة التجريب مقابل الإنتاج) واحصل على قائمة بكل فرق بنيوي أو في القيم.

اختبار واجهات API

مقارنة مستندي JSON دلالياً وإبراز القيم المضافة والمحذوفة والمتغيرة.

JSON والبيانات

حلّل استجابة API: البنية وأنواع الحقول وقيم null والتكرارات والأحجام ومشكلات جودة البيانات المحتملة.

اختبار واجهات API

للتعمّق أكثر

تقدّمك في هذا الموضوع

جزء من مسار تعلم ضمان الجودة