Skip to content

Commit

Permalink
feat: use cases (or commands)
Browse files Browse the repository at this point in the history
  • Loading branch information
thiagozf committed Apr 14, 2020
1 parent 100c875 commit de0db84
Show file tree
Hide file tree
Showing 22 changed files with 371 additions and 138 deletions.
3 changes: 3 additions & 0 deletions src/command/CommandHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface CommandHandler<I = any, O = any> {
handle: (event: I) => Promise<O>
}
61 changes: 61 additions & 0 deletions src/command/CommandRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Class } from 'type-fest'
import { Container, DefaultContainer } from '@rimo/container'
import { storage, SubscriberMetadata, SubscribersMetadata } from '@rimo/metadata'
import { CommandHandler } from './CommandHandler'
import { RunnerCommandHandler } from './RunnerCommandHandler'

export interface CommandRunnerConfig {
container?: Container
}

/**
* Runs commands
*/
export class CommandRunner {
protected container: Container
protected handlers: Map<Class<CommandHandler>, CommandHandler> = new Map()
protected subscribers: Map<Class<CommandHandler>, SubscriberMetadata> = new Map()

constructor({ container = new DefaultContainer() }: CommandRunnerConfig = {}) {
this.container = container
}

/**
* Executes commands.
*/
run = async <T extends CommandHandler>(
Handler: Class<T>,
event: Parameters<T['handle']>['0']
): Promise<ReturnType<T['handle']>> => {
const handler = await this.container.get(Handler)

if (handler instanceof RunnerCommandHandler) {
handler.setRunner(this)
}

await this.emit('middleware', Handler, event)
await this.emit('before', Handler, event)
const result = await handler.handle(event)
await this.emit('after', Handler, result)

return result
}

/**
* Serially executes `@BeforeCommand(Handler)` or `@AfterCommand(Handler)` decorated methods for the given `Handler`.
*/
emit = async <T extends CommandHandler>(
type: keyof SubscribersMetadata,
HandlerClass: Class<T>,
result: ReturnType<T['handle']>
): Promise<void> => {
if (storage.metadata.subscribers[type].has(HandlerClass)) {
const subscribers = storage.metadata.subscribers[type].get(
HandlerClass
) as SubscriberMetadata[]
for (const meta of subscribers) {
await this.container.get(meta.SubscriberClass)[meta.method](result, this)
}
}
}
}
3 changes: 3 additions & 0 deletions src/command/Middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface Middleware<I = any> {
use: (event: I) => Promise<any>
}
19 changes: 19 additions & 0 deletions src/command/RunnerCommandHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CommandHandler } from './CommandHandler'
import { CommandRunner } from './CommandRunner'

export abstract class RunnerCommandHandler<I = any, O = any> implements CommandHandler<I, O> {
abstract async handle(event: I): Promise<O>

public runner!: CommandRunner

// async run<T extends CommandHandler>(
// Handler: Class<T>,
// event: Parameters<T['handle']>['0']
// ): Promise<ReturnType<T['handle']>> {
// return this.runner.run(Handler, event)
// }

public setRunner(runner: CommandRunner) {
this.runner = runner
}
}
4 changes: 4 additions & 0 deletions src/command/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './CommandHandler'
export * from './CommandRunner'
export * from './RunnerCommandHandler'
export * from './Middleware'
19 changes: 19 additions & 0 deletions src/container/Container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface Container {
get(someClass: any): any
}

export class DefaultContainer {
private instances: Map<any, any> = new Map()

get<T>(Clazz: any): T {
if (!this.instances.has(Clazz)) {
const handler = new Clazz()

this.instances.set(Clazz, handler)

return handler
}

return this.instances.get(Clazz) as T
}
}
1 change: 1 addition & 0 deletions src/container/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Container'
16 changes: 16 additions & 0 deletions src/decorators/command/AfterCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Class } from 'type-fest'
import { CommandHandler } from '@rimo/command'
import { OnOptions, addSubscriber } from './utils'

export const AfterCommand = (
HandlerClass: Class<CommandHandler>,
options?: OnOptions
): MethodDecorator => {
return (
target: any,
propertyKey: string | symbol,
_descriptor: TypedPropertyDescriptor<any>
): void => {
addSubscriber('after', HandlerClass, options, target.constructor, propertyKey)
}
}
16 changes: 16 additions & 0 deletions src/decorators/command/BeforeCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Class } from 'type-fest'
import { CommandHandler } from '@rimo/command'
import { OnOptions, addSubscriber } from './utils'

export const BeforeCommand = (
HandlerClass: Class<CommandHandler>,
options?: OnOptions
): MethodDecorator => {
return (
target: any,
propertyKey: string | symbol,
_descriptor: TypedPropertyDescriptor<any>
): void => {
addSubscriber('before', HandlerClass, options, target.constructor, propertyKey)
}
}
13 changes: 13 additions & 0 deletions src/decorators/command/Use.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Class } from 'type-fest'
import { Middleware } from '@rimo/command'
import { OnOptions, addSubscriber } from './utils'

export const Use = (HandlerClass: Class<Middleware>, options?: OnOptions): MethodDecorator => {
return (
target: any,
_propertyKey: string | symbol,
_descriptor: TypedPropertyDescriptor<any>
): void => {
addSubscriber('middleware', target.constructor, options, HandlerClass, 'use')
}
}
3 changes: 3 additions & 0 deletions src/decorators/command/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './AfterCommand'
export * from './BeforeCommand'
export * from './Use'
38 changes: 38 additions & 0 deletions src/decorators/command/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Class } from 'type-fest'
import { CommandHandler } from '@rimo/command'
import { SubscribersMetadata, SubscriberMetadata, storage } from '@rimo/metadata'

export interface OnOptions {
priority?: number
}

const sortByPriority = (arr: { priority: number }[]): void => {
arr.sort((a, b) => (a.priority < b.priority ? 1 : -1))
}

export const addSubscriber = (
kind: keyof SubscribersMetadata,
HandlerClass: Class<CommandHandler>,
options: OnOptions = {},
SubscriberClass: any,
propertyKey: string | symbol
) => {
let subscribers: SubscriberMetadata[]

// istanbul ignore next
if (storage.metadata.subscribers[kind].has(HandlerClass)) {
subscribers = storage.metadata.subscribers[kind].get(HandlerClass) as SubscriberMetadata[]
} else {
subscribers = []
storage.metadata.subscribers[kind].set(HandlerClass, subscribers)
}

subscribers.push({
HandlerClass,
SubscriberClass,
method: propertyKey as string,
priority: options.priority || 0,
})

sortByPriority(subscribers)
}
1 change: 1 addition & 0 deletions src/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './command'
45 changes: 45 additions & 0 deletions src/metadata/Metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Class } from 'type-fest'
import { CommandHandler } from '@rimo/command'

declare var global: {
__rimo_metadata_storage?: MetadataStorage
}

export interface SubscriberMetadata {
HandlerClass: Class
SubscriberClass: Class
method: string | symbol
priority: number
}

export interface SubscribersMetadata {
middleware: Map<Class<CommandHandler>, SubscriberMetadata[]>
before: Map<Class<CommandHandler>, SubscriberMetadata[]>
after: Map<Class<CommandHandler>, SubscriberMetadata[]>
}

interface Metadata {
subscribers: SubscribersMetadata
}

export class MetadataStorage {
public metadata: Metadata = {
subscribers: {
middleware: new Map(),
before: new Map(),
after: new Map(),
},
}

private constructor() {}

static getInstance(): MetadataStorage {
if (!global.__rimo_metadata_storage) {
global.__rimo_metadata_storage = new MetadataStorage()
}

return global.__rimo_metadata_storage
}
}

export const storage: MetadataStorage = MetadataStorage.getInstance()
1 change: 1 addition & 0 deletions src/metadata/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Metadata'
4 changes: 3 additions & 1 deletion src/rimo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from './command'
export * from './container'
export * from './core'
export * from './decorators'
export * from './mapper'
export * from './monad'
export * from './repository'
export * from './service'
10 changes: 0 additions & 10 deletions src/service/Service.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/service/index.ts

This file was deleted.

111 changes: 111 additions & 0 deletions test/command/CommandRunner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { IsNotEmpty, IsEmail, validate } from 'class-validator'
import { Middleware, CommandRunner, RunnerCommandHandler, CommandHandler } from '@rimo/command'
import { Use, BeforeCommand, AfterCommand } from '@rimo/decorators'
import { Entity } from '@rimo/core'

import { DefaultContainer } from '@rimo/container'

/**
* CommandRunner test
*/
describe('CommandRunner test', () => {
describe('Acceptance test', () => {
let runner: CommandRunner
let watchers: {
before: number[]
after: number[]
}

beforeEach(() => {
runner = new CommandRunner()
watchers = {
before: [],
after: [],
}
})

class User extends Entity<{ email: string }> {
get email() {
return this.props.email
}
}

class CreateUserInput {
@IsNotEmpty()
@IsEmail()
email!: string

static populate = (data: any) => {
return Object.assign(new CreateUserInput(), data)
}
}

class ValidateCommand implements Middleware<CreateUserInput> {
async use(event: CreateUserInput) {
const errors = await validate(event)
if (errors.length > 0) {
throw new Error('validation error')
}
}
}

class CreateUserCommand extends RunnerCommandHandler<CreateUserInput, User> {
@Use(ValidateCommand)
async handle(input: CreateUserInput): Promise<User> {
return new User(input)
}
}

class CreateUserSubscriber {
@BeforeCommand(CreateUserCommand, { priority: 0 })
async beforeCreateUser(input: CreateUserInput) {
watchers.before.push(0)
}

@BeforeCommand(CreateUserCommand, { priority: 1 })
async beforeCreateUser2(input: CreateUserInput) {
watchers.before.push(1)
}

@BeforeCommand(CreateUserCommand, { priority: 2 })
async beforeCreateUser3(input: CreateUserInput) {
watchers.before.push(2)
}

@AfterCommand(CreateUserCommand)
async afterCreateUser(user: User) {
watchers.after.push(0)
}
}

it('should execute middlewares (use)', async () => {
const input = CreateUserInput.populate({})
await expect(runner.run(CreateUserCommand, input)).rejects.toThrowError()
})

it('should notify subscribers', async () => {
const input = CreateUserInput.populate({ email: '[email protected]' })
const output = await runner.run(CreateUserCommand, input)
expect(watchers.before).toStrictEqual([2, 1, 0])
expect(watchers.after).toHaveLength(1)
})
})

describe('Custom container', () => {
interface Email {
value: string
}

class RegisterEmailCommand implements CommandHandler<string, Email> {
async handle(value: string): Promise<Email> {
return { value }
}
}

it('should run', async () => {
const runner = new CommandRunner({ container: new DefaultContainer() })
const output = await runner.run(RegisterEmailCommand, '[email protected]')
expect(output.value).toBe('[email protected]')
})
})
})
Loading

0 comments on commit de0db84

Please sign in to comment.