جلب البيانات
يستخدم 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 │ │ │
└──────────────────┘ └──────────────────┘ └───────────────┘الإعداد
// 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 المُخزّن ويُعيد التوجيه لصفحة تسجيل الدخول:
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:
// 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 المُنمّط.
جلب قائمة
"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} />;
}جلب مورد واحد
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]);إنشاء مورد
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);
}
};تحديث مورد
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);
}
};حذف مورد
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:
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:
// 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 الداخلي:
// 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:
// 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);
}استخدمها لرفع الصور الرمزية أو الصور المميزة أو أي مرفقات ملفات:
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 يوفر أداتين للمعالجة المتسقة للأخطاء:
// 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:
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 الخلفي يقبل هذه المعاملات القياسية في نقاط نهاية القوائم:
| المعامل | النوع | الافتراضي | الوصف |
|---|---|---|---|
page | number | 1 | رقم الصفحة |
per_page | number | 20 | عناصر لكل صفحة |
sort_by | string | created_at | العمود للفرز |
sort_order | "asc" | "desc" | desc | اتجاه الفرز |
search | string | -- | استعلام البحث النصي الكامل |
locale | string | -- | كود اللغة للمحتوى المُترجم |
const response = await api.get<PaginatedResponse<User>>("/admin/users", {
params: {
page: 1,
per_page: 20,
sort_by: "name",
sort_order: "asc",
search: "أحمد",
},
});ما التالي
- النماذج والتحقق — استخدام عميل API مع النماذج
- المكونات — DataTable ومكونات عرض البيانات الأخرى
- التدويل — تحميل الترجمات من API