ADD new frontend
This commit is contained in:
@@ -1,142 +0,0 @@
|
||||
// frontend/src/api/client/config.ts
|
||||
/**
|
||||
* API Client Configuration
|
||||
* Centralized configuration for all API clients
|
||||
*/
|
||||
|
||||
export interface ApiConfig {
|
||||
baseURL: string;
|
||||
timeout: number;
|
||||
retries: number;
|
||||
retryDelay: number;
|
||||
enableLogging: boolean;
|
||||
enableCaching: boolean;
|
||||
cacheTimeout: number;
|
||||
}
|
||||
|
||||
export interface ServiceEndpoints {
|
||||
auth: string;
|
||||
tenant: string;
|
||||
data: string;
|
||||
training: string;
|
||||
forecasting: string;
|
||||
notification: string;
|
||||
}
|
||||
|
||||
// Environment-based configuration
|
||||
const getEnvironmentConfig = (): ApiConfig => {
|
||||
// Use import.meta.env instead of process.env for Vite
|
||||
const isDevelopment = import.meta.env.DEV;
|
||||
const isProduction = import.meta.env.PROD;
|
||||
|
||||
return {
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1',
|
||||
timeout: parseInt(import.meta.env.VITE_API_TIMEOUT || '30000'),
|
||||
retries: parseInt(import.meta.env.VITE_API_RETRIES || '3'),
|
||||
retryDelay: parseInt(import.meta.env.VITE_API_RETRY_DELAY || '1000'),
|
||||
enableLogging: isDevelopment || import.meta.env.VITE_API_LOGGING === 'true',
|
||||
enableCaching: import.meta.env.VITE_API_CACHING !== 'false',
|
||||
cacheTimeout: parseInt(import.meta.env.VITE_API_CACHE_TIMEOUT || '300000'), // 5 minutes
|
||||
};
|
||||
};
|
||||
|
||||
export const apiConfig: ApiConfig = getEnvironmentConfig();
|
||||
|
||||
// Service endpoint configuration
|
||||
export const serviceEndpoints: ServiceEndpoints = {
|
||||
auth: '/auth',
|
||||
tenant: '/tenants',
|
||||
data: '/tenants', // Data operations are tenant-scoped
|
||||
training: '/tenants', // Training operations are tenant-scoped
|
||||
forecasting: '/tenants', // Forecasting operations are tenant-scoped
|
||||
notification: '/tenants', // Notification operations are tenant-scoped
|
||||
};
|
||||
|
||||
// HTTP status codes
|
||||
export const HttpStatus = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
UNPROCESSABLE_ENTITY: 422,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
BAD_GATEWAY: 502,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
} as const;
|
||||
|
||||
// Request timeout configuration
|
||||
export const RequestTimeouts = {
|
||||
SHORT: 5000, // 5 seconds - for quick operations
|
||||
MEDIUM: 15000, // 15 seconds - for normal operations
|
||||
LONG: 60000, // 1 minute - for file uploads
|
||||
EXTENDED: 300000, // 5 minutes - for training operations
|
||||
} as const;
|
||||
|
||||
// Cache configuration
|
||||
export interface CacheConfig {
|
||||
defaultTTL: number;
|
||||
maxSize: number;
|
||||
strategies: {
|
||||
user: number;
|
||||
tenant: number;
|
||||
data: number;
|
||||
forecast: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const cacheConfig: CacheConfig = {
|
||||
defaultTTL: 300000, // 5 minutes
|
||||
maxSize: 100, // Maximum cached items
|
||||
strategies: {
|
||||
user: 600000, // 10 minutes
|
||||
tenant: 1800000, // 30 minutes
|
||||
data: 300000, // 5 minutes
|
||||
forecast: 600000, // 10 minutes
|
||||
},
|
||||
};
|
||||
|
||||
// Retry configuration
|
||||
export interface RetryConfig {
|
||||
attempts: number;
|
||||
delay: number;
|
||||
backoff: number;
|
||||
retryCondition: (error: any) => boolean;
|
||||
}
|
||||
|
||||
export const retryConfig: RetryConfig = {
|
||||
attempts: 3,
|
||||
delay: 1000,
|
||||
backoff: 2, // Exponential backoff multiplier
|
||||
retryCondition: (error: any) => {
|
||||
// Retry on network errors and specific HTTP status codes
|
||||
if (!error.response) return true; // Network error
|
||||
const status = error.response.status;
|
||||
return status >= 500 || status === 408 || status === 429;
|
||||
},
|
||||
};
|
||||
|
||||
// API versioning
|
||||
export const ApiVersion = {
|
||||
V1: 'v1',
|
||||
CURRENT: 'v1',
|
||||
} as const;
|
||||
|
||||
export interface FeatureFlags {
|
||||
enableWebSockets: boolean;
|
||||
enableOfflineMode: boolean;
|
||||
enableOptimisticUpdates: boolean;
|
||||
enableRequestDeduplication: boolean;
|
||||
enableMetrics: boolean;
|
||||
}
|
||||
|
||||
export const featureFlags: FeatureFlags = {
|
||||
enableWebSockets: import.meta.env.VITE_ENABLE_WEBSOCKETS === 'true',
|
||||
enableOfflineMode: import.meta.env.VITE_ENABLE_OFFLINE === 'true',
|
||||
enableOptimisticUpdates: import.meta.env.VITE_ENABLE_OPTIMISTIC_UPDATES !== 'false',
|
||||
enableRequestDeduplication: import.meta.env.VITE_ENABLE_DEDUPLICATION !== 'false',
|
||||
enableMetrics: import.meta.env.VITE_ENABLE_METRICS === 'true',
|
||||
};
|
||||
@@ -1,578 +0,0 @@
|
||||
// frontend/src/api/client/index.ts
|
||||
/**
|
||||
* Enhanced API Client with modern features
|
||||
* Supports caching, retries, optimistic updates, and more
|
||||
*/
|
||||
|
||||
import {
|
||||
ApiResponse,
|
||||
ApiError,
|
||||
RequestConfig,
|
||||
UploadConfig,
|
||||
UploadProgress,
|
||||
RequestInterceptor,
|
||||
ResponseInterceptor,
|
||||
CacheEntry,
|
||||
RequestMetrics,
|
||||
} from './types';
|
||||
import { apiConfig, retryConfig, cacheConfig, featureFlags } from './config';
|
||||
|
||||
export class ApiClient {
|
||||
private baseURL: string;
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private pendingRequests = new Map<string, Promise<any>>();
|
||||
private requestInterceptors: RequestInterceptor[] = [];
|
||||
private responseInterceptors: ResponseInterceptor[] = [];
|
||||
private metrics: RequestMetrics[] = [];
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
this.baseURL = baseURL || apiConfig.baseURL;
|
||||
// ✅ CRITICAL FIX: Remove trailing slash
|
||||
this.baseURL = this.baseURL.replace(/\/+$/, '');
|
||||
console.log('🔧 API Client initialized with baseURL:', this.baseURL);
|
||||
}
|
||||
|
||||
private buildURL(endpoint: string): string {
|
||||
// Remove leading slash from endpoint if present to avoid double slashes
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
const fullURL = `${this.baseURL}${cleanEndpoint}`;
|
||||
|
||||
// ✅ DEBUG: Log URL construction
|
||||
console.log('🔗 Building URL:', {
|
||||
baseURL: this.baseURL,
|
||||
endpoint: cleanEndpoint,
|
||||
fullURL: fullURL
|
||||
});
|
||||
|
||||
return fullURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add request interceptor
|
||||
*/
|
||||
addRequestInterceptor(interceptor: RequestInterceptor): void {
|
||||
this.requestInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add response interceptor
|
||||
*/
|
||||
addResponseInterceptor(interceptor: ResponseInterceptor): void {
|
||||
this.responseInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for request
|
||||
*/
|
||||
private getCacheKey(url: string, config?: RequestConfig): string {
|
||||
const method = config?.method || 'GET';
|
||||
const params = config?.params ? JSON.stringify(config.params) : '';
|
||||
return `${method}:${url}:${params}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response is cached and valid
|
||||
*/
|
||||
private getCachedResponse<T>(key: string): T | null {
|
||||
if (!featureFlags.enableRequestDeduplication && !apiConfig.enableCaching) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = this.cache.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - cached.timestamp > cached.ttl) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache response data
|
||||
*/
|
||||
private setCachedResponse<T>(key: string, data: T, ttl?: number): void {
|
||||
if (!apiConfig.enableCaching) return;
|
||||
|
||||
const cacheTTL = ttl || cacheConfig.defaultTTL;
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
ttl: cacheTTL,
|
||||
key,
|
||||
});
|
||||
|
||||
// Cleanup old cache entries if cache is full
|
||||
if (this.cache.size > cacheConfig.maxSize) {
|
||||
const oldestKey = this.cache.keys().next().value;
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply request interceptors
|
||||
*/
|
||||
private async applyRequestInterceptors(config: RequestConfig): Promise<RequestConfig> {
|
||||
let modifiedConfig = { ...config };
|
||||
|
||||
for (const interceptor of this.requestInterceptors) {
|
||||
if (interceptor.onRequest) {
|
||||
try {
|
||||
modifiedConfig = await interceptor.onRequest(modifiedConfig);
|
||||
} catch (error) {
|
||||
if (interceptor.onRequestError) {
|
||||
await interceptor.onRequestError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply response interceptors
|
||||
*/
|
||||
private async applyResponseInterceptors<T>(response: ApiResponse<T>): Promise<ApiResponse<T>> {
|
||||
let modifiedResponse = { ...response };
|
||||
|
||||
for (const interceptor of this.responseInterceptors) {
|
||||
if (interceptor.onResponse) {
|
||||
try {
|
||||
modifiedResponse = await interceptor.onResponse(modifiedResponse);
|
||||
} catch (error) {
|
||||
if (interceptor.onResponseError) {
|
||||
await interceptor.onResponseError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed requests with exponential backoff
|
||||
*/
|
||||
private async retryRequest<T>(
|
||||
requestFn: () => Promise<T>,
|
||||
attempts: number = retryConfig.attempts
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await requestFn();
|
||||
} catch (error) {
|
||||
if (attempts <= 0 || !retryConfig.retryCondition(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = retryConfig.delay * Math.pow(retryConfig.backoff, retryConfig.attempts - attempts);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
return this.retryRequest(requestFn, attempts - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record request metrics
|
||||
*/
|
||||
private recordMetrics(metrics: Partial<RequestMetrics>): void {
|
||||
if (!featureFlags.enableMetrics) return;
|
||||
|
||||
const completeMetrics: RequestMetrics = {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
duration: 0,
|
||||
status: 0,
|
||||
size: 0,
|
||||
timestamp: Date.now(),
|
||||
cached: false,
|
||||
retries: 0,
|
||||
...metrics,
|
||||
};
|
||||
|
||||
this.metrics.push(completeMetrics);
|
||||
|
||||
// Keep only recent metrics (last 1000 requests)
|
||||
if (this.metrics.length > 1000) {
|
||||
this.metrics = this.metrics.slice(-1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core request method with all features
|
||||
*/
|
||||
async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
const url = this.buildURL(endpoint);
|
||||
const method = config.method || 'GET';
|
||||
|
||||
console.log('🚀 Making API request:', {
|
||||
method,
|
||||
endpoint,
|
||||
url,
|
||||
config
|
||||
});
|
||||
|
||||
// Apply request interceptors
|
||||
const modifiedConfig = await this.applyRequestInterceptors(config);
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = this.getCacheKey(endpoint, modifiedConfig);
|
||||
|
||||
// Check cache for GET requests
|
||||
if (method === 'GET' && (config.cache !== false)) {
|
||||
const cached = this.getCachedResponse<T>(cacheKey);
|
||||
if (cached) {
|
||||
this.recordMetrics({
|
||||
url: endpoint,
|
||||
method,
|
||||
duration: Date.now() - startTime,
|
||||
status: 200,
|
||||
cached: true,
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Request deduplication for concurrent requests
|
||||
if (featureFlags.enableRequestDeduplication && method === 'GET') {
|
||||
const pendingRequest = this.pendingRequests.get(cacheKey);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
}
|
||||
|
||||
// Create request promise
|
||||
const requestPromise = this.retryRequest(async () => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...modifiedConfig.headers,
|
||||
};
|
||||
|
||||
const fetchConfig: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
|
||||
};
|
||||
|
||||
// Add body for non-GET requests
|
||||
if (method !== 'GET' && modifiedConfig.body) {
|
||||
if (modifiedConfig.body instanceof FormData) {
|
||||
// Remove Content-Type for FormData (let browser set it with boundary)
|
||||
delete headers['Content-Type'];
|
||||
fetchConfig.body = modifiedConfig.body;
|
||||
} else {
|
||||
fetchConfig.body = typeof modifiedConfig.body === 'string'
|
||||
? modifiedConfig.body
|
||||
: JSON.stringify(modifiedConfig.body);
|
||||
}
|
||||
}
|
||||
|
||||
// Add query parameters
|
||||
const urlWithParams = new URL(url);
|
||||
if (modifiedConfig.params) {
|
||||
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
urlWithParams.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(urlWithParams.toString(), fetchConfig);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorData: ApiError;
|
||||
|
||||
try {
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch {
|
||||
errorData = {
|
||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||
detail: errorText,
|
||||
code: `HTTP_${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const error = new Error(errorData.message || 'Request failed');
|
||||
(error as any).response = { status: response.status, data: errorData };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('🔍 Raw responseData from fetch:', responseData);
|
||||
|
||||
// Apply response interceptors
|
||||
const processedResponse = await this.applyResponseInterceptors(responseData);
|
||||
console.log('🔍 processedResponse after interceptors:', processedResponse);
|
||||
|
||||
return processedResponse;
|
||||
});
|
||||
|
||||
// Store pending request for deduplication
|
||||
if (featureFlags.enableRequestDeduplication && method === 'GET') {
|
||||
this.pendingRequests.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestPromise;
|
||||
|
||||
// Cache successful GET responses
|
||||
if (method === 'GET' && config.cache !== false) {
|
||||
this.setCachedResponse(cacheKey, result, config.cacheTTL);
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
this.recordMetrics({
|
||||
url: endpoint,
|
||||
method,
|
||||
duration: Date.now() - startTime,
|
||||
status: 200,
|
||||
size: JSON.stringify(result).length,
|
||||
});
|
||||
|
||||
// Handle both wrapped and unwrapped responses
|
||||
// If result has a 'data' property, return it; otherwise return the result itself
|
||||
console.log('🔍 Final result before return:', result);
|
||||
console.log('🔍 Result has data property?', result && typeof result === 'object' && 'data' in result);
|
||||
|
||||
if (result && typeof result === 'object' && 'data' in result) {
|
||||
console.log('🔍 Returning result.data:', result.data);
|
||||
return result.data as T;
|
||||
}
|
||||
console.log('🔍 Returning raw result:', result);
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
// Record error metrics
|
||||
this.recordMetrics({
|
||||
url: endpoint,
|
||||
method,
|
||||
duration: Date.now() - startTime,
|
||||
status: (error as any).response?.status || 0,
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
// Clean up pending request
|
||||
if (featureFlags.enableRequestDeduplication && method === 'GET') {
|
||||
this.pendingRequests.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience methods for HTTP verbs
|
||||
*/
|
||||
async get<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...config, method: 'GET' });
|
||||
}
|
||||
|
||||
async post<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'POST',
|
||||
body: data
|
||||
});
|
||||
}
|
||||
|
||||
async put<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'PUT',
|
||||
body: data
|
||||
});
|
||||
}
|
||||
|
||||
async patch<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'PATCH',
|
||||
body: data
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw request that returns the Response object for binary data
|
||||
*/
|
||||
async getRaw(endpoint: string, config?: RequestConfig): Promise<Response> {
|
||||
const url = this.buildURL(endpoint);
|
||||
const modifiedConfig = await this.applyRequestInterceptors(config || {});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...modifiedConfig.headers,
|
||||
};
|
||||
|
||||
const fetchConfig: RequestInit = {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
|
||||
};
|
||||
|
||||
// Add query parameters
|
||||
const urlWithParams = new URL(url);
|
||||
if (modifiedConfig.params) {
|
||||
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
urlWithParams.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(urlWithParams.toString(), fetchConfig);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorData: ApiError;
|
||||
|
||||
try {
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch {
|
||||
errorData = {
|
||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||
detail: errorText,
|
||||
code: `HTTP_${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const error = new Error(errorData.message || 'Request failed');
|
||||
(error as any).response = { status: response.status, data: errorData };
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* File upload with progress tracking
|
||||
*/
|
||||
async upload<T = any>(
|
||||
endpoint: string,
|
||||
file: File,
|
||||
additionalData?: Record<string, any>,
|
||||
config?: UploadConfig
|
||||
): Promise<T> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (additionalData) {
|
||||
Object.entries(additionalData).forEach(([key, value]) => {
|
||||
formData.append(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
// For file uploads, we need to use XMLHttpRequest for progress tracking
|
||||
if (config?.onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable && config.onProgress) {
|
||||
const progress: UploadProgress = {
|
||||
loaded: event.loaded,
|
||||
total: event.total,
|
||||
percentage: Math.round((event.loaded / event.total) * 100),
|
||||
};
|
||||
config.onProgress(progress);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
resolve(result);
|
||||
} catch {
|
||||
resolve(xhr.responseText as any);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('Upload failed'));
|
||||
});
|
||||
|
||||
xhr.open('POST', `${this.baseURL}${endpoint}`);
|
||||
|
||||
// Add headers (excluding Content-Type for FormData)
|
||||
if (config?.headers) {
|
||||
Object.entries(config.headers).forEach(([key, value]) => {
|
||||
if (key.toLowerCase() !== 'content-type') {
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to regular request for uploads without progress
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
*/
|
||||
clearCache(pattern?: string): void {
|
||||
if (pattern) {
|
||||
// Clear cache entries matching pattern
|
||||
const regex = new RegExp(pattern);
|
||||
Array.from(this.cache.keys())
|
||||
.filter(key => regex.test(key))
|
||||
.forEach(key => this.cache.delete(key));
|
||||
} else {
|
||||
// Clear all cache
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client metrics
|
||||
*/
|
||||
getMetrics() {
|
||||
if (!featureFlags.enableMetrics) {
|
||||
return {
|
||||
totalRequests: 0,
|
||||
successfulRequests: 0,
|
||||
failedRequests: 0,
|
||||
averageResponseTime: 0,
|
||||
cacheHitRate: 0,
|
||||
errorRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const total = this.metrics.length;
|
||||
const successful = this.metrics.filter(m => m.status >= 200 && m.status < 300).length;
|
||||
const cached = this.metrics.filter(m => m.cached).length;
|
||||
const averageTime = total > 0
|
||||
? this.metrics.reduce((sum, m) => sum + m.duration, 0) / total
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalRequests: total,
|
||||
successfulRequests: successful,
|
||||
failedRequests: total - successful,
|
||||
averageResponseTime: Math.round(averageTime),
|
||||
cacheHitRate: total > 0 ? Math.round((cached / total) * 100) : 0,
|
||||
errorRate: total > 0 ? Math.round(((total - successful) / total) * 100) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default API client instance
|
||||
console.log('🔧 Creating default API client...');
|
||||
export const apiClient = new ApiClient();
|
||||
@@ -1,488 +0,0 @@
|
||||
// frontend/src/api/client/interceptors.ts
|
||||
/**
|
||||
* Request and Response Interceptors
|
||||
* Handles authentication, logging, error handling, etc.
|
||||
*/
|
||||
|
||||
import { apiClient } from './index';
|
||||
import type { RequestConfig, ApiResponse } from './types';
|
||||
import { ApiErrorHandler } from '../utils';
|
||||
|
||||
/**
|
||||
* Authentication Interceptor
|
||||
* Automatically adds authentication headers to requests
|
||||
*/
|
||||
class AuthInterceptor {
|
||||
static isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return payload.exp <= currentTime;
|
||||
} catch (error) {
|
||||
console.warn('Error parsing token:', error);
|
||||
return true; // Treat invalid tokens as expired
|
||||
}
|
||||
}
|
||||
|
||||
static isTokenExpiringSoon(token: string, bufferMinutes: number = 5): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const bufferSeconds = bufferMinutes * 60;
|
||||
return payload.exp <= (currentTime + bufferSeconds);
|
||||
} catch (error) {
|
||||
console.warn('Error parsing token for expiration check:', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static async refreshTokenIfNeeded(): Promise<void> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (!token || !refreshToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If token is expiring within 5 minutes, proactively refresh
|
||||
if (this.isTokenExpiringSoon(token, 5)) {
|
||||
try {
|
||||
const baseURL = (apiClient as any).baseURL || window.location.origin;
|
||||
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('auth_token', data.access_token);
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
}
|
||||
} else {
|
||||
console.warn('Token refresh failed:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Token refresh error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static setup() {
|
||||
apiClient.addRequestInterceptor({
|
||||
onRequest: async (config: RequestConfig) => {
|
||||
// Proactively refresh token if needed
|
||||
await this.refreshTokenIfNeeded();
|
||||
|
||||
let token = localStorage.getItem('auth_token');
|
||||
|
||||
if (token) {
|
||||
// Check if token is expired
|
||||
if (this.isTokenExpired(token)) {
|
||||
console.warn('Token expired, removing from storage');
|
||||
localStorage.removeItem('auth_token');
|
||||
token = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
} else {
|
||||
console.warn('No valid auth token found - authentication required');
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
onRequestError: async (error: any) => {
|
||||
console.error('Request interceptor error:', error);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging Interceptor
|
||||
* Logs API requests and responses for debugging
|
||||
*/
|
||||
class LoggingInterceptor {
|
||||
static setup() {
|
||||
apiClient.addRequestInterceptor({
|
||||
onRequest: async (config: RequestConfig) => {
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
console.group(`🚀 API Request [${requestId}]`);
|
||||
console.log('Method:', config.method);
|
||||
console.log('URL:', config.url);
|
||||
console.log('Headers:', config.headers);
|
||||
if (config.body && config.method !== 'GET') {
|
||||
console.log('Body:', config.body);
|
||||
}
|
||||
if (config.params) {
|
||||
console.log('Params:', config.params);
|
||||
}
|
||||
console.groupEnd();
|
||||
|
||||
// Add request ID to config for response correlation
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'X-Request-ID': requestId,
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.addResponseInterceptor({
|
||||
onResponse: async <T>(response: ApiResponse<T>) => {
|
||||
const requestId = response.meta?.requestId || 'unknown';
|
||||
|
||||
console.group(`✅ API Response [${requestId}]`);
|
||||
console.log('Status:', response.status);
|
||||
console.log('Data:', response.data);
|
||||
if (response.message) {
|
||||
console.log('Message:', response.message);
|
||||
}
|
||||
console.groupEnd();
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
onResponseError: async (error: any) => {
|
||||
const requestId = error?.config?.headers?.[`X-Request-ID`] || 'unknown';
|
||||
|
||||
console.group(`❌ API Error [${requestId}]`);
|
||||
console.error('Status:', error?.response?.status);
|
||||
console.error('Error:', ApiErrorHandler.formatError(error));
|
||||
console.error('Full Error:', error);
|
||||
console.groupEnd();
|
||||
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant Context Interceptor
|
||||
* Automatically adds tenant context to tenant-scoped requests
|
||||
*/
|
||||
class TenantInterceptor {
|
||||
private static currentTenantId: string | null = null;
|
||||
|
||||
static setCurrentTenant(tenantId: string | null) {
|
||||
this.currentTenantId = tenantId;
|
||||
}
|
||||
|
||||
static getCurrentTenant(): string | null {
|
||||
return this.currentTenantId;
|
||||
}
|
||||
|
||||
static setup() {
|
||||
apiClient.addRequestInterceptor({
|
||||
onRequest: async (config: RequestConfig) => {
|
||||
// Add tenant context to tenant-scoped endpoints
|
||||
if (this.currentTenantId && this.isTenantScopedEndpoint(config.url)) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'X-Tenant-ID': this.currentTenantId,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static isTenantScopedEndpoint(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
return url.includes('/tenants/') ||
|
||||
url.includes('/training/') ||
|
||||
url.includes('/forecasts/') ||
|
||||
url.includes('/notifications/');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Recovery Interceptor
|
||||
* Handles automatic token refresh and retry logic
|
||||
*/
|
||||
class ErrorRecoveryInterceptor {
|
||||
private static isRefreshing = false;
|
||||
private static failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (error: any) => void;
|
||||
}> = [];
|
||||
|
||||
static setup() {
|
||||
apiClient.addResponseInterceptor({
|
||||
onResponseError: async (error: any) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle 401 errors with token refresh
|
||||
if (error?.response?.status === 401 && !originalRequest._retry) {
|
||||
if (this.isRefreshing) {
|
||||
// Queue the request while refresh is in progress
|
||||
return new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({ resolve, reject });
|
||||
}).then(token => {
|
||||
return this.retryRequestWithNewToken(originalRequest, token as string);
|
||||
}).catch(err => {
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
// Use direct fetch to avoid interceptor recursion
|
||||
const baseURL = (apiClient as any).baseURL || window.location.origin;
|
||||
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newToken = data.access_token;
|
||||
|
||||
if (!newToken) {
|
||||
throw new Error('No access token received');
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_token', newToken);
|
||||
|
||||
// Update new refresh token if provided
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
}
|
||||
|
||||
// Process failed queue
|
||||
this.processQueue(null, newToken);
|
||||
|
||||
// Retry original request with new token
|
||||
return this.retryRequestWithNewToken(originalRequest, newToken);
|
||||
|
||||
} catch (refreshError) {
|
||||
console.warn('Token refresh failed:', refreshError);
|
||||
this.processQueue(refreshError, null);
|
||||
|
||||
// Clear auth data and redirect to login
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_data');
|
||||
|
||||
// Only redirect if we're not already on the login page
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
throw refreshError;
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static async retryRequestWithNewToken(originalRequest: any, token: string) {
|
||||
try {
|
||||
// Use direct fetch instead of apiClient to avoid interceptor recursion
|
||||
const url = originalRequest.url || originalRequest.endpoint;
|
||||
const method = originalRequest.method || 'GET';
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...originalRequest.headers
|
||||
}
|
||||
};
|
||||
|
||||
// Add body for non-GET requests
|
||||
if (method !== 'GET' && originalRequest.body) {
|
||||
fetchOptions.body = typeof originalRequest.body === 'string'
|
||||
? originalRequest.body
|
||||
: JSON.stringify(originalRequest.body);
|
||||
}
|
||||
|
||||
// Add query parameters if present
|
||||
let fullUrl = url;
|
||||
if (originalRequest.params) {
|
||||
const urlWithParams = new URL(fullUrl, (apiClient as any).baseURL);
|
||||
Object.entries(originalRequest.params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
urlWithParams.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
fullUrl = urlWithParams.toString();
|
||||
}
|
||||
|
||||
// Retry request with refreshed token
|
||||
|
||||
const response = await fetch(fullUrl, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (retryError) {
|
||||
console.warn('Request retry failed:', retryError);
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
|
||||
private static processQueue(error: any, token: string | null) {
|
||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(token!);
|
||||
}
|
||||
});
|
||||
|
||||
this.failedQueue = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance Monitoring Interceptor
|
||||
* Tracks API performance metrics
|
||||
*/
|
||||
class PerformanceInterceptor {
|
||||
private static metrics: Array<{
|
||||
url: string;
|
||||
method: string;
|
||||
duration: number;
|
||||
status: number;
|
||||
timestamp: number;
|
||||
}> = [];
|
||||
|
||||
static setup() {
|
||||
apiClient.addRequestInterceptor({
|
||||
onRequest: async (config: RequestConfig) => {
|
||||
config.metadata = {
|
||||
...config.metadata,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.addResponseInterceptor({
|
||||
onResponse: async <T>(response: ApiResponse<T>) => {
|
||||
const startTime = response.metadata?.startTime;
|
||||
if (startTime) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.recordMetric({
|
||||
url: response.metadata?.url || 'unknown',
|
||||
method: response.metadata?.method || 'unknown',
|
||||
duration,
|
||||
status: 200,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
onResponseError: async (error: any) => {
|
||||
const startTime = error.config?.metadata?.startTime;
|
||||
if (startTime) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.recordMetric({
|
||||
url: error.config?.url || 'unknown',
|
||||
method: error.config?.method || 'unknown',
|
||||
duration,
|
||||
status: error?.response?.status || 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static recordMetric(metric: any) {
|
||||
this.metrics.push(metric);
|
||||
|
||||
// Keep only last 1000 metrics
|
||||
if (this.metrics.length > 1000) {
|
||||
this.metrics = this.metrics.slice(-1000);
|
||||
}
|
||||
}
|
||||
|
||||
static getMetrics() {
|
||||
return [...this.metrics];
|
||||
}
|
||||
|
||||
static getAverageResponseTime(): number {
|
||||
if (this.metrics.length === 0) return 0;
|
||||
|
||||
const total = this.metrics.reduce((sum, metric) => sum + metric.duration, 0);
|
||||
return Math.round(total / this.metrics.length);
|
||||
}
|
||||
|
||||
static getErrorRate(): number {
|
||||
if (this.metrics.length === 0) return 0;
|
||||
|
||||
const errorCount = this.metrics.filter(metric => metric.status >= 400).length;
|
||||
return Math.round((errorCount / this.metrics.length) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup all interceptors
|
||||
* IMPORTANT: Order matters! ErrorRecoveryInterceptor must be first to handle token refresh
|
||||
*/
|
||||
export const setupInterceptors = () => {
|
||||
// 1. Error recovery first (handles 401 and token refresh)
|
||||
ErrorRecoveryInterceptor.setup();
|
||||
|
||||
// 2. Authentication (adds Bearer tokens)
|
||||
AuthInterceptor.setup();
|
||||
|
||||
// 3. Tenant context
|
||||
TenantInterceptor.setup();
|
||||
|
||||
// 4. Development-only interceptors
|
||||
const isDevelopment = true; // Temporarily set to true for development
|
||||
if (isDevelopment) {
|
||||
LoggingInterceptor.setup();
|
||||
PerformanceInterceptor.setup();
|
||||
}
|
||||
};
|
||||
|
||||
// Export interceptor classes for manual setup if needed
|
||||
export {
|
||||
AuthInterceptor,
|
||||
LoggingInterceptor,
|
||||
TenantInterceptor,
|
||||
ErrorRecoveryInterceptor,
|
||||
PerformanceInterceptor,
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
// frontend/src/api/client/types.ts
|
||||
/**
|
||||
* Core API Client Types
|
||||
*/
|
||||
|
||||
export interface RequestConfig {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
headers?: Record<string, string>;
|
||||
params?: Record<string, any>;
|
||||
body?: any;
|
||||
url?: string;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
cache?: boolean;
|
||||
cacheTTL?: number;
|
||||
optimistic?: boolean;
|
||||
background?: boolean;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data: T;
|
||||
message?: string;
|
||||
status: string;
|
||||
timestamp?: string;
|
||||
metadata?: any;
|
||||
meta?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
total?: number;
|
||||
hasNext?: boolean;
|
||||
hasPrev?: boolean;
|
||||
requestId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
detail?: string;
|
||||
code?: string;
|
||||
field?: string;
|
||||
timestamp?: string;
|
||||
service?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
loaded: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface UploadConfig extends RequestConfig {
|
||||
onProgress?: (progress: UploadProgress) => void;
|
||||
maxFileSize?: number;
|
||||
allowedTypes?: string[];
|
||||
}
|
||||
|
||||
// Request/Response interceptor types
|
||||
export interface RequestInterceptor {
|
||||
onRequest?: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
|
||||
onRequestError?: (error: any) => any;
|
||||
}
|
||||
|
||||
export interface ResponseInterceptor {
|
||||
onResponse?: <T>(response: ApiResponse<T>) => ApiResponse<T> | Promise<ApiResponse<T>>;
|
||||
onResponseError?: (error: any) => any;
|
||||
}
|
||||
|
||||
// Cache types
|
||||
export interface CacheEntry<T = any> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface CacheStrategy {
|
||||
key: (url: string, params?: any) => string;
|
||||
ttl: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Metrics types
|
||||
export interface RequestMetrics {
|
||||
url: string;
|
||||
method: string;
|
||||
duration: number;
|
||||
status: number;
|
||||
size: number;
|
||||
timestamp: number;
|
||||
cached: boolean;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
export interface ClientMetrics {
|
||||
totalRequests: number;
|
||||
successfulRequests: number;
|
||||
failedRequests: number;
|
||||
averageResponseTime: number;
|
||||
cacheHitRate: number;
|
||||
errorRate: number;
|
||||
}
|
||||
Reference in New Issue
Block a user