Files
Hotel-Booking/Frontend/src/services/api/apiClient.ts
Iliyan Angelov 312f85530c updates
2025-11-28 02:40:05 +02:00

399 lines
12 KiB
TypeScript

import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import useAuthStore from '../../store/useAuthStore';
const rawBase = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const normalized = String(rawBase).replace(/\/$/, '');
const API_BASE_URL = /\/api(\/?$)/i.test(normalized)
? normalized
: normalized + '/api';
// Validate HTTPS in production
if (import.meta.env.MODE === 'production' && !normalized.startsWith('https://')) {
console.error(
'⚠️ SECURITY WARNING: API URL is not using HTTPS in production!',
'This exposes sensitive data to interception.',
'Please configure VITE_API_URL to use HTTPS.'
);
// Only show warning, don't block - some deployments may use reverse proxy
// But log the security concern
if (typeof window !== 'undefined') {
console.warn('API calls will be made over HTTP. Consider using HTTPS.');
}
}
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
// Note: 503 is excluded because it's used for "service unavailable" (like disabled features)
// and should not be retried - it's an intentional state, not a transient error
const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 504];
// Helper function to get CSRF token from cookies
const getCsrfToken = (): string | null => {
if (typeof document === 'undefined') {
return null;
}
// CSRF token cookie name (matches backend XSRF-TOKEN)
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return decodeURIComponent(value);
}
}
return null;
};
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 30000,
withCredentials: true,
});
// Map to store AbortControllers for cancelable requests
const cancelTokenMap = new Map<string, AbortController>();
const retryRequest = async (
error: AxiosError,
retryCount: number = 0
): Promise<any> => {
const config = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (
config._retry ||
retryCount >= MAX_RETRIES ||
!error.config ||
!error.response ||
!RETRYABLE_STATUS_CODES.includes(error.response.status)
) {
return Promise.reject(error);
}
config._retry = true;
const delay = RETRY_DELAY * Math.pow(2, retryCount);
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retrying request (${retryCount + 1}/${MAX_RETRIES}): ${config.url}`);
return apiClient.request(config);
};
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (config.url && typeof config.url === 'string') {
if (config.url.startsWith('/api/')) {
config.url = config.url.replace(/^\/api/, '');
}
config.url = config.url.replace(/\/\/+/, '/');
}
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
}
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Add CSRF token for state-changing requests (POST, PUT, DELETE, PATCH)
// Skip for GET, HEAD, OPTIONS (safe methods)
const method = (config.method || 'GET').toUpperCase();
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
// Skip CSRF token for auth endpoints (they're exempt)
const path = config.url || '';
const isAuthEndpoint = path.startsWith('/auth/') || path.startsWith('/api/auth/');
if (!isAuthEndpoint) {
const csrfToken = getCsrfToken();
if (csrfToken) {
config.headers['X-XSRF-TOKEN'] = csrfToken;
} else {
// CSRF token not found - this will be handled in error interceptor
// The backend will return 403 and we'll retry after fetching the token
if (import.meta.env.DEV) {
console.warn('CSRF token not found in cookies. Request may fail and retry after fetching token.');
}
}
}
}
const requestId = crypto.randomUUID ? crypto.randomUUID() :
`${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
config.headers['X-Request-ID'] = requestId;
// Create AbortController for request cancellation
// Skip if signal is already provided
if (!config.signal) {
const abortController = new AbortController();
config.signal = abortController.signal;
// Store controller for potential cancellation
const cancelKey = `${config.method || 'GET'}_${config.url}_${requestId}`;
cancelTokenMap.set(cancelKey, abortController);
// Clean up after request completes (set timeout to avoid memory leaks)
setTimeout(() => {
cancelTokenMap.delete(cancelKey);
}, 60000); // Clean up after 60 seconds
}
(config as any).metadata = { startTime: new Date(), requestId };
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
apiClient.interceptors.response.use(
(response) => {
const config = response.config as InternalAxiosRequestConfig & { metadata?: any };
// Clean up abort controller on successful response
if (config.metadata?.requestId && config.url) {
const cancelKey = `${config.method || 'GET'}_${config.url}_${config.metadata.requestId}`;
cancelTokenMap.delete(cancelKey);
}
if (config.metadata?.startTime) {
const duration = new Date().getTime() - config.metadata.startTime.getTime();
if (duration > 1000) {
console.warn(`Slow request detected: ${config.url} took ${duration}ms`);
}
}
const requestId = response.headers['x-request-id'];
if (requestId && import.meta.env.DEV) {
console.debug(`Request completed: ${config.url} [${requestId}]`);
}
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Clean up abort controller on error
if (originalRequest && (originalRequest as any).metadata?.requestId && originalRequest.url) {
const cancelKey = `${originalRequest.method || 'GET'}_${originalRequest.url}_${(originalRequest as any).metadata.requestId}`;
cancelTokenMap.delete(cancelKey);
}
// Don't process cancellation errors
if (error.code === 'ERR_CANCELED' || error.message === 'canceled') {
return Promise.reject({
...error,
message: 'Request was cancelled',
isCancelled: true,
});
}
if (!error.response) {
if (error.code === 'ECONNABORTED') {
return Promise.reject({
...error,
message: 'Request timeout. Please check your connection and try again.',
});
}
if (originalRequest && !originalRequest._retry) {
return retryRequest(error);
}
console.error('Network error:', error);
return Promise.reject({
...error,
message: 'Network error. Please check your internet connection.',
});
}
const status = error.response.status;
const requestId = error.response.headers['x-request-id'];
if (status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
// Update auth store state
useAuthStore.getState().logout().catch(() => {
// Ignore logout errors, just clear the state
useAuthStore.setState({
token: null,
userInfo: null,
isAuthenticated: false,
isLoading: false,
});
});
// Dispatch custom event to trigger login modal
window.dispatchEvent(new CustomEvent('auth:logout', {
detail: { message: 'Session expired. Please login again.' }
}));
const errorMessage = (error.response?.data as any)?.message || 'Session expired. Please login again.';
return Promise.reject({
...error,
message: errorMessage,
});
}
if (status === 403) {
const errorMessage = (error.response?.data as any)?.message || 'You do not have permission to access this resource.';
// Handle CSRF token missing/invalid errors - retry after getting token from error response
if (errorMessage.includes('CSRF token') && originalRequest && !originalRequest._retry) {
// The backend sets the CSRF cookie in the error response, so wait a moment for browser to process it
await new Promise(resolve => setTimeout(resolve, 200));
// Check if we now have the CSRF token
const csrfToken = getCsrfToken();
if (csrfToken && originalRequest.headers) {
// Retry the original request with the CSRF token
originalRequest._retry = true;
originalRequest.headers['X-XSRF-TOKEN'] = csrfToken;
return apiClient.request(originalRequest);
} else {
// If still no token, try fetching it via GET request
try {
await apiClient.get('/health', {
headers: { ...originalRequest.headers },
timeout: 5000
});
await new Promise(resolve => setTimeout(resolve, 100));
const newToken = getCsrfToken();
if (newToken && originalRequest.headers) {
originalRequest._retry = true;
originalRequest.headers['X-XSRF-TOKEN'] = newToken;
return apiClient.request(originalRequest);
}
} catch (e) {
// Ignore errors from token fetch attempts
if (import.meta.env.DEV) {
console.warn('Failed to fetch CSRF token:', e);
}
}
}
}
return Promise.reject({
...error,
message: errorMessage,
});
}
if (status === 404) {
const errorMessage = (error.response?.data as any)?.message || 'Resource not found.';
return Promise.reject({
...error,
message: errorMessage,
});
}
if (status === 429) {
const retryAfter = error.response.headers['retry-after'];
return Promise.reject({
...error,
message: `Too many requests. ${retryAfter ? `Please try again after ${retryAfter} seconds.` : 'Please try again later.'}`,
retryAfter: retryAfter ? parseInt(retryAfter) : undefined,
});
}
// Handle 503 (Service Unavailable) separately - often used for disabled features
// Don't retry these as they're intentional states, not transient errors
if (status === 503) {
const errorData = error.response.data as any;
const errorMessage = errorData?.detail || errorData?.message || 'Service temporarily unavailable';
return Promise.reject({
...error,
message: errorMessage,
requestId,
});
}
if (status >= 500 && status < 600) {
if (originalRequest && !originalRequest._retry) {
return retryRequest(error);
}
console.error(`Server error [${requestId || 'unknown'}]:`, error);
const errorMessage = (error.response?.data as any)?.message || 'Server error. Please try again later.';
return Promise.reject({
...error,
message: errorMessage,
requestId,
});
}
if (status === 400) {
const errorData = error.response.data as any;
return Promise.reject({
...error,
message: errorData?.message || errorData?.errors?.[0]?.message || 'Invalid request. Please check your input.',
errors: errorData?.errors || [],
});
}
const errorMessage = (error.response.data as any)?.message || error.message || 'An error occurred';
return Promise.reject({
...error,
message: errorMessage,
requestId,
});
}
);
/**
* Cancel all pending requests (useful for cleanup on unmount)
*/
export const cancelAllPendingRequests = (): void => {
cancelTokenMap.forEach((controller) => {
controller.abort();
});
cancelTokenMap.clear();
};
/**
* Cancel requests matching a pattern (useful for canceling specific endpoint requests)
*/
export const cancelRequestsByPattern = (pattern: string | RegExp): void => {
const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
const keysToCancel: string[] = [];
cancelTokenMap.forEach((controller, key) => {
if (regex.test(key)) {
controller.abort();
keysToCancel.push(key);
}
});
keysToCancel.forEach((key) => cancelTokenMap.delete(key));
};
export default apiClient;