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 clearTokensOnUnauthorized?: boolean } export class ApiClientError extends Error { status?: number code: number detail?: string requestId?: string handled?: boolean constructor(input: { message: string code?: number status?: number detail?: string requestId?: string handled?: boolean }) { super(input.message) this.name = 'ApiClientError' this.code = input.code ?? 50000 this.status = input.status this.detail = input.detail this.requestId = input.requestId this.handled = input.handled } } 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 patch: (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 const isTimeout = !error.response && (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') const fallbackMessage = isTimeout ? 'request_timeout' : (error.message ?? 'network_error') return new ApiClientError({ message: payload?.message ?? fallbackMessage, 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 } function markHandled(error: ApiClientError): ApiClientError { error.handled = true return error } 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) { const normalizedRefreshError = normalizeError(refreshError) if (normalizedRefreshError.status === 401) { auth.clearTokens() throw markHandled(normalizedRefreshError) } throw normalizedRefreshError } } if (options.clearTokensOnUnauthorized !== false) { auth.clearTokens() } throw markHandled(normalizeError(error)) } 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 patch(url: string, body?: B, config?: AxiosRequestConfig) { return unwrapEnvelope(await client.patch>(url, body, config)) }, async remove(url: string, config?: AxiosRequestConfig) { return unwrapEnvelope(await client.delete>(url, config)) }, } }