عميل HTTP بلغة TypeScript: إجراء الطلبات بشكل صحيح من الألف إلى الياء
\n\nتطوير الويب الحديث لا يمكن تصوره دون التفاعل مع الخوادم البعيدة. سواء كان ذلك الحصول على بيانات من REST API، أو إرسال النماذج، أو تحميل الملفات — فإن طلبات HTTP هي أساس بنية العميل-الخادم. تتيح TypeScript، بفضل كتابة الأنواع الصارمة وأدواتها القوية، جعل العمل مع طلبات HTTP ليس فقط آمناً، بل أيضاً مناسباً للصيانة. في هذه المقالة، سنشرح كيفية إنشاء عميل HTTP متقن بلغة TypeScript، وتجنب الأخطاء الشائعة، وكتابة كود يعمل بشكل يمكن التنبؤ به.
\n\n1. الأساسيات: fetch API وكتابة أنواعه
\n\nإن fetch المدمج في المتصفح هو أداة حديثة ومرنة لتنفيذ طلبات HTTP. ومع ذلك، فإن استخدامه في TypeScript يتطلب كتابة أنواع صحيحة للاستجابة. بشكل افتراضي، يُرجع fetch Promise<Response>، ونحتاج إلى تحديد البيانات التي نتوقع الحصول عليها بشكل صريح.
interface User {\n id: number;\n name: string;\n email: string;\n}\n\nasync function getUser(id: number): Promise<User> {\n const response = await fetch(`https://api.example.com/users/${id}`);\n \n if (!response.ok) {\n throw new Error(`خطأ HTTP! الحالة: ${response.status}`);\n }\n \n const data: User = await response.json();\n return data;\n}\n\n\nلاحظ التحقق من response.ok. هذا أمر بالغ الأهمية: حتى إذا أعاد الخادم خطأ (404، 500، إلخ)، فإن fetch لن يطرح استثناءً تلقائياً. يجب علينا معالجة الحالة يدوياً.
بالنسبة لطلبات POST مع نص، يجب تحديد الرأس Content-Type:
async function createUser(userData: Omit<User, 'id'>): Promise<User> {\n const response = await fetch('https://api.example.com/users', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(userData),\n });\n\n if (!response.ok) {\n throw new Error(`فشل إنشاء المستخدم: ${response.statusText}`);\n }\n\n return response.json();\n}\n\n\n2. إنشاء عميل HTTP شامل
\n\nبدلاً من كتابة fetch من الصفر في كل مرة، من الأفضل إنشاء غلاف — عميل HTTP شامل. سيسمح هذا بإدارة عنوان URL الأساسي والرؤوس ومعالجة الأخطاء بشكل مركزي. لنلقِ نظرة على مثال يعتمد على الفئات:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';\n\ninterface RequestConfig<TBody = unknown> {\n method: HttpMethod;\n url: string;\n body?: TBody;\n headers?: Record<string, string>;\n params?: Record<string, string>;\n}\n\nclass HttpClient {\n private baseUrl: string;\n private defaultHeaders: Record<string, string>;\n\n constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) {\n this.baseUrl = baseUrl;\n this.defaultHeaders = {\n 'Content-Type': 'application/json',\n ...defaultHeaders,\n };\n }\n\n private async request<TResponse, TBody = unknown>(\n config: RequestConfig<TBody>\n ): Promise<TResponse> {\n const url = new URL(`${this.baseUrl}${config.url}`);\n \n if (config.params) {\n Object.entries(config.params).forEach(([key, value]) => {\n url.searchParams.append(key, value);\n });\n }\n\n const response = await fetch(url.toString(), {\n method: config.method,\n headers: { ...this.defaultHeaders, ...config.headers },\n body: config.body ? JSON.stringify(config.body) : undefined,\n });\n\n if (!response.ok) {\n const errorBody = await response.text();\n throw new Error(`فشل الطلب: ${response.status} ${response.statusText}. النص: ${errorBody}`);\n }\n\n // بالنسبة لـ DELETE أو الطرق الأخرى التي قد لا تُرجع نصاً\n if (response.status === 204) {\n return undefined as TResponse;\n }\n\n return response.jso