w-lab-app/src/shared/api/httpClient.ts
2025-07-26 20:22:00 +04:00

174 lines
5.6 KiB
TypeScript

/* eslint-disable no-console */
import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerAccessToken } from "../auth/token";
import { devLogger } from "../utils/logger";
export class ApiError extends Error {
constructor(
public status: number,
public data: unknown,
message = "API Error"
) {
super(message);
this.name = "ApiError";
}
}
type RequestOpts = Omit<RequestInit, "method" | "body"> & {
tags?: string[]; // next.js cache-tag
query?: Record<string, unknown>; // query-string
schema?: z.ZodTypeAny; // runtime validation
revalidate?: number;
skipAuthRedirect?: boolean;
};
class HttpClient {
constructor(private baseUrl: string) {}
private buildUrl(
rootUrl: string,
path: string,
query?: Record<string, unknown>
) {
const url = new URL(path, rootUrl);
if (query)
Object.entries(query).forEach(([k, v]) =>
url.searchParams.append(k, String(v))
);
return url.toString();
}
private async request<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
rootUrl: string = this.baseUrl,
path: string,
opts: RequestOpts = {},
body?: unknown,
errorMessage?: string
): Promise<T> {
const {
tags = [],
schema,
query,
revalidate = 300,
skipAuthRedirect = false,
...rest
} = opts;
const fullUrl = this.buildUrl(rootUrl, path, query);
const startTime = Date.now();
// Log API request (both client and server with ENV control)
if (typeof window !== 'undefined') {
// Client-side logging
devLogger.apiRequest(fullUrl, method, body);
} else {
// Server-side logging (requires ENV variable)
if (typeof devLogger.serverApiRequest === 'function') {
devLogger.serverApiRequest(fullUrl, method, body);
} else {
// Fallback server logging
if (process.env.DEV_LOGGER_SERVER_ENABLED === 'true') {
console.group(`\n🚀 [SERVER] API REQUEST: ${method} ${fullUrl}`);
if (body !== undefined) {
console.log('📦 Request Body:', JSON.stringify(body, null, 2));
}
console.groupEnd();
}
}
}
const headers = new Headers();
const accessToken = await getServerAccessToken();
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
headers.set("Content-Type", "application/json");
const res = await fetch(fullUrl, {
method,
body: body ? JSON.stringify(body) : undefined,
headers,
next: { revalidate, tags },
...rest,
});
const payload = await res.json().catch(() => null);
const duration = Date.now() - startTime;
if (!res.ok) {
// Log API error response (both client and server)
if (typeof window !== 'undefined') {
devLogger.apiResponse(fullUrl, method, res.status, payload, duration);
} else {
if (typeof devLogger.serverApiResponse === 'function') {
devLogger.serverApiResponse(fullUrl, method, res.status, payload, duration);
} else {
// Fallback server logging
if (process.env.DEV_LOGGER_SERVER_ENABLED === 'true') {
const emoji = res.status >= 200 && res.status < 300 ? '✅' : '❌';
console.group(`\n${emoji} [SERVER] API ERROR: ${method} ${fullUrl}`);
console.log(`📊 Status: ${res.status}`);
console.log(`⏱️ Duration: ${duration}ms`);
if (payload !== undefined) {
console.log('📦 Error Response:', payload);
}
console.groupEnd();
}
}
}
if (res.status === 401 && !skipAuthRedirect) {
redirect(process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL || "");
}
throw new ApiError(res.status, payload, errorMessage);
}
const data = payload as T;
const validatedData = schema ? schema.parse(data) : data;
// Log successful API response (both client and server)
if (typeof window !== 'undefined') {
devLogger.apiResponse(fullUrl, method, res.status, validatedData, duration);
} else {
if (typeof devLogger.serverApiResponse === 'function') {
devLogger.serverApiResponse(fullUrl, method, res.status, validatedData, duration);
} else {
// Fallback server logging
if (process.env.DEV_LOGGER_SERVER_ENABLED === 'true') {
console.group(`\n✅ [SERVER] API SUCCESS: ${method} ${fullUrl}`);
console.log(`📊 Status: ${res.status}`);
console.log(`⏱️ Duration: ${duration}ms`);
if (validatedData !== undefined) {
const responsePreview = typeof validatedData === 'object' && validatedData !== null
? Array.isArray(validatedData)
? `Array[${validatedData.length}]`
: `Object{${Object.keys(validatedData).slice(0, 5).join(', ')}${Object.keys(validatedData).length > 5 ? '...' : ''}}`
: validatedData;
console.log('📦 Response Preview:', responsePreview);
}
console.groupEnd();
}
}
}
return validatedData;
}
get = <T>(p: string, o?: RequestOpts, u?: string) =>
this.request<T>("GET", u, p, o);
post = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
this.request<T>("POST", u, p, o, b);
patch = <T>(p: string, b: unknown, o?: RequestOpts, u?: string) =>
this.request<T>("PATCH", u, p, o, b);
delete = <T>(p: string, o?: RequestOpts, u?: string) =>
this.request<T>("DELETE", u, p, o);
}
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
if (!apiUrl) {
throw new Error("NEXT_PUBLIC_API_URL environment variable is required");
}
export const http = new HttpClient(apiUrl);