import axios, { type AxiosError, type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig, } from "axios"; import type { ApiEnvelope, ApiErrorEnvelope, AuthTokens } from "@geo/shared-types"; export interface AuthBindings { getAccessToken: () => string | null; getRefreshToken: () => string | null; setTokens: (tokens: AuthTokens) => void; clearTokens: () => void; refresh: (refreshToken: string) => Promise; } export interface CreateApiClientOptions { baseURL?: string; timeoutMs?: number; auth?: AuthBindings; } export class ApiClientError extends Error { status?: number; code: number; detail?: string; requestId?: string; constructor(input: { message: string; code?: number; status?: number; detail?: string; requestId?: string; }) { super(input.message); this.name = "ApiClientError"; this.code = input.code ?? 50000; this.status = input.status; this.detail = input.detail; this.requestId = input.requestId; } } export interface ApiClient { raw: AxiosInstance; get: (url: string, config?: AxiosRequestConfig) => Promise; post: (url: string, body?: B, config?: AxiosRequestConfig) => Promise; put: (url: string, body?: B, config?: AxiosRequestConfig) => Promise; remove: (url: string, config?: AxiosRequestConfig) => Promise; } function normalizeError(error: unknown): ApiClientError { if (error instanceof ApiClientError) { return error; } if (axios.isAxiosError(error)) { const payload = error.response?.data as Partial | undefined; return new ApiClientError({ message: payload?.message ?? error.message ?? "network_error", code: payload?.code ?? 50000, status: error.response?.status, detail: payload?.detail, requestId: payload?.request_id, }); } if (error instanceof Error) { return new ApiClientError({ message: error.message }); } return new ApiClientError({ message: "unknown_error" }); } function unwrapEnvelope(response: { data: ApiEnvelope }): T { return response.data.data; } export function createApiClient(options: CreateApiClientOptions = {}): ApiClient { const client = axios.create({ baseURL: options.baseURL ?? "", timeout: options.timeoutMs ?? 15000, withCredentials: false, }); const auth = options.auth; let refreshPromise: Promise | null = null; client.interceptors.request.use((config) => { if (!auth) { return config; } const accessToken = auth.getAccessToken(); if (accessToken) { config.headers = config.headers ?? {}; (config.headers as Record).Authorization = `Bearer ${accessToken}`; } return config; }); client.interceptors.response.use( (response) => response, async (error: AxiosError) => { if (auth && error.response?.status === 401 && error.config) { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; }; const refreshToken = auth.getRefreshToken(); if (refreshToken && !originalRequest._retry) { originalRequest._retry = true; try { if (!refreshPromise) { refreshPromise = auth .refresh(refreshToken) .then((tokens) => { auth.setTokens(tokens); return tokens; }) .finally(() => { refreshPromise = null; }); } const nextTokens = await refreshPromise; originalRequest.headers = originalRequest.headers ?? {}; (originalRequest.headers as Record).Authorization = `Bearer ${nextTokens.accessToken}`; return client.request(originalRequest); } catch (refreshError) { auth.clearTokens(); throw normalizeError(refreshError); } } auth.clearTokens(); } throw normalizeError(error); }, ); return { raw: client, async get(url: string, config?: AxiosRequestConfig) { return unwrapEnvelope(await client.get>(url, config)); }, async post(url: string, body?: B, config?: AxiosRequestConfig) { return unwrapEnvelope(await client.post>(url, body, config)); }, async put(url: string, body?: B, config?: AxiosRequestConfig) { return unwrapEnvelope(await client.put>(url, body, config)); }, async remove(url: string, config?: AxiosRequestConfig) { return unwrapEnvelope(await client.delete>(url, config)); }, }; }