-
Notifications
You must be signed in to change notification settings - Fork 2
[FEAT] api client axios -> fetch 수정 #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2e27335
feat: axios -> fetch 단순 변환 (임시)
9g-g9 a772ea1
feat: fetchInstance 기능 수정, errorCode(백엔드 참조), api types 추가
9g-g9 c8f1fd1
feat: 공통 예외, 에러 처리 함수 추가 및 수정
9g-g9 72f785f
test: test 파일 제거
9g-g9 0522d37
fix: 엔드포인트 객체 readonly로 설정
9g-g9 28a81c6
Merge branch 'dev' of https://github.com/4roro-moving/moving-frontend…
9g-g9 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| import { API_ROUTES } from "@/lib/constants/apiRoutes"; | ||
| import { ApiError, type ApiSuccessResponse, type ApiErrorResponse } from "@/types/api"; | ||
|
|
||
| const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL; | ||
| const DEFAULT_TIMEOUT_MS = 10_000; | ||
|
|
||
| // auth 엔드포인트 제외 리프레시 금지 | ||
| const NO_REFRESH_ENDPOINTS: readonly string[] = [ | ||
| API_ROUTES.AUTH.SIGN_IN, | ||
| API_ROUTES.AUTH.SIGN_UP, | ||
| API_ROUTES.AUTH.REFRESH, | ||
| ]; | ||
|
|
||
| // 예외 처리 공통 함수 | ||
| const safeFetch = async (url: string, init: RequestInit): Promise<Response> => { | ||
| try { | ||
| return await fetch(url, init); | ||
| } catch (err) { | ||
| if (err instanceof Error && err.name === "AbortError") throw err; | ||
|
|
||
| if (err instanceof Error && err.name === "TimeoutError") { | ||
| throw new ApiError("요청 시간이 초과되었습니다."); | ||
| } | ||
|
|
||
| throw new ApiError("네트워크 연결이 원활하지 않습니다."); | ||
| } | ||
| }; | ||
|
|
||
| // error 처리 공통 함수 | ||
| // status 와 body 를 받아 ApiError 를 반환 | ||
| const setApiError = (status: number, body: unknown): ApiError => { | ||
| // body 타입에 SuccessResponese 가 있을 수 있으므로 unknown으로 정의하고 따로 체크 | ||
| const record = typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {}; | ||
|
|
||
| const errorInfo = "error" in record ? (record.error as ApiErrorResponse["error"]) : undefined; | ||
| const debugInfo = | ||
| "path" in record | ||
| ? { | ||
| path: record.path as string | undefined, | ||
| method: record.method as string | undefined, | ||
| timestamp: record.timestamp as string | undefined, | ||
| } | ||
| : undefined; | ||
|
|
||
| return new ApiError( | ||
| errorInfo?.message ?? "알 수 없는 오류가 발생했습니다.", | ||
| status, | ||
| errorInfo?.code, | ||
| debugInfo, | ||
| ); | ||
| }; | ||
|
|
||
| // 서버(Server Component 등)에서 실행 중이면 브라우저 쿠키가 자동으로 안 실리므로 직접 포워딩한다. | ||
| const getRequestHeaders = async ( | ||
| customHeaders?: HeadersInit, | ||
| isFormData = false, | ||
| ): Promise<Headers> => { | ||
| const headers = new Headers(customHeaders); | ||
|
|
||
| if (!isFormData && !headers.has("Content-Type")) { | ||
| headers.set("Content-Type", "application/json"); | ||
| } | ||
|
|
||
| if (typeof window === "undefined") { | ||
| const { cookies } = await import("next/headers"); | ||
| const cookieStore = await cookies(); | ||
| const cookieHeader = cookieStore.toString(); | ||
| if (cookieHeader) { | ||
| headers.set("Cookie", cookieHeader); | ||
| } | ||
| } | ||
|
|
||
| return headers; | ||
| }; | ||
|
|
||
| // fetch 요청 시간 제한 신호 생성 / axios의 timeout 대신 사용 | ||
| const buildTimeoutSignal = (signal?: AbortSignal): AbortSignal => { | ||
| const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT_MS); | ||
|
|
||
| return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; | ||
| }; | ||
|
|
||
| // 토큰 리프레시 요청 | ||
| const refreshAccessToken = async (): Promise<void> => { | ||
| // SSR 에서는 쿠키 헤더 추가 | ||
| const headers = await getRequestHeaders(); | ||
|
|
||
| const res = await safeFetch(`${BASE_URL}${API_ROUTES.AUTH.REFRESH}`, { | ||
| method: "POST", | ||
| credentials: "include", | ||
| headers, | ||
| signal: buildTimeoutSignal(), | ||
| }); | ||
|
|
||
| if (!res.ok) { | ||
| const body = (await res.json().catch(() => ({}))) as ApiErrorResponse | Record<string, never>; | ||
| throw setApiError(res.status, body); | ||
| } | ||
| }; | ||
|
|
||
| // 401 동시 요청 제어 | ||
| let refreshPromise: Promise<void> | null = null; | ||
|
|
||
| const getRefreshPromise = (): Promise<void> => { | ||
| // SSR 에서는 캐싱하지 않고 매번 새로 호출함. | ||
| if (typeof window === "undefined") { | ||
| return refreshAccessToken(); | ||
| } | ||
|
|
||
| if (!refreshPromise) { | ||
| refreshPromise = refreshAccessToken() | ||
| .catch((err) => { | ||
| // token 발급 실패 시 에러 처리 | ||
| // 추후 authProvider 에서 addEventListner로 처리할 예정 | ||
| window.dispatchEvent(new CustomEvent("auth:expired")); | ||
| throw err; | ||
| }) | ||
| .finally(() => { | ||
| refreshPromise = null; | ||
| }); | ||
| } | ||
|
9g-g9 marked this conversation as resolved.
|
||
|
|
||
| return refreshPromise; | ||
| }; | ||
|
|
||
| // fetch 요청 함수 | ||
| const request = async <T>( | ||
| endpoint: string, | ||
| options: RequestInit = {}, | ||
| retry = true, | ||
| ): Promise<T> => { | ||
| const isFormData = options.body instanceof FormData; | ||
| const headers = await getRequestHeaders(options.headers, isFormData); | ||
|
|
||
| const res = await safeFetch(`${BASE_URL}${endpoint}`, { | ||
| ...options, | ||
| credentials: "include", | ||
| headers, | ||
| signal: buildTimeoutSignal(options.signal ?? undefined), | ||
| }); | ||
|
|
||
| if (res.status === 204) { | ||
| return null as T; | ||
| } | ||
|
|
||
| const body = (await res.json().catch(() => ({}))) as | ||
| ApiSuccessResponse<T> | ApiErrorResponse | Record<string, never>; | ||
|
|
||
| if (!res.ok || body.success === false) { | ||
| const shouldRefresh = retry && res.status === 401 && !NO_REFRESH_ENDPOINTS.includes(endpoint); | ||
|
|
||
| if (shouldRefresh) { | ||
| await getRefreshPromise(); | ||
| return request<T>(endpoint, options, false); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| throw setApiError(res.status, body); | ||
| } | ||
|
|
||
| return (body as ApiSuccessResponse<T>).data; | ||
| }; | ||
|
|
||
| // API 요청 함수 모음 (axios 대신 사용) | ||
| const fetchInstance = { | ||
| get: <TResponse>(endpoint: string, options?: RequestInit) => | ||
| request<TResponse>(endpoint, { ...options, method: "GET" }), | ||
|
|
||
| post: <TResponse, TBody = unknown>(endpoint: string, body?: TBody, options?: RequestInit) => | ||
| request<TResponse>(endpoint, { | ||
| ...options, | ||
| method: "POST", | ||
| body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body), | ||
| }), | ||
|
|
||
| patch: <TResponse, TBody = unknown>(endpoint: string, body?: TBody, options?: RequestInit) => | ||
| request<TResponse>(endpoint, { | ||
| ...options, | ||
| method: "PATCH", | ||
| body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body), | ||
| }), | ||
|
9g-g9 marked this conversation as resolved.
|
||
|
|
||
| delete: <TResponse>(endpoint: string, options?: RequestInit) => | ||
| request<TResponse>(endpoint, { ...options, method: "DELETE" }), | ||
| }; | ||
|
|
||
| export default fetchInstance; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| // 공통 에러 코드 | ||
| export const ERROR_CODES = { | ||
| BAD_REQUEST: { | ||
| status: 400, | ||
| code: "BAD_REQUEST", | ||
| message: "잘못된 요청입니다.", | ||
| }, | ||
|
|
||
| UNAUTHORIZED: { | ||
| status: 401, | ||
| code: "UNAUTHORIZED", | ||
| message: "인증이 필요합니다.", | ||
| }, | ||
|
|
||
| FORBIDDEN: { | ||
| status: 403, | ||
| code: "FORBIDDEN", | ||
| message: "접근 권한이 없습니다.", | ||
| }, | ||
|
|
||
| NOT_FOUND: { | ||
| status: 404, | ||
| code: "NOT_FOUND", | ||
| message: "요청한 리소스를 찾을 수 없습니다.", | ||
| }, | ||
|
|
||
| CONFLICT: { | ||
| status: 409, | ||
| code: "CONFLICT", | ||
| message: "이미 존재하는 데이터입니다.", | ||
| }, | ||
|
|
||
| INTERNAL_SERVER_ERROR: { | ||
| status: 500, | ||
| code: "INTERNAL_SERVER_ERROR", | ||
| message: "서버 내부 오류가 발생했습니다.", | ||
| }, | ||
|
|
||
| VALIDATION_ERROR: { | ||
| status: 422, | ||
| code: "VALIDATION_ERROR", | ||
| message: "입력값이 올바르지 않습니다.", | ||
| }, | ||
|
|
||
| ACTIVE_REQUEST_EXISTS: { | ||
| status: 409, | ||
| code: "ACTIVE_REQUEST_EXISTS", | ||
| message: "이미 진행 중인 견적 요청이 있습니다.", | ||
| }, | ||
|
|
||
| INVALID_MOVE_DATE: { | ||
| status: 400, | ||
| code: "INVALID_MOVE_DATE", | ||
| message: "이사 예정일은 오늘 이후여야 합니다.", | ||
| }, | ||
|
|
||
| REGION_NOT_FOUND: { | ||
| status: 400, | ||
| code: "REGION_NOT_FOUND", | ||
| message: "지원하지 않는 지역입니다.", | ||
| }, | ||
|
|
||
| ESTIMATE_REQUEST_NOT_FOUND: { | ||
| status: 404, | ||
| code: "ESTIMATE_REQUEST_NOT_FOUND", | ||
| message: "견적 요청을 찾을 수 없습니다.", | ||
| }, | ||
|
|
||
| REQUEST_NOT_EDITABLE: { | ||
| status: 409, | ||
| code: "REQUEST_NOT_EDITABLE", | ||
| message: "견적이 도착한 요청은 수정할 수 없습니다.", | ||
| }, | ||
|
|
||
| MOVER_NOT_FOUND: { | ||
| status: 404, | ||
| code: "MOVER_NOT_FOUND", | ||
| message: "존재하지 않는 기사님입니다.", | ||
| }, | ||
|
|
||
| ALREADY_DESIGNATED: { | ||
| status: 409, | ||
| code: "ALREADY_DESIGNATED", | ||
| message: "이미 지정한 기사님입니다.", | ||
| }, | ||
|
|
||
| DESIGNATION_LIMIT_EXCEEDED: { | ||
| status: 409, | ||
| code: "DESIGNATION_LIMIT_EXCEEDED", | ||
| message: "지정 견적은 최대 3명까지 요청할 수 있습니다.", | ||
| }, | ||
| } as const; | ||
|
|
||
| // ErrorCode의 key 타입 | ||
| export type ErrorCode = keyof typeof ERROR_CODES; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import type { ErrorCode } from "@/lib/constants/errorCodes"; | ||
|
|
||
| export interface ApiSuccessResponse<T> { | ||
| success: true; | ||
| message?: string; | ||
| data: T; | ||
| } | ||
|
|
||
| export interface ApiErrorResponse { | ||
| success: false; | ||
| error: { | ||
| code: ErrorCode; | ||
| message: string; | ||
| }; | ||
| path?: string; | ||
| method?: string; | ||
| timestamp?: string; | ||
| } | ||
|
|
||
| export class ApiError extends Error { | ||
| status?: number; | ||
| code?: ErrorCode; | ||
| data?: unknown; // path/method/timestamp 등 디버깅용 정보 | ||
|
|
||
| constructor(message: string, status?: number, code?: ErrorCode, data?: unknown) { | ||
| super(message); | ||
| this.name = "ApiError"; | ||
| this.status = status; | ||
| this.code = code; | ||
| this.data = data; | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NEXT_PUBLIC_API_BASE_URL이 설정되지 않으면 요청 URL이undefined/auth/me처럼 만들어져서 원인 찾기 어려운 에러가 날 것 같습니다. 미설정 시 바로 에러를 던지도록 가드를 추가하는 것도 좋을 것 같습니다.