o2o-negosium-original/frontend/src/apis/http.ts
민헌 a867daa98f feat(frontend): TanStack Query 기반 apis 레이어 추가
- auth/negotiation 도메인별 api/keys/queries/mutations/type 5파일 구조
- axios 인스턴스: access token 주입, 434 만료 시 refresh 자동 재발급(단일 비행)
  후 원요청 재시도, 433/435/1203(TOKEN_REVOKED) 시 세션 종료 처리
- HTTP 200 + result.success=false 비즈니스 에러를 ApiError 로 변환,
  에러코드별 한국어 메시지 맵(getApiErrorMessage) 제공
- 토큰 localStorage 저장소(tokenStorage)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:12:07 +09:00

119 lines
4.7 KiB
TypeScript

// 공용 axios 인스턴스.
// - 요청 시 access token 을 Authorization 헤더에 주입
// - 응답의 result.success=false 봉투를 ApiError 로 변환
// - access token 만료(434) 시 refresh_token 으로 1회 자동 재발급 후 원요청 재시도
// - refresh 실패 / 토큰 폐기(1203) / refresh 만료(435) 시 세션 종료 처리
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { tokenStorage } from './tokenStorage'
import { ApiError, ErrorCode, type ApiResult } from './types'
const BASE_URL = import.meta.env.VITE_API_BASE_URL
export const http = axios.create({
baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' },
})
// 인증 만료 시 앱이 처리할 핸들러 (로그인 페이지로 이동 등). App 에서 등록한다.
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(handler: (() => void) | null): void {
onUnauthorized = handler
}
function handleUnauthorized(): void {
tokenStorage.clear()
onUnauthorized?.()
}
// --- 요청 인터셉터: access token 주입 ------------------------------------
http.interceptors.request.use((config) => {
const token = tokenStorage.getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// --- 토큰 재발급 (단일 비행: 동시 요청은 하나의 refresh 만 공유) ----------
let refreshPromise: Promise<string> | null = null
async function refreshAccessToken(): Promise<string> {
const refreshToken = tokenStorage.getRefreshToken()
if (!refreshToken) {
throw new ApiError(ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED, 'NO_REFRESH_TOKEN')
}
// 인터셉터 재귀를 피하려고 인스턴스가 아닌 기본 axios 로 호출한다.
const res = await axios.post<{ result: ApiResult; access_token?: string }>(
`${BASE_URL}/v1/auth/refresh_token`,
null,
{ headers: { Authorization: `Bearer ${refreshToken}` } },
)
const { result, access_token } = res.data
if (!result.success || !access_token) {
throw new ApiError(result.code, result.desc)
}
tokenStorage.setAccessToken(access_token)
return access_token
}
function toApiError(error: unknown): ApiError {
if (error instanceof ApiError) return error
if (axios.isAxiosError(error)) {
const result = (error.response?.data as { result?: ApiResult } | undefined)?.result
if (result) return new ApiError(result.code, result.desc, error.message)
// 토큰 관련 HTTPException 은 result 봉투 대신 {detail: "HTTP_*"} 형태로 온다
const detail = (error.response?.data as { detail?: string } | undefined)?.detail
const status = error.response?.status ?? 0
return new ApiError(status, detail ?? error.code ?? 'NETWORK_ERROR', error.message)
}
return new ApiError(ErrorCode.FAIL, 'UNKNOWN', String(error))
}
// --- 응답 인터셉터 -------------------------------------------------------
http.interceptors.response.use(
(response) => {
// HTTP 200 이지만 result.success=false 인 비즈니스 에러를 ApiError 로 변환
const result = (response.data as { result?: ApiResult } | undefined)?.result
if (result && !result.success) {
// 저장 토큰 무효화(로그아웃/타기기 로그인)는 200 + TOKEN_REVOKED 로 온다 → 세션 종료
if (result.code === ErrorCode.TOKEN_REVOKED && tokenStorage.hasToken()) {
handleUnauthorized()
}
throw new ApiError(result.code, result.desc)
}
return response
},
async (error: AxiosError) => {
const status = error.response?.status
const original = error.config as
| (InternalAxiosRequestConfig & { _retried?: boolean })
| undefined
const bodyCode = (error.response?.data as { result?: ApiResult } | undefined)?.result?.code
const isRefreshCall = original?.url?.includes('/v1/auth/refresh_token') ?? false
// access token 만료 → refresh 후 1회 재시도
if (status === 434 && original && !original._retried && !isRefreshCall) {
original._retried = true
try {
refreshPromise ??= refreshAccessToken().finally(() => {
refreshPromise = null
})
const newToken = await refreshPromise
original.headers.Authorization = `Bearer ${newToken}`
return http(original)
} catch (refreshError) {
handleUnauthorized()
throw toApiError(refreshError)
}
}
// 인증 실패(헤더 누락 403/401, 잘못된 토큰 433/436, refresh 만료 435) /
// 토큰 폐기(200+1203 이 아닌 경로) → 세션 종료
const isAuthFailStatus =
status === 401 || status === 403 || status === 433 || status === 435 || status === 436
if (isAuthFailStatus || bodyCode === ErrorCode.TOKEN_REVOKED || isRefreshCall) {
handleUnauthorized()
}
throw toApiError(error)
},
)