diff --git a/src/index.ts b/src/index.ts index f2f6eed6f..a0996aa9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -434,6 +434,7 @@ export * from './util/errors/UnsupportedMediaTypeHttpError'; export * from './util/handlers/ArrayUnionHandler'; export * from './util/handlers/AsyncHandler'; export * from './util/handlers/BooleanHandler'; +export * from './util/handlers/CachedHandler'; export * from './util/handlers/ConditionalHandler'; export * from './util/handlers/HandlerUtil'; export * from './util/handlers/MethodFilterHandler'; diff --git a/src/util/handlers/CachedHandler.ts b/src/util/handlers/CachedHandler.ts new file mode 100644 index 000000000..bd25ccdd0 --- /dev/null +++ b/src/util/handlers/CachedHandler.ts @@ -0,0 +1,52 @@ +import { AsyncHandler } from './AsyncHandler'; + +/** + * Caches output data from the source handler based on the input object. + * The `field` parameter can be used to instead use a specific entry from the input object as cache key, + * so has as actual required typing `keyof TIn`. + * + * A {@link WeakMap} is used internally so strict object equality determines cache hits, + * and data will be removed once the key stops existing. + * This also means that the cache key needs to be an object. + * Errors will be thrown in case a primitve is used. + */ +export class CachedHandler extends AsyncHandler { + private readonly source: AsyncHandler; + private readonly field?: string; + + private readonly cache: WeakMap; + + public constructor(source: AsyncHandler, field?: string) { + super(); + this.source = source; + this.field = field; + this.cache = new WeakMap(); + } + + public async canHandle(input: TIn): Promise { + const key = this.getKey(input); + + if (this.cache.has(key)) { + return; + } + return this.source.canHandle(input); + } + + public async handle(input: TIn): Promise { + const key = this.getKey(input); + + let result = this.cache.get(key); + if (result) { + return result; + } + + result = await this.source.handle(input); + this.cache.set(key, result); + + return result; + } + + protected getKey(input: TIn): any { + return this.field ? input[this.field as keyof TIn] : input; + } +} diff --git a/test/unit/util/handlers/CachedHandler.test.ts b/test/unit/util/handlers/CachedHandler.test.ts new file mode 100644 index 000000000..14e8c9a08 --- /dev/null +++ b/test/unit/util/handlers/CachedHandler.test.ts @@ -0,0 +1,62 @@ +import type { AsyncHandler } from '../../../../src/util/handlers/AsyncHandler'; +import { + CachedHandler, +} from '../../../../src/util/handlers/CachedHandler'; + +describe('A CachedHandler', (): void => { + const input = { entry: { key: 'value' }}; + const output = 'response'; + let source: jest.Mocked>; + let handler: CachedHandler<{ entry: { key: string }}, string>; + + beforeEach(async(): Promise => { + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(output), + } as any; + handler = new CachedHandler(source); + }); + + it('can handle input if its source can.', async(): Promise => { + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + source.canHandle.mockRejectedValue(new Error('bad input')); + await expect(handler.canHandle(input)).rejects.toThrow('bad input'); + expect(source.canHandle).toHaveBeenCalledTimes(2); + }); + + it('returns the source result.', async(): Promise => { + await expect(handler.handle(input)).resolves.toBe(output); + expect(source.handle).toHaveBeenCalledTimes(1); + expect(source.handle).toHaveBeenLastCalledWith(input); + }); + + it('caches the result.', async(): Promise => { + await expect(handler.handle(input)).resolves.toBe(output); + await expect(handler.handle(input)).resolves.toBe(output); + expect(source.handle).toHaveBeenCalledTimes(1); + }); + + it('caches on the object itself.', async(): Promise => { + const copy = { ...input }; + await expect(handler.handle(input)).resolves.toBe(output); + await expect(handler.handle(copy)).resolves.toBe(output); + expect(source.handle).toHaveBeenCalledTimes(2); + }); + + it('can handle the input if it has a cached result.', async(): Promise => { + await expect(handler.handle(input)).resolves.toBe(output); + source.canHandle.mockRejectedValue(new Error('bad input')); + await expect(handler.canHandle(input)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(0); + }); + + it('can use a specific field of the input as key.', async(): Promise => { + handler = new CachedHandler(source, 'entry'); + + const copy = { ...input }; + await expect(handler.handle(input)).resolves.toBe(output); + await expect(handler.handle(copy)).resolves.toBe(output); + expect(source.handle).toHaveBeenCalledTimes(1); + }); +});