import createOpenApiClient from "openapi-fetch"; import type { Middleware, MiddlewareCallbackParams, } from "openapi-fetch"; import type { paths } from "../__generated__/types"; export class ApiError extends Error { constructor( message: string, public readonly response: Response, public readonly body?: unknown ) { super(message); this.name = "ApiError"; } } export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError; type StrictApiClient = ReturnType>; type FlexibleApiMethods = { GET(path: string, options?: unknown): Promise<{ data?: T | null }>; POST(path: string, options?: unknown): Promise<{ data?: T | null }>; PUT(path: string, options?: unknown): Promise<{ data?: T | null }>; PATCH(path: string, options?: unknown): Promise<{ data?: T | null }>; DELETE(path: string, options?: unknown): Promise<{ data?: T | null }>; }; export type ApiClient = StrictApiClient & FlexibleApiMethods; export type AuthHeaderResolver = () => string | undefined; type EnvKey = | "NEXT_PUBLIC_API_BASE" | "NEXT_PUBLIC_API_URL" | "API_BASE_URL" | "API_BASE" | "API_URL"; const BASE_URL_ENV_KEYS: readonly EnvKey[] = [ "NEXT_PUBLIC_API_BASE", "NEXT_PUBLIC_API_URL", "API_BASE_URL", "API_BASE", "API_URL", ]; const DEFAULT_BASE_URL = "http://localhost:4000/api"; const normalizeBaseUrl = (value: string) => { const trimmed = value.trim(); if (!trimmed) { return DEFAULT_BASE_URL; } if (trimmed === "/") { return trimmed; } // Avoid accidental double slashes when openapi-fetch joins with request path return trimmed.replace(/\/+$/, ""); }; const resolveBaseUrlFromEnv = () => { for (const key of BASE_URL_ENV_KEYS) { const envValue = process.env?.[key]; if (typeof envValue === "string" && envValue.trim()) { return normalizeBaseUrl(envValue); } } return DEFAULT_BASE_URL; }; export const resolveBaseUrl = (baseUrl?: string) => { if (typeof baseUrl === "string" && baseUrl.trim()) { return normalizeBaseUrl(baseUrl); } return resolveBaseUrlFromEnv(); }; export interface CreateClientOptions { baseUrl?: string; getAuthHeader?: AuthHeaderResolver; handleError?: (response: Response) => void | Promise; } async function defaultHandleError(response: Response) { if (response.ok) return; let body: unknown; let message = response.statusText || `Request failed with status ${response.status}`; try { const cloned = response.clone(); const contentType = cloned.headers.get("content-type"); if (contentType?.includes("application/json")) { body = await cloned.json(); if (body && typeof body === "object" && "message" in body && typeof (body as any).message === "string") { message = (body as any).message; } } else { const text = await cloned.text(); if (text) { body = text; message = text; } } } catch { // Ignore body parse errors; fall back to status text } throw new ApiError(message, response, body); } export function createClient(options: CreateClientOptions = {}): ApiClient { const baseUrl = resolveBaseUrl(options.baseUrl); const client = createOpenApiClient({ baseUrl }); const handleError = options.handleError ?? defaultHandleError; if (typeof client.use === "function") { const resolveAuthHeader = options.getAuthHeader; const middleware: Middleware = { onRequest({ request }: MiddlewareCallbackParams) { if (!resolveAuthHeader) return; if (!request || typeof request.headers?.has !== "function") return; if (request.headers.has("Authorization")) return; const headerValue = resolveAuthHeader(); if (!headerValue) return; request.headers.set("Authorization", headerValue); }, async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) { await handleError(response); }, }; client.use(middleware as never); } return client as ApiClient; } export type { paths };