// 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 요청 * @param endpoint API 엔드포인트 * @param options 쿼리 파라미터 등 옵션 */ async get(endpoint: string, options?: { params?: Record }): Promise { let url = endpoint; if (options?.params) { const searchParams = new URLSearchParams(options.params); url = `${endpoint}?${searchParams.toString()}`; } return this.request(url, { 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, }); } /** * PATCH 요청 */ async patch(endpoint: string, data?: unknown): Promise { return this.request(endpoint, { method: 'PATCH', body: data ? JSON.stringify(data) : undefined, }); } /** * DELETE 요청 * @param endpoint API 엔드포인트 * @param options body 데이터 (일괄 삭제 등에서 사용) */ async delete(endpoint: string, options?: { data?: unknown }): Promise { return this.request(endpoint, { method: 'DELETE', body: options?.data ? JSON.stringify(options.data) : undefined, }); } /** * 에러 처리 (자동 토큰 갱신 포함) */ 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 - clearing cookies and redirecting to login'); // ⚠️ 무한 루프 방지: 쿠키 삭제 API 호출 후 redirect // 쿠키가 남아있으면 미들웨어가 "인증됨"으로 판단하여 무한 루프 발생 if (typeof window !== 'undefined') { try { await fetch('/api/auth/logout', { method: 'POST' }); } catch { // 로그아웃 API 실패해도 redirect 진행 } window.location.href = '/login'; } } } // Re-throw error if not handled throw error; } }