첫 커밋
This commit is contained in:
235
src/utils/api.ts
Normal file
235
src/utils/api.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import axios, { AxiosInstance, AxiosResponse, AxiosError, AxiosRequestConfig } from 'axios';
|
||||
import { ApiResponse, ApiErrorResponse, FileUploadProgress, FileUploadResponse } from '@/types';
|
||||
import { tokenManager } from './tokenManager';
|
||||
import config from '@/config';
|
||||
|
||||
class ApiClient {
|
||||
private instance: AxiosInstance;
|
||||
private isRefreshing = false;
|
||||
private failedQueue: Array<{
|
||||
resolve: (value?: unknown) => void;
|
||||
reject: (error?: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
constructor() {
|
||||
this.instance = axios.create({
|
||||
baseURL: config.api.baseURL,
|
||||
timeout: config.api.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors(): void {
|
||||
// 요청 인터셉터
|
||||
this.instance.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = tokenManager.getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// 응답 인터셉터
|
||||
this.instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (this.isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({ resolve, reject });
|
||||
}).then(token => {
|
||||
originalRequest.headers!.Authorization = `Bearer ${token}`;
|
||||
return this.instance(originalRequest);
|
||||
}).catch(err => {
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
const newToken = await this.refreshAccessToken();
|
||||
this.processQueue(null, newToken);
|
||||
originalRequest.headers!.Authorization = `Bearer ${newToken}`;
|
||||
return this.instance(originalRequest);
|
||||
} catch (refreshError) {
|
||||
this.processQueue(refreshError, null);
|
||||
tokenManager.clearTokens();
|
||||
// 로그인 페이지로 리디렉션 또는 로그아웃 처리
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(this.handleError(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private processQueue(error: unknown, token: string | null): void {
|
||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(token);
|
||||
}
|
||||
});
|
||||
|
||||
this.failedQueue = [];
|
||||
}
|
||||
|
||||
private async refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = tokenManager.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${config.api.baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
const { access_token, refresh_token: newRefreshToken } = response.data.data;
|
||||
tokenManager.setTokens(access_token, newRefreshToken);
|
||||
return access_token;
|
||||
} catch {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: AxiosError): ApiErrorResponse {
|
||||
const errorResponse: ApiErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: '알 수 없는 오류가 발생했습니다.',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (error.response) {
|
||||
// 서버 응답이 있는 경우
|
||||
const { status, data } = error.response;
|
||||
errorResponse.error.code = `HTTP_${status}`;
|
||||
|
||||
if (data && typeof data === 'object' && 'message' in data) {
|
||||
errorResponse.error.message = (data as { message: string }).message;
|
||||
} else {
|
||||
errorResponse.error.message = this.getErrorMessage(status);
|
||||
}
|
||||
|
||||
errorResponse.error.details = data;
|
||||
} else if (error.request) {
|
||||
// 네트워크 오류
|
||||
errorResponse.error.code = 'NETWORK_ERROR';
|
||||
errorResponse.error.message = '네트워크 연결을 확인해주세요.';
|
||||
} else {
|
||||
// 요청 설정 오류
|
||||
errorResponse.error.code = 'REQUEST_ERROR';
|
||||
errorResponse.error.message = '요청 처리 중 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
return errorResponse;
|
||||
}
|
||||
|
||||
private getErrorMessage(status: number): string {
|
||||
const messages: Record<number, string> = {
|
||||
400: '잘못된 요청입니다.',
|
||||
401: '인증이 필요합니다.',
|
||||
403: '접근 권한이 없습니다.',
|
||||
404: '요청한 리소스를 찾을 수 없습니다.',
|
||||
409: '데이터 충돌이 발생했습니다.',
|
||||
422: '입력 데이터를 확인해주세요.',
|
||||
500: '서버 오류가 발생했습니다.',
|
||||
502: '서버 연결에 문제가 있습니다.',
|
||||
503: '서비스를 일시적으로 사용할 수 없습니다.',
|
||||
};
|
||||
|
||||
return messages[status] || '알 수 없는 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
async get<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.instance.get(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.instance.post(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.instance.put(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.instance.patch(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.instance.delete(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
url: string,
|
||||
file: File,
|
||||
onProgress?: (progress: FileUploadProgress) => void
|
||||
): Promise<ApiResponse<FileUploadResponse>> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress: FileUploadProgress = {
|
||||
loaded: progressEvent.loaded,
|
||||
total: progressEvent.total,
|
||||
percentage: Math.round((progressEvent.loaded * 100) / progressEvent.total),
|
||||
};
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.instance.post(url, formData, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async downloadFile(url: string, filename: string): Promise<void> {
|
||||
const response = await this.instance.get(url, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data]);
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
export default apiClient;
|
||||
Reference in New Issue
Block a user