first commit
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
|
||||
// axios 요청 설정 확장 — 호출 단위 옵션
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
/** true 면 에러 발생 시 전역 알림(UI) 을 띄우지 않는다 (예: 부트스트랩용 /auth/me) */
|
||||
silent?: boolean
|
||||
/** 내부용 — 401 재시도(토큰 refresh) 1회 수행 여부 플래그 */
|
||||
_retry?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor와 동일 구조
|
||||
export interface StandardResponse<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
|
||||
const ErrorCodeLexicon: Record<string, string> = {
|
||||
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
}
|
||||
|
||||
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
|
||||
const showErrorUI = (message: string) => {
|
||||
window.alert(message)
|
||||
}
|
||||
|
||||
// 에러 코드 → 메시지 변환 (미정의 코드는 SYS_001 로 폴백, 항상 문자열 보장)
|
||||
const SYSTEM_ERROR_MESSAGE =
|
||||
ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
const resolveErrorMessage = (code: string): string => ErrorCodeLexicon[code] ?? SYSTEM_ERROR_MESSAGE
|
||||
|
||||
// 사용자 노출 메시지 결정 — 비즈니스 예외(BIZ_001)는 서버가 내려준 구체 문구를 우선 사용
|
||||
// (CLAUDE.md: "비즈니스 예외는 message 로 사용자 노출 문구를 재정의할 수 있다")
|
||||
const pickMessage = (code: string, serverMessage?: string): string =>
|
||||
code === 'BIZ_001' && serverMessage ? serverMessage : resolveErrorMessage(code)
|
||||
|
||||
// 중앙 Axios 인스턴스
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 10000,
|
||||
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// 요청 인터셉터 — CSRF, 추가 헤더 등 필요 시 확장
|
||||
api.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => config,
|
||||
(error: unknown) => Promise.reject(error),
|
||||
)
|
||||
|
||||
// 인증 만료 응답으로 판단할지 — /auth/* 자체 호출은 재시도 대상에서 제외
|
||||
const isAuthEndpoint = (url?: string): boolean => (url ?? '').includes('/auth/')
|
||||
|
||||
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 401 자동 refresh + 에러코드 매핑
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse<StandardResponse>) => {
|
||||
const payload = response.data
|
||||
|
||||
if (payload && typeof payload.success === 'boolean') {
|
||||
if (payload.success) {
|
||||
// 성공 시 data 만 언래핑하여 반환
|
||||
return payload.data as never
|
||||
}
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
|
||||
const errCode = payload.error?.code || 'BIZ_001'
|
||||
if (!response.config.silent) showErrorUI(pickMessage(errCode, payload.error?.message))
|
||||
return Promise.reject(payload.error)
|
||||
}
|
||||
return payload as never
|
||||
},
|
||||
async (error) => {
|
||||
const config = error?.config as (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined
|
||||
const status: number | undefined = error?.response?.status
|
||||
|
||||
// access 토큰 만료(401) → refresh 1회 시도 후 원요청 재시도
|
||||
if (status === 401 && config && !config._retry && !isAuthEndpoint(config.url)) {
|
||||
config._retry = true
|
||||
try {
|
||||
await api.post('/auth/refresh', null, { silent: true })
|
||||
return api(config)
|
||||
} catch {
|
||||
// refresh 실패 → 세션 만료로 간주, 아래 일반 에러 처리로 진행
|
||||
}
|
||||
}
|
||||
|
||||
let errCode = 'SYS_001'
|
||||
let serverMessage: string | undefined
|
||||
if (error?.response) {
|
||||
const payload = error.response.data as StandardResponse | undefined
|
||||
serverMessage = payload?.error?.message
|
||||
if (payload?.error?.code) {
|
||||
errCode = payload.error.code
|
||||
} else if (status === 401) errCode = 'AUTH_001'
|
||||
else if (status === 403) errCode = 'AUTH_003'
|
||||
else if (status === 400 || status === 422) errCode = 'VAL_001'
|
||||
else if (status === 404) errCode = 'RES_001'
|
||||
}
|
||||
|
||||
if (!config?.silent) showErrorUI(pickMessage(errCode, serverMessage))
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* useApi — 컴포넌트에서 axios 인스턴스를 가져올 때 사용하는 composable
|
||||
* 직접 import 대신 useApi() 사용을 권장 (테스트 시 모킹 용이)
|
||||
*/
|
||||
export function useApi() {
|
||||
return api
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useApi } from './useApi'
|
||||
import type { AuthUser, LoginPayload, SignupPayload } from '@/types/auth'
|
||||
|
||||
// 인증 도메인 API Composable — 인터셉터가 success/data 를 언래핑하므로 data 타입으로 캐스팅
|
||||
export function useAuth() {
|
||||
const api = useApi()
|
||||
|
||||
// 회원가입 (성공 시 백엔드가 인증 쿠키를 설정해 자동 로그인됨)
|
||||
async function signup(payload: SignupPayload): Promise<AuthUser> {
|
||||
return (await api.post('/auth/signup', payload)) as unknown as AuthUser
|
||||
}
|
||||
|
||||
// 로그인
|
||||
async function login(payload: LoginPayload): Promise<AuthUser> {
|
||||
return (await api.post('/auth/login', payload)) as unknown as AuthUser
|
||||
}
|
||||
|
||||
// 로그아웃 (쿠키 제거)
|
||||
async function logout(): Promise<void> {
|
||||
await api.post('/auth/logout')
|
||||
}
|
||||
|
||||
// 현재 로그인 사용자 조회 — 부트스트랩/가드용. 미인증이면 throw 되므로 호출측에서 처리
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
// silent: 미인증(401) 시 전역 알림을 띄우지 않는다
|
||||
return (await api.get('/auth/me', { silent: true })) as unknown as AuthUser
|
||||
}
|
||||
|
||||
return { signup, login, logout, fetchMe }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useApi } from './useApi'
|
||||
import type { ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo'
|
||||
|
||||
// 저장소 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
export function useRepo() {
|
||||
const api = useApi()
|
||||
|
||||
async function list(): Promise<ApiRepo[]> {
|
||||
return (await api.get('/repos')) as unknown as ApiRepo[]
|
||||
}
|
||||
|
||||
async function get(id: string): Promise<ApiRepo> {
|
||||
return (await api.get(`/repos/${id}`)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
async function create(payload: CreateRepoPayload): Promise<ApiRepo> {
|
||||
return (await api.post('/repos', payload)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
async function update(id: string, payload: UpdateRepoPayload): Promise<ApiRepo> {
|
||||
return (await api.patch(`/repos/${id}`, payload)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
await api.delete(`/repos/${id}`)
|
||||
}
|
||||
|
||||
return { list, get, create, update, remove }
|
||||
}
|
||||
Reference in New Issue
Block a user