diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a99321 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +package-lock.json diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e1ef184 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: node_js +node_js: + - "8" + +cache: + directories: + - node_modules + +install: + - npm install + +script: + - npm test diff --git a/LICENSE b/LICENSE index 1101347..47897c2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2017 Witold Wasiczko +Copyright (c) 2018 Mateusz KoĊ‚ecki Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/command-bus.ts b/command-bus.ts deleted file mode 100644 index 0b159a8..0000000 --- a/command-bus.ts +++ /dev/null @@ -1,85 +0,0 @@ -interface Command { - -} - - -interface CommandHandler { - handle(commmand: Command): any; -} - - -interface NextMiddleware { - executeNext(command: Command): any; -} - - -interface Middleware { - execute(command: Command, next: NextMiddleware): any; -} - - -interface CommandNameExtractor { - extract(command: Command): string; -} - - -interface HandlerLocator { - getHandlerForCommand(commandName: string): CommandHandler; -} - - -interface CommandBus { - handle(comand: Command): void; -} - - -class CommandHandlerMiddleware implements Middleware -{ - constructor(readonly handlerLocator: HandlerLocator, readonly commandNameExtractor: CommandNameExtractor) { - } - - execute(command: Command, next: NextMiddleware): any - { - let className: string = this.commandNameExtractor.extract(command); - - return this.handlerLocator.getHandlerForCommand(className).handle(command); - } -} - - -class InMemoryCommandBus implements CommandBus { - readonly commands: Command[]; - - handle(command: Command): void { - this.commands.push(command); - } -} - - -class Commander implements CommandBus { - private middlewareChain; - - constructor(readonly middlewares: Middleware[]) { - this.middlewareChain = this.createExcecutionChain(middlewares); - } - - handle(command: Command): void { - this.middlewareChain.executeNext(command); - } - - private createExcecutionChain(middlewares: Middleware[]): NextMiddleware { - let lastCallable: NextMiddleware = (new class implements NextMiddleware { - executeNext(command: Command): any { - } - }); - - for(let i: number = middlewares.length; i >= 0; i--) { - lastCallable = (new class implements NextMiddleware { - executeNext(command: Command): any { - return middlewares[i].execute(command, lastCallable); - } - }); - } - return lastCallable; - } -} diff --git a/package.json b/package.json new file mode 100644 index 0000000..448101d --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "commander", + "version": "0.1.0", + "description": "Command Bus For TypeScript", + "license": "MIT", + "main": "dist/src/index.js", + "directories": { + "test": "test" + }, + "scripts": { + "test": "npm run build && npm run run-test", + "build": "rm -rf dist && tsc", + "run-test": "mocha --file dist/test/setup.js 'dist/test/**/*.test.js'", + "watch-test": "mocha --require ts-node/register --watch --watch-extensions ts --file test/setup.ts 'test/**/*.test.ts'" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/snapshotpl/commander.git" + }, + "keywords": [ + "command", + "bus", + "typescript", + "commander" + ], + "devDependencies": { + "@types/chai": "^4.1.3", + "@types/chai-as-promised": "^7.1.0", + "@types/mocha": "^5.2.0", + "@types/node": "^10.0.10", + "@types/sinon": "^4.3.3", + "chai": "^4.1.2", + "chai-as-promised": "^7.1.1", + "mocha": "^5.1.1", + "sinon": "^5.0.7", + "ts-node": "^6.0.3", + "typescript": "^2.8.3" + } +} diff --git a/src/commandBus.ts b/src/commandBus.ts new file mode 100644 index 0000000..ec8745d --- /dev/null +++ b/src/commandBus.ts @@ -0,0 +1,10 @@ + +export interface Command { } // marking interface + +export interface Handler { + handle(command: C): Promise; +} + +export interface CommandBus { + handle(command: Command): Promise +} diff --git a/src/commander.ts b/src/commander.ts new file mode 100644 index 0000000..0849bcd --- /dev/null +++ b/src/commander.ts @@ -0,0 +1,35 @@ +import { Middleware } from './middlewares'; +import { CommandBus, Command } from './commandBus'; + +type ExecutionChain = (command: Command) => Promise; + +export class Commander implements CommandBus { + + private executionChain: ExecutionChain; + + constructor(middlewares: Middleware[]) { + this.executionChain = this.createExecutionChain(middlewares); + } + + public async handle(command: Command): Promise { + await this.executionChain(command); + } + + private createExecutionChain(middlewares: Middleware[]): ExecutionChain { + const last = () => { + // last callback in chain is no-op + return Promise.resolve(); + }; + + const reducer = ( + next: ExecutionChain, + middleware: Middleware + ): ExecutionChain => { + return (command: Command): Promise => { + return middleware.run(command, next); + } + }; + + return middlewares.reverse().reduce(reducer, last); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..813fc82 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ + +export * from './commander'; +export * from './commandBus'; diff --git a/src/middlewares/commandExecution.ts b/src/middlewares/commandExecution.ts new file mode 100644 index 0000000..b34ab06 --- /dev/null +++ b/src/middlewares/commandExecution.ts @@ -0,0 +1,16 @@ + +import { Middleware, NextMiddleware } from './index'; +import { Command, Handler } from '../commandBus'; +import { HandlerResolver } from '../resolvers' + +export class CommandExecutionMiddleware implements Middleware { + constructor( + private handlerResolver: HandlerResolver + ) { } + + public async run(command: Command, next: NextMiddleware): Promise { + const handler: Handler = this.handlerResolver.resolve(command); + await handler.handle(command); + await next(command); + } +} diff --git a/src/middlewares/commandLogger.ts b/src/middlewares/commandLogger.ts new file mode 100644 index 0000000..a0e5798 --- /dev/null +++ b/src/middlewares/commandLogger.ts @@ -0,0 +1,52 @@ + +import { Middleware, NextMiddleware } from './index'; +import { Command } from '../commandBus'; + + +export interface CommandLogger { + log(level: string, msg: string): CommandLogger; + log(level: string, msg: string, meta: any): CommandLogger; + log(level: string, msg: string, ...meta: any[]): CommandLogger; +} + +export interface CommandLoggerOptions { + level?: string; + errorLevel?: string; +} + +class Timer { + private start = Date.now(); + + public getTime(): number { + return Date.now() - this.start; + } +} + +export class CommandLoggerMiddleware implements Middleware { + private logger: CommandLogger; + private level: string; + private errorLevel: string; + + constructor(logger: CommandLogger, options: CommandLoggerOptions = {}) { + this.logger = logger; + this.level = options.level || 'info'; + this.errorLevel = options.errorLevel || 'error'; + } + + public async run(command: Command, next: NextMiddleware): Promise { + const commandName = command.constructor.name; + + this.logger.log(this.level, 'Command %s dispatched', commandName); + + const timer = new Timer(); + + try { + const res = await next(command); + this.logger.log(this.level, 'Command %s succeeded (%dms)', commandName, timer.getTime()); + return res; + } catch (err) { + this.logger.log(this.errorLevel, 'Command %s failed (%dms): %s', commandName, timer.getTime(), err); + throw err; + } + } +} diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts new file mode 100644 index 0000000..0794e37 --- /dev/null +++ b/src/middlewares/index.ts @@ -0,0 +1,11 @@ + +import { Command } from '../commandBus'; + +export type NextMiddleware = (command: Command) => Promise; + +export interface Middleware { + run(command: Command, next: NextMiddleware): Promise; +} + +export * from './commandLogger'; +export * from './commandExecution'; diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts new file mode 100644 index 0000000..6a31184 --- /dev/null +++ b/src/resolvers/index.ts @@ -0,0 +1,8 @@ + +import { Handler, Command } from '../commandBus'; + +export interface HandlerResolver { + resolve(command: C): Handler +} + +export * from './memory'; diff --git a/src/resolvers/memory.ts b/src/resolvers/memory.ts new file mode 100644 index 0000000..9bf4166 --- /dev/null +++ b/src/resolvers/memory.ts @@ -0,0 +1,24 @@ + +import { Handler, Command } from '../commandBus'; +import { HandlerResolver } from './index'; + +export type ConstructorOf = new (...args: any[]) => T; + +export class HandlerResolverInMemory implements HandlerResolver { + private handlers = new Map, Handler>(); + + public register(commandConstructor: ConstructorOf, handler: Handler): HandlerResolverInMemory { + this.handlers.set(commandConstructor, handler); + return this; + } + + public resolve(command: Command): Handler { + const handler = this.handlers.get(command.constructor as ConstructorOf); + + if (!handler) { + throw new Error(`Could not resolve handler for ${command.constructor.name}`); + } + + return handler; + } +} diff --git a/test/component/commander.test.ts b/test/component/commander.test.ts new file mode 100644 index 0000000..a756278 --- /dev/null +++ b/test/component/commander.test.ts @@ -0,0 +1,105 @@ + +import { expect } from "chai"; + +import { Commander } from '../../src/commander'; +import { Command, Handler } from '../../src/commandBus'; +import { + CommandLoggerMiddleware, + CommandExecutionMiddleware +} from '../../src/middlewares'; + +import { HandlerResolverInMemory } from '../../src/resolvers' +import { FakeLogger } from '../fakes/fakeLogger'; + + +describe("Commander with middlewares combo", () => { + + type Person = {id: number, firstName: string, lastName: string}; + type PersonRepository = Person[]; + + class AddPersonCommand implements Command { + constructor( + public firstName: string, + public lastName: string + ) { } + } + + class AddPersonHandler implements Handler { + private lastId = 123; + + constructor( + private repository: any[] + ) {} + + public async handle(addPerson: AddPersonCommand): Promise { + if ( + addPerson.firstName.length === 0 || + addPerson.lastName.length === 0 + ) { + throw new Error('firstName and lastName cannot be empty string'); + } + + this.repository.push({ + id: ++ this.lastId, + firstName: addPerson.firstName, + lastName: addPerson.lastName, + }); + } + } + + let logger: FakeLogger; + let commander: Commander; + let resolver: HandlerResolverInMemory; + let personRepository: PersonRepository; + + beforeEach(() => { + personRepository = []; + logger = new FakeLogger(); + resolver = new HandlerResolverInMemory(); + + resolver.register(AddPersonCommand, new AddPersonHandler(personRepository)); + + commander = new Commander([ + new CommandLoggerMiddleware(logger), + new CommandExecutionMiddleware(resolver), + ]); + }); + + it("should resolve with value from handler", async () => { + await commander.handle(new AddPersonCommand('John', 'Doe')); + + expect(personRepository).to.be.deep.equal([{ + id: 124, + firstName: 'John', + lastName: 'Doe', + }]); + }); + + it("should write info logs", async () => { + await commander.handle(new AddPersonCommand('John', 'Doe')); + + const infos = logger.Messages.filter(entry => entry.level === 'info'); + + expect(infos.length).to.be.greaterThan(0); + }); + + it("should reject when handler rejects", async () => { + const promise = commander.handle(new AddPersonCommand('John', '')); + + return expect(promise).to.eventually.be.rejectedWith( + Error, + 'firstName and lastName cannot be empty string' + ); + }); + + it("should write error logs when handler rejects", async () => { + try { + await commander.handle(new AddPersonCommand('John', '')); + } catch (err) { } + + const errors = logger.Messages.filter(entry => entry.level === 'error'); + + expect(errors.length).to.be.greaterThan(0); + }); + +}); diff --git a/test/fakes/fakeLogger.ts b/test/fakes/fakeLogger.ts new file mode 100644 index 0000000..96b7329 --- /dev/null +++ b/test/fakes/fakeLogger.ts @@ -0,0 +1,30 @@ + +import { format } from 'util'; +import { CommandLogger } from '../../src/middlewares'; + + +export class FakeLogger implements CommandLogger { + + private messages: { level: string, msg: string, meta: any }[] = []; + + public get Messages() { + return this.messages; + } + + public log(level: string, msg: string, ...meta: any[]): CommandLogger { + const placeholders: string[] = msg.match(/%[sdifjoO]/g) || []; + const formatArgs: any[] = [msg].concat( + meta.slice(0, Math.min(placeholders.length, meta.length)) + ); + + msg = format.apply(null, formatArgs); + + this.messages.push({ + level: level, + msg: msg, + meta: placeholders.length === meta.length ? {} : meta[meta.length - 1] + }); + + return this; + } +} diff --git a/test/fakes/fakeResolver.ts b/test/fakes/fakeResolver.ts new file mode 100644 index 0000000..b2d3aa0 --- /dev/null +++ b/test/fakes/fakeResolver.ts @@ -0,0 +1,22 @@ + +import { HandlerResolver } from '../../src/resolvers'; +import { Command, Handler } from '../../src/commandBus'; + +/** + * Always resolve with handler which will return result from given callback. + */ +export class FakeResolver implements HandlerResolver { + constructor( + private handlerCallback: (c: Command) => Promise + ) { } + + resolve(command: C): Handler { + const callback = this.handlerCallback; + + return new class implements Handler { + handle(c: C): Promise { + return callback(c); + } + }; + } +} diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..e53b819 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,6 @@ + +import 'mocha'; +import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; + +chai.use(chaiAsPromised); diff --git a/test/unit/commander.test.ts b/test/unit/commander.test.ts new file mode 100644 index 0000000..316a221 --- /dev/null +++ b/test/unit/commander.test.ts @@ -0,0 +1,61 @@ + +import { expect } from "chai"; + +import { Commander } from '../../src/commander'; +import { Command } from '../../src/commandBus'; +import { Middleware, NextMiddleware } from '../../src/middlewares'; + + +class CallbackMiddleware implements Middleware { + constructor( + private callback: (command: Command, next: NextMiddleware) => Promise + ) { } + public run(command: Command, next: NextMiddleware): Promise { + return this.callback(command, next); + } +} + +describe("Commander", () => { + + const testCommand = new class implements Command { }; + + + it("should return promise", async () => { + const commander = new Commander([]); + const result = commander.handle(testCommand); + + expect(result).to.be.instanceOf(Promise); + }); + + it("should resolve with void when empty middlewares array is given", async () => { + const commander = new Commander([]); + const result = await commander.handle(testCommand); + + expect(result).to.be.undefined; + }); + + it("should resolve after launching all middlewares in order of middleware next() call", async () => { + + const createPusher = (array: any[], item: any) => { + return async (command: Command, next: NextMiddleware) => { + await Promise.resolve(true); + await next(command); + array.push(item); + } + } + + let counters: number[] = []; + + const commander = new Commander([ + new CallbackMiddleware(createPusher(counters, 'first')), + new CallbackMiddleware(createPusher(counters, 'second')), + new CallbackMiddleware(createPusher(counters, 'third')), + ]); + + const promise = commander.handle(testCommand); + + expect(counters.length).to.be.equal(0); + await promise; + expect(counters).to.be.deep.equal(['third', 'second', 'first']); + }); +}); diff --git a/test/unit/middlewares/commandExecution.test.ts b/test/unit/middlewares/commandExecution.test.ts new file mode 100644 index 0000000..33552be --- /dev/null +++ b/test/unit/middlewares/commandExecution.test.ts @@ -0,0 +1,92 @@ + +import { expect } from 'chai'; +import { stub } from 'sinon'; + +import { CommandExecutionMiddleware, NextMiddleware } from '../../../src/middlewares'; +import { Command } from '../../../src/commandBus'; + +import { FakeResolver } from '../../fakes/fakeResolver'; + +describe("Command Execution Middleware", () => { + + const noop: NextMiddleware = () => Promise.resolve('no op'); + const testCommand = new class implements Command { }; + + + it("should resolve after handler resolves", async () => { + const orderArr: string[] = []; + + const executor = new CommandExecutionMiddleware( + new FakeResolver(() => { + return Promise.resolve() + .then(() => orderArr.push('resolver')) + }) + ); + + await executor.run(testCommand, noop) + .then(() => orderArr.push('after executor')); + + expect(orderArr).to.be.deep.equal([ + 'resolver', + 'after executor' + ]); + }); + + it("should reject when handler rejects with the same reason", async () => { + + const givenReason = new Error(); + + const executor = new CommandExecutionMiddleware( + new FakeResolver(() => Promise.reject(givenReason)) + ); + + const promise = executor.run(testCommand, noop); + + return expect(promise).to.eventually.be.rejectedWith(givenReason); + }); + + it("should resolve efter next() resolves", async () => { + const executor = new CommandExecutionMiddleware( + new FakeResolver(() => Promise.resolve()) + ); + + const next = stub(); + next.resolves(); + + const last = stub(); + + await executor.run(testCommand, next).then(last); + + expect(last.calledImmediatelyAfter(next)).to.be.true; + }); + + it("should call next() exacly once", async () => { + + const executor = new CommandExecutionMiddleware( + new FakeResolver(() => Promise.resolve()) + ); + + const next = stub(); + next.resolves(); + + await executor.run(testCommand, next); + + expect(next.calledOnce).to.be.true; + }); + + it("should reject promise when next() rejects with same reason", async () => { + + const executor = new CommandExecutionMiddleware( + new FakeResolver(() => Promise.resolve()) + ); + + const givenReason = new Error(); + + const next = stub(); + next.rejects(givenReason); + + const promise = executor.run(testCommand, next); + + return expect(promise).to.eventually.be.rejectedWith(givenReason); + }); +}); diff --git a/test/unit/middlewares/commandLogger.test.ts b/test/unit/middlewares/commandLogger.test.ts new file mode 100644 index 0000000..6974f98 --- /dev/null +++ b/test/unit/middlewares/commandLogger.test.ts @@ -0,0 +1,88 @@ + +import { expect } from 'chai'; +import { useFakeTimers, SinonFakeTimers } from 'sinon'; + +import { FakeLogger } from './../../fakes/fakeLogger'; +import { Command } from '../../../src/commandBus'; + +import { CommandLoggerMiddleware, NextMiddleware } from '../../../src/middlewares'; + +class TestCommand implements Command { +} + +describe("Command Logger Middleware", () => { + let logger: FakeLogger; + let commandLogger: CommandLoggerMiddleware; + let clock: SinonFakeTimers; + + beforeEach(() => { + logger = new FakeLogger(); + commandLogger = new CommandLoggerMiddleware(logger); + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it("should log command dispatch and command execution time", async () => { + const next: NextMiddleware = () => { + clock.tick(25); + return Promise.resolve('foo bar'); + } + + await commandLogger.run(new TestCommand(), next); + + const infos = logger.Messages.filter((entry) => entry.level === 'info'); + const errors = logger.Messages.filter((entry) => entry.level === 'errors'); + + expect(infos.length).to.be.equal(2); + expect(errors.length).to.be.equal(0); + + expect(infos[0].msg).to.equal("Command TestCommand dispatched"); + expect(infos[1].msg).to.equal("Command TestCommand succeeded (25ms)"); + }); + + it("should resolve with same value as next()", async () => { + const next: NextMiddleware = () => { + return Promise.resolve('foo bar'); + } + + const promise = commandLogger.run(new TestCommand(), next); + + return expect(promise).to.eventually.be.equal('foo bar'); + }); + + it("should reject with same error as next() rejects", async () => { + const givenReason = new Error('foo bar'); + + const next: NextMiddleware = () => { + return Promise.reject(givenReason); + } + + const promise = commandLogger.run(new TestCommand(), next); + + return expect(promise).to.eventually.be.rejectedWith(givenReason); + }); + + it("should log command dispatch, command execution time and error when occured", async () => { + const next: NextMiddleware = () => { + clock.tick(125); + return Promise.reject(new Error('foo bar')); + } + + try { + await commandLogger.run(new TestCommand(), next); + } catch (e) { } + + const infos = logger.Messages.filter((entry) => entry.level === 'info'); + const errors = logger.Messages.filter((entry) => entry.level === 'error'); + + expect(infos.length).to.be.equal(1); + expect(errors.length).to.be.equal(1); + + expect(infos[0].msg).to.equal("Command TestCommand dispatched"); + expect(errors[0].msg).to.equal("Command TestCommand failed (125ms): Error: foo bar"); + }); + +}); diff --git a/test/unit/resolvers/memory.test.ts b/test/unit/resolvers/memory.test.ts new file mode 100644 index 0000000..ef75a21 --- /dev/null +++ b/test/unit/resolvers/memory.test.ts @@ -0,0 +1,62 @@ + +import { expect } from "chai"; + +import { Command, Handler } from '../../../src/commandBus'; +import { HandlerResolverInMemory } from '../../../src/resolvers'; + +describe("In Memory Handle Resolver", () => { + let resolver: HandlerResolverInMemory; + + class FooCommand implements Command { } + class BarCommand implements Command { } + class TestCommandWithNoHandler implements Command { } + + class FooHandler implements Handler { + handle(c: FooCommand): Promise { + return Promise.resolve(); + } + } + + class BarHandler implements Handler { + handle(c: FooCommand): Promise { + return Promise.resolve(); + } + } + + beforeEach(() => { + resolver = new HandlerResolverInMemory(); + }) + + it("should throw Error when no handler was registered", async () => { + expect(() => resolver.resolve(new FooCommand())) + .to.throw(Error); + }); + + it("should throw Error when handler was not registered for given command", async () => { + resolver.register(FooCommand, new FooHandler()); + + expect(() => resolver.resolve(new TestCommandWithNoHandler())) + .to.throw(Error); + }); + + it("resolve with registered handler", async () => { + const givenHandler = new FooHandler(); + resolver.register(FooCommand, givenHandler); + resolver.register(BarCommand, new BarHandler()); + + const returnedHandler = resolver.resolve(new FooCommand()); + + expect(returnedHandler).to.be.equal(givenHandler); + }); + + it("resolve with last registered handler for given command constructor", async () => { + resolver.register(BarCommand, new BarHandler()); + resolver.register(FooCommand, new FooHandler()); + + const lastHandler = new FooHandler(); + resolver.register(FooCommand, lastHandler); + + expect(resolver.resolve(new FooCommand())) + .to.be.equal(lastHandler); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..917755f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "outDir": "./dist/", + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "lib": [ + "es6", + "es5" + ], + "noImplicitAny": true, + "declaration": true, + "strict": true, + "pretty": true, + "noUnusedLocals": true + }, + "include": [ + "src/**/*", + "test/**/*" + ] +}