-
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(api): implement API request utility and test suite (#26)
* feat(api): implement API request utility and test suite - Define apiRequest function in api.ts with 'GET', 'POST', 'PUT', and 'DELETE' support. - Add tests for api.ts * Apply suggestions from code review Co-authored-by: Ryan James Meneses <[email protected]> * feat: add error handling utility with flash message support - Handles API response parsing - Processes flash messages (success/error/info) - Throws standardized ApiError with status code - Includes Jest test * feat: implement review feedback for API utility - Move error handling to separate utility function - Simplify default options structure - Add credentials option to API requests * fix: resolve file rename - Rename api.ts to apiRequest.ts - Rename api.test.ts to apiRequest.test.ts * refactor(apiRequest): simplify logic and remove error handling dependency - Remove handleApiError dependency and its test - Add TypeDoc documentation for apiRequest - Add simple error handling inside apiRequest * Apply suggestions from code review Co-authored-by: Ryan James Meneses <[email protected]> * test(fix): remove duplicate method property --------- Co-authored-by: Ryan James Meneses <[email protected]> Co-authored-by: Ryan Meneses <[email protected]>
- Loading branch information
1 parent
b7e44ab
commit 66083ff
Showing
2 changed files
with
174 additions
and
0 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 |
---|---|---|
@@ -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<string, unknown>} [body] - The request body to send with | ||
* the request (e.g., JSON data). | ||
* @property {Record<string, string>} 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<string, unknown>; | ||
headers: Record<string, string>; | ||
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<T>} 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 <T>( | ||
url: string, | ||
options: ApiOptions = DEFAULT_OPTIONS | ||
): Promise<T> => { | ||
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(); | ||
}; |
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,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: '[email protected]', 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'); | ||
}); | ||
}); |