From 73867f08272ab949aed2a9bb306aff2b481fff8e Mon Sep 17 00:00:00 2001 From: Simone Persiani Date: Tue, 17 Aug 2021 17:15:21 +0200 Subject: [PATCH] feat: Add BooleanHandler Co-Authored-By: Ludovico Granata --- src/index.ts | 1 + src/util/handlers/BooleanHandler.ts | 45 ++++++++++ src/util/handlers/HandlerUtil.ts | 89 +++++++++++++++++++ src/util/handlers/WaterfallHandler.ts | 50 +---------- .../unit/util/handlers/BooleanHandler.test.ts | 56 ++++++++++++ test/unit/util/handlers/HandlerUtil.test.ts | 84 +++++++++++++++++ .../util/handlers/WaterfallHandler.test.ts | 41 --------- 7 files changed, 279 insertions(+), 87 deletions(-) create mode 100644 src/util/handlers/BooleanHandler.ts create mode 100644 src/util/handlers/HandlerUtil.ts create mode 100644 test/unit/util/handlers/BooleanHandler.test.ts create mode 100644 test/unit/util/handlers/HandlerUtil.test.ts diff --git a/src/index.ts b/src/index.ts index 7bab76c46..acb8d16f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -292,6 +292,7 @@ export * from './util/errors/UnsupportedMediaTypeHttpError'; // Util/Handlers export * from './util/handlers/AsyncHandler'; +export * from './util/handlers/BooleanHandler'; export * from './util/handlers/ParallelHandler'; export * from './util/handlers/SequenceHandler'; export * from './util/handlers/UnsupportedAsyncHandler'; diff --git a/src/util/handlers/BooleanHandler.ts b/src/util/handlers/BooleanHandler.ts new file mode 100644 index 000000000..010d71a9e --- /dev/null +++ b/src/util/handlers/BooleanHandler.ts @@ -0,0 +1,45 @@ +import { getLoggerFor } from '../../logging/LogUtil'; +import { InternalServerError } from '../errors/InternalServerError'; +import { promiseSome } from '../PromiseUtil'; +import { AsyncHandler } from './AsyncHandler'; +import { filterHandlers } from './HandlerUtil'; + +/** + * A composite handler that returns true if any of its handlers can handle the input and return true. + * Handler errors are interpreted as false results. + */ +export class BooleanHandler extends AsyncHandler { + protected readonly logger = getLoggerFor(this); + + private readonly handlers: AsyncHandler[]; + + /** + * Creates a new BooleanHandler that stores the given handlers. + * @param handlers - Handlers over which it will run. + */ + public constructor(handlers: AsyncHandler[]) { + super(); + this.handlers = handlers; + } + + public async canHandle(input: TIn): Promise { + // We use this to generate an error if no handler supports the input + await filterHandlers(this.handlers, input); + } + + public async handleSafe(input: TIn): Promise { + const handlers = await filterHandlers(this.handlers, input); + return promiseSome(handlers.map(async(handler): Promise => handler.handle(input))); + } + + public async handle(input: TIn): Promise { + let handlers: AsyncHandler[]; + try { + handlers = await filterHandlers(this.handlers, input); + } catch (error: unknown) { + this.logger.warn('All handlers failed. This might be the consequence of calling handle before canHandle.'); + throw new InternalServerError('All handlers failed', { cause: error }); + } + return promiseSome(handlers.map(async(handler): Promise => handler.handle(input))); + } +} diff --git a/src/util/handlers/HandlerUtil.ts b/src/util/handlers/HandlerUtil.ts new file mode 100644 index 000000000..3be746a0c --- /dev/null +++ b/src/util/handlers/HandlerUtil.ts @@ -0,0 +1,89 @@ +import { BadRequestHttpError } from '../errors/BadRequestHttpError'; +import { createErrorMessage, isError } from '../errors/ErrorUtil'; +import { HttpError } from '../errors/HttpError'; +import { InternalServerError } from '../errors/InternalServerError'; +import type { AsyncHandler } from './AsyncHandler'; + +/** + * Combines a list of errors into a single HttpErrors. + * Status code depends on the input errors. If they all share the same status code that code will be re-used. + * If they are all within the 4xx range, 400 will be used, otherwise 500. + * + * @param errors - Errors to combine. + * @param messagePrefix - Prefix for the aggregate error message. Will be followed with an array of all the messages. + */ +export function createAggregateError(errors: Error[], messagePrefix = 'No handler supports the given input:'): +HttpError { + const httpErrors = errors.map((error): HttpError => + HttpError.isInstance(error) ? error : new InternalServerError(createErrorMessage(error))); + const joined = httpErrors.map((error: Error): string => error.message).join(', '); + const message = `${messagePrefix} [${joined}]`; + + // Check if all errors have the same status code + if (httpErrors.length > 0 && httpErrors.every((error): boolean => error.statusCode === httpErrors[0].statusCode)) { + return new HttpError(httpErrors[0].statusCode, httpErrors[0].name, message); + } + + // Find the error range (4xx or 5xx) + if (httpErrors.some((error): boolean => error.statusCode >= 500)) { + return new InternalServerError(message); + } + return new BadRequestHttpError(message); +} + +/** + * Finds a handler that can handle the given input data. + * Otherwise an error gets thrown. + * + * @param handlers - List of handlers to search in. + * @param input - The input data. + * + * @returns A promise resolving to a handler that supports the data or otherwise rejecting. + */ +export async function findHandler(handlers: AsyncHandler[], input: TIn): +Promise> { + const errors: Error[] = []; + + for (const handler of handlers) { + try { + await handler.canHandle(input); + + return handler; + } catch (error: unknown) { + if (isError(error)) { + errors.push(error); + } else { + errors.push(new Error(createErrorMessage(error))); + } + } + } + + throw createAggregateError(errors); +} + +/** + * Filters a list of handlers to only keep those that can handle the input. + * Will error if no matching handlers are found. + * + * @param handlers - Handlers to filter. + * @param input - Input that needs to be supported. + */ +export async function filterHandlers(handlers: AsyncHandler[], input: TIn): +Promise[]> { + const results = await Promise.allSettled(handlers.map(async(handler): Promise> => { + await handler.canHandle(input); + return handler; + })); + const matches = results.filter((result): boolean => result.status === 'fulfilled') + .map((result): AsyncHandler => + (result as PromiseFulfilledResult>).value); + + if (matches.length > 0) { + return matches; + } + + // Generate error in case no matches were found + const errors = results.map((result): Error => (result as PromiseRejectedResult).reason); + + throw createAggregateError(errors); +} diff --git a/src/util/handlers/WaterfallHandler.ts b/src/util/handlers/WaterfallHandler.ts index de1ac5cd0..b3ef570b6 100644 --- a/src/util/handlers/WaterfallHandler.ts +++ b/src/util/handlers/WaterfallHandler.ts @@ -1,9 +1,7 @@ import { getLoggerFor } from '../../logging/LogUtil'; -import { BadRequestHttpError } from '../errors/BadRequestHttpError'; -import { createErrorMessage } from '../errors/ErrorUtil'; -import { HttpError } from '../errors/HttpError'; import { InternalServerError } from '../errors/InternalServerError'; import type { AsyncHandler } from './AsyncHandler'; +import { findHandler } from './HandlerUtil'; /** * A composite handler that tries multiple handlers one by one @@ -31,7 +29,7 @@ export class WaterfallHandler implements AsyncHandler { * @returns A promise resolving if at least 1 handler supports to input, or rejecting if none do. */ public async canHandle(input: TIn): Promise { - await this.findHandler(input); + await findHandler(this.handlers, input); } /** @@ -45,7 +43,7 @@ export class WaterfallHandler implements AsyncHandler { let handler: AsyncHandler; try { - handler = await this.findHandler(input); + handler = await findHandler(this.handlers, input); } catch (error: unknown) { this.logger.warn('All handlers failed. This might be the consequence of calling handle before canHandle.'); throw new InternalServerError('All handlers failed', { cause: error }); @@ -63,48 +61,8 @@ export class WaterfallHandler implements AsyncHandler { * It rejects if no handlers support the given data. */ public async handleSafe(input: TIn): Promise { - const handler = await this.findHandler(input); + const handler = await findHandler(this.handlers, input); return handler.handle(input); } - - /** - * Finds a handler that can handle the given input data. - * Otherwise an error gets thrown. - * - * @param input - The input data. - * - * @returns A promise resolving to a handler that supports the data or otherwise rejecting. - */ - private async findHandler(input: TIn): Promise> { - const errors: HttpError[] = []; - - for (const handler of this.handlers) { - try { - await handler.canHandle(input); - - return handler; - } catch (error: unknown) { - if (HttpError.isInstance(error)) { - errors.push(error); - } else { - errors.push(new InternalServerError(createErrorMessage(error))); - } - } - } - - const joined = errors.map((error: Error): string => error.message).join(', '); - const message = `No handler supports the given input: [${joined}]`; - - // Check if all errors have the same status code - if (errors.length > 0 && errors.every((error): boolean => error.statusCode === errors[0].statusCode)) { - throw new HttpError(errors[0].statusCode, errors[0].name, message); - } - - // Find the error range (4xx or 5xx) - if (errors.some((error): boolean => error.statusCode >= 500)) { - throw new InternalServerError(message); - } - throw new BadRequestHttpError(message); - } } diff --git a/test/unit/util/handlers/BooleanHandler.test.ts b/test/unit/util/handlers/BooleanHandler.test.ts new file mode 100644 index 000000000..1612f0e70 --- /dev/null +++ b/test/unit/util/handlers/BooleanHandler.test.ts @@ -0,0 +1,56 @@ +import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; +import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler'; +import { BooleanHandler } from '../../../../src/util/handlers/BooleanHandler'; +import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler'; + +describe('A BooleanHandler', (): void => { + let handlerFalse: AsyncHandler; + let handlerTrue: AsyncHandler; + let handlerError: AsyncHandler; + let handlerCanNotHandle: AsyncHandler; + + beforeEach(async(): Promise => { + handlerFalse = new StaticAsyncHandler(true, false); + handlerTrue = new StaticAsyncHandler(true, true); + handlerError = new StaticAsyncHandler(true, null) as any; + handlerError.handle = (): never => { + throw new InternalServerError(); + }; + handlerCanNotHandle = new StaticAsyncHandler(false, null); + }); + + it('can handle the input if any of its handlers can.', async(): Promise => { + const handler = new BooleanHandler([ handlerFalse, handlerCanNotHandle ]); + await expect(handler.canHandle(null)).resolves.toBeUndefined(); + }); + + it('errors if none of its handlers supports the input.', async(): Promise => { + const handler = new BooleanHandler([ handlerCanNotHandle, handlerCanNotHandle ]); + await expect(handler.canHandle(null)).rejects.toThrow('[Not supported, Not supported]'); + }); + + it('returns true if any of its handlers returns true.', async(): Promise => { + const handler = new BooleanHandler([ handlerFalse, handlerTrue, handlerCanNotHandle ]); + await expect(handler.handle(null)).resolves.toBe(true); + }); + + it('returns false if none of its handlers returns true.', async(): Promise => { + const handler = new BooleanHandler([ handlerFalse, handlerError, handlerCanNotHandle ]); + await expect(handler.handle(null)).resolves.toBe(false); + }); + + it('throw an internal error when calling handle with unsupported input.', async(): Promise => { + const handler = new BooleanHandler([ handlerCanNotHandle, handlerCanNotHandle ]); + await expect(handler.handle(null)).rejects.toThrow(InternalServerError); + }); + + it('returns the same handle results with handleSafe.', async(): Promise => { + const handler = new BooleanHandler([ handlerFalse, handlerTrue, handlerCanNotHandle ]); + await expect(handler.handleSafe(null)).resolves.toBe(true); + }); + + it('throws the canHandle error when calling handleSafe with unsupported input.', async(): Promise => { + const handler = new BooleanHandler([ handlerCanNotHandle, handlerCanNotHandle ]); + await expect(handler.handleSafe(null)).rejects.toThrow('[Not supported, Not supported]'); + }); +}); diff --git a/test/unit/util/handlers/HandlerUtil.test.ts b/test/unit/util/handlers/HandlerUtil.test.ts new file mode 100644 index 000000000..9cef18305 --- /dev/null +++ b/test/unit/util/handlers/HandlerUtil.test.ts @@ -0,0 +1,84 @@ +import { HttpError } from '../../../../src/util/errors/HttpError'; +import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler'; +import { createAggregateError, filterHandlers, findHandler } from '../../../../src/util/handlers/HandlerUtil'; +import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler'; + +describe('HandlerUtil', (): void => { + describe('createAggregateError', (): void => { + const error401 = new HttpError(401, 'UnauthorizedHttpError'); + const error415 = new HttpError(415, 'UnsupportedMediaTypeHttpError'); + const error501 = new HttpError(501, 'NotImplementedHttpError'); + const error = new Error('noStatusCode'); + + it('throws an error with matching status code if all errors have the same.', async(): Promise => { + expect(createAggregateError([ error401, error401 ])).toMatchObject({ + statusCode: 401, + name: 'UnauthorizedHttpError', + }); + }); + + it('throws an InternalServerError if one of the errors has status code 5xx.', async(): Promise => { + expect(createAggregateError([ error401, error501 ])).toMatchObject({ + statusCode: 500, + name: 'InternalServerError', + }); + }); + + it('throws an BadRequestHttpError if all handlers have 4xx status codes.', async(): Promise => { + expect(createAggregateError([ error401, error415 ])).toMatchObject({ + statusCode: 400, + name: 'BadRequestHttpError', + }); + }); + + it('interprets non-HTTP errors as internal errors.', async(): Promise => { + expect(createAggregateError([ error ])).toMatchObject({ + statusCode: 500, + name: 'InternalServerError', + }); + }); + }); + + describe('findHandler', (): void => { + let handlerTrue: AsyncHandler; + let handlerFalse: AsyncHandler; + + beforeEach(async(): Promise => { + handlerTrue = new StaticAsyncHandler(true, null); + handlerFalse = new StaticAsyncHandler(false, null); + }); + + it('finds a matching handler.', async(): Promise => { + await expect(findHandler([ handlerFalse, handlerTrue ], null)).resolves.toBe(handlerTrue); + }); + + it('errors if there is no matching handler.', async(): Promise => { + await expect(findHandler([ handlerFalse, handlerFalse ], null)).rejects.toThrow('[Not supported, Not supported]'); + }); + + it('supports non-native Errors.', async(): Promise => { + handlerFalse.canHandle = jest.fn().mockRejectedValue('apple'); + await expect(findHandler([ handlerFalse ], null)).rejects.toThrow('[Unknown error: apple]'); + }); + }); + + describe('filterHandlers', (): void => { + let handlerTrue: AsyncHandler; + let handlerFalse: AsyncHandler; + + beforeEach(async(): Promise => { + handlerTrue = new StaticAsyncHandler(true, null); + handlerFalse = new StaticAsyncHandler(false, null); + }); + + it('finds matching handlers.', async(): Promise => { + await expect(filterHandlers([ handlerTrue, handlerFalse, handlerTrue ], null)) + .resolves.toEqual([ handlerTrue, handlerTrue ]); + }); + + it('errors if there is no matching handler.', async(): Promise => { + await expect(filterHandlers([ handlerFalse, handlerFalse ], null)) + .rejects.toThrow('[Not supported, Not supported]'); + }); + }); +}); diff --git a/test/unit/util/handlers/WaterfallHandler.test.ts b/test/unit/util/handlers/WaterfallHandler.test.ts index 3a8f3eeb3..a424a3aa0 100644 --- a/test/unit/util/handlers/WaterfallHandler.test.ts +++ b/test/unit/util/handlers/WaterfallHandler.test.ts @@ -1,5 +1,3 @@ -import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError'; -import { HttpError } from '../../../../src/util/errors/HttpError'; import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler'; import { WaterfallHandler } from '../../../../src/util/handlers/WaterfallHandler'; import { StaticAsyncHandler } from '../../../util/StaticAsyncHandler'; @@ -83,44 +81,5 @@ describe('A WaterfallHandler', (): void => { await expect(handler.handleSafe(null)).rejects.toThrow('[Not supported, Not supported]'); }); - - it('throws an error with matching status code if all handlers threw the same.', async(): Promise => { - handlerTrue.canHandle = async(): Promise => { - throw new HttpError(401, 'UnauthorizedHttpError'); - }; - const handler = new WaterfallHandler([ handlerTrue, handlerTrue ]); - - await expect(handler.canHandle(null)).rejects.toMatchObject({ - statusCode: 401, - name: 'UnauthorizedHttpError', - }); - }); - - it('throws an internal server error if one of the handlers threw one.', async(): Promise => { - handlerTrue.canHandle = async(): Promise => { - throw new HttpError(401, 'UnauthorizedHttpError'); - }; - handlerFalse.canHandle = async(): Promise => { - throw new Error('Server is crashing!'); - }; - const handler = new WaterfallHandler([ handlerTrue, handlerFalse ]); - - await expect(handler.canHandle(null)).rejects.toMatchObject({ - statusCode: 500, - name: 'InternalServerError', - }); - }); - - it('throws an BadRequestHttpError if handlers throw different errors.', async(): Promise => { - handlerTrue.canHandle = async(): Promise => { - throw new HttpError(401, 'UnauthorizedHttpError'); - }; - handlerFalse.canHandle = async(): Promise => { - throw new HttpError(415, 'UnsupportedMediaTypeHttpError'); - }; - const handler = new WaterfallHandler([ handlerTrue, handlerFalse ]); - - await expect(handler.canHandle(null)).rejects.toThrow(BadRequestHttpError); - }); }); });