From 705ec94db00e7aa8d5fcdde3ca406a28d996bee2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 1 Feb 2024 15:50:13 +0100 Subject: [PATCH] feat: add command interceptors This commit introduces command interceptors, enabling the interception of commands. Now, actions can be executed both before and after the command is handled. This feature enhances the flexibility and extensibility of command processing. --- package-lock.json | 28 ++++ package.json | 3 +- src/command-bus.ts | 15 +- src/command-interception-executor.ts | 48 ++++++ src/command-interception.spec.ts | 140 ++++++++++++++++++ src/cqrs.module.ts | 7 +- .../command-interceptor.decorator.ts | 15 ++ src/decorators/constants.ts | 1 + src/decorators/index.ts | 1 + .../commands/command-interceptor.interface.ts | 15 ++ src/interfaces/cqrs-options.interface.ts | 2 + src/interfaces/index.ts | 1 + src/services/explorer.service.ts | 8 +- 13 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 src/command-interception-executor.ts create mode 100644 src/command-interception.spec.ts create mode 100644 src/decorators/command-interceptor.decorator.ts create mode 100644 src/interfaces/commands/command-interceptor.interface.ts diff --git a/package-lock.json b/package-lock.json index 52790fbf..01b66c39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@commitlint/config-angular": "18.6.0", "@nestjs/common": "10.3.1", "@nestjs/core": "10.3.1", + "@nestjs/testing": "^10.3.1", "@types/jest": "29.5.11", "@types/node": "20.11.7", "@typescript-eslint/eslint-plugin": "6.19.1", @@ -2207,6 +2208,33 @@ } } }, + "node_modules/@nestjs/testing": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.1.tgz", + "integrity": "sha512-74aSAugWT31jSPnStyRWDXgjHXWO3GYaUfAZ2T7Dml88UGkGy95iwaWgYy7aYM8/xVFKcDYkfL5FAYqZYce/yg==", + "dev": true, + "dependencies": { + "tslib": "2.6.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/microservices": "^10.0.0", + "@nestjs/platform-express": "^10.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } + } + }, "node_modules/@nicolo-ribaudo/semver-v6": { "version": "6.3.3", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/semver-v6/-/semver-v6-6.3.3.tgz", diff --git a/package.json b/package.json index 87c0199b..a2219f5f 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,9 @@ "@commitlint/config-angular": "18.6.0", "@nestjs/common": "10.3.1", "@nestjs/core": "10.3.1", - "@types/node": "20.11.7", + "@nestjs/testing": "^10.3.1", "@types/jest": "29.5.11", + "@types/node": "20.11.7", "@typescript-eslint/eslint-plugin": "6.19.1", "@typescript-eslint/parser": "6.19.1", "eslint": "8.56.0", diff --git a/src/command-bus.ts b/src/command-bus.ts index f14c4329..18aa54b6 100644 --- a/src/command-bus.ts +++ b/src/command-bus.ts @@ -16,6 +16,7 @@ import { ICommandPublisher, } from './interfaces/index'; import { ObservableBus } from './utils/observable-bus'; +import { CommandInterceptionExecutor } from './command-interception-executor'; export type CommandHandlerType = Type>; @@ -27,7 +28,10 @@ export class CommandBus private handlers = new Map>(); private _publisher: ICommandPublisher; - constructor(private readonly moduleRef: ModuleRef) { + constructor( + private readonly moduleRef: ModuleRef, + private readonly commandInterceptor: CommandInterceptionExecutor, + ) { super(); this.useDefaultPublisher(); } @@ -61,8 +65,13 @@ export class CommandBus const commandName = this.getCommandName(command); throw new CommandHandlerNotFoundException(commandName); } - this._publisher.publish(command); - return handler.execute(command); + + const next = (): Promise => { + this._publisher.publish(command); + return handler.execute(command); + }; + + return this.commandInterceptor.intercept(command, next); } bind(handler: ICommandHandler, id: string) { diff --git a/src/command-interception-executor.ts b/src/command-interception-executor.ts new file mode 100644 index 00000000..aeff09ba --- /dev/null +++ b/src/command-interception-executor.ts @@ -0,0 +1,48 @@ +import { Injectable, Type } from '@nestjs/common'; +import { ICommand, ICommandInterceptor } from './interfaces'; +import { ModuleRef } from '@nestjs/core'; + +export type CommandInterceptorType = Type; + +@Injectable() +export class CommandInterceptionExecutor { + private interceptors: ICommandInterceptor[] = []; + + constructor(private readonly moduleRef: ModuleRef) {} + + intercept( + command: T, + next: () => Promise, + ): Promise { + if (this.interceptors.length === 0) { + return next(); + } + + const nextFn = async (i = 0): Promise => { + if (i >= this.interceptors.length) { + return next(); + } + + const handler = () => nextFn(i + 1); + return this.interceptors[i].intercept(command, handler); + }; + + return nextFn(); + } + + register(interceptors: CommandInterceptorType[] = []) { + interceptors.forEach((interceptor) => + this.registerInterceptor(interceptor), + ); + } + + private registerInterceptor(interceptor: CommandInterceptorType) { + const instance = this.moduleRef.get(interceptor, { strict: false }); + + if (!instance) { + return; + } + + this.interceptors.push(instance); + } +} diff --git a/src/command-interception.spec.ts b/src/command-interception.spec.ts new file mode 100644 index 00000000..772fb147 --- /dev/null +++ b/src/command-interception.spec.ts @@ -0,0 +1,140 @@ +import { Test } from '@nestjs/testing'; +import { + CqrsModule, + CommandBus, + ICommandHandler, + ICommand, + CommandHandler, + CommandInterceptor, + ICommandInterceptor, +} from './index'; +import { Type } from '@nestjs/common'; + +class TestCommand implements ICommand {} + +@CommandHandler(TestCommand) +class TestCommandHandler implements ICommandHandler { + execute(): Promise { + return Promise.resolve(undefined); + } +} + +@CommandInterceptor() +class FirstCommandInterceptor implements ICommandInterceptor { + intercept(_: unknown, next: () => Promise) { + return next(); + } +} + +@CommandInterceptor() +class SecondCommandInterceptor implements ICommandInterceptor { + intercept(_: unknown, next: () => Promise) { + return next(); + } +} + +describe('Command interception', () => { + const bootstrap = async ( + ...interceptors: Type[] + ): Promise<{ + commandBus: CommandBus; + commandHandler: TestCommandHandler; + interceptors: ICommandInterceptor[]; + }> => { + const moduleRef = await Test.createTestingModule({ + providers: [TestCommandHandler, ...interceptors], + imports: [CqrsModule], + }).compile(); + await moduleRef.init(); + + return { + commandBus: moduleRef.get(CommandBus), + commandHandler: moduleRef.get(TestCommandHandler), + interceptors: interceptors.map((interceptor) => + moduleRef.get(interceptor), + ), + }; + }; + + it('should invoke command handler', async () => { + const { commandBus, commandHandler } = await bootstrap( + FirstCommandInterceptor, + SecondCommandInterceptor, + ); + + const fakeResult = {}; + const commandExecuteSpy = jest + .spyOn(commandHandler, 'execute') + .mockImplementation(() => Promise.resolve(fakeResult)); + + const command = new TestCommand(); + const executionResult = await commandBus.execute(command); + + expect(commandExecuteSpy).toHaveBeenCalledWith(command); + expect(executionResult).toEqual(fakeResult); + }); + + it('should invoke every interceptor', async () => { + const { + commandBus, + interceptors: [firstCommandInterceptor, secondCommandInterceptor], + } = await bootstrap(FirstCommandInterceptor, SecondCommandInterceptor); + + const firstHandlerInterceptSpy = jest.spyOn( + firstCommandInterceptor, + 'intercept', + ); + const secondHandlerInterceptSpy = jest.spyOn( + secondCommandInterceptor, + 'intercept', + ); + + const command = new TestCommand(); + await commandBus.execute(command); + + expect(firstHandlerInterceptSpy).toHaveBeenCalledWith( + command, + expect.anything(), + ); + expect(secondHandlerInterceptSpy).toHaveBeenCalledWith( + command, + expect.anything(), + ); + }); + + it('should allow modification of a command', async () => { + const { + commandBus, + interceptors: [commandInterceptor], + } = await bootstrap(FirstCommandInterceptor); + + const fakeResult = {}; + jest + .spyOn(commandInterceptor, 'intercept') + .mockImplementation(() => Promise.resolve(fakeResult)); + + const executionResult = commandBus.execute(new TestCommand()); + await expect(executionResult).resolves.toEqual(fakeResult); + }); + + it('should propagate errors and stop execution', async () => { + const { + commandBus, + interceptors: [firstCommandInterceptor, secondCommandInterceptor], + } = await bootstrap(FirstCommandInterceptor, SecondCommandInterceptor); + + const fakeError = new Error('FAKE_ERROR'); + jest + .spyOn(firstCommandInterceptor, 'intercept') + .mockImplementation(() => Promise.reject(fakeError)); + const secondInterceptorInterceptSpy = jest.spyOn( + secondCommandInterceptor, + 'intercept', + ); + + await expect(commandBus.execute(new TestCommand())).rejects.toEqual( + fakeError, + ); + expect(secondInterceptorInterceptSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cqrs.module.ts b/src/cqrs.module.ts index 325dd70b..04cbdbf6 100644 --- a/src/cqrs.module.ts +++ b/src/cqrs.module.ts @@ -6,6 +6,7 @@ import { IEvent } from './interfaces'; import { QueryBus } from './query-bus'; import { ExplorerService } from './services/explorer.service'; import { UnhandledExceptionBus } from './unhandled-exception-bus'; +import { CommandInterceptionExecutor } from './command-interception-executor'; @Module({ providers: [ @@ -15,6 +16,7 @@ import { UnhandledExceptionBus } from './unhandled-exception-bus'; UnhandledExceptionBus, EventPublisher, ExplorerService, + CommandInterceptionExecutor, ], exports: [ CommandBus, @@ -43,14 +45,17 @@ export class CqrsModule private readonly eventBus: EventBus, private readonly commandBus: CommandBus, private readonly queryBus: QueryBus, + private readonly commandInterceptor: CommandInterceptionExecutor, ) {} onApplicationBootstrap() { - const { events, queries, sagas, commands } = this.explorerService.explore(); + const { events, queries, sagas, commands, interceptors } = + this.explorerService.explore(); this.eventBus.register(events); this.commandBus.register(commands); this.queryBus.register(queries); this.eventBus.registerSagas(sagas); + this.commandInterceptor.register(interceptors); } } diff --git a/src/decorators/command-interceptor.decorator.ts b/src/decorators/command-interceptor.decorator.ts new file mode 100644 index 00000000..4c57887d --- /dev/null +++ b/src/decorators/command-interceptor.decorator.ts @@ -0,0 +1,15 @@ +import 'reflect-metadata'; +import { COMMAND_INTERCEPTOR_METADATA } from './constants'; + +/** + * Decorator that marks a class as a Nest command interceptor. A command interceptor + * intercepts commands (actions) executed by your application code and allows you to implement + * cross-cutting concerns. + * + * The decorated class must implement the `ICommandInterceptor` interface. + */ +export const CommandInterceptor = (): ClassDecorator => { + return (target: object) => { + Reflect.defineMetadata(COMMAND_INTERCEPTOR_METADATA, {}, target); + }; +}; diff --git a/src/decorators/constants.ts b/src/decorators/constants.ts index 57e7a1e2..aaec1b6b 100644 --- a/src/decorators/constants.ts +++ b/src/decorators/constants.ts @@ -5,3 +5,4 @@ export const QUERY_HANDLER_METADATA = '__queryHandler__'; export const EVENT_METADATA = '__event__'; export const EVENTS_HANDLER_METADATA = '__eventsHandler__'; export const SAGA_METADATA = '__saga__'; +export const COMMAND_INTERCEPTOR_METADATA = '__commandInterceptor__'; diff --git a/src/decorators/index.ts b/src/decorators/index.ts index 558df983..6e18e2a3 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -2,3 +2,4 @@ export * from './command-handler.decorator'; export * from './events-handler.decorator'; export * from './query-handler.decorator'; export * from './saga.decorator'; +export * from './command-interceptor.decorator'; diff --git a/src/interfaces/commands/command-interceptor.interface.ts b/src/interfaces/commands/command-interceptor.interface.ts new file mode 100644 index 00000000..54c40100 --- /dev/null +++ b/src/interfaces/commands/command-interceptor.interface.ts @@ -0,0 +1,15 @@ +import { ICommand } from './command.interface'; + +/** + * Interface describing implementation of a command interceptor + * + * @publicApi + */ +export interface ICommandInterceptor { + /** + * Method to implement a custom command interceptor. + * @param command the command to execute. + * @param next a reference to the function, which provides access to the command handler + */ + intercept(command: T, next: () => Promise): Promise; +} diff --git a/src/interfaces/cqrs-options.interface.ts b/src/interfaces/cqrs-options.interface.ts index e232afb9..71bffb11 100644 --- a/src/interfaces/cqrs-options.interface.ts +++ b/src/interfaces/cqrs-options.interface.ts @@ -2,10 +2,12 @@ import { Type } from '@nestjs/common'; import { ICommandHandler } from './commands/command-handler.interface'; import { IEventHandler } from './events/event-handler.interface'; import { IQueryHandler } from './queries/query-handler.interface'; +import { ICommandInterceptor } from './commands/command-interceptor.interface'; export interface CqrsOptions { events?: Type[]; queries?: Type[]; commands?: Type[]; sagas?: Type[]; + interceptors?: Type[]; } diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index a9df2105..123e505a 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -2,6 +2,7 @@ export * from './commands/command-bus.interface'; export * from './commands/command-handler.interface'; export * from './commands/command-publisher.interface'; export * from './commands/command.interface'; +export * from './commands/command-interceptor.interface'; export * from './events/event-bus.interface'; export * from './events/event-handler.interface'; export * from './events/event-publisher.interface'; diff --git a/src/services/explorer.service.ts b/src/services/explorer.service.ts index c3bf1687..363b11d0 100644 --- a/src/services/explorer.service.ts +++ b/src/services/explorer.service.ts @@ -4,6 +4,7 @@ import { Module } from '@nestjs/core/injector/module'; import { ModulesContainer } from '@nestjs/core/injector/modules-container'; import { COMMAND_HANDLER_METADATA, + COMMAND_INTERCEPTOR_METADATA, EVENTS_HANDLER_METADATA, QUERY_HANDLER_METADATA, SAGA_METADATA, @@ -15,6 +16,7 @@ import { IQueryHandler, } from '../interfaces'; import { CqrsOptions } from '../interfaces/cqrs-options.interface'; +import { ICommandInterceptor } from '../interfaces/commands/command-interceptor.interface'; @Injectable() export class ExplorerService { @@ -34,7 +36,11 @@ export class ExplorerService { const sagas = this.flatMap(modules, (instance) => this.filterProvider(instance, SAGA_METADATA), ); - return { commands, queries, events, sagas }; + const interceptors = this.flatMap( + modules, + (instance) => this.filterProvider(instance, COMMAND_INTERCEPTOR_METADATA), + ); + return { commands, queries, events, sagas, interceptors }; } flatMap(