Kodax

توثيق الـ API بـ OpenAPI — REST APIs

توثيق الـ API بـ OpenAPI

API بلا توثيق يعني أن مطور الواجهة يسألك في كل مرة: "ما شكل الاستجابة؟ هل الحقل إلزامي؟ ما الأخطاء الممكنة؟". OpenAPI معيار لوصف الـ API في ملف واحد (YAML أو JSON) يقرؤه الإنسان والأدوات.

ماذا يصف؟

openapi: 3.1.0
info:
  title: Kodax Library API
  version: 1.2.0
servers:
  - url: https://api.example.com/v1
paths:
  /books/{id}:
    get:
      summary: جلب كتاب
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: الكتاب
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Book" }
        "404":
          $ref: "#/components/responses/NotFound"
components:
  schemas:
    Book:
      type: object
      required: [id, title]
      properties:
        id: { type: integer }
        title: { type: string, maxLength: 200 }
        borrowed: { type: boolean }

| القسم | يحتوي |

|---|---|

| paths | كل مسار وطرقه ومعاملاته (path، query، header) وجسم الطلب والاستجابات لكل رمز حالة |

| components.schemas | المخططات المشتركة (JSON Schema) تُعاد بـ $ref |

| components.responses | استجابات متكررة (أخطاء 404 و 422 بنفس الشكل) |

| components.securitySchemes | طريقة المصادقة (Bearer JWT، cookie، API key) |

ماذا تحصل مقابله؟

- صفحة توثيق تفاعلية (Swagger UI، Scalar، Redoc) يجرب منها المطورون الطلبات.

- توليد عملاء بأنواع TypeScript (openapi-typescript، orval) — الواجهة تعرف الحقول وأنواعها دون كتابتها يدوياً.

- التحقق تلقائياً من الطلبات والاستجابات مقابل المواصفة (مثلاً express-openapi-validator).

- اختبارات عقد: تأكد أن ما يعيده الخادم يطابق الموثق.

من أين تأتي المواصفة؟

| الأسلوب | الفكرة |

|---|---|

| العقد أولاً (Design-first) | تكتب المواصفة وتتفق عليها الفرق، ثم تُبنى الواجهة والخادم بالتوازي |

| الكود أولاً (Code-first) | تولَّد من الكود (مخططات Zod مع zod-to-openapi، أو أطر مثل Fastify و NestJS) |

المهم ألا تنفصل المواصفة عن الحقيقة: توثيق قديم أسوأ من لا توثيق. ولّدها من مصدر واحد أو اختبرها في CI.

الإصدارات والإيقاف التدريجي

- info.version بالإصدارات الدلالية؛ التغيير الكاسر = إصدار رئيسي جديد (/v2).

- علّم ما سيُزال بـ deprecated: true وأرسل ترويسة Deprecation و Sunset بتاريخ الإزالة، وأعطِ العملاء مهلة.

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

الأمان في التوثيق

لا تضع أمثلة برموز حقيقية أو بيانات مستخدمين حقيقية في المواصفة. وفكّر: هل تريد صفحة التوثيق عامة؟ API داخلي لا يحتاج نشر خريطته كاملة.

تمرين

اكتب مواصفة OpenAPI لـ API المكتبة: GET /books (ترقيم وفلترة)، POST /books (جسم مع تحقق)، POST /loans (يحتاج Bearer JWT)، مع استجابات الأخطاء 400 و 401 و 404 و 409 بمخطط Error موحد. اعرضها في Swagger Editor.

// مواصفة OpenAPI ككائن JavaScript + فحص بسيط يضمن أن كل مسار في الكود موثق
const spec = {
  openapi: "3.1.0",
  info: { title: "Kodax Library API", version: "1.2.0" },
  servers: [{ url: "https://api.example.com/v1" }],
  components: {
    securitySchemes: { bearer: { type: "http", scheme: "bearer", bearerFormat: "JWT" } },
    schemas: {
      Book: {
        type: "object",
        required: ["id", "title"],
        properties: { id: { type: "integer" }, title: { type: "string", maxLength: 200 }, borrowed: { type: "boolean" } },
      },
      Error: {
        type: "object",
        required: ["error"],
        properties: { error: { type: "object", required: ["code", "message"], properties: { code: { type: "string" }, message: { type: "string" } } } },
      },
    },
    responses: {
      NotFound: { description: "غير موجود", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
      Unauthorized: { description: "غير مسجل", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
    },
  },
  paths: {
    "/books": {
      get: {
        summary: "قائمة الكتب",
        parameters: [
          { name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 } },
          { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100, default: 20 } },
        ],
        responses: { 200: { description: "صفحة من الكتب" } },
      },
    },
    "/books/{id}": {
      get: {
        summary: "جلب كتاب",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer", minimum: 1 } }],
        responses: { 200: { description: "الكتاب" }, 404: { $ref: "#/components/responses/NotFound" } },
      },
    },
    "/loans": {
      post: {
        summary: "استعارة كتاب",
        security: [{ bearer: [] }],
        requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["bookId"], properties: { bookId: { type: "integer" } } } } } },
        responses: { 201: { description: "تمت الاستعارة" }, 401: { $ref: "#/components/responses/Unauthorized" }, 409: { description: "الكتاب مُعار" } },
      },
    },
  },
};

// المسارات المعرّفة فعلاً في التطبيق (في مشروع حقيقي: تُستخرج من الـ router)
const implemented = [
  ["get", "/books"],
  ["get", "/books/{id}"],
  ["post", "/loans"],
  ["delete", "/loans/{id}"],   // ← نُسي توثيقه
];

// فحص في CI: كل مسار مُنفّذ موثّق، وكل مرجع $ref موجود
const undocumented = implemented.filter(([method, path]) => !spec.paths[path]?.[method]);
const refs = JSON.stringify(spec).match(/"#\/components\/[^"]+"/g) ?? [];
const brokenRefs = refs.filter((r) => {
  const parts = r.slice(3, -1).split("/");
  return parts.reduce((node, key) => node?.[key], spec) === undefined;
});

console.log("مسارات موثقة:", Object.keys(spec.paths).length);
console.log("غير موثقة:", undocumented.map(([m, p]) => `${m.toUpperCase()} ${p}`));
console.log("مراجع مكسورة:", brokenRefs);
if (undocumented.length || brokenRefs.length) process.exitCode = 0;   // في CI اجعلها 1 لإفشال البناء
اختبر فهمك