From 342cda7fb8c09bc6b8d5764ca92cea38a4901626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=84=9C=ED=98=84?= Date: Sun, 29 Dec 2024 00:22:04 +0900 Subject: [PATCH] =?UTF-8?q?Feature/#33=20Fetcher=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=ED=98=84=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 객체로 리팩토링 --- packages/utils/package.json | 4 + packages/utils/src/errorHandler/HTTPError.ts | 47 +++ .../utils/src/{ => fetcher}/fetcher.test.ts | 0 packages/utils/src/fetcher/fetcher.ts | 298 ++++++++++++++++++ pnpm-lock.yaml | 12 +- 5 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 packages/utils/src/errorHandler/HTTPError.ts rename packages/utils/src/{ => fetcher}/fetcher.test.ts (100%) create mode 100644 packages/utils/src/fetcher/fetcher.ts diff --git a/packages/utils/package.json b/packages/utils/package.json index 13cab92..977b679 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -9,6 +9,10 @@ "test": "jest" }, "dependencies": { + "lodash": "^4.17.21", "react": "^19.0.0" + }, + "devDependencies": { + "@types/lodash": "^4.17.13" } } diff --git a/packages/utils/src/errorHandler/HTTPError.ts b/packages/utils/src/errorHandler/HTTPError.ts new file mode 100644 index 0000000..118c200 --- /dev/null +++ b/packages/utils/src/errorHandler/HTTPError.ts @@ -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; diff --git a/packages/utils/src/fetcher.test.ts b/packages/utils/src/fetcher/fetcher.test.ts similarity index 100% rename from packages/utils/src/fetcher.test.ts rename to packages/utils/src/fetcher/fetcher.test.ts diff --git a/packages/utils/src/fetcher/fetcher.ts b/packages/utils/src/fetcher/fetcher.ts new file mode 100644 index 0000000..ed3e126 --- /dev/null +++ b/packages/utils/src/fetcher/fetcher.ts @@ -0,0 +1,298 @@ +import { isEqual } from "lodash"; +import HTTPError from "../errorHandler/HTTPError.ts"; + +type ApiResponse = Response & { data?: T; success?: boolean }; + +type RequestInterceptor = ( + options: RequestInit +) => RequestInit | Promise; + +type ResponseInterceptor = ( + response: Response +) => ApiResponse | Promise>; + +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; + set: (handler: (error: Error) => void | Promise) => 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 + ): ErrorHandler { + return { + handler: errorHandler, + set: (handler: (error: Error) => void | Promise) => { + 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 { + 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( + response: Response + ): Promise> { + let interceptedResponse = response; + for (const interceptor of this.interceptors.response.handlers) { + interceptedResponse = await interceptor(interceptedResponse); + } + return interceptedResponse; + } + + public async request( + url: string, + options: RequestInit + ): Promise> { + try { + const interceptedOptions = await this.interceptRequests(options); + const fetchOptions: RequestInit = { + ...interceptedOptions, + credentials: "include", + }; + const requestUrl = this.baseUrlConfig.value + url; + + let response: ApiResponse = 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( + response: Response + ): Promise { + const contentType = response.headers.get("Content-Type") || ""; + + if (contentType.includes("application/json")) { + return response.json() as Promise; + } 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( + url: string, + options: RequestInit = {}, + params: Record = {} + ): Promise> { + const queryString = + params && Object.keys(params).length + ? `?${new URLSearchParams(params).toString()}` + : ""; + const fullUrl = `${url}${queryString}`; + + return this.request(fullUrl, { ...options, method: "GET" }); + } + + public post( + url: string, + body = {}, + options: RequestInit = {} + ): Promise> { + return this.request(url, { + ...options, + method: "POST", + body: JSON.stringify(body), + }); + } + + public put( + url: string, + body = {}, + options: RequestInit = {} + ): Promise> { + return this.request(url, { + ...options, + method: "PUT", + body: JSON.stringify(body), + }); + } + + public patch( + url: string, + body = {}, + options: RequestInit = {} + ): Promise> { + return this.request(url, { + ...options, + method: "PATCH", + body: JSON.stringify(body), + }); + } + + public delete( + url: string, + body = {}, + options: RequestInit = {} + ): Promise> { + return this.request(url, { + ...options, + method: "DELETE", + body: JSON.stringify(body), + }); + } +} + +export default Fetcher; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b1d5c6..8f6add0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,9 +268,16 @@ importers: packages/utils: dependencies: + lodash: + specifier: ^4.17.21 + version: 4.17.21 react: specifier: ^19.0.0 version: 19.0.0 + devDependencies: + '@types/lodash': + specifier: ^4.17.13 + version: 4.17.13 packages/webview-bridge: dependencies: @@ -4723,6 +4730,10 @@ packages: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true + /@types/lodash@4.17.13: + resolution: {integrity: sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==} + dev: true + /@types/mdx@2.0.13: resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} dev: true @@ -9933,7 +9944,6 @@ packages: /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true /log-symbols@3.0.0: resolution: {integrity: sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==}