veza/apps/web/src/services/api/client.ts

814 lines
28 KiB
TypeScript
Raw Normal View History

import axios, { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
import toast from 'react-hot-toast';
import { TokenStorage } from '../tokenStorage';
import { refreshToken } from '../tokenRefresh';
import { env } from '@/config/env';
import { parseApiError } from '@/utils/apiErrorHandler';
import { csrfService } from '../csrf';
import { logger } from '@/utils/logger';
import { isTimeoutError, getTimeoutMessage } from '@/utils/timeoutHandler';
import { offlineQueue } from '../offlineQueue';
import { requestDeduplication } from '../requestDeduplication';
import { responseCache } from '../responseCache';
import { invalidateStateAfterMutation } from '@/utils/stateInvalidation';
import type { ApiResponse } from '@/types/api';
/**
* Client API avec interceptors pour refresh automatique des tokens,
* unwrapping du format backend { success, data, error },
* et retry automatique avec exponential backoff
* Aligné avec FRONTEND_INTEGRATION.md
*/
// Client API réutilisable
export const apiClient = axios.create({
baseURL: env.API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Flag pour éviter les refresh en boucle
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: any) => void;
reject: (error?: any) => void;
}> = [];
/**
* Sleep utility function
*/
const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
/**
* Retry configuration
*/
interface RetryConfig {
maxRetries: number;
baseDelay: number;
maxDelay: number;
retryableStatusCodes: number[];
retryableNetworkErrors: string[];
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelay: 1000, // 1 second
maxDelay: 10000, // 10 seconds
retryableStatusCodes: [429, 500, 502, 503, 504], // Rate limit, server errors, gateway errors
retryableNetworkErrors: [
'ECONNABORTED', // Timeout
'ETIMEDOUT', // Timeout
'ENOTFOUND', // DNS error
'ECONNREFUSED', // Connection refused
'ECONNRESET', // Connection reset
'EAI_AGAIN', // DNS lookup failed
'Network Error', // Generic network error
],
};
/**
* Check if a request method is idempotent (safe to retry)
*/
const isIdempotentMethod = (method?: string): boolean => {
const idempotentMethods = ['GET', 'HEAD', 'OPTIONS'];
return method ? idempotentMethods.includes(method.toUpperCase()) : false;
};
/**
* Check if an error is retryable
*/
const isRetryableError = (error: AxiosError, config: RetryConfig = DEFAULT_RETRY_CONFIG): boolean => {
// Don't retry if request was cancelled
if (axios.isCancel(error)) {
return false;
}
// Check if retry is disabled for this request
if ((error.config as any)?._disableRetry) {
return false;
}
// Check status code
if (error.response?.status) {
return config.retryableStatusCodes.includes(error.response.status);
}
// Check network errors
if (error.code) {
return config.retryableNetworkErrors.includes(error.code);
}
// Check error message for network-related errors
if (error.message) {
const message = error.message.toLowerCase();
const networkErrorPatterns = ['network', 'timeout', 'connection', 'econn', 'etimedout', 'enotfound'];
return networkErrorPatterns.some((pattern) => message.includes(pattern));
}
// For errors without response (network errors), retry if it's an idempotent method
if (!error.response && error.request) {
return isIdempotentMethod(error.config?.method);
}
return false;
};
/**
* Get retry delay from Retry-After header or use exponential backoff with jitter
*/
const getRetryDelay = (
error: AxiosError,
attempt: number,
baseDelay: number = DEFAULT_RETRY_CONFIG.baseDelay,
maxDelay: number = DEFAULT_RETRY_CONFIG.maxDelay,
): number => {
// Check for Retry-After header (case-insensitive)
const retryAfterHeader =
error.response?.headers['retry-after'] ||
error.response?.headers['Retry-After'];
if (retryAfterHeader) {
const delay = parseInt(String(retryAfterHeader), 10);
if (!isNaN(delay) && delay > 0) {
return Math.min(delay * 1000, maxDelay); // Convert to milliseconds, cap at maxDelay
}
}
// Exponential backoff with jitter: baseDelay * 2^attempt + random(0, baseDelay)
const exponentialDelay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * baseDelay; // Add jitter to avoid thundering herd
return Math.min(exponentialDelay + jitter, maxDelay);
};
/**
* Sanitize sensitive data from request/response for logging
*/
const sanitizeForLogging = (data: any): any => {
if (!data || typeof data !== 'object') {
return data;
}
const sensitiveKeys = ['password', 'token', 'access_token', 'refresh_token', 'secret', 'authorization', 'x-csrf-token'];
const sanitized = Array.isArray(data) ? [...data] : { ...data };
for (const key in sanitized) {
const lowerKey = key.toLowerCase();
if (sensitiveKeys.some((sk) => lowerKey.includes(sk))) {
sanitized[key] = '[REDACTED]';
} else if (typeof sanitized[key] === 'object' && sanitized[key] !== null) {
sanitized[key] = sanitizeForLogging(sanitized[key]);
}
}
return sanitized;
};
/**
* Get request ID from headers or generate one
*/
const getRequestId = (config: InternalAxiosRequestConfig): string => {
// Try to get request_id from headers (if set by caller)
const requestId = (config.headers as any)?.['X-Request-ID'] ||
(config.headers as any)?.['x-request-id'] ||
`req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Store in config for later use
(config as any)._requestId = requestId;
return requestId;
};
// T0177: Fonction pour traiter la queue de requêtes en attente
const processQueue = (error: Error | null, token: string | null = null) => {
2025-12-13 02:34:34 +00:00
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// T0177: Interceptor de requête pour ajouter le token d'accès
// CRITIQUE: Récupérer TOUJOURS le token frais depuis localStorage car Zustand peut ne pas être hydraté
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = TokenStorage.getAccessToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token} `;
}
// Pour FormData, laisser Axios gérer automatiquement le Content-Type avec boundary
// Ne pas forcer application/json si c'est un FormData
if (config.data instanceof FormData && config.headers) {
// Supprimer Content-Type pour que Axios calcule automatiquement multipart/form-data avec boundary
delete config.headers['Content-Type'];
}
// Ajouter le token CSRF pour les méthodes qui modifient l'état
const method = config.method?.toUpperCase();
const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method || '');
const isCSRFRoute = config.url?.includes('/csrf-token');
if (isStateChanging && !isCSRFRoute && config.headers) {
const csrfToken = csrfService.getToken();
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
}
}
// Support AbortController: si un signal est fourni dans la config, l'utiliser
// Sinon, créer un nouveau AbortController si nécessaire
if (!config.signal && !config.cancelToken) {
// Si aucune annulation n'est configurée, on peut créer un AbortController optionnel
// Mais on ne le fait pas automatiquement pour éviter de créer des signaux inutiles
// Les utilisateurs peuvent passer un signal via config.signal
}
// Store request start time for duration calculation
(config as any)._requestStartTime = Date.now();
// Log request (only in development or if explicitly enabled)
if (import.meta.env.DEV || (config as any)?._enableLogging) {
const requestId = getRequestId(config);
const sanitizedHeaders = sanitizeForLogging({ ...config.headers });
const sanitizedData = sanitizeForLogging(config.data);
logger.debug(`[API Request] ${method || 'GET'} ${config.url}`, {
request_id: requestId,
method: method || 'GET',
url: config.url,
baseURL: config.baseURL,
headers: sanitizedHeaders,
params: config.params,
data: sanitizedData,
timeout: config.timeout,
signal: config.signal ? 'AbortController' : undefined,
});
}
return config;
},
(error) => {
// Log request error
if (import.meta.env.DEV) {
logger.error('[API Request Error]', {
error: error.message,
config: error.config ? {
url: error.config.url,
method: error.config.method,
} : undefined,
});
}
return Promise.reject(error);
2025-12-13 02:34:34 +00:00
},
);
// Interceptor de réponse pour unwrap le format backend et gérer les erreurs
apiClient.interceptors.response.use(
(response: AxiosResponse<ApiResponse<any> | any>) => {
// Log successful response (only in development or if explicitly enabled)
const requestId = (response.config as any)?._requestId;
const shouldLog = import.meta.env.DEV || (response.config as any)?._enableLogging;
if (shouldLog) {
const sanitizedData = sanitizeForLogging(response.data);
const sanitizedHeaders = sanitizeForLogging(response.headers);
logger.debug(`[API Response] ${response.config.method?.toUpperCase() || 'GET'} ${response.config.url} ${response.status}`, {
request_id: requestId,
status: response.status,
statusText: response.statusText,
headers: sanitizedHeaders,
data: sanitizedData,
duration: (response.config as any)?._requestStartTime
? Date.now() - (response.config as any)._requestStartTime
: undefined,
});
}
// Backend peut retourner plusieurs formats :
// 1. Format standard avec wrapper: { success: true, data: {...} }
// 2. Format direct JSON: { tracks: [...], pagination: {...} } (ex: SearchTracks, ListTracks)
// 3. Format avec message: { success: true, data: {...}, message: "..." }
if (!response.data || typeof response.data !== 'object') {
// Si response.data n'est pas un objet, retourner tel quel
return response;
}
// FE-COMP-005: Show success toast for mutation operations if enabled
const method = response.config.method?.toUpperCase();
const isMutation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method || '');
const shouldShowSuccessToast = isMutation &&
(response.config as any)?._showSuccessToast &&
typeof window !== 'undefined';
if (shouldShowSuccessToast) {
const successMessage = (response.config as any)?._successMessage ||
(response.data as any)?.message ||
getDefaultSuccessMessage(method || '');
if (successMessage) {
toast.success(successMessage);
}
}
// FE-API-017: Cache GET responses
if (method === 'GET' && !(response.config as any)?._disableCache) {
responseCache.set(response.config, response);
}
// FE-API-017: Invalidate cache on mutations
// FE-STATE-004: Invalidate state after mutations
if (isMutation) {
const url = response.config.url || '';
const method = response.config.method || 'POST';
// Use centralized invalidation system
invalidateStateAfterMutation(url, method);
}
// Vérifier si c'est le format wrapper avec success
if ('success' in response.data) {
if (response.data.success === true) {
// Format wrapper standard: { success: true, data: {...} }
// On unwrap pour retourner directement data
// Si data est null/undefined, on retourne null au lieu de undefined
const unwrappedData = response.data.data !== undefined ? response.data.data : null;
return {
...response,
data: unwrappedData,
} as AxiosResponse<any>;
}
// Si success === false, l'erreur sera gérée par le catch
// Mais on devrait normalement ne jamais arriver ici car le backend
// retourne un status HTTP d'erreur dans ce cas
}
// Si pas de format wrapper (format direct JSON), retourner la réponse telle quelle
// Exemples: { tracks: [...], pagination: {...} }, { user: {...}, token: {...} }
return response;
},
async (error: AxiosError<ApiResponse<any>>) => {
// Don't retry or process cancelled requests
if (axios.isCancel(error)) {
const requestId = (error.config as any)?._requestId;
if (import.meta.env.DEV || (error.config as any)?._enableLogging) {
logger.debug(`[API Request Cancelled] ${error.config?.method?.toUpperCase() || 'GET'} ${error.config?.url}`, {
request_id: requestId,
});
}
return Promise.reject(error);
}
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// Log error response (only in development or if explicitly enabled)
const requestId = (originalRequest as any)?._requestId;
const shouldLog = import.meta.env.DEV || (originalRequest as any)?._enableLogging;
if (shouldLog && error.response) {
const sanitizedErrorData = sanitizeForLogging(error.response.data);
const sanitizedHeaders = sanitizeForLogging(error.response.headers);
logger.error(`[API Error Response] ${originalRequest?.method?.toUpperCase() || 'GET'} ${originalRequest?.url} ${error.response.status}`, {
request_id: requestId,
status: error.response.status,
statusText: error.response.statusText,
headers: sanitizedHeaders,
data: sanitizedErrorData,
duration: (originalRequest as any)?._requestStartTime
? Date.now() - (originalRequest as any)._requestStartTime
: undefined,
});
} else if (shouldLog && error.request && !error.response) {
// Network error (no response received)
logger.error(`[API Network Error] ${originalRequest?.method?.toUpperCase() || 'GET'} ${originalRequest?.url}`, {
request_id: requestId,
message: error.message,
code: error.code,
duration: (originalRequest as any)?._requestStartTime
? Date.now() - (originalRequest as any)._requestStartTime
: undefined,
});
}
// Détecter 401 et refresh automatiquement
// EXCLURE l'endpoint /auth/refresh pour éviter les boucles infinies
const isRefreshEndpoint = originalRequest?.url?.includes('/auth/refresh');
2025-12-13 02:34:34 +00:00
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!isRefreshEndpoint
2025-12-13 02:34:34 +00:00
) {
// Éviter les refresh multiples simultanés
if (isRefreshing) {
// Si un refresh est en cours, mettre la requête en queue
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
if (originalRequest.headers && token) {
originalRequest.headers.Authorization = `Bearer ${token} `;
}
return apiClient(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Refresh automatique du token
await refreshToken();
const newToken = TokenStorage.getAccessToken();
2025-12-13 02:34:34 +00:00
if (!newToken) {
throw new Error('Failed to get new access token after refresh');
}
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken} `;
}
// Traiter la queue et retry la requête originale
processQueue(null, newToken);
return apiClient(originalRequest);
} catch (refreshError) {
// Gérer cas refresh échoué
processQueue(refreshError as Error, null);
2025-12-13 02:34:34 +00:00
// Nettoyer les tokens
TokenStorage.clearTokens();
2025-12-13 02:34:34 +00:00
// Stocker un message d'erreur pour l'afficher après redirection
if (typeof window !== 'undefined') {
sessionStorage.setItem(
'auth_error',
'Your session has expired. Please log in again.',
);
// Rediriger vers login si refresh échoue (seulement dans le navigateur)
window.location.href = '/login';
}
2025-12-13 02:34:34 +00:00
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// Unified retry logic for all retryable errors
const status = error.response?.status;
const retryCount = (originalRequest as any)?._retryCount || 0;
const maxRetries = DEFAULT_RETRY_CONFIG.maxRetries;
// Check if error is retryable
if (isRetryableError(error, DEFAULT_RETRY_CONFIG) && originalRequest && retryCount < maxRetries) {
// For non-idempotent methods (POST, PUT, DELETE, PATCH), only retry on specific errors
const method = originalRequest.method?.toUpperCase();
const isIdempotent = isIdempotentMethod(method);
// For non-idempotent methods, only retry on network errors or 5xx errors (not 429)
if (!isIdempotent && status && status !== 500 && status !== 502 && status !== 503 && status !== 504) {
// Don't retry non-idempotent methods on client errors (except 5xx)
const apiError = parseApiError(error);
return Promise.reject(apiError);
}
// Mark that we're retrying this request
(originalRequest as any)._retryCount = retryCount + 1;
// Calculate delay (respect Retry-After header if present, otherwise exponential backoff with jitter)
const delay = getRetryDelay(error, retryCount, DEFAULT_RETRY_CONFIG.baseDelay, DEFAULT_RETRY_CONFIG.maxDelay);
// Log retry attempt with request_id if available
const apiError = parseApiError(error);
const errorType = status ? `HTTP ${status}` : error.code || 'Network Error';
if (apiError.request_id) {
console.warn(
`[API Retry] ${errorType} error, retrying (${retryCount + 1}/${maxRetries}) - Request ID: ${apiError.request_id}`,
{
status: status || 'N/A',
error_code: error.code || 'N/A',
retry_count: retryCount + 1,
max_retries: maxRetries,
delay_ms: Math.round(delay),
request_id: apiError.request_id,
url: originalRequest?.url,
method: originalRequest?.method,
is_idempotent: isIdempotent,
},
);
} else {
console.warn(
`[API Retry] ${errorType} error, retrying (${retryCount + 1}/${maxRetries})`,
{
status: status || 'N/A',
error_code: error.code || 'N/A',
retry_count: retryCount + 1,
max_retries: maxRetries,
delay_ms: Math.round(delay),
url: originalRequest?.url,
method: originalRequest?.method,
is_idempotent: isIdempotent,
},
);
}
// Wait before retrying
return sleep(delay).then(() => {
// Retry the request
return apiClient(originalRequest);
});
}
// If already retried maxRetries times or error is not retryable, reject immediately
if (retryCount >= maxRetries) {
const apiError = parseApiError(error);
const errorType = status ? `HTTP ${status}` : error.code || 'Network Error';
// Log final error with request_id after all retries failed
if (apiError.request_id) {
console.error(
`[API Error] ${errorType} error after ${maxRetries} retries - Request ID: ${apiError.request_id}`,
{
code: apiError.code,
message: apiError.message,
request_id: apiError.request_id,
timestamp: apiError.timestamp,
url: originalRequest?.url,
method: originalRequest?.method,
},
);
} else {
console.error(
`[API Error] ${errorType} error after ${maxRetries} retries`,
{
code: apiError.code,
message: apiError.message,
timestamp: apiError.timestamp,
url: originalRequest?.url,
method: originalRequest?.method,
},
);
}
return Promise.reject(apiError);
}
// Parser l'erreur en ApiError standardisé pour les autres codes
const apiError = parseApiError(error);
// FE-COMP-005: Show toast notification for API errors (unless disabled)
const shouldShowToast = !(originalRequest as any)?._disableToast &&
status !== 401 && // Don't show toast for 401 (handled by refresh)
status !== 404 && // Don't show toast for 404 (handled by router)
!axios.isCancel(error); // Don't show toast for cancelled requests
if (shouldShowToast && typeof window !== 'undefined') {
// Get user-friendly error message
let errorMessage = apiError.message;
// Customize message based on status code
if (status === 403) {
errorMessage = "Vous n'avez pas les permissions nécessaires pour effectuer cette action";
} else if (status === 422) {
errorMessage = apiError.details?.[0]?.message || apiError.message || "Erreur de validation";
} else if (status === 429) {
errorMessage = "Trop de requêtes. Veuillez patienter quelques instants";
} else if (status >= 500) {
errorMessage = "Une erreur serveur s'est produite. Veuillez réessayer plus tard";
} else if (!error.response) {
// Check if it's a timeout error
if (isTimeoutError(error)) {
errorMessage = getTimeoutMessage('normal');
} else {
errorMessage = "Erreur de connexion. Vérifiez votre connexion internet";
}
// FE-API-015: Queue request for offline replay if it's a network error
if (originalRequest && offlineQueue.shouldQueueRequest(originalRequest)) {
const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
if (isOffline || (!error.response && error.request)) {
// Determine priority based on request type
const method = originalRequest.method?.toUpperCase();
const priority = method === 'DELETE' ? 'low' : method === 'POST' ? 'high' : 'normal';
try {
await offlineQueue.queueRequest(originalRequest, { priority });
// Show info toast that request was queued
toast.success('Requête mise en file d\'attente. Elle sera envoyée à la reconnexion.', {
duration: 4000,
});
} catch (queueError) {
logger.error('[API] Failed to queue request for offline replay', { error: queueError });
}
}
}
}
toast.error(errorMessage, {
duration: status === 429 ? 8000 : 5000, // Longer duration for rate limit errors
});
}
// Log error with request_id for correlation with backend logs
if (apiError.request_id) {
console.error(
`[API Error] ${apiError.message} (Code: ${apiError.code}, Request ID: ${apiError.request_id})`,
{
code: apiError.code,
message: apiError.message,
request_id: apiError.request_id,
timestamp: apiError.timestamp,
details: apiError.details,
context: apiError.context,
url: originalRequest?.url,
method: originalRequest?.method,
},
);
} else {
// Log without request_id if not available
console.error(
`[API Error] ${apiError.message} (Code: ${apiError.code})`,
{
code: apiError.code,
message: apiError.message,
timestamp: apiError.timestamp,
details: apiError.details,
url: originalRequest?.url,
method: originalRequest?.method,
},
);
}
return Promise.reject(apiError);
2025-12-13 02:34:34 +00:00
},
);
/**
* FE-COMP-005: Get default success message based on HTTP method
*/
function getDefaultSuccessMessage(method: string): string {
switch (method) {
case 'POST':
return 'Opération réussie';
case 'PUT':
case 'PATCH':
return 'Modification réussie';
case 'DELETE':
return 'Suppression réussie';
default:
return '';
}
}
/**
* Helper function to create a cancellable request
* Returns an object with the request promise and an abort function
*
* @example
* ```typescript
* const { request, abort } = createCancellableRequest((signal) =>
* apiClient.get('/api/v1/tracks', { signal })
* );
*
* // Later, to cancel:
* abort();
* ```
*/
export function createCancellableRequest<T>(
requestFn: (signal: AbortSignal) => Promise<T>,
): { request: Promise<T>; abort: () => void } {
const abortController = new AbortController();
const signal = abortController.signal;
const request = requestFn(signal);
return {
request,
abort: () => {
abortController.abort();
},
};
}
/**
* Helper function to create a request with timeout
* Automatically cancels the request if it exceeds the timeout duration
*
* @example
* ```typescript
* const { request, abort } = createRequestWithTimeout(
* (signal) => apiClient.get('/api/v1/tracks', { signal }),
* 5000 // 5 seconds timeout
* );
* ```
*/
export function createRequestWithTimeout<T>(
requestFn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
): { request: Promise<T>; abort: () => void } {
const abortController = new AbortController();
const signal = abortController.signal;
// Set up timeout
const timeoutId = setTimeout(() => {
abortController.abort();
}, timeoutMs);
const request = requestFn(signal)
.finally(() => {
// Clear timeout if request completes before timeout
clearTimeout(timeoutId);
});
return {
request,
abort: () => {
clearTimeout(timeoutId);
abortController.abort();
},
};
}
/**
* FE-API-016: Enhanced API client methods with automatic deduplication
* FE-API-017: Enhanced with response caching for GET requests
* These methods automatically deduplicate identical concurrent requests and cache GET responses
*
* @example
* ```typescript
* // Multiple identical requests will share the same promise
* const promise1 = deduplicatedApiClient.get('/tracks');
* const promise2 = deduplicatedApiClient.get('/tracks');
* // promise1 === promise2 (same promise instance)
*
* // Cached responses are returned immediately
* const response1 = await deduplicatedApiClient.get('/tracks');
* const response2 = await deduplicatedApiClient.get('/tracks'); // Returns from cache
* ```
*/
export const deduplicatedApiClient = {
get: <T = any>(url: string, config?: InternalAxiosRequestConfig) => {
// FE-API-017: Check cache first
if (!(config as any)?._disableCache) {
const cachedResponse = responseCache.get({ ...config, method: 'GET', url });
if (cachedResponse) {
logger.debug(`[API] Using cached response for: ${url}`);
return Promise.resolve(cachedResponse as AxiosResponse<T>);
}
}
return requestDeduplication.getOrCreateRequest(
{ ...config, method: 'GET', url },
() => apiClient.get<T>(url, config),
);
},
post: <T = any>(url: string, data?: any, config?: InternalAxiosRequestConfig) => {
return requestDeduplication.getOrCreateRequest(
{ ...config, method: 'POST', url, data },
() => apiClient.post<T>(url, data, config),
);
},
put: <T = any>(url: string, data?: any, config?: InternalAxiosRequestConfig) => {
return requestDeduplication.getOrCreateRequest(
{ ...config, method: 'PUT', url, data },
() => apiClient.put<T>(url, data, config),
);
},
patch: <T = any>(url: string, data?: any, config?: InternalAxiosRequestConfig) => {
return requestDeduplication.getOrCreateRequest(
{ ...config, method: 'PATCH', url, data },
() => apiClient.patch<T>(url, data, config),
);
},
delete: <T = any>(url: string, config?: InternalAxiosRequestConfig) => {
return requestDeduplication.getOrCreateRequest(
{ ...config, method: 'DELETE', url },
() => apiClient.delete<T>(url, config),
);
},
};