import axios, { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig, AxiosResponse, AxiosError, } from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Store } from '@reduxjs/toolkit'; // ─── Storage Keys ──────────────────────────────────────────────────────────── const STORAGE_KEYS = { ACCESS_TOKEN: '@auth_access_token', REFRESH_TOKEN: '@auth_refresh_token', } as const; // ─── Config ────────────────────────────────────────────────────────────────── const BASE_URL = 'https://8415-202-8-116-13.ngrok-free.app'; // TODO: replace with your actual base URL // ─── Token Helpers ─────────────────────────────────────────────────────────── export const tokenManager = { async getAccessToken(): Promise { return AsyncStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN); }, async getRefreshToken(): Promise { return AsyncStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN); }, async setTokens(accessToken: string, refreshToken: string): Promise { await AsyncStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken); await AsyncStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken); }, async clearTokens(): Promise { await AsyncStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN); await AsyncStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN); }, }; // ─── Axios Instance ────────────────────────────────────────────────────────── const axiosInstance: AxiosInstance = axios.create({ baseURL: BASE_URL, timeout: 15000, headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, }); // ─── Request Interceptor — Attach Access Token ────────────────────────────── axiosInstance.interceptors.request.use( async (config: InternalAxiosRequestConfig) => { // Skip attaching token for auth endpoints (login, register, refresh) const publicPaths = [ '/auth/otp/request', '/auth/otp/verify', '/auth/refresh-token', ]; const isPublic = publicPaths.some(path => config.url?.includes(path)); if (!isPublic) { const token = await tokenManager.getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } } // When the body is FormData, delete the Content-Type header entirely. // This forces the native XMLHttpRequest layer to auto-generate the correct // header: 'multipart/form-data; boundary=------xxxx'. The boundary is // required for the server to parse the multipart body — it cannot be set // manually because only XMLHttpRequest knows the generated boundary string. if (config.data instanceof FormData) { delete config.headers['Content-Type']; } // Debug logging (remove in production) if (__DEV__) { // console.log(`🚀 [API] ${config.method?.toUpperCase()} ${config.url}`); } return config; }, (error: AxiosError) => { return Promise.reject(error); }, ); // ─── Response Interceptor — Handle Token Refresh ──────────────────────────── let isRefreshing = false; let failedQueue: Array<{ resolve: (token: string) => void; reject: (error: unknown) => void; }> = []; const processQueue = (error: unknown, token: string | null = null) => { failedQueue.forEach(({ resolve, reject }) => { if (error) { reject(error); } else if (token) { resolve(token); } }); failedQueue = []; }; axiosInstance.interceptors.response.use( (response: AxiosResponse) => { return response; }, async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; }; // If 401 and we haven't already retried this request if (error.response?.status === 401 && !originalRequest._retry) { // If already refreshing, queue this request if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve: (token: string) => { originalRequest.headers.Authorization = `Bearer ${token}`; resolve(axiosInstance(originalRequest)); }, reject, }); }); } originalRequest._retry = true; isRefreshing = true; try { const refreshToken = await tokenManager.getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token available'); } // Call refresh endpoint (uses a fresh axios call to avoid interceptor loop) const { data } = await axios.post(`${BASE_URL}/auth/refresh-token`, { refreshToken, }); const { accessToken: newAccessToken, refreshToken: newRefreshToken } = data; await tokenManager.setTokens(newAccessToken, newRefreshToken); // Retry all queued requests with the new token processQueue(null, newAccessToken); // Retry the original request originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return axiosInstance(originalRequest); } catch (refreshError) { processQueue(refreshError, null); await tokenManager.clearTokens(); // TODO: Navigate to login screen or dispatch a logout action // e.g. store.dispatch(logout()); return Promise.reject(refreshError); } finally { isRefreshing = false; } } // For all other errors, reject as-is return Promise.reject(error); }, ); let storeInstance: Store | null = null; export const injectStore = (_store: Store | null): void => { storeInstance = _store; }; // ─── API Methods ───────────────────────────────────────────────────────────── export const apiClient = { async get(url: string, config?: AxiosRequestConfig): Promise { const response = await axiosInstance.get(url, config); return response.data; }, async post( url: string, body?: unknown, config?: AxiosRequestConfig, ): Promise { const response = await axiosInstance.post(url, body, config); return response.data; }, async put( url: string, body?: unknown, config?: AxiosRequestConfig, ): Promise { const response = await axiosInstance.put(url, body, config); return response.data; }, async patch( url: string, body?: unknown, config?: AxiosRequestConfig, ): Promise { const response = await axiosInstance.patch(url, body, config); return response.data; }, async delete(url: string, config?: AxiosRequestConfig): Promise { const response = await axiosInstance.delete(url, config); return response.data; }, }; // Export the raw instance if needed for advanced usage export { axiosInstance };