Skip to content

Commit

Permalink
Feature/#33 Fetcher 클래스 구현 (#75)
Browse files Browse the repository at this point in the history
* rename(packages/utils): utils 패키지로 fetcher 테스트 파일 이동

* feat(packages/utils): lodash 관련 라이브러리 설치

* feat(packages/utils): fetcher 클래스 타입 정의 및 속성, 생성자 정의

* refactor(packages/utils): fetcher 클래스 baseUrl, header 관리 방법 수정

* refactor(packages/utils): fetcher 클래스 응답, 요청 인터셉터 구현체 구현

* chore(packages/utils): 요청 인터셉터에서 기존 헤더 값 고려하도록 수정

* feat(packages/utils): fetcher 클래스 request 함수 정의 및 전역 에러 핸들러 추가

* chore(packages/utils): fetcher 클래스 인터셉터 생성자 인수 받을 수 있도록 수정

* feat(packages/utils): HTTP 메서드 정의 및 에러 핸들러 함수 변경

* refactor(packages/utils): 에러 핸들링 부분 HTTPError 객체로 리팩토링
  • Loading branch information
ghdtjgus76 authored Dec 28, 2024
1 parent f8f8de7 commit 342cda7
Show file tree
Hide file tree
Showing 5 changed files with 360 additions and 1 deletion.
4 changes: 4 additions & 0 deletions packages/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"test": "jest"
},
"dependencies": {
"lodash": "^4.17.21",
"react": "^19.0.0"
},
"devDependencies": {
"@types/lodash": "^4.17.13"
}
}
47 changes: 47 additions & 0 deletions packages/utils/src/errorHandler/HTTPError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
class HTTPError {
static handle(response: Response) {
const error = new Error();

switch (response.status) {
case 400:
error.name = "Bad Request";
error.message = "The request was invalid or cannot be served.";
break;
case 401:
error.name = "Unauthorized";
error.message =
"Authentication is required and has failed or has not yet been provided.";
break;
case 403:
error.name = "Forbidden";
error.message =
"The request was valid, but the server is refusing action.";
break;
case 404:
error.name = "Not Found";
error.message = "The requested resource could not be found.";
break;
case 500:
error.name = "Internal Server Error";
error.message = "An error occurred on the server.";
break;
case 502:
error.name = "Bad Gateway";
error.message =
"The server received an invalid response from the upstream server.";
break;
case 503:
error.name = "Service Unavailable";
error.message = "The server is currently unable to handle the request.";
break;
default:
error.name = "HTTP Error";
error.message = `An unexpected HTTP error occurred: ${response.status}`;
break;
}

return error;
}
}

export default HTTPError;
File renamed without changes.
298 changes: 298 additions & 0 deletions packages/utils/src/fetcher/fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
import { isEqual } from "lodash";
import HTTPError from "../errorHandler/HTTPError.ts";

type ApiResponse<T> = Response & { data?: T; success?: boolean };

type RequestInterceptor = (
options: RequestInit
) => RequestInit | Promise<RequestInit>;

type ResponseInterceptor<T = any> = (
response: Response
) => ApiResponse<T> | Promise<ApiResponse<T>>;

interface BaseUrlConfig {
value: string;
set: (url: string) => void;
}

interface HeadersConfig {
value: HeadersInit;
set: (headers: HeadersInit) => void;
merge: (headers: HeadersInit) => void;
}

interface Interceptors {
request: {
handlers: RequestInterceptor[];
add: (interceptor: RequestInterceptor) => void;
remove: (interceptor: RequestInterceptor) => void;
};
response: {
handlers: ResponseInterceptor[];
add: (interceptor: ResponseInterceptor) => void;
remove: (interceptor: ResponseInterceptor) => void;
};
}

interface ErrorHandler {
handler: (error: Error) => void | Promise<void>;
set: (handler: (error: Error) => void | Promise<void>) => void;
}

class Fetcher {
private baseUrlConfig: BaseUrlConfig;
private headersConfig: HeadersConfig;
private interceptors: Interceptors;
private errorHandler: ErrorHandler;

constructor({
baseUrl = "",
defaultHeaders = {},
requestInterceptors = [],
responseInterceptors = [],
errorHandler = () => {},
} = {}) {
this.baseUrlConfig = this.createBaseUrlConfig(baseUrl);
this.headersConfig = this.createHeadersConfig(defaultHeaders);
this.interceptors = this.createInterceptors(
requestInterceptors,
responseInterceptors
);
this.errorHandler = this.createErrorHandler(errorHandler);
}

private createBaseUrlConfig(baseUrl: string): BaseUrlConfig {
return {
value: baseUrl,
set: (url: string) => {
this.baseUrlConfig.value = url;
},
};
}

private createHeadersConfig(defaultHeaders: HeadersInit): HeadersConfig {
return {
value: defaultHeaders,
set: (headers: HeadersInit) => {
this.headersConfig.value = headers;
},
merge: (headers: HeadersInit) => {
this.headersConfig.value = {
...this.headersConfig.value,
...headers,
};
},
};
}

private createInterceptors(
requestInterceptors: RequestInterceptor[],
responseInterceptors: ResponseInterceptor[]
): Interceptors {
return {
request: {
handlers: requestInterceptors,
add: (interceptor: RequestInterceptor) => {
if (!this.interceptors.request.handlers.includes(interceptor)) {
this.interceptors.request.handlers.push(interceptor);
}
},
remove: (interceptor: RequestInterceptor) => {
this.interceptors.request.handlers =
this.interceptors.request.handlers.filter(
(currentInterceptor) =>
!isEqual(currentInterceptor.toString(), interceptor.toString())
);
},
},
response: {
handlers: responseInterceptors,
add: (interceptor: ResponseInterceptor) => {
if (!this.interceptors.response.handlers.includes(interceptor)) {
this.interceptors.response.handlers.push(interceptor);
}
},
remove: (interceptor: ResponseInterceptor) => {
this.interceptors.response.handlers =
this.interceptors.response.handlers.filter(
(currentInterceptor) =>
!isEqual(currentInterceptor.toString(), interceptor.toString())
);
},
},
};
}

private createErrorHandler(
errorHandler: (error: Error) => void | Promise<void>
): ErrorHandler {
return {
handler: errorHandler,
set: (handler: (error: Error) => void | Promise<void>) => {
this.errorHandler.handler = handler;
},
};
}

public addRequestInterceptor(interceptor: RequestInterceptor): void {
this.interceptors.request.add(interceptor);
}

public removeRequestInterceptors(interceptor: RequestInterceptor): void {
this.interceptors.request.remove(interceptor);
}

public async interceptRequests(options: RequestInit): Promise<RequestInit> {
let interceptedOptions: RequestInit = {
...options,
headers: {
...(this.headersConfig.value || {}),
...(options.headers || {}),
},
};
for (const interceptor of this.interceptors.request.handlers) {
interceptedOptions = await interceptor(interceptedOptions);
}
return interceptedOptions;
}

public addResponseInterceptor(interceptor: ResponseInterceptor): void {
this.interceptors.response.add(interceptor);
}

public removeResponseInterceptors(interceptor: ResponseInterceptor): void {
this.interceptors.response.remove(interceptor);
}

public async interceptResponses<T>(
response: Response
): Promise<ApiResponse<T>> {
let interceptedResponse = response;
for (const interceptor of this.interceptors.response.handlers) {
interceptedResponse = await interceptor(interceptedResponse);
}
return interceptedResponse;
}

public async request<T>(
url: string,
options: RequestInit
): Promise<ApiResponse<T>> {
try {
const interceptedOptions = await this.interceptRequests(options);
const fetchOptions: RequestInit = {
...interceptedOptions,
credentials: "include",
};
const requestUrl = this.baseUrlConfig.value + url;

let response: ApiResponse<any> = await fetch(requestUrl, fetchOptions);

const data = await this.parseResponseData(response);
const error = this.handleError(response);

if (error) {
throw error;
}

response = await this.interceptResponses(response);
response.data = data;

return response;
} catch (error) {
if (error instanceof Error) {
this.errorHandler.handler(error);
}

throw error;
}
}

private async parseResponseData<T>(
response: Response
): Promise<T | Blob | string> {
const contentType = response.headers.get("Content-Type") || "";

if (contentType.includes("application/json")) {
return response.json() as Promise<T>;
} else if (
contentType.startsWith("image/") ||
contentType.startsWith("application/octet-stream")
) {
return response.blob();
}

return response.text();
}

private handleError(response: Response): Error | undefined {
if (!response.ok) {
return HTTPError.handle(response);
}
}

public get<T>(
url: string,
options: RequestInit = {},
params: Record<string, any> = {}
): Promise<ApiResponse<T>> {
const queryString =
params && Object.keys(params).length
? `?${new URLSearchParams(params).toString()}`
: "";
const fullUrl = `${url}${queryString}`;

return this.request(fullUrl, { ...options, method: "GET" });
}

public post<T>(
url: string,
body = {},
options: RequestInit = {}
): Promise<ApiResponse<T>> {
return this.request(url, {
...options,
method: "POST",
body: JSON.stringify(body),
});
}

public put<T>(
url: string,
body = {},
options: RequestInit = {}
): Promise<ApiResponse<T>> {
return this.request(url, {
...options,
method: "PUT",
body: JSON.stringify(body),
});
}

public patch<T>(
url: string,
body = {},
options: RequestInit = {}
): Promise<ApiResponse<T>> {
return this.request(url, {
...options,
method: "PATCH",
body: JSON.stringify(body),
});
}

public delete<T>(
url: string,
body = {},
options: RequestInit = {}
): Promise<ApiResponse<T>> {
return this.request(url, {
...options,
method: "DELETE",
body: JSON.stringify(body),
});
}
}

export default Fetcher;
12 changes: 11 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 342cda7

Please sign in to comment.