-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
17 changed files
with
588 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
# EventModule | ||
|
||
The [`EventModule`](/src/core/eventbus/event.module.ts) can be used to listen to and publish events across the whole application. This can be used for decoupling logic. | ||
|
||
The event system stores a `RXJS.Subject` for every event that is subscribed to. | ||
Every event handler is registered as an observer of that subject and when publishing an Event it is pushed into the correct subject. | ||
|
||
A specific order of the event-handlers is not guaranteed and should not be relied upon. | ||
|
||
## Publishing events | ||
|
||
All events need to extend the [`BaseEvent`](/src/shared/events/base-event.ts). We place all events in [`/src/shared/events`](/src/shared/events). | ||
|
||
BaseEvent uses a marker property to ensure, that object literals can never be given to the event-system. This is important, because the event-system assumes that events are always specific class instances and it uses the constructor to differentiate events. | ||
|
||
```ts | ||
export class UserCreationEvent extends BaseEvent { | ||
public constructor(public readonly userId: string) { | ||
super(); | ||
} | ||
} | ||
``` | ||
|
||
Events can be published using the [`EventService`](/src/core/eventbus/services/event.service.ts). Simply inject it in your classes and publish new events. | ||
|
||
```ts | ||
@Controller({ path: '/user' }) | ||
export class UserController { | ||
public constructor(private readonly eventService: EventService) {} | ||
|
||
@Post() | ||
public createUser(@Body() user): void { | ||
this.eventService.publish(new UserCreationEvent(user.id)); | ||
} | ||
} | ||
``` | ||
|
||
Publishing an event can never fail. If one or more of the registered observers throw an error, the error will be logged. | ||
|
||
## Listening to events | ||
|
||
In order to listen to events, you need to register you handlers with the event service. | ||
|
||
Handlers have to be of type `(ev: MyEvent) => void | Promise<void>`. | ||
Errors that are thrown inside of handler methods will be logged but won't crash the application. | ||
|
||
You can register your handlers using the following methods: | ||
|
||
### Using @EventHandler() | ||
|
||
The [`@EventHandler(...)`](/src/core/eventbus/decorators/event-handler.decorator.ts)-Decorator can be used to decorate methods of providers or controllers. Event-handlers will be automatically discovered and registered by the [`EventDiscoveryService`](src/core/eventbus/services/event-discovery.service.ts). | ||
|
||
Make sure to add the class to your module definition so it gets registered by NestJS. | ||
|
||
```ts | ||
@Injectable() | ||
export class ExampleProvider { | ||
@EventHandler(UserCreationEvent) | ||
public syncEventHandler(event: UserCreationEvent): void { | ||
// Will be called for every published UserCreationEvent | ||
} | ||
|
||
@EventHandler(UserCreationEvent) | ||
public async asyncEventHandler(event: UserCreationEvent): Promise<void> { | ||
// Will be called for every published UserCreationEvent | ||
} | ||
} | ||
``` | ||
|
||
### Using the `.register`-method directly | ||
|
||
You can also register event-handlers by using the `EventService.subscribe`-method. This approach is not recommended but may be useful in some niche situations. | ||
|
||
```ts | ||
@Injectable() | ||
export class ExampleProvider { | ||
public constructor(eventService: EventService) { | ||
const handler = (ev: UserCreationEvent) => { | ||
// [...] | ||
}; | ||
|
||
eventService.subscribe(UserCreationEvent, handler); | ||
|
||
// You can unregister events using 'unsubscribe' | ||
eventService.unsubscribe(UserCreationEvent, handler); | ||
} | ||
} | ||
``` |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
src/core/eventbus/decorators/event-handler.decorator.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
/* eslint-disable max-classes-per-file */ | ||
import { BaseEvent } from '../../../shared/events/index.js'; | ||
import { EVENT_HANDLER_META } from '../types/metadata-key.js'; | ||
import { EventHandler } from './event-handler.decorator.js'; | ||
|
||
class TestEvent extends BaseEvent { | ||
public constructor() { | ||
super(); | ||
} | ||
} | ||
|
||
class TestClass { | ||
@EventHandler(TestEvent) | ||
public handler(_ev: TestEvent): void {} | ||
|
||
// @ts-expect-error typechecker should detect invalid function signature | ||
@EventHandler(TestEvent) | ||
public invalidArgs(_ev: string): void {} | ||
|
||
// @ts-expect-error typechecker should detect invalid function signature | ||
@EventHandler(TestEvent) | ||
public invalidArgs2(_ev: TestEvent, _xyz: string): void {} | ||
} | ||
|
||
describe('EventHandler decorator', () => { | ||
it('should set metadata', () => { | ||
// eslint-disable-next-line jest/unbound-method | ||
const meta: unknown = Reflect.getMetadata(EVENT_HANDLER_META, TestClass.prototype.handler); | ||
|
||
expect(meta).toBe(TestEvent); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { SetMetadata } from '@nestjs/common'; | ||
|
||
import { BaseEvent } from '../../../shared/events/index.js'; | ||
import { EVENT_HANDLER_META } from '../types/metadata-key.js'; | ||
import { Constructor, EventHandlerType, TypedMethodDecorator } from '../types/util.types.js'; | ||
|
||
export function EventHandler<Event extends BaseEvent, ClassType, MethodName extends keyof ClassType>( | ||
eventClass: Constructor<Event>, | ||
): TypedMethodDecorator<ClassType, MethodName, EventHandlerType<Event>> { | ||
return SetMetadata(EVENT_HANDLER_META, eventClass); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { Test, TestingModule } from '@nestjs/testing'; | ||
|
||
import { ConfigTestModule } from '../../../test/utils/index.js'; | ||
import { EventModule } from './event.module.js'; | ||
import { EventService } from './services/event.service.js'; | ||
|
||
describe('EventModule', () => { | ||
let module: TestingModule; | ||
|
||
beforeAll(async () => { | ||
module = await Test.createTestingModule({ | ||
imports: [EventModule, ConfigTestModule], | ||
}).compile(); | ||
|
||
await module.init(); | ||
}); | ||
|
||
afterAll(async () => { | ||
await module.close(); | ||
}); | ||
|
||
it('should be defined', () => { | ||
expect(module).toBeDefined(); | ||
}); | ||
|
||
it('should export EventService', () => { | ||
expect(module.get(EventService)).toBeDefined(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import { DiscoveryModule } from '@golevelup/nestjs-discovery'; | ||
import { Global, Module, OnApplicationBootstrap } from '@nestjs/common'; | ||
|
||
import { ClassLogger } from '../logging/class-logger.js'; | ||
import { LoggerModule } from '../logging/logger.module.js'; | ||
import { EventDiscoveryService } from './services/event-discovery.service.js'; | ||
import { EventService } from './services/event.service.js'; | ||
|
||
@Global() | ||
@Module({ | ||
imports: [LoggerModule.register(EventModule.name), DiscoveryModule], | ||
providers: [EventService, EventDiscoveryService], | ||
exports: [EventService], | ||
}) | ||
export class EventModule implements OnApplicationBootstrap { | ||
public constructor( | ||
private readonly logger: ClassLogger, | ||
private readonly discoveryService: EventDiscoveryService, | ||
) {} | ||
|
||
public async onApplicationBootstrap(): Promise<void> { | ||
const handlerCount: number = await this.discoveryService.registerEventHandlers(); | ||
this.logger.notice(`Registered ${handlerCount} event listeners`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from './event.module.js'; | ||
export * from './services/event.service.js'; |
96 changes: 96 additions & 0 deletions
96
src/core/eventbus/services/event-discovery.service.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
// eslint-disable-next-line max-classes-per-file | ||
import { DiscoveryModule } from '@golevelup/nestjs-discovery'; | ||
import { DeepMocked, createMock } from '@golevelup/ts-jest'; | ||
import { Controller, Injectable } from '@nestjs/common'; | ||
import { Test, TestingModule } from '@nestjs/testing'; | ||
|
||
import { LoggingTestModule } from '../../../../test/utils/index.js'; | ||
import { BaseEvent } from '../../../shared/events/index.js'; | ||
import { EventHandler } from '../decorators/event-handler.decorator.js'; | ||
import { EventDiscoveryService } from './event-discovery.service.js'; | ||
import { EventService } from './event.service.js'; | ||
|
||
class TestEvent extends BaseEvent { | ||
public constructor() { | ||
super(); | ||
} | ||
} | ||
|
||
@Injectable() | ||
class TestProvider { | ||
@EventHandler(TestEvent) | ||
public handleEvent(_event: TestEvent): void {} | ||
|
||
public undecoratedMethod(): void {} | ||
} | ||
|
||
@Controller({}) | ||
class TestController { | ||
@EventHandler(TestEvent) | ||
public handleEvent(_event: TestEvent): void {} | ||
|
||
public undecoratedMethod(): void {} | ||
} | ||
|
||
describe('EventService', () => { | ||
let module: TestingModule; | ||
|
||
let sut: EventDiscoveryService; | ||
let eventServiceMock: DeepMocked<EventService>; | ||
|
||
let testProvider: TestProvider; | ||
let testController: TestController; | ||
|
||
beforeAll(async () => { | ||
module = await Test.createTestingModule({ | ||
imports: [LoggingTestModule, DiscoveryModule], | ||
providers: [ | ||
EventDiscoveryService, | ||
TestProvider, | ||
{ provide: EventService, useValue: createMock<EventService>() }, | ||
], | ||
controllers: [TestController], | ||
}).compile(); | ||
|
||
await module.init(); | ||
|
||
sut = module.get(EventDiscoveryService); | ||
eventServiceMock = module.get(EventService); | ||
|
||
testProvider = module.get(TestProvider); | ||
testController = module.get(TestController); | ||
}); | ||
|
||
afterAll(async () => { | ||
await module.close(); | ||
}); | ||
|
||
afterEach(() => { | ||
jest.resetAllMocks(); | ||
}); | ||
|
||
it('should be defined', () => { | ||
expect(sut).toBeDefined(); | ||
}); | ||
|
||
describe('registerEventHandlers', () => { | ||
it('should discover and register all event handlers in controllers', async () => { | ||
await sut.registerEventHandlers(); | ||
|
||
expect(eventServiceMock.subscribe).toHaveBeenCalledWith(TestEvent, testController.handleEvent); | ||
}); | ||
|
||
it('should discover and register all event handlers in providers', async () => { | ||
await sut.registerEventHandlers(); | ||
|
||
expect(eventServiceMock.subscribe).toHaveBeenCalledWith(TestEvent, testProvider.handleEvent); | ||
}); | ||
|
||
it('should return the number of registered handlers', async () => { | ||
const count: number = await sut.registerEventHandlers(); | ||
|
||
expect(eventServiceMock.subscribe).toHaveBeenCalledTimes(2); | ||
expect(count).toBe(2); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
import { DiscoveredMethodWithMeta, DiscoveryService } from '@golevelup/nestjs-discovery'; | ||
import { Injectable } from '@nestjs/common'; | ||
|
||
import { BaseEvent } from '../../../shared/events/index.js'; | ||
import { ClassLogger } from '../../logging/class-logger.js'; | ||
import { EVENT_HANDLER_META } from '../types/metadata-key.js'; | ||
import { Constructor, EventHandlerType } from '../types/util.types.js'; | ||
import { EventService } from './event.service.js'; | ||
|
||
type HandlerMethod = DiscoveredMethodWithMeta<Constructor<BaseEvent>>; | ||
|
||
@Injectable() | ||
export class EventDiscoveryService { | ||
public constructor( | ||
private readonly logger: ClassLogger, | ||
private readonly discoveryService: DiscoveryService, | ||
private readonly eventService: EventService, | ||
) {} | ||
|
||
private async discoverHandlerMethods(): Promise<HandlerMethod[]> { | ||
const controllerMethods: HandlerMethod[] = | ||
await this.discoveryService.controllerMethodsWithMetaAtKey(EVENT_HANDLER_META); | ||
|
||
const providerMethods: HandlerMethod[] = | ||
await this.discoveryService.providerMethodsWithMetaAtKey(EVENT_HANDLER_META); | ||
|
||
return [...controllerMethods, ...providerMethods]; | ||
} | ||
|
||
public async registerEventHandlers(): Promise<number> { | ||
const results: HandlerMethod[] = await this.discoverHandlerMethods(); | ||
|
||
results.forEach((method: HandlerMethod) => { | ||
const eventConstructor: Constructor<BaseEvent> = method.meta; | ||
const eventHandler: EventHandlerType<BaseEvent> = method.discoveredMethod.handler; | ||
|
||
this.eventService.subscribe(method.meta, eventHandler); | ||
|
||
const parentClassName: string = method.discoveredMethod.parentClass.name; | ||
const handlerMethodName: string = method.discoveredMethod.methodName; | ||
|
||
this.logger.notice( | ||
`Registered handler '${parentClassName}.${handlerMethodName}' for '${eventConstructor.name}'`, | ||
); | ||
}); | ||
|
||
return results.length; | ||
} | ||
} |
Oops, something went wrong.