diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b7f55cc..e0f1b9b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ * ECS - Display all consortium tenants in the affiliation selection of the location lookup. Refs UISACQCOMP-202. * ECS - Add `isLoading` prop for `ConsortiumFieldInventory` component. Refs UISACQCOMP-215. * Add "Amount must be a positive number" validation for "Set exchange rate" field. Refs UISACQCOMP-218. +* Create common utilities for managing response errors. Refs UISACQCOMP-219. ## [5.1.2](https://github.com/folio-org/stripes-acq-components/tree/v5.1.2) (2024-09-13) [Full Changelog](https://github.com/folio-org/stripes-acq-components/compare/v5.1.1...v5.1.2) diff --git a/lib/utils/errorHandling/ResponseErrorContainer.js b/lib/utils/errorHandling/ResponseErrorContainer.js new file mode 100644 index 00000000..cb1e83f4 --- /dev/null +++ b/lib/utils/errorHandling/ResponseErrorContainer.js @@ -0,0 +1,45 @@ +import { ERROR_CODE_GENERIC } from '../../constants'; + +/** + * @class ResponseErrorContainer + * @description A container class for handling individual errors from a response. + * @param {Object} error - The error object containing details such as message, code, type, and parameters. + */ +export class ResponseErrorContainer { + constructor(error = {}) { + this.error = error; + } + + get message() { + return this.error.message; + } + + get code() { + return this.error.code || ERROR_CODE_GENERIC; + } + + get type() { + return this.error.type; + } + + get parameters() { + return this.error.parameters; + } + + /** + * @description Convert the error parameters into a Map. + * @returns {Map} A Map where the keys are parameter keys and the values are the parameter objects. + */ + getParameters() { + return new Map(this.error.parameters?.map((parameter) => [parameter.key, parameter])); + } + + /** + * @description Get a specific parameter value by its key. + * @param {string} key - The key of the parameter to retrieve. + * @returns {any} The value of the specified parameter, or undefined if not found. + */ + getParameter(key) { + return this.getParameters().get(key)?.value; + } +} diff --git a/lib/utils/errorHandling/ResponseErrorContainer.test.js b/lib/utils/errorHandling/ResponseErrorContainer.test.js new file mode 100644 index 00000000..9afa5154 --- /dev/null +++ b/lib/utils/errorHandling/ResponseErrorContainer.test.js @@ -0,0 +1,66 @@ +/* Developed collaboratively using AI (GitHub Copilot) */ + +import { ERROR_CODE_GENERIC } from '../../constants'; +import { ResponseErrorContainer } from './ResponseErrorContainer'; + +describe('ResponseErrorContainer', () => { + it('should return message, code, and type from error', () => { + const errorData = { + message: 'Test error message', + code: 'testCode', + type: 'testType', + }; + const errorContainer = new ResponseErrorContainer(errorData); + + expect(errorContainer.message).toBe('Test error message'); + expect(errorContainer.code).toBe('testCode'); + expect(errorContainer.type).toBe('testType'); + }); + + it('should return undefined if no message or type is provided, and the generic code', () => { + const errorContainer = new ResponseErrorContainer(); + + expect(errorContainer.message).toBeUndefined(); + expect(errorContainer.code).toBe(ERROR_CODE_GENERIC); + expect(errorContainer.type).toBeUndefined(); + }); + + it('should return parameters map if parameters are provided', () => { + const errorData = { + parameters: [ + { key: 'param1', value: 'value1' }, + { key: 'param2', value: 'value2' }, + ], + }; + const errorContainer = new ResponseErrorContainer(errorData); + + const parametersMap = errorContainer.getParameters(); + + expect(parametersMap.size).toBe(2); + expect(parametersMap.get('param1').value).toBe('value1'); + expect(parametersMap.get('param2').value).toBe('value2'); + }); + + it('should return an empty map if no parameters are provided', () => { + const errorContainer = new ResponseErrorContainer(); + + const parametersMap = errorContainer.getParameters(); + + expect(parametersMap.size).toBe(0); + }); + + it('should return parameter value for a given key', () => { + const errorData = { + parameters: [{ key: 'param1', value: 'value1' }], + }; + const errorContainer = new ResponseErrorContainer(errorData); + + expect(errorContainer.getParameter('param1')).toBe('value1'); + }); + + it('should return undefined if parameter key is not found', () => { + const errorContainer = new ResponseErrorContainer(); + + expect(errorContainer.getParameter('nonexistentKey')).toBeUndefined(); + }); +}); diff --git a/lib/utils/errorHandling/ResponseErrorsContainer.js b/lib/utils/errorHandling/ResponseErrorsContainer.js new file mode 100644 index 00000000..34c92dad --- /dev/null +++ b/lib/utils/errorHandling/ResponseErrorsContainer.js @@ -0,0 +1,149 @@ +import { ERROR_CODE_GENERIC } from '../../constants'; +import { ResponseErrorContainer } from './ResponseErrorContainer'; + +const ERROR_MESSAGE_GENERIC = 'An unknown error occurred'; + +/** + * @class + * @description Container for response errors + * @param {Object} responseBody - Response body + * @param {Response} response - Response + */ +export class ResponseErrorsContainer { + /* private */ constructor(responseBody, response) { + this.originalResponseBody = responseBody; + this.originalResponse = response; + + // Initialize the map of errors + this.errorsMap = new Map( + responseBody.errors?.reduce((acc, error) => { + const errorContainer = this.getStructuredError(error); + + acc.push([errorContainer.code || ERROR_CODE_GENERIC, errorContainer]); + + return acc; + }, []), + ); + + this.totalRecords = responseBody.total_records; + } + + /** + * @static + * @description Create a new instance of ResponseErrorsContainer. + * @param {Response} response - The Response object from which to create the error handler. + * @returns {Promise<{handler: ResponseErrorsContainer}>} A promise that resolves to an object containing the error handler. + */ + static async create(response) { + try { + const responseBody = await response.clone().json(); + + return { + handler: ( + responseBody?.errors + ? new ResponseErrorsContainer(responseBody, response) + : new ResponseErrorsContainer({ errors: [responseBody], total_records: 1 }, response) + ), + }; + } catch (error) { + return { + handler: new ResponseErrorsContainer({ errors: [error], total_records: 1 }, response), + }; + } + } + + /** + * @description Handle the errors using a given strategy. + * @param {Object} strategy - The error handling strategy to be applied. + */ + handle(strategy) { + return strategy.handle(this); + } + + get status() { + return this.originalResponse?.status; + } + + /** + * @description Get an array of error messages. + * @returns {Array} An array of error messages. + */ + get errorMessages() { + return this.errors.map((error) => error.message); + } + + /** + * @description Get an array of error codes. + * @returns {Array} An array of error codes. + */ + get errorCodes() { + return this.errors.map((error) => error.code); + } + + /** + * @description Get all errors as an array. + * @returns {Array} An array of error containers. + */ + get errors() { + return Array.from(this.errorsMap.values()); + } + + /** + * @description Get all errors as a map. + * @returns {Map} A map of errors with error codes as keys. + */ + getErrors() { + return this.errorsMap; + } + + /** + * @description Get a specific error by its code or the first error if no code is provided. + * @param {string} [code] - The error code to search for. + * @returns {ResponseErrorContainer} The corresponding error container or a generic error if not found. + */ + getError(code) { + return (code ? this.errorsMap.get(code) : this.errors[0]) || new ResponseErrorContainer(); + } + + /** + * @private + * @description Normalize an unknown error into a structured ResponseErrorContainer. + * @param {unknown} error - The unstructured error object. + * @returns {ResponseErrorContainer} A structured error container. + */ + getStructuredError(error) { + let normalizedError; + + if (typeof error === 'string') { + try { + const parsed = JSON.parse(error); + + normalizedError = { + message: parsed.message || error, + code: parsed.code || ERROR_CODE_GENERIC, + ...parsed, + }; + } catch { + normalizedError = { + message: error, + code: ERROR_CODE_GENERIC, + }; + } + } else { + let message; + + try { + message = error?.message || error?.error || JSON.stringify(error); + } catch { + message = ERROR_MESSAGE_GENERIC; + } + normalizedError = { + code: error?.code || ERROR_CODE_GENERIC, + message, + ...error, + }; + } + + return new ResponseErrorContainer(normalizedError); + } +} diff --git a/lib/utils/errorHandling/ResponseErrorsContainer.test.js b/lib/utils/errorHandling/ResponseErrorsContainer.test.js new file mode 100644 index 00000000..9d3c38b2 --- /dev/null +++ b/lib/utils/errorHandling/ResponseErrorsContainer.test.js @@ -0,0 +1,147 @@ +/* Developed collaboratively using AI (GitHub Copilot) */ + +import { ERROR_CODE_GENERIC } from '../../constants'; +import { ResponseErrorContainer } from './ResponseErrorContainer'; +import { ResponseErrorsContainer } from './ResponseErrorsContainer'; + +describe('ResponseErrorsContainer', () => { + let mockResponse; let mockResponseBody; let + errorStrategy; + + beforeEach(() => { + mockResponseBody = { + errors: [ + { code: 'foo', message: 'Something went wrong' }, + { code: 'bar', message: 'Internal server error' }, + ], + total_records: 2, + }; + + mockResponse = { + clone: jest.fn().mockReturnThis(), + json: jest.fn().mockResolvedValue(mockResponseBody), + status: 500, + }; + + errorStrategy = { + handle: jest.fn(), + }; + }); + + it('should create an instance with response body and status', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + + expect(handler).toBeInstanceOf(ResponseErrorsContainer); + expect(handler.status).toBe(500); // Check HTTP status + expect(handler.totalRecords).toBe(2); // Check total records + }); + + it('should create a handler when response contains a single error object', async () => { + const singleErrorBody = { code: 'baz', message: 'Bad Request' }; + + mockResponse.json.mockResolvedValueOnce(singleErrorBody); + + const { handler } = await ResponseErrorsContainer.create(mockResponse); + + expect(handler).toBeInstanceOf(ResponseErrorsContainer); + expect(handler.errorMessages).toEqual(['Bad Request']); + }); + + it('should return error messages as an array', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + + expect(handler.errorMessages).toEqual(['Something went wrong', 'Internal server error']); + }); + + it('should return error codes as an array', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + + expect(handler.errorCodes).toEqual(['foo', 'bar']); + }); + + it('should return all errors as an array of ResponseErrorContainer instances', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + const errors = handler.errors; + + expect(errors).toHaveLength(2); + expect(errors[0]).toBeInstanceOf(ResponseErrorContainer); + expect(errors[1].code).toBe('bar'); + }); + + it('should return all errors as a map', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + const errorsMap = handler.getErrors(); + + expect(errorsMap.size).toBe(2); // Check size of map + expect(errorsMap.get('foo').message).toBe('Something went wrong'); // Check first error + }); + + it('should get a specific error by code', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + const error = handler.getError('foo'); + + expect(error).toBeInstanceOf(ResponseErrorContainer); + expect(error.code).toBe('foo'); + expect(error.message).toBe('Something went wrong'); + }); + + it('should return the first error when no code is provided', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + const error = handler.getError(); + + expect(error.code).toBe('foo'); + }); + + it('should return a generic error when the requested code is not found', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + const error = handler.getError('999'); // Code that does not exist + + expect(error.code).toBe(ERROR_CODE_GENERIC); // Fallback to generic error + }); + + it('should normalize string error to structured error container', () => { + const stringError = 'This is an error'; + const handler = new ResponseErrorsContainer({ errors: [stringError], total_records: 1 }, mockResponse); + + const error = handler.getError(); + + expect(error.message).toBe(stringError); + expect(error.code).toBe(ERROR_CODE_GENERIC); + }); + + it('should normalize invalid JSON error string to generic error', () => { + const invalidJsonError = '{"invalidJson": true'; + const handler = new ResponseErrorsContainer({ errors: [invalidJsonError], total_records: 1 }, mockResponse); + + const error = handler.getError(); + + expect(error.message).toBe(invalidJsonError); // Should return the original string + expect(error.code).toBe(ERROR_CODE_GENERIC); + }); + + it('should handle unknown objects without message by converting them to a string', () => { + const unknownError = { invalid: 'no message' }; + const handler = new ResponseErrorsContainer({ errors: [unknownError], total_records: 1 }, mockResponse); + + const error = handler.getError(); + + expect(error.message).toBe(JSON.stringify(unknownError)); // Should stringify the object + expect(error.code).toBe(ERROR_CODE_GENERIC); + }); + + it('should handle response errors using a provided strategy', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + + handler.handle(errorStrategy); + expect(errorStrategy.handle).toHaveBeenCalledWith(handler); // Ensure strategy is invoked with the handler + }); + + it('should handle empty error body by returning generic error', async () => { + mockResponse.json.mockResolvedValueOnce({}); // Empty body + const { handler } = await ResponseErrorsContainer.create(mockResponse); + + const error = handler.getError(); + + expect(error.code).toBe(ERROR_CODE_GENERIC); + }); +}); diff --git a/lib/utils/errorHandling/index.js b/lib/utils/errorHandling/index.js new file mode 100644 index 00000000..872c1c86 --- /dev/null +++ b/lib/utils/errorHandling/index.js @@ -0,0 +1 @@ +export { ResponseErrorsContainer } from './ResponseErrorsContainer'; diff --git a/lib/utils/index.js b/lib/utils/index.js index e290ca15..b8af9be5 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -4,6 +4,7 @@ export * from './calculateFundAmount'; export * from './consortia'; export * from './createClearFilterHandler'; export * from './downloadBase64'; +export * from './errorHandling'; export * from './EventEmitter'; export * from './fetchAllRecords'; export * from './fetchExportDataByIds';