Files
fast-next-template/frontend/src/lib/api/client.ts
Felipe Cardoso f23fdb974a Refactor to enforce AuthContext usage over useAuthStore and improve test stability
- Replaced `useAuthStore` with `useAuth` from `AuthContext` across frontend components and tests to ensure dependency injection compliance.
- Enhanced E2E test stability by delaying navigation until the auth context is fully initialized.
- Updated Playwright configuration to use a single worker to prevent mock conflicts.
- Refactored test setup to consistently inject `AuthProvider` for improved isolation and mocking.
- Adjusted comments and documentation to clarify dependency injection and testability patterns.
2025-11-05 08:37:01 +01:00

283 lines
8.9 KiB
TypeScript

/**
* API Client Configuration with Interceptors
*
* This module configures the auto-generated API client with:
* - Token refresh interceptor (prevents race conditions with singleton pattern)
* - Request interceptor (adds Authorization header)
* - Response interceptor (handles 401, 403, 429, 500 errors)
*
* IMPORTANT: Do NOT modify generated files. All customization happens here.
*
* @module lib/api/client
*/
import type { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
import { client } from './generated/client.gen';
import { refreshToken as refreshTokenFn } from './generated/sdk.gen';
import config from '@/config/app.config';
/**
* Token refresh state management (singleton pattern)
* Prevents race conditions when multiple requests fail with 401 simultaneously
*
* The refreshPromise acts as both the lock and the shared promise.
* If it exists, a refresh is in progress - all concurrent requests wait for the same promise.
*/
let refreshPromise: Promise<string> | null = null;
/**
* Auth store accessor
* Dynamically imported to avoid circular dependencies
* Checks for E2E test store injection before using production store
*
* Note: Tested via E2E tests when interceptors are invoked
*/
/* istanbul ignore next */
const getAuthStore = async () => {
// Check for E2E test store injection (same pattern as AuthProvider)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (typeof window !== 'undefined' && (window as any).__TEST_AUTH_STORE__) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const testStore = (window as any).__TEST_AUTH_STORE__;
// Test store must have getState() method for non-React contexts
return testStore.getState();
}
// Production: use real Zustand store
// Note: Dynamic import is acceptable here (non-React context, checks __TEST_AUTH_STORE__ first)
const { useAuthStore } = await import('@/lib/stores/authStore');
return useAuthStore.getState();
};
/**
* Refresh access token using refresh token
*
* Note: Tested in E2E tests
*
* @returns Promise<string> New access token
* @throws Error if refresh fails
*/
/* istanbul ignore next */
async function refreshAccessToken(): Promise<string> {
// Singleton pattern: reuse in-flight refresh request
// If a refresh is already in progress, return the existing promise
if (refreshPromise) {
return refreshPromise;
}
// Create and store the refresh promise immediately to prevent race conditions
refreshPromise = (async () => {
try {
const authStore = await getAuthStore();
const { refreshToken } = authStore;
if (!refreshToken) {
throw new Error('No refresh token available');
}
if (config.debug.api) {
console.log('[API Client] Refreshing access token...');
}
// Use generated SDK function for refresh
const response = await refreshTokenFn({
body: { refresh_token: refreshToken },
throwOnError: true,
});
const newAccessToken = response.data.access_token;
const newRefreshToken = response.data.refresh_token || refreshToken;
// Update tokens in store
// Note: Token type from OpenAPI spec doesn't include expires_in,
// but backend may return it. We handle both cases gracefully.
await authStore.setTokens(
newAccessToken,
newRefreshToken,
undefined // expires_in not in spec, will use default
);
if (config.debug.api) {
console.log('[API Client] Token refreshed successfully');
}
return newAccessToken;
} catch (error) {
if (config.debug.api) {
console.error('[API Client] Token refresh failed:', error);
}
// Clear auth state
const authStore = await getAuthStore();
await authStore.clearAuth();
// Only redirect to login when not already on an auth route
if (typeof window !== 'undefined') {
const currentPath = window.location.pathname;
const onAuthRoute = currentPath === '/login' || currentPath === '/register' || currentPath.startsWith('/password-reset');
if (!onAuthRoute) {
const returnUrl = currentPath ? `?returnUrl=${encodeURIComponent(currentPath)}` : '';
window.location.href = `/login${returnUrl}`;
}
}
throw error;
} finally {
// Clear the promise so future 401s will trigger a new refresh
refreshPromise = null;
}
})();
return refreshPromise;
}
/**
* Request Interceptor
* Adds Authorization header with access token to all requests
*
* Note: Interceptor behavior tested in E2E tests
*/
client.instance.interceptors.request.use(
/* istanbul ignore next */ async (requestConfig: InternalAxiosRequestConfig) => {
const authStore = await getAuthStore();
const { accessToken } = authStore;
// Do not attach Authorization header for auth endpoints
const url = requestConfig.url || '';
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/refresh') || url.includes('/auth/password') || url.includes('/password');
// Add Authorization header if token exists and not hitting auth endpoints
if (accessToken && requestConfig.headers && !isAuthEndpoint) {
requestConfig.headers.Authorization = `Bearer ${accessToken}`;
}
if (config.debug.api) {
console.log('[API Client] Request:', requestConfig.method?.toUpperCase(), requestConfig.url);
}
return requestConfig;
},
/* istanbul ignore next */ (error) => {
if (config.debug.api) {
console.error('[API Client] Request error:', error);
}
return Promise.reject(error);
}
);
/**
* Response Interceptor
* Handles errors and token refresh
*
* Note: Interceptor behavior tested in E2E tests
*/
client.instance.interceptors.response.use(
/* istanbul ignore next */ (response: AxiosResponse) => {
if (config.debug.api) {
console.log('[API Client] Response:', response.status, response.config.url);
}
return response;
},
/* istanbul ignore next */ async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (config.debug.api) {
console.error('[API Client] Response error:', error.response?.status, error.config?.url);
}
// Handle 401 Unauthorized - Token expired
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
const url = originalRequest.url || '';
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/password') || url.includes('/password');
// If the 401 is from auth endpoints, do not attempt refresh
if (isAuthEndpoint) {
return Promise.reject(error);
}
// If refresh endpoint itself fails with 401, clear auth and reject
if (url.includes('/auth/refresh')) {
const authStore = await getAuthStore();
await authStore.clearAuth();
return Promise.reject(error);
}
// Ensure we have a refresh token before attempting refresh
const authStore = await getAuthStore();
if (!authStore.refreshToken) {
return Promise.reject(error);
}
originalRequest._retry = true;
try {
// Refresh token
const newAccessToken = await refreshAccessToken();
// Retry original request with new token
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
return client.instance(originalRequest);
} catch (refreshError) {
return Promise.reject(refreshError);
}
}
// Handle 403 Forbidden
if (error.response?.status === 403) {
if (config.debug.api) {
console.warn('[API Client] Access forbidden (403)');
}
// Let the component handle this (might be permission issue, not auth)
}
// Handle 429 Too Many Requests
if (error.response?.status === 429) {
if (config.debug.api) {
console.warn('[API Client] Rate limit exceeded (429)');
}
// Add retry-after handling if needed in future
}
// Handle 500+ Server Errors
if (error.response?.status && error.response.status >= 500) {
if (config.debug.api) {
console.error('[API Client] Server error:', error.response.status);
}
// Could add error tracking service integration here
}
return Promise.reject(error);
}
);
/**
* Configure the generated client with base settings
*/
client.setConfig({
baseURL: config.api.url,
timeout: config.api.timeout,
headers: {
'Content-Type': 'application/json',
},
});
/**
* Configured API client instance
* Use this for all API calls
*/
export { client as apiClient };
/**
* Re-export all SDK functions for convenience
* These are already configured with interceptors
*/
export * from './generated/sdk.gen';
/**
* Re-export types for convenience
*/
export type * from './generated/types.gen';