// Orval mutator (기본 client 방식). axios 인터셉터를 대체하는 단일 길목 — // 토큰 주입 / 에러 throw / 401 처리 / baseURL 을 여기서 처리한다. // // orval.config.ts 의 override.mutator 가 이 함수를 모든 생성 API 호출에 꽂는다. // httpClient: 'fetch' 를 쓰지 않으므로 orval 은 config-object 시그니처로 호출한다: // customFetch({ url, method, params, data, signal, headers }, requestOptions) // → 함수가 응답 본문(T)을 바로 반환하고, 상태코드별 ResponseXXX 유니온 타입은 생성되지 않는다. // baseURL — compose 의 VITE_API_BASE_URL 로 주입, 없으면 9400 폴백. const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9400'; // 토큰 보관소 — 로그인 후 setAccessToken()으로 주입. localStorage(auth/service 와 동일 키)와 동기화. const ACCESS_KEY = 'negodata.accessToken'; const REFRESH_KEY = 'negodata.refreshToken'; let accessToken: string | null = null; export const setAccessToken = (token: string | null) => { accessToken = token; }; // 동시 다발 434(액세스 토큰 만료)를 단 한 번의 refresh 로 합치기 위한 in-flight 프라미스. let refreshInFlight: Promise | null = null; // refresh 토큰으로 access 재발급. 성공 시 새 access 토큰, 실패 시 null. async function refreshAccessToken(): Promise { if (refreshInFlight) return refreshInFlight; refreshInFlight = (async () => { try { const refresh = localStorage.getItem(REFRESH_KEY); if (!refresh) return null; // refresh 토큰은 Authorization 헤더(Bearer)로 보낸다(auth/service restore() 와 동일 규약). const res = await fetch(`${BASE_URL}/v1/auth/refresh_token`, { method: 'POST', headers: { Authorization: `Bearer ${refresh}` }, }); if (!res.ok) return null; const refreshed = await res.json().catch(() => null); const newToken: string | undefined = refreshed?.access_token; if (!newToken || refreshed?.result?.success === false) return null; localStorage.setItem(ACCESS_KEY, newToken); accessToken = newToken; return newToken; } catch { return null; } finally { refreshInFlight = null; } })(); return refreshInFlight; } function clearTokens() { localStorage.removeItem(ACCESS_KEY); localStorage.removeItem(REFRESH_KEY); accessToken = null; } export class ApiError extends Error { constructor( public status: number, public statusText: string, public data: unknown, ) { super(`API ${status} ${statusText}`); this.name = 'ApiError'; } } export type RequestConfig = { url: string; method: string; params?: Record; data?: unknown; headers?: HeadersInit; signal?: AbortSignal; responseType?: string; }; export const customFetch = async ( config: RequestConfig, options?: RequestInit, ): Promise => { const { url, method, params, data, signal } = config; // 쿼리스트링 직렬화 (null/undefined 값은 제외) const queryString = params ? new URLSearchParams( Object.entries(params) .filter(([, v]) => v !== undefined && v !== null) .map(([k, v]) => [k, String(v)]), ).toString() : ''; const requestUrl = (url.startsWith('http') ? url : `${BASE_URL}${url}`) + (queryString ? `?${queryString}` : ''); // 파일 업로드(FormData)는 ① 브라우저가 boundary 포함 Content-Type 을 직접 설정해야 하고 // ② 본문을 JSON.stringify 하면 "{}" 가 되어 파일이 통째로 누락된다. 분기 처리. const isFormData = typeof FormData !== 'undefined' && data instanceof FormData; // [요청 인터셉터] 토큰을 받아 헤더를 구성하고 한 번 호출(만료 재시도 시 새 토큰으로 재구성). const doFetch = (token: string | null) => { const headers = new Headers(config.headers); if (options?.headers) { new Headers(options.headers).forEach((v, k) => headers.set(k, v)); } if (isFormData) { headers.delete('Content-Type'); // 생성 클라이언트가 박은 boundary 없는 multipart 헤더 제거 → 브라우저가 재설정 } else if (data != null && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } if (token) headers.set('Authorization', `Bearer ${token}`); return fetch(requestUrl, { ...options, method, headers, body: data != null ? (isFormData ? data : JSON.stringify(data)) : options?.body, signal, }); }; let response = await doFetch(accessToken); // 434(액세스 토큰 만료) → refresh 로 재발급 후 1회 재시도. auth 엔드포인트 자신은 제외(무한루프 방지). const isAuthEndpoint = url.includes('/v1/auth/refresh_token') || url.includes('/v1/auth/login'); if ((response.status === 434 || response.status === 401) && !isAuthEndpoint) { const newToken = await refreshAccessToken(); if (newToken) { response = await doFetch(newToken); } else { clearTokens(); // refresh 실패 → 토큰 폐기(다음 로드/가드에서 로그인으로 유도) } } // [응답 인터셉터] 본문 파싱 (204/빈 응답 대응) const isJson = response.headers.get('content-type')?.includes('application/json'); const body = response.status === 204 ? null : isJson ? await response.json() : await response.text(); // fetch는 4xx/5xx도 resolve하므로 직접 throw → React Query가 error로 처리 if (!response.ok) { throw new ApiError(response.status, response.statusText, body); } return body as T; }; export default customFetch;