343 lines
9.8 KiB
TypeScript
343 lines
9.8 KiB
TypeScript
/**
|
|
* Core HTTP client for React Query integration
|
|
*
|
|
* Architecture:
|
|
* - Axios: HTTP client for making requests
|
|
* - This Client: Handles auth tokens, tenant context, and error formatting
|
|
* - Services: Business logic that uses this client
|
|
* - React Query Hooks: Data fetching layer that uses services
|
|
*
|
|
* React Query doesn't replace HTTP clients - it manages data fetching/caching/sync
|
|
*/
|
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
|
|
|
export interface ApiError {
|
|
message: string;
|
|
status?: number;
|
|
code?: string;
|
|
details?: any;
|
|
}
|
|
|
|
export interface SubscriptionError {
|
|
error: string;
|
|
message: string;
|
|
code: string;
|
|
details: {
|
|
required_feature: string;
|
|
required_level: string;
|
|
current_plan: string;
|
|
upgrade_url: string;
|
|
};
|
|
}
|
|
|
|
// Subscription error event emitter
|
|
class SubscriptionErrorEmitter extends EventTarget {
|
|
emitSubscriptionError(error: SubscriptionError) {
|
|
this.dispatchEvent(new CustomEvent('subscriptionError', { detail: error }));
|
|
}
|
|
}
|
|
|
|
export const subscriptionErrorEmitter = new SubscriptionErrorEmitter();
|
|
|
|
class ApiClient {
|
|
private client: AxiosInstance;
|
|
private baseURL: string;
|
|
private authToken: string | null = null;
|
|
private tenantId: string | null = null;
|
|
private refreshToken: string | null = null;
|
|
private isRefreshing: boolean = false;
|
|
private refreshAttempts: number = 0;
|
|
private maxRefreshAttempts: number = 3;
|
|
private lastRefreshAttempt: number = 0;
|
|
private failedQueue: Array<{
|
|
resolve: (value?: any) => void;
|
|
reject: (error?: any) => void;
|
|
config: AxiosRequestConfig;
|
|
}> = [];
|
|
|
|
constructor(baseURL: string = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1') {
|
|
this.baseURL = baseURL;
|
|
|
|
this.client = axios.create({
|
|
baseURL: this.baseURL,
|
|
timeout: 30000,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
this.setupInterceptors();
|
|
}
|
|
|
|
private setupInterceptors() {
|
|
// Request interceptor to add auth headers
|
|
this.client.interceptors.request.use(
|
|
(config) => {
|
|
if (this.authToken) {
|
|
config.headers.Authorization = `Bearer ${this.authToken}`;
|
|
}
|
|
|
|
if (this.tenantId) {
|
|
config.headers['X-Tenant-ID'] = this.tenantId;
|
|
}
|
|
|
|
return config;
|
|
},
|
|
(error) => {
|
|
return Promise.reject(this.handleError(error));
|
|
}
|
|
);
|
|
|
|
// Response interceptor for error handling and automatic token refresh
|
|
this.client.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
const originalRequest = error.config;
|
|
|
|
// Check if error is 401 and we have a refresh token
|
|
if (error.response?.status === 401 && this.refreshToken && !originalRequest._retry) {
|
|
// Check if we've exceeded max refresh attempts in a short time
|
|
const now = Date.now();
|
|
if (this.refreshAttempts >= this.maxRefreshAttempts && (now - this.lastRefreshAttempt) < 30000) {
|
|
console.log('Max refresh attempts exceeded, logging out');
|
|
await this.handleAuthFailure();
|
|
return Promise.reject(this.handleError(error));
|
|
}
|
|
|
|
if (this.isRefreshing) {
|
|
// If already refreshing, queue this request
|
|
return new Promise((resolve, reject) => {
|
|
this.failedQueue.push({ resolve, reject, config: originalRequest });
|
|
});
|
|
}
|
|
|
|
originalRequest._retry = true;
|
|
this.isRefreshing = true;
|
|
this.refreshAttempts++;
|
|
this.lastRefreshAttempt = now;
|
|
|
|
try {
|
|
console.log(`Attempting token refresh (attempt ${this.refreshAttempts})...`);
|
|
|
|
// Attempt to refresh the token
|
|
const response = await this.client.post('/auth/refresh', {
|
|
refresh_token: this.refreshToken
|
|
});
|
|
|
|
const { access_token, refresh_token } = response.data;
|
|
|
|
console.log('Token refresh successful');
|
|
|
|
// Reset refresh attempts on success
|
|
this.refreshAttempts = 0;
|
|
|
|
// Update tokens
|
|
this.setAuthToken(access_token);
|
|
if (refresh_token) {
|
|
this.setRefreshToken(refresh_token);
|
|
}
|
|
|
|
// Update auth store if available
|
|
await this.updateAuthStore(access_token, refresh_token);
|
|
|
|
// Process failed queue
|
|
this.processQueue(null, access_token);
|
|
|
|
// Retry original request with new token
|
|
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
|
return this.client(originalRequest);
|
|
|
|
} catch (refreshError) {
|
|
console.error(`Token refresh failed (attempt ${this.refreshAttempts}):`, refreshError);
|
|
// Refresh failed, clear tokens and redirect to login
|
|
this.processQueue(refreshError, null);
|
|
await this.handleAuthFailure();
|
|
return Promise.reject(this.handleError(refreshError as AxiosError));
|
|
} finally {
|
|
this.isRefreshing = false;
|
|
}
|
|
}
|
|
|
|
return Promise.reject(this.handleError(error));
|
|
}
|
|
);
|
|
}
|
|
|
|
private handleError(error: AxiosError): ApiError {
|
|
if (error.response) {
|
|
// Server responded with error status
|
|
const { status, data } = error.response;
|
|
|
|
// Check for subscription errors
|
|
if (status === 403 && (data as any)?.code === 'SUBSCRIPTION_UPGRADE_REQUIRED') {
|
|
const subscriptionError = data as SubscriptionError;
|
|
subscriptionErrorEmitter.emitSubscriptionError(subscriptionError);
|
|
}
|
|
|
|
return {
|
|
message: (data as any)?.detail || (data as any)?.message || `Request failed with status ${status}`,
|
|
status,
|
|
code: (data as any)?.code,
|
|
details: data,
|
|
};
|
|
} else if (error.request) {
|
|
// Network error
|
|
return {
|
|
message: 'Network error - please check your connection',
|
|
status: 0,
|
|
};
|
|
} else {
|
|
// Other error
|
|
return {
|
|
message: error.message || 'Unknown error occurred',
|
|
};
|
|
}
|
|
}
|
|
|
|
private processQueue(error: any, token: string | null = null) {
|
|
this.failedQueue.forEach(({ resolve, reject, config }) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
if (token) {
|
|
config.headers = config.headers || {};
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
resolve(this.client(config));
|
|
}
|
|
});
|
|
|
|
this.failedQueue = [];
|
|
}
|
|
|
|
private async updateAuthStore(accessToken: string, refreshToken?: string) {
|
|
try {
|
|
// Dynamically import to avoid circular dependency
|
|
const { useAuthStore } = await import('../../stores/auth.store');
|
|
const setState = useAuthStore.setState;
|
|
|
|
// Update the store with new tokens
|
|
setState(state => ({
|
|
...state,
|
|
token: accessToken,
|
|
refreshToken: refreshToken || state.refreshToken,
|
|
}));
|
|
} catch (error) {
|
|
console.warn('Failed to update auth store:', error);
|
|
}
|
|
}
|
|
|
|
private async handleAuthFailure() {
|
|
try {
|
|
// Clear tokens
|
|
this.setAuthToken(null);
|
|
this.setRefreshToken(null);
|
|
|
|
// Dynamically import to avoid circular dependency
|
|
const { useAuthStore } = await import('../../stores/auth.store');
|
|
const store = useAuthStore.getState();
|
|
|
|
// Logout user
|
|
store.logout();
|
|
|
|
// Redirect to login if not already there
|
|
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
|
|
window.location.href = '/login';
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to handle auth failure:', error);
|
|
}
|
|
}
|
|
|
|
// Configuration methods
|
|
setAuthToken(token: string | null) {
|
|
this.authToken = token;
|
|
}
|
|
|
|
setRefreshToken(token: string | null) {
|
|
this.refreshToken = token;
|
|
}
|
|
|
|
setTenantId(tenantId: string | null) {
|
|
this.tenantId = tenantId;
|
|
}
|
|
|
|
getAuthToken(): string | null {
|
|
return this.authToken;
|
|
}
|
|
|
|
getRefreshToken(): string | null {
|
|
return this.refreshToken;
|
|
}
|
|
|
|
getTenantId(): string | null {
|
|
return this.tenantId;
|
|
}
|
|
|
|
// HTTP Methods - Return direct data for React Query
|
|
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
const response: AxiosResponse<T> = await this.client.get(url, config);
|
|
return response.data;
|
|
}
|
|
|
|
async post<T = any, D = any>(
|
|
url: string,
|
|
data?: D,
|
|
config?: AxiosRequestConfig
|
|
): Promise<T> {
|
|
const response: AxiosResponse<T> = await this.client.post(url, data, config);
|
|
return response.data;
|
|
}
|
|
|
|
async put<T = any, D = any>(
|
|
url: string,
|
|
data?: D,
|
|
config?: AxiosRequestConfig
|
|
): Promise<T> {
|
|
const response: AxiosResponse<T> = await this.client.put(url, data, config);
|
|
return response.data;
|
|
}
|
|
|
|
async patch<T = any, D = any>(
|
|
url: string,
|
|
data?: D,
|
|
config?: AxiosRequestConfig
|
|
): Promise<T> {
|
|
const response: AxiosResponse<T> = await this.client.patch(url, data, config);
|
|
return response.data;
|
|
}
|
|
|
|
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
const response: AxiosResponse<T> = await this.client.delete(url, config);
|
|
return response.data;
|
|
}
|
|
|
|
// File upload helper
|
|
async uploadFile<T = any>(
|
|
url: string,
|
|
file: File | FormData,
|
|
config?: AxiosRequestConfig
|
|
): Promise<T> {
|
|
const formData = file instanceof FormData ? file : new FormData();
|
|
if (file instanceof File) {
|
|
formData.append('file', file);
|
|
}
|
|
|
|
return this.post<T>(url, formData, {
|
|
...config,
|
|
headers: {
|
|
...config?.headers,
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
});
|
|
}
|
|
|
|
// Raw axios instance for advanced usage
|
|
getAxiosInstance(): AxiosInstance {
|
|
return this.client;
|
|
}
|
|
}
|
|
|
|
// Create and export singleton instance
|
|
export const apiClient = new ApiClient();
|
|
export default apiClient; |