/** * API Client * * Centralized HTTP client for API requests. * Built on axios with interceptors for error handling and retry logic. * * Features: * - Request/response interceptors * - Automatic error handling * - Retry logic with exponential backoff * - Base URL configuration * * @layer Infrastructure */ import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type AxiosError, type InternalAxiosRequestConfig, } from 'axios'; interface InternalRequestConfig extends InternalAxiosRequestConfig { _skipAuth?: boolean; _retry?: number; } import { ApiError, ApiErrorCode, createNetworkError, createAuthError, createValidationError, createServerError, createTimeoutError, mapStatusToErrorCode, } from './api/shared/errors'; import type { TokenStorage } from './api/shared/types'; import { LOGIN, REGISTER, REFRESH_TOKEN, VERIFY_EMAIL, FORGOT_PASSWORD, RESET_PASSWORD, } from '@archer/api-interface/endpoints/customer-api'; /** * API Client Configuration */ interface ApiClientConfig { baseURL: string; timeout?: number; retries?: number; retryDelay?: number; tokenStorage?: TokenStorage; } /** * ApiClient * * Axios-based HTTP client with interceptors and error handling. */ export class ApiClient { private client: AxiosInstance; private config: ApiClientConfig; private tokenStorage?: TokenStorage; private isRefreshing = false; private refreshQueue: Array<{ resolve: (token: string) => void; reject: (error: unknown) => void; }> = []; constructor(config: ApiClientConfig) { this.config = { timeout: 30000, // 30 seconds default retries: 3, retryDelay: 1000, // 1 second ...config, }; this.tokenStorage = config.tokenStorage; const defaultHeaders: Record = { 'Content-Type': 'application/json; charset=utf-8', }; // Signal WP plugin origin so customer-api can skip reCAPTCHA — site keys // are domain-locked and cannot verify from the merchant's WP admin. if (import.meta.env.VITE_WP_EMBED === 'true') { defaultHeaders['X-Twwim-Client'] = 'wp-plugin'; } this.client = axios.create({ baseURL: this.config.baseURL, timeout: this.config.timeout, headers: defaultHeaders, }); this.setupInterceptors(); } /** * Set token storage implementation */ setTokenStorage(storage: TokenStorage): void { this.tokenStorage = storage; } /** * Sets up request and response interceptors */ private setupInterceptors(): void { // Request interceptor this.client.interceptors.request.use( this.handleRequest.bind(this), this.handleRequestError.bind(this) ); // Response interceptor this.client.interceptors.response.use( this.handleResponse.bind(this), this.handleResponseError.bind(this) ); } /** * Request interceptor - adds auth headers, logging, etc. */ private handleRequest( config: InternalRequestConfig ): InternalRequestConfig { // Skip auth for public endpoints (login, register, refresh) const skipAuth = config._skipAuth || this.isPublicEndpoint(config.url || ''); // Add authorization header if token exists and auth is not skipped if (!skipAuth) { const token = this.getAuthToken(); if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } } // Request logging removed to reduce console noise // Use TanStack Query DevTools to monitor API requests // Error requests are still logged in handleRequestError() return config; } /** * Checks if endpoint is public (doesn't require authentication) */ private isPublicEndpoint(url: string): boolean { const publicEndpoints = [ LOGIN.path, REGISTER.path, REFRESH_TOKEN.path, VERIFY_EMAIL.path, FORGOT_PASSWORD.path, RESET_PASSWORD.path, ]; return publicEndpoints.some(endpoint => url.includes(endpoint)); } /** * Request error interceptor */ private handleRequestError(error: AxiosError): Promise { console.error('[API Request Error]', error); return Promise.reject(error); } /** * Response interceptor - handles successful responses */ private handleResponse(response: AxiosResponse): AxiosResponse { // Success responses are not logged to reduce console noise // TanStack Query DevTools provides better visibility into API requests // Error responses are still logged in handleResponseError() return response; } /** * Response error interceptor - handles errors and retries */ private async handleResponseError(error: AxiosError): Promise { const originalRequest = error.config as InternalRequestConfig; // Log error console.error('[API Response Error]', { url: error.config?.url, status: error.response?.status, message: error.message, data: error.response?.data, }); // Handle 401 Unauthorized - attempt token refresh, but only for // authenticated endpoints. A 401 from /auth/login itself means the // submitted credentials were rejected — not a stale session — so // running the refresh path would mask the real error and surface a // misleading "Session expired" alert. const isPublic = this.isPublicEndpoint(originalRequest?.url ?? ''); if (error.response?.status === 401 && !originalRequest._skipAuth && !isPublic) { try { const newToken = await this.refreshAccessToken(); // Retry original request with new token if (originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${newToken}`; } return this.client(originalRequest); } catch (refreshError) { // Refresh failed - clear tokens silently. Caller decides surfacing. this.tokenStorage?.removeTokens(); throw createAuthError( (error.response?.data as { error?: string; message?: string } | undefined)?.error ?? (error.response?.data as { error?: string; message?: string } | undefined)?.message ?? 'Authentication required', 401, refreshError ); } } // Retry logic for retriable errors if (this.shouldRetry(error) && originalRequest) { const retryCount = originalRequest._retry || 0; if (retryCount < (this.config.retries || 3)) { originalRequest._retry = retryCount + 1; // Exponential backoff const delay = (this.config.retryDelay || 1000) * Math.pow(2, retryCount); await this.sleep(delay); console.log(`[API Retry] Attempt ${retryCount + 1} for ${originalRequest.url}`); return this.client(originalRequest); } } // Transform error to ApiError throw this.transformError(error); } /** * Determines if request should be retried */ private shouldRetry(error: AxiosError): boolean { // Retry on network errors or gateway errors only if (!error.response) { return true; // Network error } const status = error.response.status; return [502, 503, 504].includes(status); } /** * Transforms axios error to ApiError with proper error codes */ private transformError(error: AxiosError): ApiError { // Timeout error if (error.code === 'ECONNABORTED') { return createTimeoutError('Request timeout', error); } if (error.response) { // Server responded with error status const status = error.response.status; const data = error.response.data; // Extract error message let message = 'An error occurred'; let details: unknown; if (typeof data === 'object' && data !== null) { if ('error' in data && typeof (data as { error: unknown }).error === 'string') { message = String((data as { error: string }).error); } else if ('message' in data) { message = String((data as { message: unknown }).message); } if ('errors' in data || 'details' in data) { details = 'errors' in data ? (data as { errors: unknown }).errors : (data as { details: unknown }).details; } } else if (typeof data === 'string') { message = data.includes('<') ? 'An error occurred' : data; } // Map status code to appropriate error type const errorCode = mapStatusToErrorCode(status); // Create specific error types switch (errorCode) { case ApiErrorCode.AUTH_ERROR: return createAuthError(message, status, error); case ApiErrorCode.VALIDATION_ERROR: return createValidationError(message, details, error); case ApiErrorCode.SERVER_ERROR: return createServerError(message, status, error); default: return new ApiError(message, errorCode, status, error, details); } } else if (error.request) { // Request made but no response received (network error) return createNetworkError( 'Network error: Unable to connect to server', error ); } else { // Error setting up request return new ApiError( error.message, ApiErrorCode.UNKNOWN, undefined, error ); } } /** * Gets authentication token from storage */ private getAuthToken(): string | null { if (this.tokenStorage) { return this.tokenStorage.getAccessToken(); } // Fallback to localStorage for backwards compatibility return localStorage.getItem('auth_token'); } /** * Refresh access token using refresh token * * Implements request queuing to prevent multiple simultaneous refresh attempts */ private async refreshAccessToken(): Promise { // If already refreshing, queue this request if (this.isRefreshing) { return new Promise((resolve, reject) => { this.refreshQueue.push({ resolve, reject }); }); } this.isRefreshing = true; try { const refreshToken = this.tokenStorage?.getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token available'); } // Call refresh endpoint (skip auth interceptor to avoid infinite loop) const response = await this.client.post>( REFRESH_TOKEN.path, { refreshToken }, { _skipAuth: true } as AxiosRequestConfig ); const { accessToken, refreshToken: newRefreshToken } = response.data as { accessToken: string; refreshToken: string; }; // Update tokens in storage this.tokenStorage?.setTokens(accessToken, newRefreshToken); // Refresh also returns the full TokenResponse shape — patch the global // AuthenticatedUser so the dashboard sees fresh identity + account // (currency, completeness) without waiting for the next mutation. // Dynamic imports keep ApiClient free of a hard dependency on the // dashboard's domain layer (it's also used by the WP-embed build where // the store may not be wired). try { const [{ AuthenticatedUserMapper }, { authenticatedUserStore }] = await Promise.all([ import('./api/auth/mappers/AuthenticatedUserMapper'), import('../storage/AuthenticatedUserStore'), ]); authenticatedUserStore.set(AuthenticatedUserMapper.fromTokenResponse(response.data)); } catch { /* schema mismatch or missing store in embed build — tokens are still refreshed, AuthenticatedUser stays on its previous snapshot */ } // Process queued requests this.refreshQueue.forEach(({ resolve }) => resolve(accessToken)); this.refreshQueue = []; return accessToken; } catch (error) { // Reject all queued requests this.refreshQueue.forEach(({ reject }) => reject(error)); this.refreshQueue = []; throw error; } finally { this.isRefreshing = false; } } /** * Public façade for the private refresh routine. Use after a backend * mutation that changes claim-derived state (account type, company data, * legal acceptance, plan, capabilities) so the JWT is re-minted from the * latest DB state and the AuthenticatedUser store rebuilds via * AuthenticatedUserMapper.fromTokenResponse — no ad-hoc local patching. */ async forceRefresh(): Promise { await this.refreshAccessToken(); } /** * Sleep utility for retry delays */ private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * GET request */ async get(url: string, config?: AxiosRequestConfig): Promise { const response = await this.client.get(url, config); return response.data; } /** * POST request */ async post( url: string, data?: unknown, config?: AxiosRequestConfig ): Promise { const response = await this.client.post(url, data, config); return response.data; } /** * PUT request */ async put( url: string, data?: unknown, config?: AxiosRequestConfig ): Promise { const response = await this.client.put(url, data, config); return response.data; } /** * PATCH request */ async patch( url: string, data?: unknown, config?: AxiosRequestConfig ): Promise { const response = await this.client.patch(url, data, config); return response.data; } /** * DELETE request */ async delete(url: string, config?: AxiosRequestConfig): Promise { const response = await this.client.delete(url, config); return response.data; } } /** * Default API Client Instance * * Configured with base URL from VITE_API_URL environment variable. */ const getBaseURL = (): string => { if (typeof (import.meta as any).env !== 'undefined' && (import.meta as any).env.VITE_API_URL) { return (import.meta as any).env.VITE_API_URL as string; } return ''; }; // Import token storage for default client import { tokenStorage } from '../storage/LocalTokenStorage'; export const apiClient = new ApiClient({ baseURL: getBaseURL(), tokenStorage, });