import createOpenApiClient from "openapi-fetch"; import type { Middleware, MiddlewareCallbackParams } from "openapi-fetch"; import type { paths } from "../__generated__/types"; import type { ApiResponse } from "../response-helpers"; 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>; POST(path: string, options?: unknown): Promise>; PUT(path: string, options?: unknown): Promise>; PATCH(path: string, options?: unknown): Promise>; DELETE(path: string, options?: unknown): Promise>; }; 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; } const getBodyMessage = (body: unknown): string | null => { if (typeof body === "string") { return body; } if (typeof body === "object" && body !== null && "message" in body) { const maybeMessage = (body as { message?: unknown }).message; if (typeof maybeMessage === "string") { return maybeMessage; } } return null; }; 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(); const jsonMessage = getBodyMessage(body); if (jsonMessage) { message = jsonMessage; } } 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; const normalizePath = (path: string): string => { if (!path) return "/api"; const ensured = path.startsWith("/") ? path : `/${path}`; if (ensured === "/api" || ensured.startsWith("/api/")) { return ensured; } return `/api${ensured}`; }; if (typeof client.use === "function") { const resolveAuthHeader = options.getAuthHeader; const middleware: Middleware = { onRequest({ request }: MiddlewareCallbackParams) { if (!request) return; const nextRequest = new Request(request, { credentials: "include", }); if (!resolveAuthHeader) { return nextRequest; } if (typeof nextRequest.headers?.has !== "function") { return nextRequest; } if (nextRequest.headers.has("Authorization")) { return nextRequest; } const headerValue = resolveAuthHeader(); if (!headerValue) { return nextRequest; } nextRequest.headers.set("Authorization", headerValue); return nextRequest; }, async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) { await handleError(response); }, }; client.use(middleware as never); } const flexibleClient = client as ApiClient; flexibleClient.GET = (async (path: string, options?: unknown) => { return (client.GET as FlexibleApiMethods["GET"])(normalizePath(path), options); }) as ApiClient["GET"]; flexibleClient.POST = (async (path: string, options?: unknown) => { return (client.POST as FlexibleApiMethods["POST"])(normalizePath(path), options); }) as ApiClient["POST"]; flexibleClient.PUT = (async (path: string, options?: unknown) => { return (client.PUT as FlexibleApiMethods["PUT"])(normalizePath(path), options); }) as ApiClient["PUT"]; flexibleClient.PATCH = (async (path: string, options?: unknown) => { return (client.PATCH as FlexibleApiMethods["PATCH"])(normalizePath(path), options); }) as ApiClient["PATCH"]; flexibleClient.DELETE = (async (path: string, options?: unknown) => { return (client.DELETE as FlexibleApiMethods["DELETE"])(normalizePath(path), options); }) as ApiClient["DELETE"]; return flexibleClient; } export type { paths };