From daf9db9624f8b9b4f89192ff2b7d3ee243b31771 Mon Sep 17 00:00:00 2001 From: Yury Saukou Date: Thu, 3 Oct 2024 16:02:45 +0400 Subject: [PATCH] UISACQCOMP-219 Create common utilities for managing response errors --- CHANGELOG.md | 1 + .../errorHandling/ResponseErrorContainer.js | 43 +++++ .../ResponseErrorContainer.test.js | 63 ++++++++ .../errorHandling/ResponseErrorsContainer.js | 149 ++++++++++++++++++ .../ResponseErrorsContainer.test.js | 54 +++++++ lib/utils/errorHandling/index.js | 1 + lib/utils/index.js | 1 + 7 files changed, 312 insertions(+) create mode 100644 lib/utils/errorHandling/ResponseErrorContainer.js create mode 100644 lib/utils/errorHandling/ResponseErrorContainer.test.js create mode 100644 lib/utils/errorHandling/ResponseErrorsContainer.js create mode 100644 lib/utils/errorHandling/ResponseErrorsContainer.test.js create mode 100644 lib/utils/errorHandling/index.js 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..08bfd4e4 --- /dev/null +++ b/lib/utils/errorHandling/ResponseErrorContainer.js @@ -0,0 +1,43 @@ +/** + * @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; + } + + 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..8875725a --- /dev/null +++ b/lib/utils/errorHandling/ResponseErrorContainer.test.js @@ -0,0 +1,63 @@ +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, code, or type is provided', () => { + const errorContainer = new ResponseErrorContainer(); + + expect(errorContainer.message).toBeUndefined(); + expect(errorContainer.code).toBeUndefined(); + 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..d382bf97 --- /dev/null +++ b/lib/utils/errorHandling/ResponseErrorsContainer.test.js @@ -0,0 +1,54 @@ +import { ERROR_CODE_GENERIC } from '../../constants'; +import { ResponseErrorsContainer } from './ResponseErrorsContainer'; +import { ResponseErrorContainer } from './ResponseErrorContainer'; + +describe('ResponseErrorsContainer', () => { + let mockResponse; let + mockResponseBody; + + beforeEach(() => { + mockResponseBody = { + errors: [{ code: '404', message: 'Not found' }], + total_records: 1, + }; + mockResponse = { + clone: jest.fn().mockReturnThis(), + json: jest.fn().mockResolvedValue(mockResponseBody), + status: 404, + }; + }); + + it('should create a new ResponseErrorsContainer instance from a response', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + + expect(handler).toBeInstanceOf(ResponseErrorsContainer); + expect(handler.status).toBe(404); // Check for status + expect(handler.errorMessages).toEqual(['Not found']); + }); + + it('should return a specific error by code', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + const error = handler.getError('404'); + + expect(error).toBeInstanceOf(ResponseErrorContainer); + expect(error.message).toBe('Not found'); + expect(error.code).toBe('404'); + }); + + it('should handle unknown errors', async () => { + const mockUnknownError = { errors: ['Unknown error'] }; + + mockResponse.json.mockResolvedValueOnce(mockUnknownError); + + const { handler } = await ResponseErrorsContainer.create(mockResponse); + + expect(handler.errorMessages).toEqual(['Unknown error']); + }); + + it('should return generic error when no error code is found', async () => { + const { handler } = await ResponseErrorsContainer.create(mockResponse); + const error = handler.getError('500'); + + 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';