// lib/api/client.ts import { AUTH_CONFIG } from './auth/auth-config'; import type { AuthMode } from './auth/types'; interface ClientConfig { mode: AuthMode; apiKey?: string; // API Key 모드용 getToken?: () => string | null; // Bearer 모드용 } interface ApiErrorResponse { message: string; errors?: Record; code?: string; } export class ApiClient { private baseURL: string; private mode: AuthMode; private apiKey?: string; private getToken?: () => string | null; constructor(config: ClientConfig) { this.baseURL = AUTH_CONFIG.apiUrl; this.mode = config.mode; this.apiKey = config.apiKey; this.getToken = config.getToken; } /** * 인증 헤더 생성 */ private getAuthHeaders(): Record { const headers: Record = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; // API Key는 모든 모드에서 기본으로 포함 (PHP API 요구사항) if (this.apiKey) { headers['X-API-KEY'] = this.apiKey; } switch (this.mode) { case 'api-key': // API Key만 사용 (이미 위에서 추가됨) break; case 'bearer': { const token = this.getToken?.(); if (token) { headers['Authorization'] = `Bearer ${token}`; } // API Key도 함께 전송 (이미 위에서 추가됨) break; } case 'sanctum': // 쿠키 기반 - 별도 헤더 불필요 break; } return headers; } /** * HTTP 요청 실행 */ async request( endpoint: string, options?: RequestInit ): Promise { const url = `${this.baseURL}${endpoint}`; const headers = { ...this.getAuthHeaders(), ...options?.headers, }; const config: RequestInit = { ...options, headers, }; // Sanctum 모드는 쿠키 포함 if (this.mode === 'sanctum') { config.credentials = 'include'; } const response = await fetch(url, config); if (!response.ok) { await this.handleError(response); } // 204 No Content 처리 if (response.status === 204) { return undefined as T; } return await response.json(); } /** * GET 요청 */ async get(endpoint: string): Promise { return this.request(endpoint, { method: 'GET' }); } /** * POST 요청 */ async post(endpoint: string, data?: unknown): Promise { return this.request(endpoint, { method: 'POST', body: data ? JSON.stringify(data) : undefined, }); } /** * PUT 요청 */ async put(endpoint: string, data?: unknown): Promise { return this.request(endpoint, { method: 'PUT', body: data ? JSON.stringify(data) : undefined, }); } /** * DELETE 요청 */ async delete(endpoint: string): Promise { return this.request(endpoint, { method: 'DELETE' }); } /** * 에러 처리 (자동 토큰 갱신 포함) */ private async handleError(response: Response): Promise { const data = await response.json().catch(() => ({})); // 401 Unauthorized - Try token refresh if (response.status === 401) { console.warn('⚠️ 401 Unauthorized - Token may be expired'); // Client-side: Suggest token refresh to caller throw { status: 401, message: 'Unauthorized - Token expired', needsTokenRefresh: true, errors: data.errors, code: data.code, }; } const error: ApiErrorResponse = { message: data.message || 'An error occurred', errors: data.errors, code: data.code, }; throw { status: response.status, ...error, }; } } /** * Helper function to handle API calls with automatic token refresh * * Usage: * ```typescript * const data = await withTokenRefresh(() => apiClient.get('/protected')); * ``` */ export async function withTokenRefresh( apiCall: () => Promise, maxRetries: number = 1 ): Promise { try { return await apiCall(); } catch (error: unknown) { const apiError = error as { status?: number; needsTokenRefresh?: boolean }; // If 401 and token refresh needed, try refreshing if (apiError.status === 401 && apiError.needsTokenRefresh && maxRetries > 0) { console.log('🔄 Attempting token refresh...'); // Call refresh endpoint const refreshResponse = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', }); if (refreshResponse.ok) { console.log('✅ Token refreshed, retrying API call'); // Retry the original API call return withTokenRefresh(apiCall, maxRetries - 1); } else { console.error('❌ Token refresh failed - redirecting to login'); // Refresh failed - redirect to login if (typeof window !== 'undefined') { window.location.href = '/login'; } } } // Re-throw error if not handled throw error; } }