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

Error handling utility #28

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/hooks/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';

interface ApiOptions {
method?: Method;
body?: Record<string, unknown>;
headers?: Record<string, string>;
token?: string;
}

interface ApiError extends Error {
flash?: Record<string, unknown>;
statusCode?: number;
}

const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL;

export const apiRequest = async <T>(
url: string,
{ method = 'GET', body, headers = {}, token }: ApiOptions = {}
): Promise<T> => {
const defaultHeaders: Record<string, string> = {
'Content-Type': 'application/json',
};

const authHeaders: Record<string, string> = token
? { Authorization: `Bearer ${token}` }
: {};

try {
const response = await fetch(`${API_BASE_URL}${url}`, {
method,
headers: {
...defaultHeaders,
...authHeaders,
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});

const jsonResponse: T = await response.json();

if (!response.ok) {
console.error('API Error Response:', jsonResponse);

interface JsonResponseWithFlash {
flash?: Record<string, unknown>;
}

const flashMessages =
(jsonResponse as JsonResponseWithFlash)?.flash || {};
const errorMessages = flashMessages as {
error?: string[];
info?: string[];
};
const errorMessage =
errorMessages.error?.[0] || errorMessages.info?.[0] || 'API Error';

const apiError: ApiError = new Error(errorMessage);
apiError.flash = flashMessages;
apiError.statusCode = response.status;
throw apiError;
}

return jsonResponse;
} catch (error) {
if (error instanceof Error && 'flash' in error) {
throw error;
}
console.error('Unexpected API Error:', error);
throw new Error('Unexpected API Error');
}
};
38 changes: 38 additions & 0 deletions src/utils/errorHandling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export interface ApiError extends Error {
flash?: Record<string, unknown>;
statusCode?: number;
}

export const handleApiError = async <T>(response: Response): Promise<T> => {
const jsonResponse: T = await response.json();

interface JsonResponseWithFlash {
flash?: {
success?: string[];
error?: string[];
info?: string[];
};
}

const flashMessages = (jsonResponse as JsonResponseWithFlash)?.flash || {};
const successMessages = flashMessages.success;

if (!response.ok) {
const errorMessages = flashMessages.error || [];
const errorMessage = errorMessages[0] || 'API Error';

const apiError: ApiError = new Error(errorMessage);
apiError.flash = flashMessages;
apiError.statusCode = response.status;
throw apiError;
}

if (successMessages && successMessages.length > 0) {
return {
...jsonResponse,
flash: { success: successMessages },
} as T;
}

return jsonResponse;
};
102 changes: 102 additions & 0 deletions tests/app/hooks/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// ApiRequest.test.ts
import { apiRequest } from '../../../src/hooks/api';

describe('apiRequest', () => {
beforeEach(() => {
// Mock the global fetch function
global.fetch = jest.fn();
});

afterEach(() => {
jest.clearAllMocks(); // Clear mocks after each test
});

it('should make a GET request and return data', async () => {
// Mocked response for fetch
const mockResponse = { data: 'success' };
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce(mockResponse),
});

const result = await apiRequest('/test-endpoint', { method: 'GET' });

expect(global.fetch).toHaveBeenCalledWith(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/test-endpoint`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
body: undefined,
}
);
expect(result).toEqual(mockResponse);
});

it('should handle API errors correctly', async () => {
const mockErrorResponse = {
flash: { error: ['Something went wrong'] },
};
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 400,
json: jest.fn().mockResolvedValueOnce(mockErrorResponse),
});

await expect(
apiRequest('/test-endpoint', { method: 'GET' })
).rejects.toThrow('Something went wrong');
});

it('should handle unexpected errors', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(
new Error('Network Error')
);

await expect(
apiRequest('/test-endpoint', { method: 'GET' })
).rejects.toThrow('Unexpected API Error');
});
});

it('should handle missing optional parameters', async () => {
const mockResponse = { data: 'default test' };
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce(mockResponse),
});

const result = await apiRequest('/test-endpoint');

expect(global.fetch).toHaveBeenCalledWith(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/test-endpoint`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
body: undefined,
}
);
expect(result).toEqual(mockResponse);
});

it('should include Authorization header if token is provided', async () => {
const mockResponse = { data: 'authorized' };
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce(mockResponse),
});

const result = await apiRequest('/test-endpoint', { token: 'mock-token' });

expect(global.fetch).toHaveBeenCalledWith(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/test-endpoint`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-token',
},
body: undefined,
}
);
expect(result).toEqual(mockResponse);
});
108 changes: 108 additions & 0 deletions tests/utils/errorHandling.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { handleApiError } from '../../src/utils/errorHandling';

describe('handleApiError', () => {
const createMockResponse = (options: {
ok: boolean;
status: number;
jsonData: {
data?: string;
flash?: { success?: string[]; error?: string[]; info?: string[] };
};
}): Response => {
return {
ok: options.ok,
status: options.status,
json: () => Promise.resolve(options.jsonData),
} as Response;
};

it('should return json response for successful requests without flash messages', async () => {
const mockResponse = createMockResponse({
ok: true,
status: 200,
jsonData: { data: 'test data' },
});

const result = await handleApiError(mockResponse);
expect(result).toEqual({ data: 'test data' });
});

it('should return json response with success flash messages', async () => {
const mockResponse = createMockResponse({
ok: true,
status: 200,
jsonData: {
data: 'test data',
flash: { success: ['Operation completed successfully'] },
},
});

const result = await handleApiError(mockResponse);
expect(result).toEqual({
data: 'test data',
flash: { success: ['Operation completed successfully'] },
});
});

it('should throw error with default message when response is not ok', async () => {
const mockResponse = createMockResponse({
ok: false,
status: 400,
jsonData: {},
});

await expect(handleApiError(mockResponse)).rejects.toMatchObject({
message: 'API Error',
statusCode: 400,
flash: {},
});
});

it('should throw error with custom message and flash data', async () => {
const mockResponse = createMockResponse({
ok: false,
status: 403,
jsonData: {
flash: {
error: ['Access denied'],
info: ['Please check your permissions'],
},
},
});

await expect(handleApiError(mockResponse)).rejects.toMatchObject({
message: 'Access denied',
statusCode: 403,
flash: {
error: ['Access denied'],
info: ['Please check your permissions'],
},
});
});

it('should handle multiple error messages and use the first one', async () => {
const mockResponse = createMockResponse({
ok: false,
status: 400,
jsonData: {
flash: { error: ['Primary error', 'Secondary error'] },
},
});

await expect(handleApiError(mockResponse)).rejects.toMatchObject({
message: 'Primary error',
flash: { error: ['Primary error', 'Secondary error'] },
});
});

it('should handle json parsing errors', async () => {
const mockResponse = {
...createMockResponse({ ok: false, status: 500, jsonData: {} }),
json: () => Promise.reject(new Error('JSON parsing failed')),
};

await expect(handleApiError(mockResponse)).rejects.toThrow(
'JSON parsing failed'
);
});
});
Loading