222 lines
7.4 KiB
TypeScript

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<string | null> {
return AsyncStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
},
async getRefreshToken(): Promise<string | null> {
return AsyncStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
},
async setTokens(accessToken: string, refreshToken: string): Promise<void> {
await AsyncStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
await AsyncStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
},
async clearTokens(): Promise<void> {
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<AxiosResponse>((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<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await axiosInstance.get<T>(url, config);
return response.data;
},
async post<T>(
url: string,
body?: unknown,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await axiosInstance.post<T>(url, body, config);
return response.data;
},
async put<T>(
url: string,
body?: unknown,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await axiosInstance.put<T>(url, body, config);
return response.data;
},
async patch<T>(
url: string,
body?: unknown,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await axiosInstance.patch<T>(url, body, config);
return response.data;
},
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await axiosInstance.delete<T>(url, config);
return response.data;
},
};
// Export the raw instance if needed for advanced usage
export { axiosInstance };