Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): implement API request utility and test suite #26

Merged
merged 9 commits into from
Jan 8, 2025
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');
});
});
Loading