توثيق الـ 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 لإفشال البناءاختبر فهمك