174 lines
5.6 KiB
TypeScript
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);
|