another step constructing base project
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Client-only fetch service.
|
||||
* Import as: import { clientFetch } from '@/lib/api/client'
|
||||
*
|
||||
* Use this in Client Components and service hooks — never in Server Components or Server Actions.
|
||||
*
|
||||
* Error behaviour:
|
||||
* - 401: clears auth cookies, toasts "session expired", redirects to login (no throw)
|
||||
* - 403: toasts "forbidden", throws ApiError
|
||||
* - 5xx: toasts "server error", throws ApiError
|
||||
* - Other 4xx: throws ApiError without toasting — the calling hook owns the message
|
||||
* - Network failure: toasts "network error", throws ApiError
|
||||
*/
|
||||
import { API_URL } from '@/config';
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { deleteClientCookie, getClientCookie } from '@/lib/cookies/client';
|
||||
import { dispatchToast } from '@/lib/toast/dispatchToast';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from './errors';
|
||||
|
||||
async function parseBody(response: Response): Promise<{ message?: string; code?: string }> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function clientFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = getClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
const locale = window.location.pathname.split('/')[1] || 'fa';
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': locale,
|
||||
};
|
||||
if (token) {
|
||||
reqHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_URL}${path}`, {
|
||||
...options,
|
||||
headers: { ...reqHeaders, ...(options?.headers as Record<string, string>) },
|
||||
});
|
||||
} catch {
|
||||
dispatchToast('Network error. Please check your connection.', 'error');
|
||||
throw new ApiError(0, 'Network error');
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const body = await parseBody(response);
|
||||
const message = body.message ?? response.statusText;
|
||||
const { code } = body;
|
||||
|
||||
if (response.status === 401) {
|
||||
deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
dispatchToast('Session expired. Please log in again.', 'error');
|
||||
window.location.replace(`/${locale}${ROUTES.LOGIN}`);
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
dispatchToast('You do not have permission to perform this action.', 'error');
|
||||
throw new ApiError(403, message, code);
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
dispatchToast('Server error. Please try again later.', 'error');
|
||||
throw new ApiError(response.status, message, code);
|
||||
}
|
||||
|
||||
throw new ApiError(response.status, message, code);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Server-only fetch service.
|
||||
* Import as: import { serverFetch } from '@/lib/api/server'
|
||||
*
|
||||
* Use this in Server Components and Server Actions — never in client components.
|
||||
* All errors throw ApiError; callers decide whether to notFound(), redirect(), or surface an error boundary.
|
||||
*/
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { API_URL } from '@/config';
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { getServerCookie } from '@/lib/cookies/server';
|
||||
import { ApiError } from './errors';
|
||||
|
||||
async function parseBody(response: Response): Promise<{ message?: string; code?: string }> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function serverFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = await getServerCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
|
||||
let locale = 'fa';
|
||||
try {
|
||||
const reqHeaders = await headers();
|
||||
locale = reqHeaders.get('x-next-intl-locale') ?? 'fa';
|
||||
} catch {
|
||||
// Outside request context (build-time prerendering) — use default locale
|
||||
}
|
||||
|
||||
const reqHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept-Language': locale,
|
||||
};
|
||||
if (token) {
|
||||
reqHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_URL}${path}`, {
|
||||
cache: 'no-store',
|
||||
...options,
|
||||
headers: { ...reqHeaders, ...(options?.headers as Record<string, string>) },
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, 'Network error');
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const body = await parseBody(response);
|
||||
throw new ApiError(response.status, body.message ?? response.statusText, body.code);
|
||||
}
|
||||
Reference in New Issue
Block a user