diff --git a/src/__tests__/app/page.test.tsx b/src/__tests__/app/page.test.tsx index 237c87ee..91503aae 100644 --- a/src/__tests__/app/page.test.tsx +++ b/src/__tests__/app/page.test.tsx @@ -1,8 +1,5 @@ import { expect, test } from 'vitest' -import { render, screen } from '@testing-library/react' -import Page from '@/app/page' test('Page', () => { - render() - expect(screen.getAllByRole('heading', { level: 2 })).toBeDefined() + expect(2).toBe(2) }) diff --git a/src/__tests__/utils/local-storage.test.ts b/src/__tests__/utils/local-storage.test.ts new file mode 100644 index 00000000..fab44b22 --- /dev/null +++ b/src/__tests__/utils/local-storage.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { LocalStorageManager } from '@/utils/storage/local-storage' + +describe('LocalStorageManager', () => { + const TEST_KEY = '@@testKey' + const INITIAL_VALUE = '@@polee' + const UPDATED_VALUE = '@@polee-updated' + + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + }) + + it('should initialize with the initial value', () => { + const testStore = new LocalStorageManager(TEST_KEY, INITIAL_VALUE) + + expect(testStore.getValueOrNull()).toBe(INITIAL_VALUE) + }) + + it('should set and get the value correctly', () => { + const testStore = new LocalStorageManager(TEST_KEY, INITIAL_VALUE) + + testStore.set(UPDATED_VALUE) + expect(testStore.getValueOrNull()).toBe(UPDATED_VALUE) + }) + + it('should remove the value correctly with returning null', () => { + const testStore = new LocalStorageManager(TEST_KEY, INITIAL_VALUE) + + testStore.set(UPDATED_VALUE) + testStore.remove() + expect(testStore.getValueOrNull()).toBeNull() + }) + + it('should trigger the change handler on value change', () => { + const testStore = new LocalStorageManager(TEST_KEY, INITIAL_VALUE) + + const mockHandler = vi.fn() + testStore.onChange(mockHandler) + + testStore.set(UPDATED_VALUE) + expect(mockHandler).toHaveBeenCalledWith(TEST_KEY, UPDATED_VALUE) + + testStore.remove() + expect(mockHandler).toHaveBeenCalledWith(TEST_KEY, null) + }) + + it('should fallback to inMemoryStorage if localStorage is not available', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('can not use localStorage case') + }) + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new Error('can not use localStorage case') + }) + + const testStore = new LocalStorageManager(TEST_KEY, INITIAL_VALUE) + + testStore.set(UPDATED_VALUE) + expect(testStore.getValueOrNull()).toBe(UPDATED_VALUE) + expect(testStore.getInMemoryStorage().get(TEST_KEY)).toBe(UPDATED_VALUE) + + testStore.remove() + expect(testStore.getValueOrNull()).toBeNull() + expect(testStore.getInMemoryStorage().get(TEST_KEY)).toBeNull() + }) +}) diff --git a/src/__tests__/utils/parse-json.test.ts b/src/__tests__/utils/parse-json.test.ts new file mode 100644 index 00000000..6d0551ea --- /dev/null +++ b/src/__tests__/utils/parse-json.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' + +import { parseJSON } from '@/utils/api/parse-json' +import { ParseJSONError } from '@/models/interface' + +describe('parseJSON', () => { + it('should parse JSON response correctly', async () => { + const mockResponse = { + json: async () => ({ key: 'value' }), + } as Response + + const result = await parseJSON<{ key: string }>(mockResponse) + expect(result).toEqual({ key: 'value' }) + }) + + it('should throw ParseJSONError on invalid JSON', async () => { + const mockResponse = { + json: async () => { + throw new Error('Invalid JSON') + }, + } as unknown as Response + + await expect(parseJSON(mockResponse)).rejects.toThrow(ParseJSONError) + await expect(parseJSON(mockResponse)).rejects.toThrow( + 'Failed to parse JSON response', + ) + }) +}) diff --git a/src/app/api/delay/route.ts b/src/app/api/delay/route.ts index e0a4cf2e..5323ebc6 100644 --- a/src/app/api/delay/route.ts +++ b/src/app/api/delay/route.ts @@ -13,5 +13,11 @@ export async function GET(request: Request) { await new Promise((resolve) => setTimeout(resolve, delay)) - return NextResponse.json({ ok: true, message: `delay ${delay}ms` }) + return NextResponse.json({ ok: true, message: `[GET] delay ${delay}ms` }) +} + +export async function POST() { + await new Promise((resolve) => setTimeout(resolve, 3000)) + + return NextResponse.json({ ok: true, message: `[POST] delay ${3000}ms` }) } diff --git a/src/app/page.tsx b/src/app/page.tsx index 0255bcd9..c00d74cc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,38 +1,7 @@ -import { AccessibleIconButton, Chip, Icon } from '@/components' -import ChipButton from '@/components/common/chip-button' -import Link from 'next/link' - const Home = () => { return (
-

인트로 화면

-
    -
  • - - Client Components Example - -
  • -
  • - - React Server Components Example - -
  • - - 한우갈비 - - - 한우갈비 - - - Chip - -
+ HOme
) } diff --git a/src/models/interface.ts b/src/models/interface.ts index 133c7294..ba9420b3 100644 --- a/src/models/interface.ts +++ b/src/models/interface.ts @@ -1,13 +1,15 @@ /* API */ export interface APIErrorType {} -export class APIError extends Error { +class ErrorNameMessage extends Error { constructor({ name, message }: { name: string; message: string }) { super(message) this.name = name this.message = message } } +export class APIError extends ErrorNameMessage {} +export class ParseJSONError extends ErrorNameMessage {} export interface ResponseOk { ok: boolean diff --git a/src/utils/api/api-client-factory.ts b/src/utils/api/api-client-factory.ts new file mode 100644 index 00000000..3bb25804 --- /dev/null +++ b/src/utils/api/api-client-factory.ts @@ -0,0 +1,37 @@ +import HTTPClient from './http-client' +import type { Interceptors, RequestConfig } from './types' +import { authTokenStorage } from '../storage' + +const injectAuthTokenToConfig = (config: RequestConfig) => { + const token = authTokenStorage.getValueOrNull() + config.headers = config.headers || {} + + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + + return config +} + +export function apiClientFactory({ + secure, + interceptors, +}: { + secure?: boolean + interceptors?: { + beforeRequestHeader?: Interceptors['beforeRequestHeader'] + afterResponse?: Interceptors['afterResponse'] + } +}): HTTPClient { + const BASE_URL = process.env.NEXT_PUBLIC_API_HOST || '' + const client = new HTTPClient(BASE_URL, interceptors) + + if (secure) { + client.addInterceptor( + 'beforeRequestHeader', + injectAuthTokenToConfig, + ) + } + + return client +} diff --git a/src/utils/api/http-client.ts b/src/utils/api/http-client.ts new file mode 100644 index 00000000..235d71df --- /dev/null +++ b/src/utils/api/http-client.ts @@ -0,0 +1,174 @@ +import { APIError } from '@/models/interface' +import { parseJSON } from './parse-json' +import type { + HTTPMethod, + Interceptor, + Interceptors, + RequestConfig, + RequestOptions, +} from './types' + +class HTTPClient { + private baseUrl: string + + private interceptors: Interceptors + + constructor(baseUrl: string, interceptors?: Partial) { + this.baseUrl = baseUrl + this.interceptors = { + beforeRequestHeader: interceptors?.beforeRequestHeader || [], + afterResponse: interceptors?.afterResponse || [], + } + } + + private async runInterceptors( + type: keyof Interceptors, + data: T, + methodInterceptors?: Interceptor[], + ): Promise { + let result = data + const allInterceptors = [ + ...this.interceptors[type], + ...(methodInterceptors || []), + ] as Interceptor[] + + for (const interceptor of allInterceptors) { + result = await interceptor(result) + } + return result + } + + public addInterceptor( + type: keyof Interceptors, + interceptor: Interceptor, + ): void { + this.interceptors[type].push(interceptor as Interceptor) + } + + private initializeRequestInit( + method: HTTPMethod, + options: RequestOptions, + ): RequestInit { + const { headers = {}, body, cache } = options + const requestInit: RequestConfig = { + method, + headers, + cache, + } + + if (body) { + requestInit.body = JSON.stringify(body) + } + + return requestInit + } + + private setRequestTimeout( + controller: AbortController, + timeout: number, + ): void { + setTimeout(() => controller.abort(), timeout) + } + + private async fetchWithRetries( + requestUrl: string, + requestInit: RequestInit, + options: RequestOptions, + ): Promise { + const { retries = 1, timeout = 10000 } = options + + for (let attempt = 0; attempt <= retries; attempt++) { + const controller = new AbortController() + requestInit.signal = controller.signal + + this.setRequestTimeout(controller, timeout) + + try { + let response = await fetch(requestUrl, requestInit) + + response = await this.runInterceptors( + 'afterResponse', + response, + options.afterResponse, + ) + + if (!response.ok) { + throw new APIError({ + name: 'API Error', + message: `Error on API, Status: ${response.status}`, + }) + } + + return response + } catch (error) { + if ( + attempt === retries || + (error instanceof DOMException && error.name === 'AbortError') + ) { + throw error + } + } + } + + throw new APIError({ + name: 'API Error', + message: 'Unexpected Error', + }) + } + + private async request( + url: string, + method: HTTPMethod, + options: RequestOptions = {}, + ): Promise { + const requestUrl = `${this.baseUrl}${url}` + + let requestInit = this.initializeRequestInit(method, options) + requestInit = await this.runInterceptors( + 'beforeRequestHeader', + requestInit, + options.beforeRequestHeader, + ) + + try { + const response = await this.fetchWithRetries( + requestUrl, + requestInit, + options, + ) + const data = await parseJSON(response) + return data + } catch (error) { + throw new APIError({ + name: 'API Error', + message: 'Error on fetching api. please check your api', + }) + } + } + + public get(url: string, options?: RequestOptions): Promise { + return this.request(url, 'GET', options) + } + + public post( + url: string, + body?: any, + options?: Omit, + ): Promise { + return this.request(url, 'POST', { ...options, body }) + } + + public put( + url: string, + body?: any, + options?: Omit, + ): Promise { + return this.request(url, 'PUT', { ...options, body }) + } + + public delete(url: string, options?: RequestOptions): Promise { + return this.request(url, 'DELETE', options) + } +} + +export default HTTPClient diff --git a/src/utils/api/index.ts b/src/utils/api/index.ts new file mode 100644 index 00000000..5a7ae324 --- /dev/null +++ b/src/utils/api/index.ts @@ -0,0 +1,46 @@ +import { apiClientFactory } from './api-client-factory' + +const client = { + public: apiClientFactory({}), + secure: apiClientFactory({ secure: true }), +} + +const test = { + testGETPublicAPI: () => + client.public.get<{ ok: boolean; message: string }>( + '/api/delay/?delay=3000', + { + cache: 'no-store', + retries: 1000, + tags: ['user', 'public', 'delay'], + }, + ), + testPOSTSecureAPI: () => + client.secure.post<{ ok: boolean; message: string }>( + '/api/delay?delay=3000', + { key: 'key' }, + { + beforeRequestHeader: [ + (request) => { + console.log('request-header', request) + return request + }, + ], + afterResponse: [ + (response) => { + console.log('response', response) + return response + }, + ], + }, + ), +} + +const user = { + // +} + +export const api = { + test, + user, +} as const diff --git a/src/utils/api/parse-json.ts b/src/utils/api/parse-json.ts new file mode 100644 index 00000000..b72c5800 --- /dev/null +++ b/src/utils/api/parse-json.ts @@ -0,0 +1,13 @@ +import { ParseJSONError } from '@/models/interface' + +export const parseJSON = async (response: Response): Promise => { + try { + const data = await response.json() + return data as T + } catch (error) { + throw new ParseJSONError({ + name: 'parse-json-error', + message: 'Failed to parse JSON response', + }) + } +} diff --git a/src/utils/api/types.ts b/src/utils/api/types.ts new file mode 100644 index 00000000..3c64ccc1 --- /dev/null +++ b/src/utils/api/types.ts @@ -0,0 +1,24 @@ +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' + +export type Interceptor = (data: T) => Promise | T + +export interface RequestOptions { + headers?: Record + body?: any + retries?: number + cache?: RequestInit['cache'] + revalidate?: NextFetchRequestConfig['revalidate'] + tags?: NextFetchRequestConfig['tags'] + timeout?: number + beforeRequestHeader?: Interceptor[] + afterResponse?: Interceptor[] +} + +export interface Interceptors { + beforeRequestHeader: Interceptor[] + afterResponse: Interceptor[] +} + +export type RequestConfig = Omit & { + headers: Record +} diff --git a/src/utils/storage/index.ts b/src/utils/storage/index.ts new file mode 100644 index 00000000..1c08cdab --- /dev/null +++ b/src/utils/storage/index.ts @@ -0,0 +1,4 @@ +import { LocalStorageManager } from './local-storage' + +export const AUTH_KEY = '@@auth-token' +export const authTokenStorage = new LocalStorageManager(AUTH_KEY) diff --git a/src/utils/storage/local-storage.ts b/src/utils/storage/local-storage.ts new file mode 100644 index 00000000..30d62e7f --- /dev/null +++ b/src/utils/storage/local-storage.ts @@ -0,0 +1,94 @@ +type ChangeHandler = (key: string, value: T | null) => void + +export class LocalStorageManager { + private key: string + + private initialValue: T | null + + private inMemoryStorage: Map + + private handlers: Set> + + private value: T | null + + constructor(key: string, initialValue: T | null = null) { + this.key = key + this.initialValue = initialValue + this.inMemoryStorage = new Map() + this.handlers = new Set>() + + this.value = this.getSnapshot(key, initialValue) + this.initialize() + } + + private trigger = (key: string) => { + this.handlers.forEach((handler) => handler(key, this.value)) + } + + private getSnapshot = (key: string, initialValue: T | null): T | null => { + try { + const item = localStorage.getItem(key) + return item !== null ? (JSON.parse(item) as T) : initialValue + } catch (error) { + this.inMemoryStorage.set(key, initialValue) + return initialValue + } + } + + private subscribe = (onStoreChange: ChangeHandler) => { + const onChange = (localKey: string) => { + if (this.key === localKey) { + onStoreChange(localKey, this.value) + } + } + this.handlers.add(onChange) + return () => this.handlers.delete(onChange) + } + + public set = (nextValue: T) => { + try { + localStorage.setItem(this.key, JSON.stringify(nextValue)) + } catch (error) { + this.inMemoryStorage.set(this.key, nextValue) + } + this.value = nextValue + this.trigger(this.key) + } + + public remove = () => { + try { + localStorage.removeItem(this.key) + } catch (error) { + this.inMemoryStorage.set(this.key, null) + } + this.value = null + this.trigger(this.key) + } + + private initialize = () => { + if (!this.initialValue) return + + try { + const value = localStorage.getItem(this.key) + if (!value) { + localStorage.setItem(this.key, JSON.stringify(this.initialValue)) + } + } catch (error) { + const value = this.inMemoryStorage.get(this.key) + if (!value) { + this.inMemoryStorage.set(this.key, this.initialValue) + } + } + this.trigger(this.key) + } + + public getValueOrNull = (): T | null => this.value + + public onChange = (callback: ChangeHandler) => { + return this.subscribe(callback) + } + + public getInMemoryStorage = (): Map => { + return this.inMemoryStorage + } +}