Skip to content

Commit

Permalink
feat(api): implement API request utility and test suite (#26)
Browse files Browse the repository at this point in the history
* 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
3 people authored Jan 8, 2025
1 parent b7e44ab commit 66083ff
Show file tree
Hide file tree
Showing 2 changed files with 174 additions and 0 deletions.
62 changes: 62 additions & 0 deletions src/hooks/apiRequest.ts
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();
};
112 changes: 112 additions & 0 deletions tests/app/hooks/apiRequest.test.tsx
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');
});
});

0 comments on commit 66083ff

Please sign in to comment.