export type ApiMethod = "GET" | "POST" | "PATCH" | "DELETE"; export interface ApiClientConfig { baseUrl: string; getAccessToken?: () => string | Promise | undefined; } export class ApiError extends Error { status: number; body: unknown; constructor(status: number, message: string, body: unknown) { super(message); this.status = status; this.body = body; } } export class ApiClient { private baseUrl: string; private getAccessToken?: ApiClientConfig["getAccessToken"]; constructor(config: ApiClientConfig) { this.baseUrl = config.baseUrl.replace(/\/+$/, ""); this.getAccessToken = config.getAccessToken; } private async buildHeaders(extra?: HeadersInit): Promise { const token = this.getAccessToken ? await this.getAccessToken() : undefined; return { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...extra, }; } private buildUrl(path: string, query?: Record) { const url = new URL(`${this.baseUrl}${path}`); if (query) { for (const [k, v] of Object.entries(query)) { if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); } } return url.toString(); } async request(params: { method: ApiMethod; path: string; query?: Record; body?: TBody; headers?: HeadersInit; isFormData?: boolean; }): Promise { const { method, path, query, body, headers, isFormData } = params; const finalHeaders = await this.buildHeaders(headers); const response = await fetch(this.buildUrl(path, query), { method, headers: isFormData ? finalHeaders : { "Content-Type": "application/json", ...finalHeaders }, body: body ? isFormData ? (body as unknown as FormData) : JSON.stringify(body) : undefined, }); const text = await response.text(); const data = text ? safeJsonParse(text) : null; if (!response.ok) { throw new ApiError(response.status, response.statusText, data); } return data as TResponse; } get(path: string, query?: Record) { return this.request({ method: "GET", path, query }); } post( path: string, body?: TBody, options?: { headers?: HeadersInit; isFormData?: boolean } ) { return this.request({ method: "POST", path, body, headers: options?.headers, isFormData: options?.isFormData, }); } patch(path: string, body?: TBody) { return this.request({ method: "PATCH", path, body }); } delete(path: string) { return this.request({ method: "DELETE", path }); } } function safeJsonParse(text: string) { try { return JSON.parse(text); } catch { return text; } }