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"; 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 = () => { if (typeof process !== "undefined" && process.env) { 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; enableCsrf?: boolean; } 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); } // CSRF token management class CsrfTokenManager { private token: string | null = null; private tokenPromise: Promise | null = null; private baseUrl: string; constructor(baseUrl: string) { this.baseUrl = baseUrl; } async getToken(): Promise { if (this.token) { return this.token; } if (this.tokenPromise) { return this.tokenPromise; } this.tokenPromise = this.fetchToken(); try { this.token = await this.tokenPromise; return this.token; } finally { this.tokenPromise = null; } } private async fetchToken(): Promise { const response = await fetch(`${this.baseUrl}/api/security/csrf/token`, { method: "GET", credentials: "include", headers: { Accept: "application/json", }, }); if (!response.ok) { throw new Error(`Failed to fetch CSRF token: ${response.status}`); } const data = await response.json(); if (!data.success || !data.token) { throw new Error("Invalid CSRF token response"); } return data.token; } clearToken(): void { this.token = null; this.tokenPromise = null; } async refreshToken(): Promise { this.clearToken(); return this.getToken(); } } export function createClient(options: CreateClientOptions = {}): ApiClient { const baseUrl = resolveBaseUrl(options.baseUrl); const client = createOpenApiClient({ baseUrl }); const handleError = options.handleError ?? defaultHandleError; const enableCsrf = options.enableCsrf ?? true; const csrfManager = enableCsrf ? new CsrfTokenManager(baseUrl) : null; if (typeof client.use === "function") { const resolveAuthHeader = options.getAuthHeader; const middleware: Middleware = { async onRequest({ request }: MiddlewareCallbackParams) { if (!request) return; const nextRequest = new Request(request, { credentials: "include", }); // Add CSRF token for non-safe methods if (csrfManager && ["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) { try { const csrfToken = await csrfManager.getToken(); nextRequest.headers.set("X-CSRF-Token", csrfToken); } catch (error) { console.warn("Failed to get CSRF token:", error); // Continue without CSRF token - let the server handle the error } } // Add auth header if available if (resolveAuthHeader && typeof nextRequest.headers?.has === "function") { if (!nextRequest.headers.has("Authorization")) { const headerValue = resolveAuthHeader(); if (headerValue) { nextRequest.headers.set("Authorization", headerValue); } } } return nextRequest; }, async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) { // Handle CSRF token refresh on 403 errors if (response.status === 403 && csrfManager) { try { const errorText = await response.clone().text(); if (errorText.includes("CSRF") || errorText.includes("csrf")) { // Clear the token so next request will fetch a new one csrfManager.clearToken(); } } catch { // Ignore errors when checking response body } } await handleError(response); }, }; client.use(middleware as never); } const flexibleClient = client as ApiClient; // Store references to original methods before overriding const originalGET = client.GET.bind(client); const originalPOST = client.POST.bind(client); const originalPUT = client.PUT.bind(client); const originalPATCH = client.PATCH.bind(client); const originalDELETE = client.DELETE.bind(client); flexibleClient.GET = (async (path: string, options?: unknown) => { return (originalGET as FlexibleApiMethods["GET"])(path, options); }) as ApiClient["GET"]; flexibleClient.POST = (async (path: string, options?: unknown) => { return (originalPOST as FlexibleApiMethods["POST"])(path, options); }) as ApiClient["POST"]; flexibleClient.PUT = (async (path: string, options?: unknown) => { return (originalPUT as FlexibleApiMethods["PUT"])(path, options); }) as ApiClient["PUT"]; flexibleClient.PATCH = (async (path: string, options?: unknown) => { return (originalPATCH as FlexibleApiMethods["PATCH"])(path, options); }) as ApiClient["PATCH"]; flexibleClient.DELETE = (async (path: string, options?: unknown) => { return (originalDELETE as FlexibleApiMethods["DELETE"])(path, options); }) as ApiClient["DELETE"]; return flexibleClient; } export type { paths };