- DELETE 요청 시 body 데이터 전송 가능하도록 수정 - 일괄 삭제 기능 (bulk delete) 정상 작동 지원 - 영향 범위: 7개 모듈의 일괄 삭제 기능 Phase 3.2 품목관리 API 연동 완료
241 lines
6.0 KiB
TypeScript
241 lines
6.0 KiB
TypeScript
// 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<string, string[]>;
|
|
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<string, string> {
|
|
const headers: Record<string, string> = {
|
|
'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<T>(
|
|
endpoint: string,
|
|
options?: RequestInit
|
|
): Promise<T> {
|
|
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<T>(endpoint: string, options?: { params?: Record<string, string> }): Promise<T> {
|
|
let url = endpoint;
|
|
if (options?.params) {
|
|
const searchParams = new URLSearchParams(options.params);
|
|
url = `${endpoint}?${searchParams.toString()}`;
|
|
}
|
|
return this.request<T>(url, { method: 'GET' });
|
|
}
|
|
|
|
/**
|
|
* POST 요청
|
|
*/
|
|
async post<T>(endpoint: string, data?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'POST',
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* PUT 요청
|
|
*/
|
|
async put<T>(endpoint: string, data?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'PUT',
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* PATCH 요청
|
|
*/
|
|
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'PATCH',
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* DELETE 요청
|
|
* @param endpoint API 엔드포인트
|
|
* @param options body 데이터 (일괄 삭제 등에서 사용)
|
|
*/
|
|
async delete<T>(endpoint: string, options?: { data?: unknown }): Promise<T> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'DELETE',
|
|
body: options?.data ? JSON.stringify(options.data) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 에러 처리 (자동 토큰 갱신 포함)
|
|
*/
|
|
private async handleError(response: Response): Promise<never> {
|
|
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<T>(
|
|
apiCall: () => Promise<T>,
|
|
maxRetries: number = 1
|
|
): Promise<T> {
|
|
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;
|
|
}
|
|
} |