proposal-system/mobile/src/lib/api/client.ts

137 lines
3.6 KiB
TypeScript

import axios from 'axios';
import Config from '../../config';
import { tokenStorage } from '../storage';
// Fix: CONC-L2 — carries HTTP status + response body so screens can branch on
// optimistic-concurrency conflicts (409 { message, currentState }).
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown,
) {
super(message);
this.name = 'ApiError';
}
}
type RefreshFn = () => Promise<void>;
let _refreshTokens: RefreshFn | null = null;
export function registerTokenRefresh(fn: RefreshFn) {
_refreshTokens = fn;
}
type SessionExpiredListener = () => void;
const sessionExpiredListeners: SessionExpiredListener[] = [];
export function onSessionExpired(listener: SessionExpiredListener): () => void {
sessionExpiredListeners.push(listener);
return () => {
const idx = sessionExpiredListeners.indexOf(listener);
if (idx >= 0) sessionExpiredListeners.splice(idx, 1);
};
}
function emitSessionExpired() {
sessionExpiredListeners.forEach((fn) => fn());
}
let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = [];
function subscribeToRefresh(cb: (token: string) => void) {
refreshSubscribers.push(cb);
}
function onRefreshComplete(token: string) {
refreshSubscribers.forEach((cb) => cb(token));
refreshSubscribers = [];
}
const apiClient = axios.create({
baseURL: Config.API_URL,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
apiClient.interceptors.request.use(
async (config) => {
const tokens = await tokenStorage.get();
if (tokens?.accessToken) {
config.headers.Authorization = `Bearer ${tokens.accessToken}`;
}
return config;
},
(error) => Promise.reject(error),
);
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response) {
const { status, data, config: originalRequest } = error.response;
if (status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (isRefreshing) {
return new Promise((resolve) => {
subscribeToRefresh((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(apiClient(originalRequest));
});
});
}
isRefreshing = true;
try {
if (!_refreshTokens) throw new Error('No refresh handler');
await _refreshTokens();
const tokens = await tokenStorage.get();
const newToken = tokens?.accessToken ?? '';
onRefreshComplete(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient(originalRequest);
} catch {
await tokenStorage.clear();
emitSessionExpired();
return Promise.reject(
new Error('Session expired. Please log in again.'),
);
} finally {
isRefreshing = false;
}
}
if (status === 403) {
return Promise.reject(
new Error('You do not have permission to perform this action.'),
);
}
if (status === 404) {
return Promise.reject(
new Error('The requested resource was not found.'),
);
}
const message =
data?.detail || data?.title || data?.message || 'An error occurred';
return Promise.reject(new ApiError(message, status, data));
}
if (error.request) {
return Promise.reject(
new Error('No response from server. Please check your connection.'),
);
}
return Promise.reject(error);
},
);
export default apiClient;