diff --git a/src/hooks/apiRequest.ts b/src/hooks/apiRequest.ts new file mode 100644 index 0000000..95ae488 --- /dev/null +++ b/src/hooks/apiRequest.ts @@ -0,0 +1,62 @@ +type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL; + +const DEFAULT_OPTIONS: ApiOptions = { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', +}; + +/** + * Options for configuring an API request. + * + * @property {Method} [method] - The HTTP method to use for the request (e.g., + * 'GET', 'POST', 'PUT', 'DELETE'). + * @property {Record} [body] - The request body to send with + * the request (e.g., JSON data). + * @property {Record} headers - Headers to include in the + * request (e.g., 'Content-Type', 'Authorization'). + * @property {RequestCredentials} [credentials] - The credentials policy for + * the request (e.g., 'include', 'same-origin', 'omit'). + */ +export interface ApiOptions { + method: Method; + body?: Record; + headers: Record; + credentials?: RequestCredentials; +} + +/** + * Makes an API request to the specified URL with the given options. + * + * @param {string} url - The endpoint to call, relative to `API_BASE_URL`. + * @param {ApiOptions} [options=defaultOptions] - The options to use for the + * request. + * @returns {Promise} A promise that resolves to the response data as a + * generic type `T`. + * @throws {Error} Throws an error if the network response is not OK or if the + * request fails. The error contains the response body as a JSON string. + * @example + * const options: ApiOptions = { + * method: 'POST', + * }; + * + * const response = await apiRequest('/test-endpoint', options); + **/ +export const apiRequest = async ( + url: string, + options: ApiOptions = DEFAULT_OPTIONS +): Promise => { + const response = await fetch(`${API_BASE_URL}${url}`, { + ...(options as RequestInit), + body: options.body && JSON.stringify(options.body), + }); + + if (!response.ok) { + const errorResponse = await response.json(); + throw new Error(JSON.stringify(errorResponse)); + } + + return response.json(); +}; diff --git a/tests/app/hooks/apiRequest.test.tsx b/tests/app/hooks/apiRequest.test.tsx new file mode 100644 index 0000000..b9fb253 --- /dev/null +++ b/tests/app/hooks/apiRequest.test.tsx @@ -0,0 +1,112 @@ +import { ApiOptions, apiRequest } from '../../../src/hooks/apiRequest'; + +global.fetch = jest.fn(); + +describe('apiRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should use default options for a basic GET request', async () => { + const mockResponse = { data: 'test data' }; + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + const response = await apiRequest('/test-endpoint'); + + const fetchCalls = (global.fetch as jest.Mock).mock.calls; + + expect(fetchCalls[0][0]).toBe( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/test-endpoint` + ); + expect(fetchCalls[0][1]).toMatchObject({ + method: 'GET', + credentials: 'include', + }); + expect(response).toEqual(mockResponse); + }); + + it('should override default options with custom options', async () => { + const mockResponse = { data: 'custom data' }; + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + const customOptions: ApiOptions = { + method: 'POST', + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + }, + body: { name: 'test' }, + credentials: 'include', + }; + + await apiRequest('/test-endpoint', customOptions); + + const fetchCalls = (global.fetch as jest.Mock).mock.calls; + + expect(fetchCalls[0][1]).toMatchObject({ + method: 'POST', + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ name: 'test' }), + }); + }); + + it('should make a successful POST request and return the correct response', async () => { + const mockResponse = { message: 'Success' }; + const requestBody = { email: 'test@example.com', password: 'password123' }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + const options: ApiOptions = { + method: 'POST', + body: requestBody, + headers: { 'Content-Type': 'application/json' }, + }; + + const response = await apiRequest('/register', options); + + expect(response).toEqual(mockResponse); + + const fetchCalls = (global.fetch as jest.Mock).mock.calls; + + expect(fetchCalls[0][1]).toMatchObject({ + method: 'POST', + body: JSON.stringify(requestBody), + headers: { 'Content-Type': 'application/json' }, + }); + }); + + it('should throw an error for non-OK responses', async () => { + const errorResponse = { message: 'Error occurred' }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + json: jest.fn().mockResolvedValueOnce(errorResponse), + }); + + await expect(apiRequest('/error-endpoint')).rejects.toThrow( + JSON.stringify(errorResponse) + ); + }); + + it('should handle network errors gracefully', async () => { + (global.fetch as jest.Mock).mockRejectedValueOnce( + new Error('Network error') + ); + + await expect(apiRequest('/test-endpoint')).rejects.toThrow('Network error'); + }); +});