Files
sam-react-prod/src/lib/api/client.ts
kent b9af603cb7 feat(api): apiClient.delete에 body 데이터 지원 추가
- DELETE 요청 시 body 데이터 전송 가능하도록 수정
- 일괄 삭제 기능 (bulk delete) 정상 작동 지원
- 영향 범위: 7개 모듈의 일괄 삭제 기능

Phase 3.2 품목관리 API 연동 완료
2026-01-09 22:07:30 +09:00

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;
}
}