diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ec6f2ce..26e243d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,7 +1,9 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { Capacitor } from '@capacitor/core'; +import { CapacitorHttp, HttpOptions, HttpResponse } from '@capacitor/core'; // Detect if running in Capacitor native environment -const isCapacitor = typeof window !== 'undefined' && !!(window as any).Capacitor?.isNativePlatform?.(); +const isCapacitor = Capacitor.isNativePlatform(); // Production API URL for native app, relative URL for web const API_BASE_URL = isCapacitor @@ -30,110 +32,238 @@ const processQueue = (error: unknown = null) => { failedQueue = []; }; -const api = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - }, - timeout: 30000, // 30 second timeout -}); +// Helper to get auth token +const getAuthToken = (): string | null => { + return localStorage.getItem('token'); +}; -// Request interceptor - add auth token -api.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; +// CapacitorHttp-based API client for native platforms +class CapacitorApiClient { + private baseURL: string; + + constructor(baseURL: string) { + this.baseURL = baseURL; } - return config; -}); -// Response interceptor - handle errors and retries -api.interceptors.response.use( - (response) => response, - async (error: AxiosError) => { - const originalRequest = error.config as InternalAxiosRequestConfig & { _retryCount?: number }; + private async request(method: string, url: string, data?: unknown, retryCount = 0): Promise<{ data: T; status: number }> { + const fullUrl = url.startsWith('http') ? url : `${this.baseURL}${url}`; + const token = getAuthToken(); + + const options: HttpOptions = { + url: fullUrl, + method: method as HttpOptions['method'], + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + ...(data ? { data } : {}), + }; + + try { + const response: HttpResponse = await CapacitorHttp.request(options); + + // Handle 401 Unauthorized - token expired + if (response.status === 401) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then(() => this.request(method, url, data)); + } + + isRefreshing = true; + + try { + const refreshToken = localStorage.getItem('refreshToken'); + if (refreshToken) { + const refreshResponse = await CapacitorHttp.post({ + url: `${this.baseURL}/auth/refresh`, + headers: { 'Content-Type': 'application/json' }, + data: { refreshToken }, + }); + + if (refreshResponse.status === 200) { + localStorage.setItem('token', refreshResponse.data.accessToken); + if (refreshResponse.data.refreshToken) { + localStorage.setItem('refreshToken', refreshResponse.data.refreshToken); + } + processQueue(); + return this.request(method, url, data); + } + } + } catch (refreshError) { + processQueue(refreshError); + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + window.location.href = '/login'; + throw refreshError; + } finally { + isRefreshing = false; + } + } + + // Handle errors (4xx, 5xx) + if (response.status >= 400) { + const shouldRetry = response.status >= 500 && response.status < 600; + + if (shouldRetry && retryCount < MAX_RETRIES) { + const delay = RETRY_DELAY_BASE * Math.pow(2, retryCount); + await new Promise(resolve => setTimeout(resolve, delay)); + console.log(`Retrying request (${retryCount + 1}/${MAX_RETRIES}):`, url); + return this.request(method, url, data, retryCount + 1); + } + + // Handle rate limiting + if (response.status === 429) { + window.dispatchEvent(new CustomEvent('api:rate-limited', { + detail: { waitTime: 60000, url } + })); + } + + const error = new Error(response.data?.message || `HTTP ${response.status}`); + (error as any).response = { status: response.status, data: response.data }; + throw error; + } + + return { data: response.data as T, status: response.status }; + } catch (networkError: any) { + // Network error - retry + if (!networkError.response && retryCount < MAX_RETRIES) { + const delay = RETRY_DELAY_BASE * Math.pow(2, retryCount); + await new Promise(resolve => setTimeout(resolve, delay)); + console.log(`Retrying request due to network error (${retryCount + 1}/${MAX_RETRIES}):`, url); + return this.request(method, url, data, retryCount + 1); + } + throw networkError; + } + } + + get(url: string) { + return this.request('GET', url); + } + + post(url: string, data?: unknown) { + return this.request('POST', url, data); + } + + put(url: string, data?: unknown) { + return this.request('PUT', url, data); + } + + patch(url: string, data?: unknown) { + return this.request('PATCH', url, data); + } + + delete(url: string) { + return this.request('DELETE', url); + } +} + +// Create the appropriate client based on platform +let api: CapacitorApiClient | ReturnType; + +if (isCapacitor) { + // Use CapacitorHttp for native platforms + api = new CapacitorApiClient(API_BASE_URL); +} else { + // Use axios for web + const axiosInstance = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, + timeout: 30000, + }); + + // Request interceptor - add auth token + axiosInstance.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + + // Response interceptor - handle errors and retries + axiosInstance.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retryCount?: number }; + + if (!originalRequest) { + return Promise.reject(error); + } + + // Handle 401 Unauthorized - token expired + if (error.response?.status === 401) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then(() => axiosInstance(originalRequest)); + } + + isRefreshing = true; + + try { + const refreshToken = localStorage.getItem('refreshToken'); + if (refreshToken) { + const { data } = await axios.post( + `${API_BASE_URL}/auth/refresh`, + { refreshToken } + ); + + localStorage.setItem('token', data.accessToken); + if (data.refreshToken) { + localStorage.setItem('refreshToken', data.refreshToken); + } + + processQueue(); + return axiosInstance(originalRequest); + } + } catch (refreshError) { + processQueue(refreshError); + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + window.location.href = '/login'; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + // Retry logic for network errors and 5xx errors + const shouldRetry = ( + !error.response || + (error.response.status >= 500 && error.response.status < 600) + ); + + if (shouldRetry) { + originalRequest._retryCount = originalRequest._retryCount || 0; + + if (originalRequest._retryCount < MAX_RETRIES) { + originalRequest._retryCount++; + const delay = RETRY_DELAY_BASE * Math.pow(2, originalRequest._retryCount - 1); + await new Promise(resolve => setTimeout(resolve, delay)); + console.log(`Retrying request (${originalRequest._retryCount}/${MAX_RETRIES}):`, originalRequest.url); + return axiosInstance(originalRequest); + } + } + + // Handle rate limiting (429) + if (error.response?.status === 429) { + const retryAfter = error.response.headers['retry-after']; + const waitTime = retryAfter ? parseInt(retryAfter as string, 10) * 1000 : 60000; + window.dispatchEvent(new CustomEvent('api:rate-limited', { + detail: { waitTime, url: originalRequest.url } + })); + } - if (!originalRequest) { return Promise.reject(error); } + ); - // Handle 401 Unauthorized - token expired - if (error.response?.status === 401) { - // If already refreshing, queue this request - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }).then(() => api(originalRequest)); - } - - // Try to refresh token - isRefreshing = true; - - try { - const refreshToken = localStorage.getItem('refreshToken'); - if (refreshToken) { - const { data } = await axios.post( - `${API_BASE_URL}/auth/refresh`, - { refreshToken } - ); - - localStorage.setItem('token', data.accessToken); - if (data.refreshToken) { - localStorage.setItem('refreshToken', data.refreshToken); - } - - processQueue(); - return api(originalRequest); - } - } catch (refreshError) { - processQueue(refreshError); - // Clear tokens and redirect to login - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); - window.location.href = '/login'; - return Promise.reject(refreshError); - } finally { - isRefreshing = false; - } - } - - // Retry logic for network errors and 5xx errors - const shouldRetry = ( - !error.response || // Network error - (error.response.status >= 500 && error.response.status < 600) // Server error - ); - - if (shouldRetry) { - originalRequest._retryCount = originalRequest._retryCount || 0; - - if (originalRequest._retryCount < MAX_RETRIES) { - originalRequest._retryCount++; - - // Exponential backoff: 1s, 2s, 4s - const delay = RETRY_DELAY_BASE * Math.pow(2, originalRequest._retryCount - 1); - - await new Promise(resolve => setTimeout(resolve, delay)); - - console.log(`Retrying request (${originalRequest._retryCount}/${MAX_RETRIES}):`, originalRequest.url); - return api(originalRequest); - } - } - - // Handle rate limiting (429) - if (error.response?.status === 429) { - const retryAfter = error.response.headers['retry-after']; - const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60000; - - // Dispatch event so UI can show a message - window.dispatchEvent(new CustomEvent('api:rate-limited', { - detail: { waitTime, url: originalRequest.url } - })); - } - - return Promise.reject(error); - } -); + api = axiosInstance; +} export default api; -