Skip to content

جلب البيانات

يستخدم FORGE عميل API مُنمّط مبني على Fetch API لجميع الاتصالات مع الخلفية، مدمجاً مع إدارة الحالة المدمجة في React لتحميل البيانات. عميل API يتعامل مع مصادقة JWT، تجديد الـ token التلقائي، ردود الأخطاء، إعادة توجيه 401، ورفع الملفات عبر واجهة نظيفة وtype-safe.

عميل API

عميل API هو singleton class (lib/api.ts) يغلف Fetch API بالمصادقة ومعالجة الأخطاء وتحليل الاستجابات. كل استدعاء API في تطبيقي الإدارة والويب يمر عبر هذا العميل.

المعمارية

┌──────────────────┐     ┌──────────────────┐     ┌───────────────┐
│    المكون        │────▶│    عميل API      │────▶│   API الخلفي  │
│                  │     │                   │     │               │
│  api.get()       │     │  + حقن JWT       │     │  /api/v1/...  │
│  api.post()      │     │  + تحليل JSON    │     │               │
│  api.put()       │     │  + تعيين الأخطاء │     │               │
│  api.delete()    │     │  + إعادة توجيه 401│     │               │
│  api.upload()    │     │  + معاملات Query │     │               │
└──────────────────┘     └──────────────────┘     └───────────────┘

الإعداد

typescript
// lib/api.ts
const API_URL = process.env.NEXT_PUBLIC_API_URL || "https://api.myapp.test";
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || "v1";

class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  private getAuthToken(): string | null {
    if (typeof window === "undefined") return null;
    return localStorage.getItem("access_token");
  }

  private buildUrl(
    endpoint: string,
    params?: Record<string, string | number | boolean | undefined>
  ): string {
    const url = new URL(
      `${this.baseUrl}/api/${API_VERSION}${endpoint}`
    );
    if (params) {
      Object.entries(params).forEach(([key, value]) => {
        if (value !== undefined) {
          url.searchParams.append(key, String(value));
        }
      });
    }
    return url.toString();
  }

  private async request<T>(
    method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
    endpoint: string,
    data?: unknown,
    options: RequestOptions = {}
  ): Promise<T> {
    const url = this.buildUrl(endpoint, options.params);
    const token = this.getAuthToken();

    const headers: Record<string, string> = {
      "Content-Type": "application/json",
      Accept: "application/json",
      ...options.headers,
    };

    if (token) {
      headers["Authorization"] = `Bearer ${token}`;
    }

    const config: RequestInit = {
      method,
      headers,
      credentials: "include",
      signal: options.signal,
    };

    if (data && method !== "GET") {
      config.body = JSON.stringify(data);
    }

    const response = await fetch(url, config);
    return this.handleResponse<T>(response);
  }

  // HTTP method shortcuts
  async get<T>(endpoint: string, options?: RequestOptions): Promise<T> {
    return this.request<T>("GET", endpoint, undefined, options);
  }

  async post<T>(endpoint: string, data?: unknown, options?: RequestOptions): Promise<T> {
    return this.request<T>("POST", endpoint, data, options);
  }

  async put<T>(endpoint: string, data?: unknown, options?: RequestOptions): Promise<T> {
    return this.request<T>("PUT", endpoint, data, options);
  }

  async patch<T>(endpoint: string, data?: unknown, options?: RequestOptions): Promise<T> {
    return this.request<T>("PATCH", endpoint, data, options);
  }

  async delete<T>(endpoint: string, options?: RequestOptions): Promise<T> {
    return this.request<T>("DELETE", endpoint, undefined, options);
  }
}

// Export singleton
export const api = new ApiClient(API_URL);

إدارة JWT Token

عميل API يُدير اثنين من الـ tokens: access token (قصير العمر، مُخزّن في localStorage) و refresh token (أطول عمراً، مُخزّن كـ HTTP-only cookie بواسطة الخلفية).

تدفق الـ Token

تسجيل الدخول


POST /auth/login

  ├── access_token  → localStorage
  └── refresh_token → HTTP-only cookie (تُعيّنه الخلفية)

طلب API


Authorization: Bearer {access_token}

  ├── 200 OK → إرجاع البيانات
  └── 401 Unauthorized


      POST /auth/refresh

        ├── access_token جديد → localStorage
        │   إعادة محاولة الطلب الأصلي
        └── 401 → مسح الـ tokens، إعادة التوجيه لـ /login

معالجة 401

عندما يُرجع API استجابة 401 Unauthorized، يمسح العميل الـ token المُخزّن ويُعيد التوجيه لصفحة تسجيل الدخول:

typescript
private async handleResponse<T>(response: Response): Promise<T> {
  if (response.status === 401) {
    // Clear stored token
    if (typeof window !== "undefined") {
      localStorage.removeItem("access_token");
      // Redirect to login
      window.location.href = "/login";
    }
    throw new ApiError("Session expired", 401);
  }

  if (!response.ok) {
    const error = await response.json().catch(() => ({
      message: "حدث خطأ",
    }));
    throw new ApiError(error.message, response.status, error.errors);
  }

  // 204 No Content
  if (response.status === 204) {
    return {} as T;
  }

  return response.json();
}

تحذير

عميل API يتعامل تلقائياً مع استجابات 401 بمسح الـ token المُخزّن وإعادة التوجيه لـ /login. هذا يضمن معالجة الجلسات المنتهية بسلاسة عبر التطبيق.

أنواع الاستجابات

API يُرجع أشكال استجابة متسقة مُعرّفة في lib/types.ts:

typescript
// Success response wrapper
interface ApiResponse<T> {
  success: boolean;
  data: T;
  message?: string;
}

// Paginated list response
interface PaginatedResponse<T> {
  data: T[];
  meta: {
    current_page: number;
    per_page: number;
    total: number;
    total_pages: number;
  };
}

// Error response
interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}

أنماط تحميل البيانات

يستخدم FORGE useState و useEffect المدمجين في React لجلب البيانات، مدمجين مع عميل API المُنمّط.

جلب قائمة

tsx
"use client";

import * as React from "react";
import { toast } from "sonner";
import { api, getErrorMessage } from "@/lib/api";
import type { User } from "@/lib/types";

export default function UsersPage() {
  const [users, setUsers] = React.useState<User[]>([]);
  const [isLoading, setIsLoading] = React.useState(true);

  const fetchUsers = React.useCallback(async () => {
    setIsLoading(true);
    try {
      const response = await api.get<{
        data: { data: User[]; meta: unknown };
      }>("/admin/users");
      setUsers(response.data.data);
    } catch (error) {
      toast.error(getErrorMessage(error));
    } finally {
      setIsLoading(false);
    }
  }, []);

  React.useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  if (isLoading) return <LoadingSkeleton />;

  return <DataTable columns={columns} data={users} />;
}

جلب مورد واحد

tsx
const [user, setUser] = React.useState<User | null>(null);
const [isLoading, setIsLoading] = React.useState(true);

React.useEffect(() => {
  const fetchUser = async () => {
    try {
      const response = await api.get<{ data: User }>(
        `/admin/users/${id}`
      );
      setUser(response.data);
    } catch (error) {
      toast.error(getErrorMessage(error));
    } finally {
      setIsLoading(false);
    }
  };
  fetchUser();
}, [id]);

إنشاء مورد

tsx
const handleCreate = async (data: UserCreateFormData) => {
  setIsSubmitting(true);
  try {
    await api.post("/admin/users", data);
    toast.success("تم إنشاء المستخدم بنجاح");
    router.push("/users");
  } catch (error) {
    if (isApiError(error) && error.errors) {
      // Map server validation errors to form fields
      Object.entries(error.errors).forEach(([field, messages]) => {
        form.setError(field as keyof UserCreateFormData, {
          message: messages[0],
        });
      });
    } else {
      toast.error(getErrorMessage(error));
    }
  } finally {
    setIsSubmitting(false);
  }
};

تحديث مورد

tsx
const handleUpdate = async (data: UserUpdateFormData) => {
  setIsSubmitting(true);
  try {
    await api.put(`/admin/users/${id}`, data);
    toast.success("تم تحديث المستخدم بنجاح");
    router.push("/users");
  } catch (error) {
    toast.error(getErrorMessage(error));
  } finally {
    setIsSubmitting(false);
  }
};

حذف مورد

tsx
const handleDelete = async () => {
  if (!deleteTarget) return;
  setIsDeleting(true);
  try {
    await api.delete(`/admin/users/${deleteTarget.id}`);
    toast.success("تم حذف المستخدم بنجاح");
    setDeleteTarget(null);
    fetchUsers(); // Refresh the list
  } catch (error) {
    toast.error(getErrorMessage(error));
  } finally {
    setIsDeleting(false);
  }
};

الترقيم

لنقاط النهاية المُرقّمة، مرر معاملات الترقيم كـ query params:

tsx
const [page, setPage] = React.useState(1);
const [perPage] = React.useState(20);
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);

const fetchUsers = React.useCallback(async () => {
  setIsLoading(true);
  try {
    const response = await api.get<PaginatedResponse<User>>(
      "/admin/users",
      {
        params: {
          page,
          per_page: perPage,
          sort_by: "created_at",
          sort_order: "desc",
        },
      }
    );
    setUsers(response.data);
    setMeta(response.meta);
  } catch (error) {
    toast.error(getErrorMessage(error));
  } finally {
    setIsLoading(false);
  }
}, [page, perPage]);

مكون DataTable يوفر ترقيم مدمج من جانب العميل، لكن لمجموعات البيانات الكبيرة، يُوصى بالترقيم من جانب الخادم باستخدام كائن meta المُرجع من API:

tsx
// Pagination controls using meta from API
<div className="flex items-center justify-between">
  <p className="text-sm text-muted-foreground">
    عرض صفحة {meta.current_page} من {meta.total_pages}
    ({meta.total} إجمالي)
  </p>
  <div className="flex gap-2">
    <Button
      variant="outline"
      size="sm"
      disabled={page <= 1}
      onClick={() => setPage((p) => p - 1)}
    >
      السابق
    </Button>
    <Button
      variant="outline"
      size="sm"
      disabled={page >= meta.total_pages}
      onClick={() => setPage((p) => p + 1)}
    >
      التالي
    </Button>
  </div>
</div>

جلب البيانات من جانب الخادم

صفحات المحتوى في تطبيق الويب تستخدم Next.js Server Components لجلب البيانات. هذه الطلبات تتجاوز عميل API وتستخدم fetch مباشرة مع URL الـ API الداخلي:

tsx
// Server Component -- no "use client" directive
async function getContent(slug: string, locale?: string) {
  const apiUrl = process.env.INTERNAL_API_URL
    || process.env.NEXT_PUBLIC_API_URL;

  const res = await fetch(
    `${apiUrl}/api/v1/contents/${slug}?locale=${locale}`,
    { next: { revalidate: 60 } }
  );

  if (!res.ok) return null;
  const json = await res.json();
  return json.data;
}

نصيحة

جلب البيانات من جانب الخادم يستخدم INTERNAL_API_URL (مثل http://api:8080) للاتصال الداخلي في Docker، متجنباً الوكيل العكسي. خيار next: { revalidate: 60 } يُفعّل Incremental Static Regeneration، مُخزّناً الاستجابة لـ 60 ثانية.

رفع الملفات

عميل API يوفر طريقة upload مخصصة لرفع الملفات التي تستخدم multipart/form-data بدلاً من JSON:

tsx
// File upload method on API client
async upload<T>(endpoint: string, file: File, fieldName = "file"): Promise<T> {
  const url = this.buildUrl(endpoint);
  const token = this.getAuthToken();

  const formData = new FormData();
  formData.append(fieldName, file);

  const headers: Record<string, string> = {
    Accept: "application/json",
  };
  if (token) {
    headers["Authorization"] = `Bearer ${token}`;
  }

  const response = await fetch(url, {
    method: "POST",
    headers,
    credentials: "include",
    body: formData,
  });

  return this.handleResponse<T>(response);
}

استخدمها لرفع الصور الرمزية أو الصور المميزة أو أي مرفقات ملفات:

tsx
const handleAvatarUpload = async (file: File) => {
  try {
    const response = await api.upload<{
      data: { url: string; id: string };
    }>("/media", file, "file");

    setAvatarUrl(response.data.url);
    toast.success("تم رفع الصورة الرمزية بنجاح");
  } catch (error) {
    toast.error(getErrorMessage(error));
  }
};

تحذير

طريقة upload تتعمد حذف header Content-Type، مما يسمح للمتصفح بتعيينه تلقائياً مع حد multipart/form-data الصحيح. لا تُعيّن Content-Type يدوياً لرفع الملفات.

معالجة الأخطاء

عميل API يوفر أداتين للمعالجة المتسقة للأخطاء:

typescript
// Type guard to check if error is an API error
export function isApiError(error: unknown): error is ApiError {
  return (
    typeof error === "object" &&
    error !== null &&
    "message" in error &&
    "status" in error
  );
}

// Extract user-friendly error message
export function getErrorMessage(error: unknown): string {
  if (isApiError(error)) return error.message;
  if (error instanceof Error) return error.message;
  return "حدث خطأ غير متوقع";
}

استخدمهما في كل block catch:

typescript
try {
  await api.post("/endpoint", data);
} catch (error) {
  // Show toast notification with error message
  toast.error(getErrorMessage(error));

  // Or handle validation errors specifically
  if (isApiError(error) && error.errors) {
    // error.errors is Record<string, string[]>
    // Example: { email: ["Email already exists"] }
    Object.entries(error.errors).forEach(([field, messages]) => {
      form.setError(field, { message: messages[0] });
    });
  }
}

اصطلاحات معاملات Query

API الخلفي يقبل هذه المعاملات القياسية في نقاط نهاية القوائم:

المعاملالنوعالافتراضيالوصف
pagenumber1رقم الصفحة
per_pagenumber20عناصر لكل صفحة
sort_bystringcreated_atالعمود للفرز
sort_order"asc" | "desc"descاتجاه الفرز
searchstring--استعلام البحث النصي الكامل
localestring--كود اللغة للمحتوى المُترجم
typescript
const response = await api.get<PaginatedResponse<User>>("/admin/users", {
  params: {
    page: 1,
    per_page: 20,
    sort_by: "name",
    sort_order: "asc",
    search: "أحمد",
  },
});

ما التالي

Released under the MIT License.