Archived
170 lines
4.7 KiB
TypeScript
170 lines
4.7 KiB
TypeScript
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import type { TokenResponse } from "@cloud-reader/shared";
|
|
|
|
const STORAGE_KEYS = {
|
|
ACCESS_TOKEN: "access_token",
|
|
REFRESH_TOKEN: "refresh_token",
|
|
} as const;
|
|
|
|
interface RetryConfig extends InternalAxiosRequestConfig {
|
|
_retry?: boolean;
|
|
}
|
|
|
|
const api = axios.create({
|
|
baseURL: process.env.EXPO_PUBLIC_API_URL || "http://localhost:8000",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
// ── Token helpers ────────────────────────────────────────────────────
|
|
|
|
async function getAccessToken(): Promise<string | null> {
|
|
return AsyncStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
|
|
}
|
|
|
|
async function getRefreshToken(): Promise<string | null> {
|
|
return AsyncStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
|
}
|
|
|
|
async function setTokens(access: string, refresh: string): Promise<void> {
|
|
await AsyncStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access);
|
|
await AsyncStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh);
|
|
}
|
|
|
|
async function clearTokens(): Promise<void> {
|
|
await AsyncStorage.multiRemove([
|
|
STORAGE_KEYS.ACCESS_TOKEN,
|
|
STORAGE_KEYS.REFRESH_TOKEN,
|
|
]);
|
|
}
|
|
|
|
async function saveTokens(tokens: TokenResponse): Promise<void> {
|
|
await setTokens(tokens.access, tokens.refresh);
|
|
}
|
|
|
|
async function loadTokens(): Promise<TokenResponse | null> {
|
|
const [access, refresh] = await Promise.all([
|
|
getAccessToken(),
|
|
getRefreshToken(),
|
|
]);
|
|
if (!access || !refresh) {
|
|
return null;
|
|
}
|
|
return { access, refresh };
|
|
}
|
|
|
|
/** Absolute base URL used for direct (non-axios) requests like file downloads. */
|
|
function getApiBaseUrl(): string {
|
|
return api.defaults.baseURL ?? "";
|
|
}
|
|
|
|
// ── Request interceptor ─────────────────────────────────────────────
|
|
|
|
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
|
const token = await getAccessToken();
|
|
if (token && config.headers) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// ── Response interceptor: auto-refresh on 401 ───────────────────────
|
|
|
|
let isRefreshing = false;
|
|
let failedQueue: Array<{
|
|
resolve: (token: string) => void;
|
|
reject: (err: unknown) => void;
|
|
}> = [];
|
|
|
|
function processQueue(error: unknown, token: string | null = null): void {
|
|
failedQueue.forEach((prom) => {
|
|
if (error) {
|
|
prom.reject(error);
|
|
} else if (token) {
|
|
prom.resolve(token);
|
|
}
|
|
});
|
|
failedQueue = [];
|
|
}
|
|
|
|
api.interceptors.response.use(
|
|
(response) => response,
|
|
async (error: AxiosError) => {
|
|
const originalRequest = error.config as RetryConfig | undefined;
|
|
|
|
if (!originalRequest) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
if (
|
|
error.response?.status !== 401 ||
|
|
originalRequest._retry ||
|
|
originalRequest.url?.includes("/api/auth/token/refresh/") ||
|
|
originalRequest.url?.includes("/api/auth/login/") ||
|
|
originalRequest.url?.includes("/api/auth/register/") ||
|
|
originalRequest.url?.includes("/api/auth/logout/")
|
|
) {
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
if (isRefreshing) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
failedQueue.push({ resolve, reject });
|
|
}).then((token) => {
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return api(originalRequest);
|
|
});
|
|
}
|
|
|
|
originalRequest._retry = true;
|
|
isRefreshing = true;
|
|
|
|
const refreshToken = await getRefreshToken();
|
|
|
|
if (!refreshToken) {
|
|
isRefreshing = false;
|
|
await clearTokens();
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
try {
|
|
const response = await axios.post(
|
|
`${api.defaults.baseURL}/api/auth/token/refresh/`,
|
|
{ refresh: refreshToken },
|
|
);
|
|
|
|
const newAccess = response.data.access as string;
|
|
const newRefresh = response.data.refresh as string;
|
|
await setTokens(newAccess, newRefresh);
|
|
|
|
processQueue(null, newAccess);
|
|
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
|
|
}
|
|
return api(originalRequest);
|
|
} catch (refreshError) {
|
|
processQueue(refreshError, null);
|
|
await clearTokens();
|
|
return Promise.reject(refreshError);
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
},
|
|
);
|
|
|
|
const apiClient = api;
|
|
|
|
export {
|
|
apiClient,
|
|
getAccessToken,
|
|
saveTokens,
|
|
loadTokens,
|
|
clearTokens,
|
|
getApiBaseUrl,
|
|
};
|
|
export default api; |