Skip to content

Commit

Permalink
feat: add fetchData to utils package
Browse files Browse the repository at this point in the history
- improve the code
- add unit test
  • Loading branch information
AliKdhim87 committed Oct 8, 2024
1 parent 06e9b8d commit 73aa569
Show file tree
Hide file tree
Showing 2 changed files with 382 additions and 0 deletions.
236 changes: 236 additions & 0 deletions packages/utils/src/fetchData/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/* eslint-disable no-undef */
import { fetchData } from './index';

const url = 'https://api.example.com/graphql';
const query = `
query {
users {
id
name
}
}
`;

describe('fetchData', () => {
it('should fetch data successfully', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: { users: [] } }),
ok: true,
status: 200,
statusText: 'OK',
}),
) as jest.Mock;
global.fetch = mockFetch;

const data = await fetchData({ url, query });
expect(data).toEqual({ data: { users: [] } });
expect(mockFetch).toHaveBeenCalledWith(url, {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
});
it('should have POST method by default', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: { users: [] } }),
ok: true,
status: 200,
statusText: 'OK',
}),
) as jest.Mock;
global.fetch = mockFetch;
const data = await fetchData({ url, query });
expect(data).toEqual({ data: { users: [] } });
expect(mockFetch).toHaveBeenCalledWith(url, {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
});
it('should handle GET method', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: { users: [] } }),
ok: true,
status: 200,
statusText: 'OK',
}),
) as jest.Mock;
global.fetch = mockFetch;
const data = await fetchData({ url: 'https://example.com/api/users', method: 'GET' });
expect(data).toEqual({ data: { users: [] } });
expect(mockFetch).toHaveBeenCalledWith('https://example.com/api/users', {
method: 'GET',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
});
});
it('should handle cache', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: { users: [] } }),
ok: true,
status: 200,
statusText: 'OK',
}),
) as jest.Mock;
global.fetch = mockFetch;

const data = await fetchData({ url, query });
expect(data).toEqual({ data: { users: [] } });
expect(mockFetch).toHaveBeenCalledWith(url, {
cache: 'no-store',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
});
it('should handle network error', async () => {
const mockFetch = jest.fn(() => Promise.reject(new Error('Network error')));
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('Network error');
});
it('should handle error', async () => {
const mockFetch = jest.fn(() => Promise.reject(new Error('Fetch failed')));
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow();
});
it('should handle error with status code', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 404,
}),
) as jest.Mock;
global.fetch = mockFetch;

expect(fetchData({ url, query })).rejects.toThrow('Resource Not Found');
});
it('should handle error with status code 400', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 400,
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('Bad Request');
});
it('should handle error with status code 401', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 401,
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('Unauthorized');
});
it('should handle error with status code 403', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ errors: [{ message: 'Forbidden' }] }),
ok: false,
status: 403,
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('Forbidden');
});
it('should handle error with status code 404', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 404,
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('Resource Not Found');
});
it('should handle error with status code 422', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 422,
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('Unprocessable Entity');
});
it('should handle error with status code 500', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 500,
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('Internal Server Error');
});
it('should handle error with status code 503', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 503,
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('Service Unavailable');
});
it('should handle error with status code 504', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 504,
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('Gateway Timeout');
});
it('should handle error with status code 505', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 505,
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('HTTP Version Not Supported');
});

it('should handle error with status code 506', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
ok: false,
status: 506,
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('Unexpected error: 506');
});

it('should handle GraphQL error', async () => {
const mockFetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ errors: [{ message: 'GraphQL error' }] }),
ok: true,
status: 200,
statusText: 'OK',
}),
) as jest.Mock;
global.fetch = mockFetch;
expect(fetchData({ url, query })).rejects.toThrow('GraphQL error');
});
});
146 changes: 146 additions & 0 deletions packages/utils/src/fetchData/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { ErrorHandler } from '../errorHandler';

interface HandleGraphqlRequestProps {
query?: string;
variables: any;
headers?: HeadersInit;
}

const handleGraphqlRequest = ({ query, variables, headers }: HandleGraphqlRequestProps) =>
({
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers, // Merge custom headers (including the ability to overwrite 'Content-Type')
},
body: JSON.stringify({ query, variables }),
cache: 'no-store',
}) as RequestInit;

export interface FetchDataProps {
url: string;
query?: string;
variables?: any;
method?: string;
headers?: HeadersInit; // Allow custom headers to be passed
}

/**
* @description Fetches data from the server (GraphQL or REST).
* @param {string} url - The URL to fetch data from.
* @param {string} query - The GraphQL query (if applicable).
* @param {any} variables - The variables to pass to the GraphQL queries.
* @param {string} method - The HTTP method, default is POST for GraphQL.
* @param {HeadersInit} headers - Custom headers to pass to the request.
* @returns {Promise<T>} - The fetched data.
*/
export const fetchData = async <T>({
url,
query,
variables,
method = 'POST',
headers = {}, // Default to an empty object if no headers are provided
}: FetchDataProps): Promise<T> => {
// Default headers, which can be overwritten by custom headers (e.g., Content-Type)
const defaultHeaders: HeadersInit = {
'Content-Type': 'application/json',
};

const requestOptions: RequestInit = query
? handleGraphqlRequest({ query, variables, headers: { ...defaultHeaders, ...headers } })
: {
method,
cache: 'no-store',
headers: {
...defaultHeaders,
...headers, // Merge custom headers with default ones (overwriting defaults if needed)
},
};

try {
const response = await fetch(url, requestOptions);

// Check for non-successful responses (status not in the 2xx range)
if (!response.ok) {
handleHttpError(response);
}

const data = await response.json();

// Handle GraphQL-specific errors
if (data.errors && data.errors.length > 0) {
data.errors.forEach(handleGraphqlError); // Process each error
}

return data;
} catch (error: any) {
// Handle and log client-side or unexpected errors
throw new ErrorHandler(error.message || 'Unknown error occurred', {
statusCode: error?.options?.statusCode || 500,
});
}
};

/**
* Handle common HTTP errors and throw the appropriate ErrorHandler
* @param response - Fetch API Response object
*/
const handleHttpError = (response: Response) => {
const status = response.status;

let errorMessage = response.statusText || 'Unknown error';

// Specific error messages based on status codes
switch (status) {
case 400:
errorMessage = 'Bad Request';
break;
case 401:
errorMessage = 'Unauthorized';
break;
case 403:
errorMessage = 'Forbidden';
break;
case 404:
errorMessage = 'Resource Not Found';
break;
case 422:
errorMessage = 'Unprocessable Entity';
break;
case 500:
errorMessage = 'Internal Server Error';
break;
case 503:
errorMessage = 'Service Unavailable';
break;
case 504:
errorMessage = 'Gateway Timeout';
break;
case 505:
errorMessage = 'HTTP Version Not Supported';
break;
default:
errorMessage = `Unexpected error: ${status}`;
break;
}
throw new ErrorHandler(errorMessage, { statusCode: status });
};

/**
* Handle GraphQL-specific errors like 'Forbidden access'
* @param error - The error object returned by GraphQL
*/
const handleGraphqlError = (error: any) => {
const errorMessage = error?.message || 'GraphQL error';
const errorCode = error?.extensions?.code || 400; // Handle extensions (specific to GraphQL)
// Handle known GraphQL error messages
if (errorCode === 'FORBIDDEN') {
throw new ErrorHandler('Forbidden access: You do not have the required permissions.', {

Check warning on line 138 in packages/utils/src/fetchData/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/utils/src/fetchData/index.ts#L138

Added line #L138 was not covered by tests
statusCode: 403,
});
}

throw new ErrorHandler(errorMessage, {
statusCode: errorCode,
});
};

0 comments on commit 73aa569

Please sign in to comment.