diff --git a/src/index.ts b/src/index.ts index 703558162..568acde88 100644 --- a/src/index.ts +++ b/src/index.ts @@ -318,6 +318,7 @@ export * from './util/handlers/ConditionalHandler'; export * from './util/handlers/ParallelHandler'; export * from './util/handlers/SequenceHandler'; export * from './util/handlers/StaticHandler'; +export * from './util/handlers/UnionHandler'; export * from './util/handlers/UnsupportedAsyncHandler'; export * from './util/handlers/WaterfallHandler'; diff --git a/src/util/handlers/UnionHandler.ts b/src/util/handlers/UnionHandler.ts new file mode 100644 index 000000000..c165686fb --- /dev/null +++ b/src/util/handlers/UnionHandler.ts @@ -0,0 +1,94 @@ +import { AsyncHandler } from './AsyncHandler'; +import { createAggregateError, filterHandlers, findHandler } from './HandlerUtil'; + +// Helper types to make sure the UnionHandler has the same in/out types as the AsyncHandler type it wraps +type ThenArg = T extends PromiseLike ? U : T; +type InType> = Parameters[0]; +type OutType> = ThenArg>; +type HandlerType = AsyncHandler, OutType>; + +/** + * Utility handler that allows combining the results of multiple handlers into one. + * Will run all the handlers and then call the abstract `combine` function with the results, + * which should return the output of the class. + * + * If `requireAll` is true, the handler will fail if any of the handlers do not support the input. + * If `requireAll` is false, only the handlers that support the input will be called, + * only if all handlers reject the input will this handler reject as well. + * With `requireAll` set to false, the length of the input array + * for the `combine` function is variable (but always at least 1). + */ +export abstract class UnionHandler> extends AsyncHandler, OutType> { + protected readonly handlers: T[]; + private readonly requireAll: boolean; + + protected constructor(handlers: T[], requireAll = false) { + super(); + this.handlers = handlers; + this.requireAll = requireAll; + } + + public async canHandle(input: InType): Promise { + if (this.requireAll) { + await this.allCanHandle(input); + } else { + // This will error if no handler supports the input + await findHandler(this.handlers, input); + } + } + + public async handle(input: InType): Promise> { + let handlers: HandlerType[]; + if (this.requireAll) { + // Handlers were already checked in canHandle + // eslint-disable-next-line prefer-destructuring + handlers = this.handlers; + } else { + handlers = await filterHandlers(this.handlers, input); + } + + const results = await Promise.all( + handlers.map(async(handler): Promise> => handler.handle(input)), + ); + + return this.combine(results); + } + + public async handleSafe(input: InType): Promise> { + let handlers: HandlerType[]; + if (this.requireAll) { + await this.allCanHandle(input); + // eslint-disable-next-line prefer-destructuring + handlers = this.handlers; + } else { + // This will error if no handler supports the input + handlers = await filterHandlers(this.handlers, input); + } + + const results = await Promise.all( + handlers.map(async(handler): Promise> => handler.handle(input)), + ); + + return this.combine(results); + } + + /** + * Checks if all handlers can handle the input. + * If not, throw an error based on the errors of the failed handlers. + */ + private async allCanHandle(input: InType): Promise { + const results = await Promise.allSettled(this.handlers.map(async(handler): Promise> => { + await handler.canHandle(input); + return handler; + })); + if (results.some(({ status }): boolean => status === 'rejected')) { + const errors = results.map((result): Error => (result as PromiseRejectedResult).reason); + throw createAggregateError(errors); + } + } + + /** + * Combine the results of the handlers into a single output. + */ + protected abstract combine(results: OutType[]): Promise>; +} diff --git a/test/unit/util/handlers/UnionHandler.test.ts b/test/unit/util/handlers/UnionHandler.test.ts new file mode 100644 index 000000000..27a803054 --- /dev/null +++ b/test/unit/util/handlers/UnionHandler.test.ts @@ -0,0 +1,64 @@ +import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler'; +import { UnionHandler } from '../../../../src/util/handlers/UnionHandler'; + +class SimpleUnionHandler extends UnionHandler> { + public constructor(handlers: AsyncHandler[], requireAll?: boolean) { + super(handlers, requireAll); + } + + protected async combine(results: string[]): Promise { + return results.join(''); + } +} + +describe('A UnionHandler', (): void => { + const input = { data: 'text' }; + let handlers: jest.Mocked>[]; + let handler: SimpleUnionHandler; + + beforeEach(async(): Promise => { + handlers = [ + { canHandle: jest.fn(), handle: jest.fn().mockResolvedValue('a') } as any, + { canHandle: jest.fn(), handle: jest.fn().mockResolvedValue('b') } as any, + ]; + + handler = new SimpleUnionHandler(handlers); + }); + + it('can handle a request if at least one extractor can handle it.', async(): Promise => { + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + + handlers[0].canHandle.mockRejectedValue(new Error('bad request')); + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + + handlers[1].canHandle.mockRejectedValue(new Error('bad request')); + await expect(handler.canHandle(input)).rejects.toThrow('bad request'); + + await expect(handler.handleSafe(input)).rejects.toThrow('bad request'); + }); + + it('requires all handlers to support the input if requireAll is true.', async(): Promise => { + handler = new SimpleUnionHandler(handlers, true); + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + + handlers[0].canHandle.mockRejectedValue(new Error('bad request')); + await expect(handler.canHandle(input)).rejects.toThrow('bad request'); + + await expect(handler.handleSafe(input)).rejects.toThrow('bad request'); + }); + + it('calls all handlers that support the input.', async(): Promise => { + handlers[0].canHandle.mockRejectedValue(new Error('bad request')); + await expect(handler.handle(input)).resolves.toBe('b'); + await expect(handler.handleSafe(input)).resolves.toBe('b'); + }); + + it('calls all handlers if requireAll is true.', async(): Promise => { + handler = new SimpleUnionHandler(handlers, true); + await expect(handler.handleSafe(input)).resolves.toBe('ab'); + + // `handle` call does not need to check `canHandle` values anymore + handlers[0].canHandle.mockRejectedValue(new Error('bad request')); + await expect(handler.handle(input)).resolves.toBe('ab'); + }); +});