-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add API client abstraction for easier fetch usage (#24)
* 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
Showing
13 changed files
with
499 additions
and
38 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.