From 6d33fb946716986adaa269380bf17a551cb76b06 Mon Sep 17 00:00:00 2001 From: David Ludwig Date: Fri, 11 Mar 2022 00:40:44 -0600 Subject: [PATCH] Microservice package should be ready to go --- package.json | 2 + packages/microservice/src/InternalService.ts | 43 +++++++ packages/microservice/src/Microservice.ts | 100 ++++++++++++--- packages/microservice/src/errors.ts | 13 ++ packages/microservice/src/index.ts | 2 + packages/microservice/src/schema.ts | 5 +- .../test/integration/InternalService.spec.ts | 120 ++++++++++++++++++ .../test/unit/Microservice.spec.ts | 27 ++-- yarn.lock | 16 ++- 9 files changed, 298 insertions(+), 30 deletions(-) create mode 100644 packages/microservice/src/InternalService.ts create mode 100644 packages/microservice/src/errors.ts create mode 100644 packages/microservice/test/integration/InternalService.spec.ts diff --git a/package.json b/package.json index 353bfbe..c8c5fcf 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", "@types/chai": "^4.3.0", + "@types/chai-as-promised": "^7.1.5", "@types/mocha": "^9.1.0", "@types/node": "^17.0.21", "@types/sinon": "^10.0.11", "chai": "^4.3.6", + "chai-as-promised": "^7.1.1", "lerna": "^4.0.0", "mocha": "^9.2.1", "nodemon": "^2.0.15", diff --git a/packages/microservice/src/InternalService.ts b/packages/microservice/src/InternalService.ts new file mode 100644 index 0000000..4d7d1c8 --- /dev/null +++ b/packages/microservice/src/InternalService.ts @@ -0,0 +1,43 @@ +import { EventEmitter } from "events"; +import { Microservice } from "./Microservice"; + + +export class InternalService extends EventEmitter +{ + #microservice: M; + + public constructor(microservice: M) { + super(); + this.#microservice = microservice; + } + + /** + * Boot the service + */ + public async boot(): Promise { + // no-op + } + + /** + * Start the service + */ + public async start(): Promise { + // no-op + } + + /** + * Shutdown hte service + */ + public async shutdown(): Promise { + // no-op + } + + // Accessors ----------------------------------------------------------------------------------- + + /** + * Fetch the instance of the microservice + */ + public microservice() { + return this.#microservice; + } +}; diff --git a/packages/microservice/src/Microservice.ts b/packages/microservice/src/Microservice.ts index 0ecba53..b4325a9 100644 --- a/packages/microservice/src/Microservice.ts +++ b/packages/microservice/src/Microservice.ts @@ -1,13 +1,15 @@ import assert from "assert"; import { EventEmitter } from "events"; -import { MicroserviceState } from "./schema"; +import { InternalServiceConflictError, InternalServiceNotFoundError } from "./errors"; +import { InternalService } from "./InternalService"; +import { ExitCode, MicroserviceState } from "./schema"; /** * The InternalService constructor type */ -// type InternalServiceConstructor< -// T extends Microservice = Microservice -// > = new (microservice: T) => InternalService; +type InternalServiceConstructor< + T extends Microservice = Microservice +> = new (microservice: T) => InternalService; /** * Declare EventEmitter types @@ -43,13 +45,22 @@ export class Microservice extends EventEmitter */ #quitHandler?: (value: number | PromiseLike) => void; - /** * The current state of the microservice */ #state: MicroserviceState = MicroserviceState.Idle; - // Microservice Management --------------------------------------------------------------------- + /** + * A map of the installed services + */ + #services = new Map, InternalService>(); + + /** + * Indicate if exec has been invoked + */ + #hasStarted: boolean = false; + + // Microservice State Procedures --------------------------------------------------------------- /** * Invoke the boot phase of the microservice @@ -58,7 +69,7 @@ export class Microservice extends EventEmitter process.on("SIGINT", this.quit.bind(this)); this.setState(MicroserviceState.Booting); this.emit("boot"); - return true; + return await this.dispatch("boot"); } /** @@ -67,7 +78,7 @@ export class Microservice extends EventEmitter protected async start() { this.setState(MicroserviceState.Starting); this.emit("start"); - return true; + return await this.dispatch("start"); } /** @@ -88,18 +99,41 @@ export class Microservice extends EventEmitter process.off("SIGINT", this.quit.bind(this)); this.setState(MicroserviceState.Quitting); this.emit("shutdown"); + return await this.dispatch("shutdown"); + } + + // Internal Service Handling ------------------------------------------------------------------- + + /** + * Dispatch an event across all installed services + */ + protected async dispatch>( + this: T, method: K, ...args: Parameters[K]>) + { + let services = []> Array.from(this.services().values()); + try { + await Promise.all(services.map(service => (service[method]).apply(service, args))); + } catch(e) { + console.error(e); + return false; + } return true; } + // Microservice Interface ---------------------------------------------------------------------- + /** * Run the application */ - public async exec() { + public async exec(): Promise { // Exit if not in an idling state - if (this.state() != MicroserviceState.Idle) { - console.error("Cannot exec an already-started microservice"); - return 1; + if (this.#hasStarted) { + throw Error("Cannot exec an already-started microservice"); } + + // Indicate that the microservice has started + this.#hasStarted = true; + // Run the microservice application let exitCode = await (async () => { // Create the microservice execution promise to listen for quit events @@ -112,12 +146,12 @@ export class Microservice extends EventEmitter // Boot the microservice and internal services if (!await this.boot()) { // no need to check for hasQuit console.error("Failed to boot the microservice"); - return 1; + return ExitCode.BootError; } // Start the internal services if (!hasQuit && !await this.start()) { console.error("Failed to start the microservice"); - return 2; + return ExitCode.StartError; } // If the application has not quit, we can run the app let exitCode: number; @@ -130,7 +164,7 @@ export class Microservice extends EventEmitter // Shutdown the microservice if (!await this.shutdown()) { console.error("Failed to shutdown the microservice"); - return 3; + return ExitCode.ShutdownError; } return exitCode; })(); @@ -141,7 +175,7 @@ export class Microservice extends EventEmitter /** * Quit the application */ - public quit(code: number = 0) { + public quit(code: number = ExitCode.Ok) { if (this.state() == MicroserviceState.Idle) { this.setState(MicroserviceState.Finished); return; @@ -152,8 +186,42 @@ export class Microservice extends EventEmitter this.#quitHandler(code); } + /** + * Install InternalServices into th eapplication + */ + public installServices(this: T, Classes: InternalServiceConstructor[]) { + Classes.forEach(Class => this.installService(Class)); + } + + /** + * Install an InternalService into the application + */ + public installService(this: T, Class: InternalServiceConstructor) { + if (this.#services.has(Class)) { + throw new InternalServiceConflictError(Class); + } + this.#services.set(Class, new Class(this)); + } + // Accessors ----------------------------------------------------------------------------------- + /** + * Fetch an instance of an installed service + */ + public service>(this: T, Class: U) { + if (!this.#services.has(Class)) { + throw new InternalServiceNotFoundError(Class); + } + return >this.#services.get(Class); + } + + /** + * Get the map of installed services + */ + public services() { + return this.#services; + } + /** * Get the current state of the microservice */ diff --git a/packages/microservice/src/errors.ts b/packages/microservice/src/errors.ts new file mode 100644 index 0000000..e27cf4e --- /dev/null +++ b/packages/microservice/src/errors.ts @@ -0,0 +1,13 @@ +export class InternalServiceConflictError extends Error { + public constructor(internalServiceClass: any) { + super(`Unable to register service '${internalServiceClass}' as it conflicts with an already registered service.`); + Object.setPrototypeOf(this, InternalServiceConflictError.prototype); + } +} + +export class InternalServiceNotFoundError extends Error { + public constructor(internalServiceClass: any) { + super(`Internal service not found: ${internalServiceClass}.`); + Object.setPrototypeOf(this, InternalServiceNotFoundError.prototype); + } +} diff --git a/packages/microservice/src/index.ts b/packages/microservice/src/index.ts index f4a8ce4..d06b2a6 100644 --- a/packages/microservice/src/index.ts +++ b/packages/microservice/src/index.ts @@ -1,2 +1,4 @@ +export * from "./errors"; export * from "./schema"; +export * from "./InternalService"; export * from "./Microservice"; diff --git a/packages/microservice/src/schema.ts b/packages/microservice/src/schema.ts index 02f7ecf..8d6a764 100644 --- a/packages/microservice/src/schema.ts +++ b/packages/microservice/src/schema.ts @@ -16,6 +16,7 @@ export enum ExitCode { Ok, BootError, - StartupError, - ShutdownError + StartError, + ShutdownError, + InvalidStateError } diff --git a/packages/microservice/test/integration/InternalService.spec.ts b/packages/microservice/test/integration/InternalService.spec.ts new file mode 100644 index 0000000..37d9099 --- /dev/null +++ b/packages/microservice/test/integration/InternalService.spec.ts @@ -0,0 +1,120 @@ +import { expect } from "chai"; +import sinon from "sinon"; +import "mocha"; + +import { + ExitCode, + InternalService, + Microservice +} from "../../src"; + +class MockService1 extends InternalService {}; +class MockService2 extends InternalService {}; + +class FaultyBootService extends InternalService { + public async boot() { + throw new Error("Boot error"); + } +}; + +class FaultyStartService extends InternalService { + public async start() { + throw new Error("Start error"); + } +} + +class FaultyShutdownService extends InternalService { + public async shutdown() { + throw new Error("Shutdown error"); + } +} + +describe("Internal service integration", () => { + describe("Service installation", () => { + let service = new Microservice(); + service.installServices([MockService1, MockService2]); + it("should have one service installed", () => { + expect(service.services().size).to.equal(2); + }); + it("should have the microservice instance", () => { + expect(service.service(MockService1).microservice()).to.equal(service); + }); + }); + describe("Duplicate service installation", () => { + let service = new Microservice(); + service.installService(MockService1); + it("should throw the correct error type", () => { + // For some reason checking error type fails here... + expect(() => service.installService(MockService1)).to.throw(); + // expect(() => service.installService(MockService1)).to.throw(InternalServiceNotFoundError); + }); + }); + describe("Fetch an installed service", () => { + let service = new Microservice(); + service.installService(MockService1); + it("should return the correct service instance", () => { + expect(service.service(MockService1)).to.be.an.instanceOf(MockService1); + }); + }); + describe("Fetch a non-installed service", () => { + let service = new Microservice(); + it("should throw an error", () => { + expect(() => service.service(MockService1)).to.throw(); + }); + }); + + describe("Invoke the appropriate boot, start, and shutdown methods", () => { + let microservice = new Microservice(); + microservice.installService(MockService1); + let service = microservice.service(MockService1); + + let spyBoot = sinon.spy(service, "boot"); + let spyStart = sinon.spy(service, "start"); + let spyShutdown = sinon.spy(service, "shutdown"); + it("should start and finish the app", async () => { + let cb = new Promise((resolve) => microservice.once("ready", resolve)); + let execPromise = microservice.exec(); + await cb; + microservice.quit(); + await execPromise; + }); + it("should have invoked boot method", () => { + sinon.assert.calledOnce(spyBoot); + }); + it("should have invoked start method", () => { + sinon.assert.calledOnce(spyStart); + }); + it("should have invoked shutdown method", () => { + sinon.assert.calledOnce(spyShutdown); + }); + }); + + describe("Error handling", () => { + describe("Faulty boot service", () => { + let microservice = new Microservice(); + microservice.installServices([MockService1, FaultyBootService]); + it("should crash with correct exit code", async () => { + let exitCode = await microservice.exec(); + expect(exitCode).to.equal(ExitCode.BootError); + }); + }); + describe("Faulty start service", () => { + let microservice = new Microservice(); + microservice.installServices([MockService1, FaultyStartService]); + it("should crash with correct exit code", async () => { + let exitCode = await microservice.exec(); + expect(exitCode).to.equal(ExitCode.StartError); + }); + }); + describe("Faulty shutdown service", () => { + let microservice = new Microservice(); + microservice.installServices([MockService1, FaultyShutdownService]); + it("should crash with correct exit code", async () => { + let execPromise = microservice.exec(); + microservice.quit(); + let exitCode = await execPromise; + expect(exitCode).to.equal(ExitCode.ShutdownError); + }); + }); + }); +}); diff --git a/packages/microservice/test/unit/Microservice.spec.ts b/packages/microservice/test/unit/Microservice.spec.ts index ed8a03a..a02778f 100644 --- a/packages/microservice/test/unit/Microservice.spec.ts +++ b/packages/microservice/test/unit/Microservice.spec.ts @@ -1,11 +1,18 @@ -import { expect } from "chai"; +import chai, { expect } from "chai"; +import chaiAsPromised from "chai-as-promised"; import "mocha"; +chai.should(); +chai.use(chaiAsPromised); + import { Microservice, MicroserviceState } from "../../src"; +/** + * A microservice that initiates quit events at the desired state. + */ class StateControlledMicroservice extends Microservice { public readonly quitState: MicroserviceState; @@ -66,22 +73,20 @@ describe("Microservice", () => { describe("Running state", () => { let microservice = new Microservice(); it("should be running", async () => { - let cb = new Promise(fulfill => microservice.on("ready", fulfill)); + let cb = new Promise(resolve => microservice.on("ready", resolve)); microservice.exec(); await cb; expect(microservice.state()).to.equal(MicroserviceState.Running); }); }); - // describe("Finished State", () => { - // let microservice = new Microservice(); - // let cb = microservice.exec(); - // microservice.quit(); - // it("Should be in a finished state", async () => { - // await cb; - // expect(microservice.state()).to.equal(MicroserviceState.Finished); - // }); - // }); + describe("Start an already-running microservice", () => { + let microservice = new Microservice(); + microservice.exec(); + it("should throw an error", () => { + microservice.exec().should.be.rejected; + }); + }); }); describe("Quit state control flow", () => { diff --git a/yarn.lock b/yarn.lock index a090877..659dbae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1183,7 +1183,14 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== -"@types/chai@^4.3.0": +"@types/chai-as-promised@^7.1.5": + version "7.1.5" + resolved "https://registry.yarnpkg.com/@types/chai-as-promised/-/chai-as-promised-7.1.5.tgz#6e016811f6c7a64f2eed823191c3a6955094e255" + integrity sha512-jStwss93SITGBwt/niYrkf2C+/1KTeZCZl1LaeezTlqppAKeoQC7jxyqYuP72sxBGKCIbw7oHgbYssIRzT5FCQ== + dependencies: + "@types/chai" "*" + +"@types/chai@*", "@types/chai@^4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.0.tgz#23509ebc1fa32f1b4d50d6a66c4032d5b8eaabdc" integrity sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw== @@ -1643,6 +1650,13 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= +chai-as-promised@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/chai-as-promised/-/chai-as-promised-7.1.1.tgz#08645d825deb8696ee61725dbf590c012eb00ca0" + integrity sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA== + dependencies: + check-error "^1.0.2" + chai@^4.3.6: version "4.3.6" resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.6.tgz#ffe4ba2d9fa9d6680cc0b370adae709ec9011e9c"