Skip to content

Commit

Permalink
feat: add API client abstraction for easier fetch usage (#24)
Browse files Browse the repository at this point in the history
* feat: localStorage util with implementing onChange, inMemoryStorage

* test: localStorage util

* chore: for testing success

* feat: add dummy api with delaying time

* feat: add parseJSON utility function for parsing JSON responses

* test: parseJSON util

* feat: auth token storage

* feat: add API client abstraction for easier fetch usage

* chore: test api instance

* chore: api error name 통일

* chore: rename with kebab convention

* chore: remove useless code

* refactor: omit body type, duplicate parameter

* refactor: make error extends architect

* chore: default secure value to false
  • Loading branch information
poiu694 authored Jun 29, 2024
1 parent 3a62ceb commit db23d5b
Show file tree
Hide file tree
Showing 13 changed files with 499 additions and 38 deletions.
5 changes: 1 addition & 4 deletions src/__tests__/app/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { expect, test } from 'vitest'
import { render, screen } from '@testing-library/react'
import Page from '@/app/page'

test('Page', () => {
render(<Page />)
expect(screen.getAllByRole('heading', { level: 2 })).toBeDefined()
expect(2).toBe(2)
})
67 changes: 67 additions & 0 deletions src/__tests__/utils/local-storage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>(TEST_KEY, INITIAL_VALUE)

expect(testStore.getValueOrNull()).toBe(INITIAL_VALUE)
})

it('should set and get the value correctly', () => {
const testStore = new LocalStorageManager<string>(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<string>(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<string>(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<string>(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()
})
})
28 changes: 28 additions & 0 deletions src/__tests__/utils/parse-json.test.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
})
8 changes: 7 additions & 1 deletion src/app/api/delay/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` })
}
33 changes: 1 addition & 32 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="w-full min-h-screen flex flex-col justify-center items-center">
<p className="text-black">인트로 화면</p>
<ul className="list-disc text-xl space-y-4 text-black">
<li>
<Link href="/cc" className="underline hover:text-sky-400">
Client Components Example
</Link>
</li>
<li>
<Link href="/rsc" className="underline hover:text-sky-400">
React Server Components Example
</Link>
</li>
<ChipButton rightIcon={{ type: 'heartStraight', size: 'md' }}>
한우갈비
</ChipButton>
<ChipButton
isActive
rightIcon={{ type: 'heartStraight', size: 'md', fill: 'neutral-000' }}
>
한우갈비
</ChipButton>
<AccessibleIconButton
icon={{ type: 'heartStraight', fill: 'green' }}
label="어떤걸 클릭"
/>
<Chip>Chip</Chip>
<Icon type="heartStraight" size="md" fill="green" stroke="orange" />
</ul>
HOme
</main>
)
}
Expand Down
4 changes: 3 additions & 1 deletion src/models/interface.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/utils/api/api-client-factory.ts
Original file line number Diff line number Diff line change
@@ -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<RequestConfig>(
'beforeRequestHeader',
injectAuthTokenToConfig,
)
}

return client
}
174 changes: 174 additions & 0 deletions src/utils/api/http-client.ts
Original file line number Diff line number Diff line change
@@ -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<Interceptors>) {
this.baseUrl = baseUrl
this.interceptors = {
beforeRequestHeader: interceptors?.beforeRequestHeader || [],
afterResponse: interceptors?.afterResponse || [],
}
}

private async runInterceptors<T extends RequestInit | Response>(
type: keyof Interceptors,
data: T,
methodInterceptors?: Interceptor<T>[],
): Promise<T> {
let result = data
const allInterceptors = [
...this.interceptors[type],
...(methodInterceptors || []),
] as Interceptor<T>[]

for (const interceptor of allInterceptors) {
result = await interceptor(result)
}
return result
}

public addInterceptor<T>(
type: keyof Interceptors,
interceptor: Interceptor<T>,
): void {
this.interceptors[type].push(interceptor as Interceptor<any>)
}

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<Response> {
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<T>(
url: string,
method: HTTPMethod,
options: RequestOptions = {},
): Promise<T> {
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<T>(response)
return data
} catch (error) {
throw new APIError({
name: 'API Error',
message: 'Error on fetching api. please check your api',
})
}
}

public get<T>(url: string, options?: RequestOptions): Promise<T> {
return this.request<T>(url, 'GET', options)
}

public post<T>(
url: string,
body?: any,
options?: Omit<RequestOptions, 'body'>,
): Promise<T> {
return this.request<T>(url, 'POST', { ...options, body })
}

public put<T>(
url: string,
body?: any,
options?: Omit<RequestOptions, 'body'>,
): Promise<T> {
return this.request<T>(url, 'PUT', { ...options, body })
}

public delete<T>(url: string, options?: RequestOptions): Promise<T> {
return this.request<T>(url, 'DELETE', options)
}
}

export default HTTPClient
Loading

0 comments on commit db23d5b

Please sign in to comment.