Skip to content

Commit

Permalink
Merge pull request #13 from GDSC-Hongik/feature/fetch-setting
Browse files Browse the repository at this point in the history
[Feature] fetch 클래스 및 테스트 코드 작성
  • Loading branch information
ghdtjgus76 authored Aug 12, 2024
2 parents 43250dd + 2f1df76 commit 178c622
Show file tree
Hide file tree
Showing 12 changed files with 1,748 additions and 20 deletions.
1 change: 1 addition & 0 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@types/react-dom": "^18",
"@wow-class/eslint-config": "workspace:*",
"@wow-class/typescript-config": "workspace:*",
"@wow-class/utils": "workspace:*",
"eslint": "^8",
"eslint-config-next": "^14.2.5",
"typescript": "^5"
Expand Down
1 change: 1 addition & 0 deletions apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@types/react-dom": "^18",
"@wow-class/eslint-config": "workspace:*",
"@wow-class/typescript-config": "workspace:*",
"@wow-class/utils": "workspace:*",
"eslint": "^8",
"eslint-config-next": "^14.2.5",
"typescript": "^5"
Expand Down
2 changes: 1 addition & 1 deletion apps/client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"baseUrl": ".",
"paths": {
"@/*": ["app/*"],
"@styled-system/*": ["./styled-system/*"],
"@styled-system/*": ["./styled-system/*"]
}
},
"include": [
Expand Down
2 changes: 1 addition & 1 deletion packages/eslint-config/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ module.exports = {
],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"turbo/no-undeclared-env-vars": "off",
},

settings: {
Expand All @@ -113,7 +114,6 @@ module.exports = {
version: "detect",
},
},

ignorePatterns: [
".*.js",
"node_modules/",
Expand Down
11 changes: 11 additions & 0 deletions packages/utils/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@wow-class/eslint-config/basic.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
ecmaVersion: 2020,
sourceType: "module",
},
};
10 changes: 10 additions & 0 deletions packages/utils/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
setupFiles: ["<rootDir>/jest.setup.ts"],
moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
transform: {
"^.+\\.ts?$": "ts-jest",
},
testMatch: ["<rootDir>/**/*.(test|spec).ts"],
};
3 changes: 3 additions & 0 deletions packages/utils/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import fetchMock from "jest-fetch-mock";

fetchMock.enableMocks();
15 changes: 15 additions & 0 deletions packages/utils/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@wow-class/utils",
"version": "0.0.0",
"private": true,
"scripts": {
"test": "jest"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"jest": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"ts-jest": "^29.2.4",
"@wow-class/typescript-config": "workspace:*"
}
}
126 changes: 126 additions & 0 deletions packages/utils/src/fetcher/fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import fetchMock from "jest-fetch-mock";

import { fetcher } from "./fetcher";

describe("Fetcher", () => {
beforeEach(() => {
fetchMock.resetMocks();
});

it("should set the baseURL correctly", () => {
fetcher.setBaseUrl("https://api.example.com");

expect(fetcher["baseUrl"]).toBe("https://api.example.com");
});

it("should set default headers correctly", () => {
fetcher.setDefaultHeaders({ Authorization: "Bearer test-token" });

expect(fetcher["defaultHeaders"]).toEqual({
Authorization: "Bearer test-token",
});
});

it("should make a GET request with the correct headers and URL", async () => {
fetchMock.mockResponseOnce(JSON.stringify({ success: true }));
fetcher.setBaseUrl("https://api.example.com");
fetcher.setDefaultHeaders({
"Content-Type": "application/json",
Authorization: "Bearer test-token",
});

const response = await fetcher.get("/test-endpoint");

expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/test-endpoint",
{
method: "GET",
headers: {
Authorization: "Bearer test-token",
"Content-Type": "application/json",
},
}
);
const jsonData = JSON.parse(response.data);
expect(jsonData).toEqual({ success: true });
});

it("should make a GET request with query parameters", async () => {
fetchMock.mockResponseOnce(JSON.stringify({ success: true }));
fetcher.setBaseUrl("https://api.example.com");
fetcher.setDefaultHeaders({
"Content-Type": "application/json",
Authorization: "Bearer test-token",
});

const params = { key1: "value1", key2: "value2" };
const response = await fetcher.get("/test-endpoint", {}, params);

expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/test-endpoint?key1=value1&key2=value2",
{
method: "GET",
headers: {
Authorization: "Bearer test-token",
"Content-Type": "application/json",
},
}
);
const jsonData = JSON.parse(response.data);
expect(jsonData).toEqual({ success: true });
});

it("should make a POST request with the correct headers and URL and body", async () => {
fetchMock.mockResponseOnce(JSON.stringify({ success: true }));
fetcher.setBaseUrl("https://api.example.com");
fetcher.setDefaultHeaders({
"Content-Type": "application/json",
Authorization: "Bearer test-token",
});

const response = await fetcher.post("/test-endpoint", { foo: "bar" });

expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/test-endpoint",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer test-token",
},
body: JSON.stringify({ foo: "bar" }),
}
);
const jsonData = JSON.parse(response.data);
expect(jsonData).toEqual({ success: true });
});

it("should handle plain text responses", async () => {
fetchMock.mockResponseOnce("plain text response", {
headers: { "Content-Type": "text/plain" },
});
fetcher.setBaseUrl("https://api.example.com");

const response = await fetcher.get("/test-endpoint");
expect(response.data).toBe("plain text response");
});

it("should handle HTTP errors correctly", async () => {
fetchMock.mockResponseOnce("Not Found", { status: 404 });
fetcher.setBaseUrl("https://api.example.com");
fetcher.setDefaultHeaders({
"Content-Type": "application/json",
Authorization: "Bearer test-token",
});

try {
await fetcher.get("/test-endpoint");
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as any).response).toBeInstanceOf(Response);
expect((error as any).response.status).toBe(404);
expect((error as any).responseText).toBe("Not Found");
expect((error as any).message).toBe("HTTP Error: 404 Not Found");
}
});
});
166 changes: 166 additions & 0 deletions packages/utils/src/fetcher/fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
type ApiResponse<T = any> = Response & { data?: T };

type RequestInterceptor = (
options: RequestInit
) => RequestInit | Promise<RequestInit>;
type ResponseInterceptor<T = any> = (
response: ApiResponse
) => ApiResponse<T> | Promise<ApiResponse<T>>;

class Fetcher {
private baseUrl: string;
private defaultHeaders: HeadersInit;
private requestInterceptors: RequestInterceptor[];
private responseInterceptors: ResponseInterceptor[];

constructor({ baseUrl = "", defaultHeaders = {} } = {}) {
this.baseUrl = baseUrl;
this.defaultHeaders = defaultHeaders;
this.requestInterceptors = [];
this.responseInterceptors = [];
}

setBaseUrl(baseUrl: string) {
this.baseUrl = baseUrl;
}

setDefaultHeaders(headers: HeadersInit) {
this.defaultHeaders = headers;
}

addRequestInterceptor(interceptor: RequestInterceptor) {
this.requestInterceptors.push(interceptor);
}

addResponseInterceptor<T = any>(interceptor: ResponseInterceptor<T>) {
this.responseInterceptors.push(interceptor);
}

private async interceptRequest(options: RequestInit): Promise<RequestInit> {
options.headers = { ...this.defaultHeaders, ...options.headers };

for (const interceptor of this.requestInterceptors) {
options = (await interceptor(options)) || options;
}

return options;
}

private async interceptResponse<T = any>(
response: Response
): Promise<ApiResponse<T>> {
for (const interceptor of this.responseInterceptors) {
response = (await interceptor(response)) || response;
}

return response;
}

private async parseJsonResponse(response: Response): Promise<any> {
const contentType = response.headers.get("Content-Type") || "";

if (contentType.includes("application/json")) {
return response.json();
} else if (contentType.startsWith("image/")) {
return response.blob();
}

return response.text();
}

private async handleError(response: Response) {
if (!response.ok) {
const text = await response.text();
const error = new Error(
`HTTP Error: ${response.status} ${response.statusText}`
);
(error as any).response = response;
(error as any).responseText = text;

throw error;
}
}

async request<T = any>(
url: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
options = await this.interceptRequest(options);

const fullUrl = this.baseUrl + url;

let response: ApiResponse = await fetch(fullUrl, options);

await this.handleError(response);

response = await this.interceptResponse(response);
response.data = await this.parseJsonResponse(response);

return response;
}

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

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

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

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

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

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

export const fetcher = new Fetcher({
baseUrl:
process.env.NODE_ENV === "production"
? process.env.NEXT_PUBLIC_PROD_BASE_URL
: process.env.NEXT_PUBLIC_DEV_BASE_URL,
defaultHeaders: { "Content-Type": "application/json" },
});
5 changes: 5 additions & 0 deletions packages/utils/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "@wow-class/typescript-config/basic.json",
"include": ["src", "jest.setup.ts"],
"exclude": ["node_modules"]
}
Loading

0 comments on commit 178c622

Please sign in to comment.