diff --git a/packages/microservice/README.md b/packages/microservice/README.md new file mode 100644 index 0000000..ca0058d --- /dev/null +++ b/packages/microservice/README.md @@ -0,0 +1,3 @@ +# Microservice + +A shared package used to quickly assemble and run microservice applications diff --git a/packages/microservice/jest.config.ts b/packages/microservice/jest.config.ts new file mode 100644 index 0000000..7c94e14 --- /dev/null +++ b/packages/microservice/jest.config.ts @@ -0,0 +1,11 @@ +import base from "../../jest.config"; + +export default { + ...base, + name: "@autoplex/microservice", + displayName: "Package: Micoservice", + moduleNameMapper: { + '^@src/(.*)$': '/src/$1', + '^@test@/(.*)$': '/test/$1' + } +} diff --git a/packages/microservice/package.json b/packages/microservice/package.json index 00501ba..f21c189 100644 --- a/packages/microservice/package.json +++ b/packages/microservice/package.json @@ -5,7 +5,10 @@ "types": "dist/typings/index.d.ts", "license": "MIT", "scripts": { - "build": "yarn run clean && tsc", - "clean": "rimraf ./dist" + "build": "yarn run clean && ttsc", + "clean": "rimraf ./coverage ./dist", + "coverage": "yarn test --coverage", + "test": "jest --silent", + "test:verbose": "jest" } } diff --git a/packages/microservice/src/InternalService.ts b/packages/microservice/src/InternalService.ts index 3e02dc9..ddeb536 100644 --- a/packages/microservice/src/InternalService.ts +++ b/packages/microservice/src/InternalService.ts @@ -1,73 +1,60 @@ -import { Microservice } from "./Microservice"; import EventEmitter from "events"; +import { Microservice } from "./Microservice"; /** - * A generic service + * Define the dispatchable methosd available in the internal service */ -export abstract class InternalService extends EventEmitter +export type IDispatchableMethods = { + [K in "boot"|"start"|"shutdown"]: () => Promise +} + +export abstract class InternalService extends EventEmitter implements IDispatchableMethods { /** - * The application instance + * The name of the service */ - protected readonly app: T; + public abstract readonly NAME: string; /** - * Enable/disable logging for this service + * Store a reference to the owning microservice instance */ - public logging: boolean = true; + readonly #microservice: M; /** - * Create a new service + * Create a new internal service instance */ - public constructor(app: T) { + public constructor(microservice: M) { super(); - this.app = app; + this.#microservice = microservice; } - // Required Service Implementation ------------------------------------------------------------- - - /** - * The service name - */ - public abstract readonly NAME: string; - /** * Boot the service */ - public async boot(): Promise { - // no-op + public async boot() { + // NO-OP } /** - * Shut the application down + * Start the service */ - public async shutdown(): Promise { - // no-op - } - - // Miscellaneous ------------------------------------------------------------------------------ + public async start() { + // NO-OP + } /** - * Link to local services + * Shutdown the service */ - public link(app: T) { - // no-op + public async shutdown() { + // NO-OP } - /** - * Indicate the application is ready - */ - public start() { - // no-op - }; + // Accessors ----------------------------------------------------------------------------------- /** - * Service-specific logging + * Fetch the reference to the owning microservice instance */ - public log(...args: any[]) { - if (!this.logging) { - return; - } - console.log(`[${this.NAME}]:`, ...args); + public get microservice() { + return this.#microservice; } } diff --git a/packages/microservice/src/Microservice.ts b/packages/microservice/src/Microservice.ts index 90fdcda..d12d1ac 100644 --- a/packages/microservice/src/Microservice.ts +++ b/packages/microservice/src/Microservice.ts @@ -1,156 +1,202 @@ -import { InternalService } from "./InternalService"; -import assert from "assert"; +import EventEmitter from "events"; +import { IDispatchableMethods, InternalService } from "./InternalService"; +import { MicroserviceState } from "./schema"; +import { InternalServiceConflictError, InternalServiceNotFoundError } from "./errors"; /** - * Application InternalService map + * The InternalService constructor type */ -interface InternalServiceMap { - [name: string]: InternalService -} +type InternalServiceConstructor = new (microservice: T) => InternalService; /** - * The InternalService constructor type + * Declare EventEmitter types */ -type InternalServiceConstructor = new (app: T) => InternalService; + interface Events { + "ready": () => void, + "finished": () => void +} /** - * Microservice states + * Torrent IPC events */ -export enum MicroserviceState { - Idling, - Booting, - Starting, - Running, - Quitting, - Finished +export declare interface Microservice { + on(event: U, listener: Events[U]): this, + emit(event: U, ...args: Parameters): boolean } /** * The main application class */ -export class Microservice +export class Microservice extends EventEmitter { /** - * Maintain a static reference to the application instance + * The exec promise used to wait for quit event */ - private static __instance: Microservice; + #execPromise?: Promise; /** * A handler function to quit the microservice application */ - private __quitHandler!: (value: number | PromiseLike) => void; + #quitHandler?: (value: number | PromiseLike) => void; /** * All available services */ - protected services: InternalServiceMap = {}; + #services = new Map, InternalService>(); /** * The current state of the microservice */ - protected state: MicroserviceState; + #state: MicroserviceState = MicroserviceState.Idling; + + // Event Handling ------------------------------------------------------------------------------ /** - * Return the current application instance + * Invoked when the application has finished booting */ - public static instance() { - return this.__instance; + protected onStateChange(state: MicroserviceState): void|Promise { + switch(state) { + case MicroserviceState.Running: + this.emit("ready"); + break; + case MicroserviceState.Finished: + this.emit("finished"); + break; + } } + // Microservice Management --------------------------------------------------------------------- + /** - * Create a new application instance + * Run the application */ - public constructor() { - Microservice.__instance = this; - this.state = MicroserviceState.Idling; - } + public async exec() { + // Exit if not in an idling state + if (this.state !== MicroserviceState.Idling) { + console.error("Cannot exec an already-started microservice"); + return 1; + } + // Run the microservice application + let exitCode = await (async () => { + // Create the microservice execution promise to listen for quit events + let hasQuit = false; + this.#execPromise = new Promise(resolve => this.#quitHandler = (exitCode) => { + resolve(exitCode); + console.log("Quit has been invoked"); + hasQuit = true; + }); + + // 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; + } + // Start the internal services + if (!hasQuit && !await this.start()) { + console.error("Failed to start the microservice"); + return 2; + } + // If the application has not quit, we can run the app + let exitCode: number; + if (!hasQuit) { + exitCode = await this.run(); + } else { + exitCode = await this.#execPromise; + } - // Overridable -------------------------------------------------------------------------------- + // Shutdown the microservice + if (!await this.shutdown()) { + console.error("Failed to shutdown the microservice"); + return 3; + } + return exitCode; + })(); + this.state = MicroserviceState.Finished; + return exitCode; + } /** - * Invoked when the application has finished booting + * Quit the application */ - protected onStart(): void|Promise {} + public async quit(code: number = 0) { + if (this.state === MicroserviceState.Idling) { + this.state = MicroserviceState.Finished; + return; + } + if (this.state > MicroserviceState.Running || this.#quitHandler === undefined) { + return; + } + this.#quitHandler(code); + } - // Application Management ---------------------------------------------------------------------- + // Microservice Internal Handling -------------------------------------------------------------- /** - * Boot the application and all of the services + * Dispatch a method call to the services, returning a boolean indicating the result */ - protected async boot() { - let InternalServices = Object.values(this.services); - return Promise.all(InternalServices.map(InternalService => InternalService.boot())); + protected dispatch(this: T, method: keyof IDispatchableMethods, + onFail: (service: InternalService, error: Error|string) => void) + { + return new Promise(resolve => { + let services = Array.from(this.#services.values()); + Promise.all(services.map(service => (async () => { + try { + await service[method](); + } catch(e) { + onFail(service, e); + throw e; + } + })())) + .then(() => resolve(true)) + .catch(() => resolve(false)); + }); } /** - * Shutdown the application + * Boot the application and all of the services */ - protected shutdown() { - let InternalServices = Object.values(this.services); - return Promise.all(InternalServices.map(InternalService => InternalService.shutdown())); + protected boot() { + console.log("Booting..."); + this.state = MicroserviceState.Booting; + return this.dispatch("boot", (service, error) => { + console.error(`Failed to boot service: ${service.NAME}\n`, error); + }); } /** - * Start the application + * Start the application servvices */ - public async exec() { - // Exit if not in an idling state - if (this.state !== MicroserviceState.Idling) { - return -1; - } - - try { - // Boot the microservice - console.log("Booting services..."); - this.state = MicroserviceState.Booting; - await this.boot(); - - // Linking the internal services - console.log("Linking services..."); - for (let service of Object.values(this.services)) { - service.link(this); - } - - // Start the microservice - console.log("Starting services..."); - this.state = MicroserviceState.Starting - await this.onStart(); - for (let service of Object.values(this.services)) { - service.start(); - } - } catch(e) { - console.error("Failed to start the microservice:", e); - return 1; - } + protected start() { + console.log("Starting..."); + this.state = MicroserviceState.Starting; + return this.dispatch("start", (service, error) => { + console.error(`Failed to start service: ${service.NAME}\n`, error); + }); + } - // Run the microservice - console.log("Running"); + /** + * Run the application and wait for shutdown + */ + protected run() { + console.log("Running."); this.state = MicroserviceState.Running; process.on("SIGINT", this.quit.bind(this)); - let exitCode = await new Promise((resolve) => this.__quitHandler = resolve); - - // Shutdown the microservice - console.log("Shutting down..."); - await this.shutdown().catch(() => { - console.log("Error ocurred during shutdown..."); - exitCode = 1; - }); - - // Return the exit code - return exitCode; + return this.#execPromise!; } /** - * Quit the application + * Shutdown the application */ - public async quit(code: number = 0) { - if (this.state !== MicroserviceState.Running) { - return; - } - this.__quitHandler(code); + protected shutdown() { + console.log("Shutting down..."); + this.state = MicroserviceState.Quitting; + process.off("SIGINT", this.quit.bind(this)); + return this.dispatch("shutdown", (service, error) => { + console.error(`Failed to shutdown service: ${service.NAME}\n`, error); + }); } - // InternalService Management -------------------------------------------------------------------------- + // Internal Service Management ----------------------------------------------------------------- /** * Install InternalServices into the application @@ -165,25 +211,36 @@ export class Microservice * Install a InternalService into the application */ public installService(this: T, InternalServiceClass: InternalServiceConstructor) { - let InternalService = new InternalServiceClass(this); - if (InternalService.NAME in this.services) { - throw new Error("Install Service Error: Attempted to register multiple services with the same name"); + if (this.#services.has(InternalServiceClass)) { + throw new InternalServiceConflictError(InternalServiceClass); } - this.services[InternalService.NAME] = InternalService; + this.#services.set(InternalServiceClass, new InternalServiceClass(this)); } + // Accessors ----------------------------------------------------------------------------------- + /** - * Get all available services + * Get an application services instance */ - public serviceList() { - return Object.keys(this.services); + public service>(this: T, InternalServiceClass: U) { + if (!this.#services.has(InternalServiceClass)) { + throw new InternalServiceNotFoundError(InternalServiceClass); + } + return >this.#services.get(InternalServiceClass); } /** - * Get an application services instance + * Get the current state of the microservice + */ + public get state() { + return this.#state; + } + + /** + * Set the current state and invoke an onChange event */ - public service>(InternalServiceName: string) { - assert(InternalServiceName in this.services); - return this.services[InternalServiceName]; + protected set state(state: MicroserviceState) { + this.#state = state; + this.onStateChange(state); } } 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 91c2b81..d06b2a6 100644 --- a/packages/microservice/src/index.ts +++ b/packages/microservice/src/index.ts @@ -1,2 +1,4 @@ -export * from "./Microservice"; +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 new file mode 100644 index 0000000..521906a --- /dev/null +++ b/packages/microservice/src/schema.ts @@ -0,0 +1,21 @@ +/** + * Microservice states + */ +export enum MicroserviceState { + Idling, + Booting, + Starting, + Running, + Quitting, + Finished +} + +/** + * Microservice exit codes + */ +export enum ExitCode { + Ok, + BootError, + StartupError, + ShutdownError +} diff --git a/packages/microservice/test/integration/internal-services.test.ts b/packages/microservice/test/integration/internal-services.test.ts new file mode 100644 index 0000000..380d8f3 --- /dev/null +++ b/packages/microservice/test/integration/internal-services.test.ts @@ -0,0 +1,68 @@ +import { ExitCode, InternalService, Microservice, MicroserviceState } from "../../src"; + +class MockService extends InternalService { + public NAME = "Mock Service"; + public hasBooted = false; + public hasStarted = false; + public hasShutdown = false; + public override async boot() { this.hasBooted = true; } + public override async start() { this.hasStarted = true; } + public override async shutdown() { this.hasShutdown = true; } +} + +class MockServiceBootFail extends InternalService { + public NAME = "Boot Fail"; + public override async boot() { + throw new Error(); + } +} + +class MockServiceStartFail extends InternalService { + public NAME = "Start Fail"; + public override async start() { + throw new Error(); + } +} + +class MockServiceShutdownFail extends InternalService { + public NAME = "Shutdown Fail"; + public override async shutdown() { + throw new Error(); + } +} + +describe("Microservice/Internal Service Integration", () => { + test("Check that internal service state methods have been executed", async () => { + let microservice = new Microservice(); + microservice.installService(MockService); + let executing = microservice.exec(); + microservice.on("ready", () => microservice.quit()); + await executing; + expect(microservice.service(MockService).hasBooted).toBe(true); + expect(microservice.service(MockService).hasStarted).toBe(true); + expect(microservice.service(MockService).hasShutdown).toBe(true); + }); + test("Trigger boot failure", async () => { + let microservice = new Microservice(); + microservice.installService(MockServiceBootFail); + let exitCode = await microservice.exec(); + expect(exitCode).toEqual(ExitCode.BootError); + expect(microservice.state).toEqual(MicroserviceState.Finished); + }); + test("Trigger start failure", async () => { + let microservice = new Microservice(); + microservice.installService(MockServiceStartFail); + let exitCode = await microservice.exec(); + expect(exitCode).toEqual(ExitCode.StartupError); + expect(microservice.state).toEqual(MicroserviceState.Finished); + }); + test("Trigger shutdown failure", async () => { + let microservice = new Microservice(); + microservice.installService(MockServiceShutdownFail); + let execPromise = microservice.exec(); + microservice.on("ready", () => microservice.quit()); + let exitCode = await execPromise; + expect(exitCode).toEqual(ExitCode.ShutdownError); + expect(microservice.state).toEqual(MicroserviceState.Finished); + }); +}); diff --git a/packages/microservice/test/unit/InternalService.spec.ts b/packages/microservice/test/unit/InternalService.spec.ts new file mode 100644 index 0000000..5343975 --- /dev/null +++ b/packages/microservice/test/unit/InternalService.spec.ts @@ -0,0 +1,20 @@ +import { InternalService, Microservice } from "../../src"; + +class MockMicroservice extends Microservice { + public readonly customProp: string = "Test property"; +} +class MockInternalService extends InternalService { NAME = "Test"; } + +describe("InternalService", () => { + test("Get owning microservice instance", () => { + let microservice = new Microservice(); + let service = new MockInternalService(microservice); + expect(service.microservice).toBeInstanceOf(Microservice); + }); + + test("Access properties from custom microservices", () => { + let microservice = new MockMicroservice(); + let service = new MockInternalService(microservice); + expect(service.microservice.customProp).toEqual("Test property"); + }); +}); diff --git a/packages/microservice/test/unit/Microservice.spec.ts b/packages/microservice/test/unit/Microservice.spec.ts new file mode 100644 index 0000000..d5dc278 --- /dev/null +++ b/packages/microservice/test/unit/Microservice.spec.ts @@ -0,0 +1,109 @@ +import { InternalService, Microservice } from "../../src"; +import { InternalServiceConflictError, InternalServiceNotFoundError } from "../../src/errors"; +import { ExitCode, MicroserviceState } from "../../src/schema"; + +class MockInternalService1 extends InternalService { NAME = "1"; } +class MockInternalService2 extends InternalService { NAME = "2"; } + +class MockedMicroservice extends Microservice { + + public readonly quitState: MicroserviceState; + public hasBooted = false; + public hasStarted = false; + public hasRun = false; + public hasShutdown = false; + + public constructor(quitState: MicroserviceState) { + super(); + this.quitState = quitState; + } + + protected override async boot() { + this.hasBooted = true; + let result = await super.boot(); + this.quitState === MicroserviceState.Booting && this.quit(25); + return result; + } + + protected override async start() { + this.hasStarted = true; + let result = await super.start(); + this.quitState === MicroserviceState.Starting && this.quit(50); + return result; + } + + protected override async run() { + this.hasRun = true; + return await super.run(); + } + + protected override async shutdown() { + this.hasShutdown = true; + let result = super.shutdown(); + this.quitState === MicroserviceState.Quitting && this.quit(100); + return result; + } +} + +describe("Microservice:", () => { + describe("Internal Service Installation", () => { + test("Install an internal service", () => { + let microservice = new Microservice(); + microservice.installServices([MockInternalService1, MockInternalService2]); + expect(microservice.service(MockInternalService1)).toBeInstanceOf(MockInternalService1); + expect(microservice.service(MockInternalService2)).toBeInstanceOf(MockInternalService2); + expect(microservice.service(MockInternalService1)).not.toBeInstanceOf(MockInternalService2); + }); + test("Prevent installing already-installed services", () => { + let microservice = new Microservice(); + expect(() => microservice.installServices([MockInternalService1, MockInternalService1])).toThrowError(InternalServiceConflictError); + }); + test("Fail when accessing non-installed services", () => { + let microservice = new Microservice(); + expect(() => microservice.service(MockInternalService1)).toThrowError(InternalServiceNotFoundError); + }); + }); + describe("Microservice State", () => { + // Quitting + test("Quit before executing process", async () => { + let microservice = new Microservice(); + microservice.quit(); + expect(microservice.state).toEqual(MicroserviceState.Finished); + expect(await microservice.exec()).toEqual(ExitCode.BootError); + }); + test("Quit during boot state", async () => { + let microservice = new MockedMicroservice(MicroserviceState.Booting); + let exitCode = await microservice.exec(); + expect(microservice.state).toEqual(MicroserviceState.Finished); + expect(microservice.hasBooted).toBe(true); + expect(microservice.hasShutdown).toBe(true); + expect(microservice.hasStarted).toBe(false); + expect(exitCode).toEqual(25); + }); + test("Quit during starting state", async () => { + let microservice = new MockedMicroservice(MicroserviceState.Starting); + let exitCode = await microservice.exec(); + expect(microservice.state).toEqual(MicroserviceState.Finished); + expect(microservice.hasBooted).toBe(true); + expect(microservice.hasShutdown).toBe(true); + expect(microservice.hasStarted).toBe(true); + expect(exitCode).toEqual(50); + }); + test("Quit during running state", async () => { + let microservice = new MockedMicroservice(MicroserviceState.Running); + let runningPromise = microservice.exec(); + setImmediate(() => microservice.quit(75)); + let exitCode = await runningPromise; + expect(microservice.state).toEqual(MicroserviceState.Finished); + expect(exitCode).toEqual(75); + }); + test("Quit during quitting state", async () => { + let microservice = new MockedMicroservice(MicroserviceState.Quitting); + let runningPromise = microservice.exec(); + microservice.quit(); + let exitCode = await runningPromise; + expect(microservice.state).toEqual(MicroserviceState.Finished); + expect(exitCode).toEqual(0); + }); + }); +});