diff --git a/docs/site/Creating-components.md b/docs/site/Creating-components.md index e57a6a5c2e65..78620696fdd6 100644 --- a/docs/site/Creating-components.md +++ b/docs/site/Creating-components.md @@ -15,15 +15,20 @@ import {MyValueProvider} from './providers/my-value.provider'; import {Component} from '@loopback/core'; export class MyComponent implements Component { - constructor() { - this.controllers = [MyController]; - this.providers = { - 'my-value': MyValueProvider, - }; - this.classes = { - 'my-validator': MyValidator, - }; + servers = { + 'my-server': MyServer, + }; + lifeCycleObservers = [MyObserver]; + controllers = [MyController]; + providers = { + 'my-value': MyValueProvider, + }; + classes = { + 'my-validator': MyValidator, + }; + constructor() { + // Set up `bindings` const bindingX = Binding.bind('x').to('Value X'); const bindingY = Binding.bind('y').toClass(ClassY); this.bindings = [bindingX, bindingY]; @@ -57,6 +62,8 @@ class is created and then: - Each Class is bound to its key in `classes` object via `app.bind(key).toClass(cls)` - Each Binding is added via `app.add(binding)` +- Each Server class is registered via `app.server()` +- Each LifeCycleObserver class is registered via `app.lifeCycleObserver()` Please note that `providers` and `classes` are shortcuts for provider and class `bindings`. @@ -68,6 +75,8 @@ create the following bindings in the application context: - `my-validator` -> `MyValidator` (class) - `x` -> `'Value X'` (value) - `y` -> `ClassY` (class) +- `my-server` -> `MyServer` (server) +- `lifeCycleObservers.MyObserver` -> `MyObserver` (life cycle observer) ## Providers diff --git a/docs/site/Extension-life-cycle.md b/docs/site/Extension-life-cycle.md new file mode 100644 index 000000000000..e9415405ee8b --- /dev/null +++ b/docs/site/Extension-life-cycle.md @@ -0,0 +1,80 @@ +--- +lang: en +title: 'Extension life cycle' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Extension-life-cycle.html +--- + +## Extension life cycle + +As described in [Life cycle](Life-cycle.md), a LoopBack +[Application](Application.md) has its own life cycles at runtime. Corresponding +events such as `start` and `stop` are emitted upon the state change. Please note +that LoopBack only support `start` and `stop` events for an application's life +cycles at this moment. + +Extension modules for LoopBack often contribute artifacts such as servers, +datasources, and connectors to the application. They typically provide a +component to bind such artifacts to the context together. Being able to listen +on life cycle events is important for extension modules to collaborate with the +application. + +An extension module follows the same way as applications to implement and +register life cycle observers. + +### Implement a life cycle observer + +A life cycle observer class optionally implements `start` and `stop` methods to +be invoked upon `start` and `stop` events emitted by an application's life cycle +respectively. + +```ts +import {LifeCycleObserver} from '@loopback/core'; + +export class MyLifeCycleObserver implements LifeCycleObserver { + start() { + // It can return `void` or `Promise` + } + stop() { + // It can return `void` or `Promise` + } +} +``` + +A life cycle observer can be tagged with `CoreTags.LIFE_CYCLE_OBSERVER_GROUP` to +indicate its group to be invoked for ordering. We can decorate the observer +class with `@lifeCycleObserver` to provide more metadata for the binding. + +```ts +import {lifeCycleObserver} from '@loopback/core'; + +@lifeCycleObserver('g1') +export class MyLifeCycleObserver { + // ... +} +``` + +### Register a life cycle observer + +A life cycle observer can be registered by calling `lifeCycleObserver()` of the +application. It binds the observer to the application context with a special +tag - `CoreTags.LIFE_CYCLE_OBSERVER`. + +```ts +app.lifeCycleObserver(MyObserver); +``` + +Life cycle observers can be declared via a component class too. when the +component is mounted to an application, the observers are automatically +registered. + +```ts +export class MyComponentWithObservers implements Component { + lifeCycleObservers = [XObserver, YObserver]; +} + +// Mount the component +app.mount(MyComponentWithObservers); +// Now `XObserver` and `YObserver` are registered in the application. +``` diff --git a/docs/site/Life-cycle-observer-generator.md b/docs/site/Life-cycle-observer-generator.md new file mode 100644 index 000000000000..e03fde853396 --- /dev/null +++ b/docs/site/Life-cycle-observer-generator.md @@ -0,0 +1,83 @@ +--- +lang: en +title: 'Life cycle observer generator' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Life-cycle-observer-generator.html +--- + +{% include content/generator-create-app.html lang=page.lang %} + +### Synopsis + +Adds a new [LifeCycleObserver](Life-cycle.md) class to a LoopBack application. + +```sh +lb4 observer [--group ] [] +``` + +### Arguments and options + +`` - Required name of the observer to create as an argument to the +command. If provided, the tool will use that as the default when it prompts for +the name. + +`--group ` - Optional name of the observer group to sort the execution of +observers by group. + +### Interactive Prompts + +The tool will prompt you for: + +- **Name of the observer.** _(observerName)_ If the name had been supplied from + the command line, the prompt is skipped. + +- **Group of the observer.** _(groupName)_ If the group had been supplied from + the command line, the prompt is skipped. + +### Output + +Once all the prompts have been answered, the CLI will do the following: + +- Create a LifeCycleObserver class as follows: + `/src/observers/${observerName}.observer.ts` +- Update `/src/observers/index.ts` to export the newly created LifeCycleObserver + class. + +The generated class looks like: + +```ts +import { + /* inject, Application, CoreBindings, */ + lifeCycleObserver, // The decorator + CoreTags, + LifeCycleObserver, // The interface +} from '@loopback/core'; + +/** + * This class will be bound to the application as a `LifeCycleObserver` during + * `boot` + */ +@lifeCycleObserver('observer-group-name') +export class HelloObserver implements LifeCycleObserver { + /* + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) private app: Application, + ) {} + */ + + /** + * This method will be invoked when the application starts + */ + async start(): Promise { + // Add your logic for start + } + + /** + * This method will be invoked when the application stops + */ + async stop(): Promise { + // Add your logic for start + } +} +``` diff --git a/docs/site/Life-cycle.md b/docs/site/Life-cycle.md new file mode 100644 index 000000000000..7eea9ffc6e62 --- /dev/null +++ b/docs/site/Life-cycle.md @@ -0,0 +1,256 @@ +--- +lang: en +title: 'Life cycle events and observers' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Life-cycle.html +--- + +## Overview + +A LoopBack application has its own life cycles at runtime. There are two methods +to control the transition of states of `Application`. + +- start(): Start the application +- stop(): Stop the application + +It's often desirable for various types of artifacts to participate in the life +cycles and perform related processing upon `start` and `stop`. Good examples of +such artifacts are: + +- Servers + + - start: Starts the HTTP server listening for connections. + - stop: Stops the server from accepting new connections. + +- Components + + - A component itself can be a life cycle observer and it can also contribute + life cycle observers + +- DataSources + + - connect: Connect to the underlying database or service + - disconnect: Disconnect from the underlying database or service + +- Custom scripts + - start: Custom logic to be invoked when the application starts + - stop: Custom logic to be invoked when the application stops + +## The `LifeCycleObserver` interface + +To react on life cycle events, a life cycle observer implements the +`LifeCycleObserver` interface. + +```ts +import {ValueOrPromise} from '@loopback/context'; + +/** + * Observers to handle life cycle start/stop events + */ +export interface LifeCycleObserver { + start?(): ValueOrPromise; + stop?(): ValueOrPromise; +} +``` + +Both `start` and `stop` methods are optional so that an observer can opt in +certain events. + +## Register a life cycle observer + +A life cycle observer can be registered by calling `lifeCycleObserver()` of the +application. It binds the observer to the application context with a special +tag - `CoreTags.LIFE_CYCLE_OBSERVER`. + +```ts +app.lifeCycleObserver(MyObserver); +``` + +Please note that `app.server()` automatically registers servers as life cycle +observers. + +Life cycle observers can be registered via a component too: + +```ts +export class MyComponentWithObservers implements Component { + /** + * Populate `lifeCycleObservers` per `Component` interface to register life + * cycle observers + */ + lifeCycleObservers = [XObserver, YObserver]; +} +``` + +## Discover life cycle observers + +The `Application` finds all bindings tagged with `CoreTags.LIFE_CYCLE_OBSERVER` +within the context chain and resolve them as observers to be notified. + +## Notify life cycle observers of start/stop related events by order + +There may be dependencies between life cycle observers and their order of +processing for `start` and `stop` need to be coordinated. For example, we +usually start a server to listen on incoming requests only after other parts of +the application are ready to handle requests. The stop sequence is typically +processed in the reverse order. To support such cases, we introduce +two-dimension steps to control the order of life cycle actions. + +### Observer groups + +First of all, we allow each of the life cycle observers to be tagged with a +group. For example: + +- datasource (connect/disconnect) + + - mongodb + - mysql + +- server + - rest + - gRPC + +We can then configure the application to trigger observers group by group as +configured by an array of groups in order such as `['datasource', 'server']`. + +For example, + +```ts +app + .bind('observers.MyObserver') + .toClass(MyObserver) + .tag({ + [CoreTags.LIFE_CYCLE_OBSERVER_GROUP]: 'g1', + }) + .apply(asLifeCycleObserver); +``` + +The observer class can also be decorated with `@bind` to provide binding +metadata. + +```ts +import {bind, createBindingFromClass} from '@loopback/context'; +import {CoreTags, asLifeCycleObserver} from '@loopback/core'; + +@bind( + { + tags: { + [CoreTags.LIFE_CYCLE_OBSERVER_GROUP]: 'g1', + }, + }, + asLifeCycleObserver, +) +export class MyObserver { + // ... +} + +app.add(createBindingFromClass(MyObserver)); +``` + +Or even simpler with `@lifeCycleObserver`: + +```ts +import {createBindingFromClass} from '@loopback/context'; +import {lifeCycleObserver} from '@loopback/core'; + +@lifeCycleObserver('g1') +export class MyObserver { + // ... +} + +app.add(createBindingFromClass(MyObserver)); +``` + +The order of observers is controlled by a `orderedGroups` property of +`LifeCycleObserverRegistry`, which receives its options including the +`orderedGroups` from `CoreBindings.LIFE_CYCLE_OBSERVER_OPTIONS`. + +```ts +export type LifeCycleObserverOptions = { + /** + * Control the order of observer groups for notifications. For example, + * with `['datasource', 'server']`, the observers in `datasource` group are + * notified before those in `server` group during `start`. Please note that + * observers are notified in the reverse order during `stop`. + */ + orderedGroups: string[]; + /** + * Notify observers of the same group in parallel, default to `true` + */ + parallel?: boolean; +}; +``` + +Thus the initial `orderedGroups` can be set as follows: + +```ts +app + .bind(CoreBindings.LIFE_CYCLE_OBSERVER_OPTIONS) + .to({orderedGroups: ['g1', 'g2', 'server']}); +``` + +Or: + +```ts +const registry = await app.get(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY); +registry.setOrderedGroups(['g1', 'g2', 'server']); +``` + +Observers are sorted using `orderedGroups` as the relative order. If an observer +is tagged with a group that is not defined in `orderedGroups`, it will come +before any groups included in `orderedGroups`. Such custom groups are also +sorted by their names alphabetically. + +In the example below, `orderedGroups` is set to +`['setup-servers', 'publish-services']`. Given the following observers: + +- 'my-observer-1' ('setup-servers') +- 'my-observer-2' ('publish-services') +- 'my-observer-4' ('2-custom-group') +- 'my-observer-3' ('1-custom-group') + +The sorted observer groups will be: + +```ts +{ + '1-custom-group': ['my-observer-3'], // by alphabetical order + '2-custom-group': ['my-observer-4'], // by alphabetical order + 'setup-servers': ['my-observer-1'], // by orderedGroups + 'publish-services': ['my-observer-2'], // orderedGroups +} +``` + +The execution order of observers within the same group is controlled by +`LifeCycleObserverOptions.parallel`: + +- `true` (default): observers within the same group are notified in parallel +- `false`: observers within the same group are notified one by one. The order is + not defined. If you want to have one to be invoked before the other, mark them + with two distinct groups. + +## Add custom life cycle observers by convention + +Each application can have custom life cycle observers to be dropped into +`src/observers` folder as classes implementing `LifeCycleObserver`. + +During application.boot(), such artifacts are discovered, loaded, and bound to +the application context as life cycle observers. This is achieved by a built-in +`LifeCycleObserverBooter` extension. + +## CLI command to generate life cycle observers + +To make it easy for application developers to add custom life cycle observers, +we introduce `lb4 observer` command as part the CLI. + +```sh +$ lb4 observer +? Observer name: test +? Observer group: g1 + create src/observers/test.observer.ts + update src/observers/index.ts + +Observer test was created in src/observers/ +``` + +See [Life cycle observer generator](Life-cycle-observer-generator.md) for more +details. diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index 1eb6d2fc1256..b6ba1c9a0076 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -145,6 +145,10 @@ children: url: DataSources.html output: 'web, pdf' + - title: 'Life cycle events and observers' + url: Life-cycle.html + output: 'web, pdf' + - title: 'Routes' url: Routes.html output: 'web, pdf' @@ -325,22 +329,26 @@ children: output: 'web, pdf' children: - - title: 'Creating Components' + - title: 'Creating components' url: Creating-components.html output: 'web, pdf' - - title: 'Creating Decorators' + - title: 'Creating decorators' url: Creating-decorators.html output: 'web, pdf' - - title: 'Creating Servers' + - title: 'Creating servers' url: Creating-servers.html output: 'web, pdf' - - title: 'Extending Request Body Parsing' + - title: 'Extending request body parsing' url: Extending-request-body-parsing.html output: 'web, pdf' + - title: 'Extension life cycle' + url: Extension-life-cycle.html + output: 'web, pdf' + - title: 'Testing your extension' url: Testing-your-extension.html output: 'web, pdf' diff --git a/docs/site/tables/lb4-artifact-commands.html b/docs/site/tables/lb4-artifact-commands.html index 554f0822ebe5..e9646f84bbdc 100644 --- a/docs/site/tables/lb4-artifact-commands.html +++ b/docs/site/tables/lb4-artifact-commands.html @@ -47,5 +47,11 @@ OpenAPI generator + + lb4 observer + Generate life cycle observers for application start/stop + Life cycle observer generator + + diff --git a/packages/boot/src/__tests__/fixtures/lifecycle-observer.artifact.ts b/packages/boot/src/__tests__/fixtures/lifecycle-observer.artifact.ts new file mode 100644 index 000000000000..0927cd216958 --- /dev/null +++ b/packages/boot/src/__tests__/fixtures/lifecycle-observer.artifact.ts @@ -0,0 +1,30 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {LifeCycleObserver} from '@loopback/core'; + +/** + * An mock-up `LifeCycleObserver`. Please note that `start` and `stop` methods + * can be async or sync. + */ +export class MyLifeCycleObserver implements LifeCycleObserver { + status = ''; + + /** + * Handling `start` event asynchronously + */ + async start() { + // Perform some work asynchronously + // await startSomeAsyncWork(...) + this.status = 'started'; + } + + /** + * Handling `stop` event synchronously. + */ + stop() { + this.status = 'stopped'; + } +} diff --git a/packages/boot/src/__tests__/integration/lifecycle-observer.booter.integration.ts b/packages/boot/src/__tests__/integration/lifecycle-observer.booter.integration.ts new file mode 100644 index 000000000000..6944084765af --- /dev/null +++ b/packages/boot/src/__tests__/integration/lifecycle-observer.booter.integration.ts @@ -0,0 +1,53 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + BindingScope, + ContextTags, + CoreBindings, + CoreTags, +} from '@loopback/core'; +import {expect, TestSandbox} from '@loopback/testlab'; +import {resolve} from 'path'; +import {BooterApp} from '../fixtures/application'; + +describe('lifecycle script booter integration tests', () => { + const SANDBOX_PATH = resolve(__dirname, '../../.sandbox'); + const sandbox = new TestSandbox(SANDBOX_PATH); + + const OBSERVER_PREFIX = CoreBindings.LIFE_CYCLE_OBSERVERS; + const OBSERVER_TAG = CoreTags.LIFE_CYCLE_OBSERVER; + + let app: BooterApp; + + beforeEach('reset sandbox', () => sandbox.reset()); + beforeEach(getApp); + + it('boots life cycle observers when app.boot() is called', async () => { + const expectedBinding = { + key: `${OBSERVER_PREFIX}.MyLifeCycleObserver`, + tags: [ContextTags.TYPE, OBSERVER_TAG], + scope: BindingScope.SINGLETON, + }; + + await app.boot(); + + const bindings = app + .findByTag(OBSERVER_TAG) + .map(b => ({key: b.key, tags: b.tagNames, scope: b.scope})); + expect(bindings).to.containEql(expectedBinding); + }); + + async function getApp() { + await sandbox.copyFile(resolve(__dirname, '../fixtures/application.js')); + await sandbox.copyFile( + resolve(__dirname, '../fixtures/lifecycle-observer.artifact.js'), + 'observers/lifecycle-observer.observer.js', + ); + + const MyApp = require(resolve(SANDBOX_PATH, 'application.js')).BooterApp; + app = new MyApp(); + } +}); diff --git a/packages/boot/src/boot.component.ts b/packages/boot/src/boot.component.ts index e5474f0eace0..18ba2840d46b 100644 --- a/packages/boot/src/boot.component.ts +++ b/packages/boot/src/boot.component.ts @@ -3,16 +3,17 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Bootstrapper} from './bootstrapper'; -import {Component, Application, CoreBindings} from '@loopback/core'; -import {inject, BindingScope} from '@loopback/context'; +import {BindingScope, inject} from '@loopback/context'; +import {Application, Component, CoreBindings} from '@loopback/core'; import { + ApplicationMetadataBooter, ControllerBooter, - RepositoryBooter, DataSourceBooter, + RepositoryBooter, ServiceBooter, - ApplicationMetadataBooter, + LifeCycleObserverBooter, } from './booters'; +import {Bootstrapper} from './bootstrapper'; import {BootBindings} from './keys'; /** @@ -29,6 +30,7 @@ export class BootComponent implements Component { RepositoryBooter, ServiceBooter, DataSourceBooter, + LifeCycleObserverBooter, ]; /** diff --git a/packages/boot/src/booters/index.ts b/packages/boot/src/booters/index.ts index 6bb4f8676110..fb5057c6e7b8 100644 --- a/packages/boot/src/booters/index.ts +++ b/packages/boot/src/booters/index.ts @@ -10,3 +10,4 @@ export * from './datasource.booter'; export * from './repository.booter'; export * from './service.booter'; export * from './application-metadata.booter'; +export * from './lifecyle-observer.booter'; diff --git a/packages/boot/src/booters/lifecyle-observer.booter.ts b/packages/boot/src/booters/lifecyle-observer.booter.ts new file mode 100644 index 000000000000..493b2bf36402 --- /dev/null +++ b/packages/boot/src/booters/lifecyle-observer.booter.ts @@ -0,0 +1,71 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Constructor, inject} from '@loopback/context'; +import { + Application, + CoreBindings, + isLifeCycleObserverClass, + LifeCycleObserver, +} from '@loopback/core'; +import * as debugFactory from 'debug'; +import {ArtifactOptions} from '../interfaces'; +import {BootBindings} from '../keys'; +import {BaseArtifactBooter} from './base-artifact.booter'; + +const debug = debugFactory('loopback:boot:lifecycle-observer-booter'); + +type LifeCycleObserverClass = Constructor; + +/** + * A class that extends BaseArtifactBooter to boot the 'LifeCycleObserver' artifact type. + * + * Supported phases: configure, discover, load + * + * @param app Application instance + * @param projectRoot Root of User Project relative to which all paths are resolved + * @param [bootConfig] LifeCycleObserver Artifact Options Object + */ +export class LifeCycleObserverBooter extends BaseArtifactBooter { + observers: LifeCycleObserverClass[]; + + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) + public app: Application, + @inject(BootBindings.PROJECT_ROOT) projectRoot: string, + @inject(`${BootBindings.BOOT_OPTIONS}#observers`) + public observerConfig: ArtifactOptions = {}, + ) { + super( + projectRoot, + // Set LifeCycleObserver Booter Options if passed in via bootConfig + Object.assign({}, LifeCycleObserverDefaults, observerConfig), + ); + } + + /** + * Uses super method to get a list of Artifact classes. Boot each file by + * creating a DataSourceConstructor and binding it to the application class. + */ + async load() { + await super.load(); + + this.observers = this.classes.filter(isLifeCycleObserverClass); + for (const observer of this.observers) { + debug('Bind life cycle observer: %s', observer.name); + const binding = this.app.lifeCycleObserver(observer); + debug('Binding created for life cycle observer: %j', binding); + } + } +} + +/** + * Default ArtifactOptions for DataSourceBooter. + */ +export const LifeCycleObserverDefaults: ArtifactOptions = { + dirs: ['observers'], + extensions: ['.observer.js'], + nested: true, +}; diff --git a/packages/boot/src/booters/service.booter.ts b/packages/boot/src/booters/service.booter.ts index b897babb9e68..a5f4f9ab4ad0 100644 --- a/packages/boot/src/booters/service.booter.ts +++ b/packages/boot/src/booters/service.booter.ts @@ -13,14 +13,14 @@ import {BootBindings} from '../keys'; type ServiceProviderClass = Constructor>; /** - * A class that extends BaseArtifactBooter to boot the 'DataSource' artifact type. + * A class that extends BaseArtifactBooter to boot the 'Service' artifact type. * Discovered DataSources are bound using `app.controller()`. * * Supported phases: configure, discover, load * * @param app Application instance * @param projectRoot Root of User Project relative to which all paths are resolved - * @param [bootConfig] DataSource Artifact Options Object + * @param [bootConfig] Service Artifact Options Object */ export class ServiceBooter extends BaseArtifactBooter { serviceProviders: ServiceProviderClass[]; @@ -34,7 +34,7 @@ export class ServiceBooter extends BaseArtifactBooter { ) { super( projectRoot, - // Set DataSource Booter Options if passed in via bootConfig + // Set Service Booter Options if passed in via bootConfig Object.assign({}, ServiceDefaults, serviceConfig), ); } diff --git a/packages/cli/README.md b/packages/cli/README.md index 8e4ef5400b5e..80daa42942f8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -252,7 +252,32 @@ Run the following command to install the CLI. url # URL or file path of the OpenAPI spec Type: String Required: false ``` -10. To list available commands +10. To generate a life cycle observer class + + ```sh + cd + lb4 observer + ``` + + ```sh + Usage: + lb4 observer [] [options] + + Options: + -h, --help # Print the generator's options and usage + --skip-cache # Do not remember prompt answers Default: false + --skip-install # Do not automatically install dependencies Default: false + --force-install # Fail on install dependencies error Default: false + --group # Name of the observer group for ordering + -c, --config # JSON file name or value to configure options + -y, --yes # Skip all confirmation prompts with default or provided value + --format # Format generated code using npm run lint:fix + + Arguments: + name # Name for the observer Type: String Required: false + ``` + +11. To list available commands `lb4 --commands` (or `lb4 -l`) @@ -271,7 +296,7 @@ Run the following command to install the CLI. Please note `lb4 --help` also prints out available commands. -11. To print out version information +12. To print out version information `lb4 --version` (or `lb4 -v`) diff --git a/packages/cli/generators/observer/index.js b/packages/cli/generators/observer/index.js new file mode 100644 index 000000000000..ac7da2d43f02 --- /dev/null +++ b/packages/cli/generators/observer/index.js @@ -0,0 +1,132 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; +const _ = require('lodash'); +const ArtifactGenerator = require('../../lib/artifact-generator'); +const debug = require('../../lib/debug')('observer-generator'); +const inspect = require('util').inspect; +const path = require('path'); +const utils = require('../../lib/utils'); + +const SCRIPT_TEMPLATE = 'observer-template.ts.ejs'; + +module.exports = class ObserverGenerator extends ArtifactGenerator { + // Note: arguments and options should be defined in the constructor. + constructor(args, opts) { + super(args, opts); + } + + _setupGenerator() { + this.option('group', { + description: 'Name of the observer group for ordering', + required: false, + type: String, + }); + + this.artifactInfo = { + type: 'observer', + rootDir: utils.sourceRootDir, + }; + + this.artifactInfo.outDir = path.resolve( + this.artifactInfo.rootDir, + utils.observersDir, + ); + + this.artifactInfo.defaultTemplate = SCRIPT_TEMPLATE; + + return super._setupGenerator(); + } + + setOptions() { + return super.setOptions(); + } + + checkLoopBackProject() { + if (this.shouldExit()) return; + return super.checkLoopBackProject(); + } + + /** + * Ask for Service Name + */ + async promptArtifactName() { + debug('Prompting for observer name'); + if (this.shouldExit()) return; + + if (this.options.name) { + Object.assign(this.artifactInfo, {name: this.options.name}); + } + + const prompts = [ + { + type: 'input', + name: 'name', + // capitalization + message: utils.toClassName(this.artifactInfo.type) + ' name:', + when: !this.artifactInfo.name, + validate: utils.validateClassName, + }, + ]; + return this.prompt(prompts).then(props => { + Object.assign(this.artifactInfo, props); + return props; + }); + } + + async promptObserverGroup() { + debug('Prompting for observer group'); + if (this.shouldExit()) return; + + if (this.options.group) { + Object.assign(this.artifactInfo, {group: this.options.group}); + } + + const prompts = [ + { + type: 'input', + name: 'group', + // capitalization + message: utils.toClassName(this.artifactInfo.type) + ' group:', + default: '', + when: !this.artifactInfo.group, + }, + ]; + return this.prompt(prompts).then(props => { + Object.assign(this.artifactInfo, props); + return props; + }); + } + + scaffold() { + if (this.shouldExit()) return false; + + // Setting up data for templates + this.artifactInfo.className = + utils.toClassName(this.artifactInfo.name) + 'Observer'; + this.artifactInfo.fileName = utils.kebabCase(this.artifactInfo.name); + + Object.assign(this.artifactInfo, { + outFile: utils.getObserverFileName(this.artifactInfo.name), + }); + + const source = this.templatePath(this.artifactInfo.defaultTemplate); + + const dest = this.destinationPath( + path.join(this.artifactInfo.outDir, this.artifactInfo.outFile), + ); + + debug(`artifactInfo: ${inspect(this.artifactInfo)}`); + debug(`Copying artifact to: ${dest}`); + + this.copyTemplatedFiles(source, dest, this.artifactInfo); + return; + } + + async end() { + await super.end(); + } +}; diff --git a/packages/cli/generators/observer/templates/observer-template.ts.ejs b/packages/cli/generators/observer/templates/observer-template.ts.ejs new file mode 100644 index 000000000000..155f2552a0df --- /dev/null +++ b/packages/cli/generators/observer/templates/observer-template.ts.ejs @@ -0,0 +1,33 @@ +import { + /* inject, Application, CoreBindings, */ + lifeCycleObserver, // The decorator + CoreTags, + LifeCycleObserver, // The interface +} from '@loopback/core'; + +/** + * This class will be bound to the application as a `LifeCycleObserver` during + * `boot` + */ +@lifeCycleObserver('<%= group %>') +export class <%= className %> implements LifeCycleObserver { + /* + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) private app: Application, + ) {} + */ + + /** + * This method will be invoked when the application starts + */ + async start(): Promise { + // Add your logic for start + } + + /** + * This method will be invoked when the application stops + */ + async stop(): Promise { + // Add your logic for start + } +} diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 2a8bb4a37604..0be7f52ba55c 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -79,6 +79,10 @@ function setupGenerators() { path.join(__dirname, '../generators/openapi'), PREFIX + 'openapi', ); + env.register( + path.join(__dirname, '../generators/observer'), + PREFIX + 'observer', + ); return env; } diff --git a/packages/cli/lib/utils.js b/packages/cli/lib/utils.js index 4233093066b5..f60deb699c74 100644 --- a/packages/cli/lib/utils.js +++ b/packages/cli/lib/utils.js @@ -395,6 +395,14 @@ exports.getServiceFileName = function(serviceName) { return `${_.kebabCase(serviceName)}.service.ts`; }; +/** + * Returns the observerName in the directory file format for the observer + * @param {string} observerName + */ +exports.getObserverFileName = function(observerName) { + return `${_.kebabCase(observerName)}.observer.ts`; +}; + /** * * Returns the connector property for the datasource file @@ -515,4 +523,5 @@ exports.repositoriesDir = 'repositories'; exports.datasourcesDir = 'datasources'; exports.servicesDir = 'services'; exports.modelsDir = 'models'; +exports.observersDir = 'observers'; exports.sourceRootDir = 'src'; diff --git a/packages/cli/test/fixtures/observer/index.js b/packages/cli/test/fixtures/observer/index.js new file mode 100644 index 000000000000..3680edefaf39 --- /dev/null +++ b/packages/cli/test/fixtures/observer/index.js @@ -0,0 +1,11 @@ +const CONFIG_PATH = '.'; + +exports.SANDBOX_FILES = [ + { + path: CONFIG_PATH, + file: 'myobserverconfig.json', + content: JSON.stringify({ + name: 'myObserver', + }), + }, +]; diff --git a/packages/cli/test/integration/cli/cli.integration.js b/packages/cli/test/integration/cli/cli.integration.js index 947ec7541c66..203a9fd7ce0c 100644 --- a/packages/cli/test/integration/cli/cli.integration.js +++ b/packages/cli/test/integration/cli/cli.integration.js @@ -24,7 +24,8 @@ describe('cli', () => { expect(entries).to.eql([ 'Available commands: ', ' lb4 app\n lb4 extension\n lb4 controller\n lb4 datasource\n ' + - 'lb4 model\n lb4 repository\n lb4 service\n lb4 example\n lb4 openapi', + 'lb4 model\n lb4 repository\n lb4 service\n lb4 example\n ' + + 'lb4 openapi\n lb4 observer', ]); }); @@ -42,7 +43,8 @@ describe('cli', () => { expect(entries).to.containEql('Available commands: '); expect(entries).to.containEql( ' lb4 app\n lb4 extension\n lb4 controller\n lb4 datasource\n ' + - 'lb4 model\n lb4 repository\n lb4 service\n lb4 example\n lb4 openapi', + 'lb4 model\n lb4 repository\n lb4 service\n lb4 example\n ' + + 'lb4 openapi\n lb4 observer', ); }); diff --git a/packages/cli/test/integration/generators/observer.integration.js b/packages/cli/test/integration/generators/observer.integration.js new file mode 100644 index 000000000000..7a3d750f338a --- /dev/null +++ b/packages/cli/test/integration/generators/observer.integration.js @@ -0,0 +1,87 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const path = require('path'); +const assert = require('yeoman-assert'); +const testlab = require('@loopback/testlab'); + +const TestSandbox = testlab.TestSandbox; + +const generator = path.join(__dirname, '../../../generators/observer'); +const SANDBOX_FILES = require('../../fixtures/observer').SANDBOX_FILES; +const testUtils = require('../../test-utils'); + +// Test Sandbox +const SANDBOX_PATH = path.resolve(__dirname, '..', '.sandbox'); +const sandbox = new TestSandbox(SANDBOX_PATH); + +describe('lb4 observer', () => { + beforeEach('reset sandbox', async () => { + await sandbox.reset(); + }); + + describe('valid generation of observers', () => { + it('generates a basic observer from command line arguments', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withArguments('myObserver'); + verifyGeneratedScript(); + }); + + it('generates a basic observer from CLI with group', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withArguments('myObserver --group myGroup'); + verifyGeneratedScript('myGroup'); + }); + + it('generates a observer from a config file', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withArguments('--config myobserverconfig.json'); + verifyGeneratedScript(); + }); + }); +}); + +// Sandbox constants +const SCRIPT_APP_PATH = 'src/observers'; +const INDEX_FILE = path.join(SANDBOX_PATH, SCRIPT_APP_PATH, 'index.ts'); + +function verifyGeneratedScript(group = '') { + const expectedFile = path.join( + SANDBOX_PATH, + SCRIPT_APP_PATH, + 'my-observer.observer.ts', + ); + assert.file(expectedFile); + assert.fileContent(expectedFile, 'lifeCycleObserver, // The decorator'); + assert.fileContent( + expectedFile, + /export class MyObserverObserver implements LifeCycleObserver {/, + ); + assert.fileContent(expectedFile, `@lifeCycleObserver('${group}')`); + assert.fileContent(expectedFile, /async start\(\): Promise\ {/); + assert.fileContent(expectedFile, /async stop\(\): Promise\ {/); + assert.file(INDEX_FILE); + assert.fileContent(INDEX_FILE, /export \* from '.\/my-observer.observer';/); +} diff --git a/packages/core/docs.json b/packages/core/docs.json index f4634ab5e5c0..925b1678681b 100644 --- a/packages/core/docs.json +++ b/packages/core/docs.json @@ -5,7 +5,8 @@ "src/component.ts", "src/index.ts", "src/keys.ts", - "src/server.ts" + "src/server.ts", + "src/lifecycle.ts" ], "codeSectionDepth": 4 } diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 0d3371b2fc1d..41adf8fb9b07 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -4,11 +4,30 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/debug": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.2.tgz", + "integrity": "sha512-jkf6UiWUjcOqdQbatbvOm54/YbCdjt3JjiAzT/9KS2XtMmOkYHdKsI5u8fulhbuTUuiqNBfa6J5GSDiwjK+zLA==", + "dev": true + }, "@types/node": { "version": "10.12.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.30.tgz", "integrity": "sha512-nsqTN6zUcm9xtdJiM9OvOJ5EF0kOI8f1Zuug27O/rgtxCRJHGqncSWfCMZUP852dCKPsDsYXGvBhxfRjDBkF5Q==", "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" } } } diff --git a/packages/core/package.json b/packages/core/package.json index 8986f6f10487..1f0e9717b477 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,12 +20,14 @@ "copyright.owner": "IBM Corp.", "license": "MIT", "dependencies": { - "@loopback/context": "^1.9.0" + "@loopback/context": "^1.9.0", + "debug": "^4.1.0" }, "devDependencies": { "@loopback/build": "^1.4.1", "@loopback/testlab": "^1.2.2", "@loopback/tslint-config": "^2.0.4", + "@types/debug": "^4.1.2", "@types/node": "^10.11.2" }, "files": [ diff --git a/packages/core/src/__tests__/unit/application-lifecycle.unit.ts b/packages/core/src/__tests__/unit/application-lifecycle.unit.ts new file mode 100644 index 000000000000..9a3b63a8362f --- /dev/null +++ b/packages/core/src/__tests__/unit/application-lifecycle.unit.ts @@ -0,0 +1,235 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + bind, + BindingScope, + Constructor, + Context, + createBindingFromClass, +} from '@loopback/context'; +import {expect} from '@loopback/testlab'; +import { + Application, + Component, + CoreBindings, + CoreTags, + LifeCycleObserver, + lifeCycleObserver, + Server, +} from '../..'; + +describe('Application life cycle', () => { + describe('start', () => { + it('starts all injected servers', async () => { + const app = new Application(); + app.component(ObservingComponentWithServers); + const component = await app.get( + `${CoreBindings.COMPONENTS}.ObservingComponentWithServers`, + ); + expect(component.status).to.equal('not-initialized'); + await app.start(); + const server = await app.getServer(ObservingServer); + + expect(server).to.not.be.null(); + expect(server.listening).to.equal(true); + expect(component.status).to.equal('started'); + await app.stop(); + }); + + it('starts servers bound with `LIFE_CYCLE_OBSERVER` tag', async () => { + const app = new Application(); + app + .bind('fake-server') + .toClass(ObservingServer) + .tag(CoreTags.LIFE_CYCLE_OBSERVER, CoreTags.SERVER) + .inScope(BindingScope.SINGLETON); + await app.start(); + const server = await app.get('fake-server'); + + expect(server).to.not.be.null(); + expect(server.listening).to.equal(true); + await app.stop(); + }); + + it('starts/stops all registered components', async () => { + const app = new Application(); + app.component(ObservingComponentWithServers); + const component = await app.get( + `${CoreBindings.COMPONENTS}.ObservingComponentWithServers`, + ); + expect(component.status).to.equal('not-initialized'); + await app.start(); + expect(component.status).to.equal('started'); + await app.stop(); + expect(component.status).to.equal('stopped'); + }); + + it('starts/stops all observers from the component', async () => { + const app = new Application(); + app.component(ComponentWithObservers); + const observer = await app.get( + 'lifeCycleObservers.MyObserver', + ); + const observerWithDecorator = await app.get( + 'lifeCycleObservers.MyObserverWithDecorator', + ); + expect(observer.status).to.equal('not-initialized'); + expect(observerWithDecorator.status).to.equal('not-initialized'); + await app.start(); + expect(observer.status).to.equal('started'); + expect(observerWithDecorator.status).to.equal('started'); + await app.stop(); + expect(observer.status).to.equal('stopped'); + expect(observerWithDecorator.status).to.equal('stopped'); + }); + + it('starts/stops all registered life cycle observers', async () => { + const app = new Application(); + app.lifeCycleObserver(MyObserver, 'my-observer'); + + const observer = await app.get( + 'lifeCycleObservers.my-observer', + ); + expect(observer.status).to.equal('not-initialized'); + await app.start(); + expect(observer.status).to.equal('started'); + await app.stop(); + expect(observer.status).to.equal('stopped'); + }); + + it('honors @bind', async () => { + @bind({ + tags: { + [CoreTags.LIFE_CYCLE_OBSERVER]: CoreTags.LIFE_CYCLE_OBSERVER, + [CoreTags.LIFE_CYCLE_OBSERVER_GROUP]: 'my-group', + namespace: CoreBindings.LIFE_CYCLE_OBSERVERS, + }, + scope: BindingScope.SINGLETON, + }) + class MyObserverWithBind implements LifeCycleObserver { + status = 'not-initialized'; + + start() { + this.status = 'started'; + } + stop() { + this.status = 'stopped'; + } + } + + const app = new Application(); + const binding = createBindingFromClass(MyObserverWithBind); + app.add(binding); + expect(binding.tagMap[CoreTags.LIFE_CYCLE_OBSERVER_GROUP]).to.eql( + 'my-group', + ); + + const observer = await app.get(binding.key); + expect(observer.status).to.equal('not-initialized'); + await app.start(); + expect(observer.status).to.equal('started'); + await app.stop(); + expect(observer.status).to.equal('stopped'); + }); + + it('honors @lifeCycleObserver', async () => { + const app = new Application(); + const binding = createBindingFromClass(MyObserverWithDecorator); + app.add(binding); + expect(binding.tagMap[CoreTags.LIFE_CYCLE_OBSERVER_GROUP]).to.eql( + 'my-group', + ); + expect(binding.scope).to.eql(BindingScope.SINGLETON); + + const observer = await app.get(binding.key); + expect(observer.status).to.equal('not-initialized'); + await app.start(); + expect(observer.status).to.equal('started'); + await app.stop(); + expect(observer.status).to.equal('stopped'); + }); + + it('does not attempt to start poorly named bindings', async () => { + const app = new Application(); + let startInvoked = false; + let stopInvoked = false; + + // The app.start should not attempt to start this binding. + app.bind('controllers.servers').to({ + start: () => { + startInvoked = true; + }, + stop: () => { + stopInvoked = true; + }, + }); + await app.start(); + expect(startInvoked).to.be.false(); // not invoked + await app.stop(); + expect(stopInvoked).to.be.false(); // not invoked + }); + }); +}); + +class ObservingComponentWithServers implements Component, LifeCycleObserver { + status = 'not-initialized'; + servers: { + [name: string]: Constructor; + }; + constructor() { + this.servers = { + ObservingServer: ObservingServer, + ObservingServer2: ObservingServer, + }; + } + start() { + this.status = 'started'; + } + stop() { + this.status = 'stopped'; + } +} + +class ObservingServer extends Context implements Server { + listening: boolean = false; + constructor() { + super(); + } + async start(): Promise { + this.listening = true; + } + + async stop(): Promise { + this.listening = false; + } +} + +class MyObserver implements LifeCycleObserver { + status = 'not-initialized'; + + start() { + this.status = 'started'; + } + stop() { + this.status = 'stopped'; + } +} + +@lifeCycleObserver('my-group', {scope: BindingScope.SINGLETON}) +class MyObserverWithDecorator implements LifeCycleObserver { + status = 'not-initialized'; + + start() { + this.status = 'started'; + } + stop() { + this.status = 'stopped'; + } +} + +class ComponentWithObservers implements Component { + lifeCycleObservers = [MyObserver, MyObserverWithDecorator]; +} diff --git a/packages/core/src/__tests__/unit/application.unit.ts b/packages/core/src/__tests__/unit/application.unit.ts index 4cc86c6b430d..61d993999f0d 100644 --- a/packages/core/src/__tests__/unit/application.unit.ts +++ b/packages/core/src/__tests__/unit/application.unit.ts @@ -7,13 +7,12 @@ import { bind, Binding, BindingScope, - Constructor, Context, inject, Provider, } from '@loopback/context'; import {expect} from '@loopback/testlab'; -import {Application, Component, CoreBindings, Server} from '../..'; +import {Application, Component, CoreBindings, CoreTags, Server} from '../..'; describe('Application', () => { describe('controller binding', () => { @@ -24,17 +23,21 @@ describe('Application', () => { it('binds a controller', () => { const binding = app.controller(MyController); - expect(Array.from(binding.tagNames)).to.containEql('controller'); + expect(Array.from(binding.tagNames)).to.containEql(CoreTags.CONTROLLER); expect(binding.key).to.equal('controllers.MyController'); expect(binding.scope).to.equal(BindingScope.TRANSIENT); - expect(findKeysByTag(app, 'controller')).to.containEql(binding.key); + expect(findKeysByTag(app, CoreTags.CONTROLLER)).to.containEql( + binding.key, + ); }); it('binds a controller with custom name', () => { const binding = app.controller(MyController, 'my-controller'); - expect(Array.from(binding.tagNames)).to.containEql('controller'); + expect(Array.from(binding.tagNames)).to.containEql(CoreTags.CONTROLLER); expect(binding.key).to.equal('controllers.my-controller'); - expect(findKeysByTag(app, 'controller')).to.containEql(binding.key); + expect(findKeysByTag(app, CoreTags.CONTROLLER)).to.containEql( + binding.key, + ); }); it('binds a singleton controller', () => { @@ -61,14 +64,14 @@ describe('Application', () => { it('binds a component', () => { const binding = app.component(MyComponent); expect(binding.scope).to.equal(BindingScope.SINGLETON); - expect(findKeysByTag(app, 'component')).to.containEql( + expect(findKeysByTag(app, CoreTags.COMPONENT)).to.containEql( 'components.MyComponent', ); }); it('binds a component with custom name', () => { app.component(MyComponent, 'my-component'); - expect(findKeysByTag(app, 'component')).to.containEql( + expect(findKeysByTag(app, CoreTags.COMPONENT)).to.containEql( 'components.my-component', ); }); @@ -191,7 +194,7 @@ describe('Application', () => { it('defaults to constructor name', async () => { const binding = app.server(FakeServer); expect(binding.scope).to.equal(BindingScope.SINGLETON); - expect(Array.from(binding.tagNames)).to.containEql('server'); + expect(Array.from(binding.tagNames)).to.containEql(CoreTags.SERVER); const result = await app.getServer(FakeServer.name); expect(result.constructor.name).to.equal(FakeServer.name); }); @@ -213,8 +216,8 @@ describe('Application', () => { it('allows binding of multiple servers as an array', async () => { const bindings = app.servers([FakeServer, AnotherServer]); - expect(Array.from(bindings[0].tagNames)).to.containEql('server'); - expect(Array.from(bindings[1].tagNames)).to.containEql('server'); + expect(Array.from(bindings[0].tagNames)).to.containEql(CoreTags.SERVER); + expect(Array.from(bindings[1].tagNames)).to.containEql(CoreTags.SERVER); const fakeResult = await app.getServer(FakeServer); expect(fakeResult.constructor.name).to.equal(FakeServer.name); const AnotherResult = await app.getServer(AnotherServer); @@ -226,46 +229,11 @@ describe('Application', () => { } }); - describe('start', () => { - it('starts all injected servers', async () => { - const app = new Application(); - app.component(FakeComponent); - - await app.start(); - const server = await app.getServer(FakeServer); - expect(server).to.not.be.null(); - expect(server.listening).to.equal(true); - await app.stop(); - }); - - it('does not attempt to start poorly named bindings', async () => { - const app = new Application(); - app.component(FakeComponent); - - // The app.start should not attempt to start this binding. - app.bind('controllers.servers').to({}); - await app.start(); - await app.stop(); - }); - }); - function findKeysByTag(ctx: Context, tag: string | RegExp) { return ctx.findByTag(tag).map(binding => binding.key); } }); -class FakeComponent implements Component { - servers: { - [name: string]: Constructor; - }; - constructor() { - this.servers = { - FakeServer, - FakeServer2: FakeServer, - }; - } -} - class FakeServer extends Context implements Server { listening: boolean = false; constructor() { diff --git a/packages/core/src/__tests__/unit/lifecycle-registry.unit.ts b/packages/core/src/__tests__/unit/lifecycle-registry.unit.ts new file mode 100644 index 000000000000..2826d48a39be --- /dev/null +++ b/packages/core/src/__tests__/unit/lifecycle-registry.unit.ts @@ -0,0 +1,204 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + bind, + BindingScope, + Context, + createBindingFromClass, +} from '@loopback/context'; +import {expect} from '@loopback/testlab'; +import {promisify} from 'util'; +import { + asLifeCycleObserver, + CoreBindings, + CoreTags, + LifeCycleObserver, + LifeCycleObserverRegistry, +} from '../..'; +import {DEFAULT_ORDERED_GROUPS} from '../../lifecycle-registry'; +const sleep = promisify(setTimeout); + +describe('LifeCycleRegistry', () => { + let context: Context; + let registry: TestObserverRegistry; + const events: string[] = []; + + beforeEach(() => events.splice(0, events.length)); + beforeEach(givenContext); + beforeEach(givenLifeCycleRegistry); + + it('starts all registered observers', async () => { + givenObserver('1'); + givenObserver('2'); + await registry.start(); + expect(events).to.eql(['1-start', '2-start']); + }); + + it('starts all registered async observers', async () => { + givenAsyncObserver('1', 'g1'); + givenAsyncObserver('2', 'g2'); + registry.setOrderedGroups(['g1', 'g2']); + await registry.start(); + expect(events).to.eql(['1-start', '2-start']); + }); + + it('stops all registered observers in reverse order', async () => { + givenObserver('1'); + givenObserver('2'); + await registry.stop(); + expect(events).to.eql(['2-stop', '1-stop']); + }); + + it('stops all registered async observers in reverse order', async () => { + givenAsyncObserver('1', 'g1'); + givenAsyncObserver('2', 'g2'); + registry.setOrderedGroups(['g1', 'g2']); + await registry.stop(); + expect(events).to.eql(['2-stop', '1-stop']); + }); + + it('starts all registered observers by group', async () => { + givenObserver('1', 'g1'); + givenObserver('2', 'g2'); + givenObserver('3', 'g1'); + registry.setOrderedGroups(['g1', 'g2']); + const groups = registry.getOrderedGroups(); + expect(groups).to.eql(['g1', 'g2']); + await registry.start(); + expect(events).to.eql(['1-start', '3-start', '2-start']); + }); + + it('stops all registered observers in reverse order by group', async () => { + givenObserver('1', 'g1'); + givenObserver('2', 'g2'); + givenObserver('3', 'g1'); + registry.setOrderedGroups(['g1', 'g2']); + await registry.stop(); + expect(events).to.eql(['2-stop', '3-stop', '1-stop']); + }); + + it('starts observers by alphabetical groups if no order is configured', async () => { + givenObserver('1', 'g1'); + givenObserver('2', 'g2'); + givenObserver('3', 'g1'); + givenObserver('4', 'g0'); + const groups = registry.getOrderedGroups(); + expect(groups).to.eql(['g0', 'g1', 'g2']); + await registry.start(); + expect(events).to.eql(['4-start', '1-start', '3-start', '2-start']); + }); + + it('runs all registered observers within the same group in parallel', async () => { + // 1st group: g1-1 takes 20 ms more than g1-2 to finish + givenAsyncObserver('g1-1', 'g1', 20); + givenAsyncObserver('g1-2', 'g1', 0); + + // 2nd group: g2-1 takes 20 ms more than g2-2 to finish + givenAsyncObserver('g2-1', 'g2', 20); + givenAsyncObserver('g2-2', 'g2', 0); + + registry.setOrderedGroups(['g1', 'g2']); + registry.setParallel(true); + await registry.start(); + expect(events.length).to.equal(4); + expect(events).to.eql([ + 'g1-2-start', + 'g1-1-start', + 'g2-2-start', + 'g2-1-start', + ]); + }); + + it('runs all registered observers within the same group in serial', async () => { + // 1st group: g1-1 takes 20 ms more than g1-2 to finish + givenAsyncObserver('g1-1', 'g1', 20); + givenAsyncObserver('g1-2', 'g1', 0); + + // 2nd group: g2-1 takes 20 ms more than g2-2 to finish + givenAsyncObserver('g2-1', 'g2', 20); + givenAsyncObserver('g2-2', 'g2', 0); + + registry.setOrderedGroups(['g1', 'g2']); + registry.setParallel(false); + await registry.start(); + expect(events.length).to.equal(4); + expect(events).to.eql([ + 'g1-1-start', + 'g1-2-start', + 'g2-1-start', + 'g2-2-start', + ]); + }); + + function givenContext() { + context = new Context('app'); + } + + /** + * Create a subclass to expose some protected properties/methods for testing + */ + class TestObserverRegistry extends LifeCycleObserverRegistry { + getOrderedGroups(): string[] { + return super.getObserverGroupsByOrder().map(g => g.group); + } + + setParallel(parallel?: boolean) { + this.options.parallel = parallel; + } + } + + async function givenLifeCycleRegistry() { + context.bind(CoreBindings.LIFE_CYCLE_OBSERVER_OPTIONS).to({ + orderedGroups: DEFAULT_ORDERED_GROUPS, + parallel: false, + }); + context + .bind(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY) + .toClass(TestObserverRegistry) + .inScope(BindingScope.SINGLETON); + registry = (await context.get( + CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY, + )) as TestObserverRegistry; + } + + function givenObserver(name: string, group = '') { + @bind({tags: {[CoreTags.LIFE_CYCLE_OBSERVER_GROUP]: group}}) + class MyObserver implements LifeCycleObserver { + start() { + events.push(`${name}-start`); + } + stop() { + events.push(`${name}-stop`); + } + } + const binding = createBindingFromClass(MyObserver, { + key: `observers.observer-${name}`, + }).apply(asLifeCycleObserver); + context.add(binding); + + return MyObserver; + } + + function givenAsyncObserver(name: string, group = '', delayInMs = 0) { + @bind({tags: {[CoreTags.LIFE_CYCLE_OBSERVER_GROUP]: group}}) + class MyAsyncObserver implements LifeCycleObserver { + async start() { + await sleep(delayInMs); + events.push(`${name}-start`); + } + async stop() { + await sleep(delayInMs); + events.push(`${name}-stop`); + } + } + const binding = createBindingFromClass(MyAsyncObserver, { + key: `observers.observer-${name}`, + }).apply(asLifeCycleObserver); + context.add(binding); + + return MyAsyncObserver; + } +}); diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index 501c659d1b86..254b3ba41179 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -10,19 +10,31 @@ import { Context, createBindingFromClass, } from '@loopback/context'; +import * as debugFactory from 'debug'; import {Component, mountComponent} from './component'; import {CoreBindings, CoreTags} from './keys'; +import { + asLifeCycleObserver, + isLifeCycleObserverClass, + LifeCycleObserver, +} from './lifecycle'; +import {LifeCycleObserverRegistry} from './lifecycle-registry'; import {Server} from './server'; +const debug = debugFactory('loopback:core:application'); /** * Application is the container for various types of artifacts, such as * components, servers, controllers, repositories, datasources, connectors, * and models. */ -export class Application extends Context { +export class Application extends Context implements LifeCycleObserver { constructor(public options: ApplicationConfig = {}) { super('application'); + // Bind the life cycle observer registry + this.bind(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY) + .toClass(LifeCycleObserverRegistry) + .inScope(BindingScope.SINGLETON); // Bind to self to allow injection of application context in other modules. this.bind(CoreBindings.APPLICATION_INSTANCE).to(this); // Make options available to other modules as well. @@ -46,6 +58,7 @@ export class Application extends Context { * ``` */ controller(controllerCtor: ControllerClass, name?: string): Binding { + debug('Adding controller %s', name || controllerCtor.name); const binding = createBindingFromClass(controllerCtor, { name, namespace: CoreBindings.CONTROLLERS, @@ -77,12 +90,13 @@ export class Application extends Context { ctor: Constructor, name?: string, ): Binding { + debug('Adding server %s', name || ctor.name); const binding = createBindingFromClass(ctor, { name, namespace: CoreBindings.SERVERS, type: CoreTags.SERVER, defaultScope: BindingScope.SINGLETON, - }); + }).apply(asLifeCycleObserver); this.add(binding); return binding; } @@ -136,40 +150,28 @@ export class Application extends Context { } /** - * Start the application, and all of its registered servers. + * Start the application, and all of its registered observers. * * @returns {Promise} * @memberof Application */ public async start(): Promise { - await this._forEachServer(s => s.start()); + const registry = await this.getLifeCycleObserverRegistry(); + await registry.start(); } /** - * Stop the application instance and all of its registered servers. + * Stop the application instance and all of its registered observers. * @returns {Promise} * @memberof Application */ public async stop(): Promise { - await this._forEachServer(s => s.stop()); + const registry = await this.getLifeCycleObserverRegistry(); + await registry.stop(); } - /** - * Helper function for iterating across all registered server components. - * @protected - * @template T - * @param {(s: Server) => Promise} fn The function to run against all - * registered servers - * @memberof Application - */ - protected async _forEachServer(fn: (s: Server) => Promise) { - const bindings = this.find(`${CoreBindings.SERVERS}.*`); - await Promise.all( - bindings.map(async binding => { - const server = await this.get(binding.key); - return await fn(server); - }), - ); + private async getLifeCycleObserverRegistry() { + return await this.get(CoreBindings.LIFE_CYCLE_OBSERVER_REGISTRY); } /** @@ -194,12 +196,16 @@ export class Application extends Context { * ``` */ public component(componentCtor: Constructor, name?: string) { + debug('Adding component: %s', name || componentCtor.name); const binding = createBindingFromClass(componentCtor, { name, namespace: CoreBindings.COMPONENTS, type: CoreTags.COMPONENT, defaultScope: BindingScope.SINGLETON, }); + if (isLifeCycleObserverClass(componentCtor)) { + binding.apply(asLifeCycleObserver); + } this.add(binding); // Assuming components can be synchronously instantiated const instance = this.getSync(binding.key); @@ -216,6 +222,26 @@ export class Application extends Context { public setMetadata(metadata: ApplicationMetadata) { this.bind(CoreBindings.APPLICATION_METADATA).to(metadata); } + + /** + * Register a life cycle observer class + * @param ctor A class implements LifeCycleObserver + * @param name Optional name for the life cycle observer + */ + public lifeCycleObserver( + ctor: Constructor, + name?: string, + ): Binding { + debug('Adding life cycle observer %s', name || ctor.name); + const binding = createBindingFromClass(ctor, { + name, + namespace: CoreBindings.LIFE_CYCLE_OBSERVERS, + type: CoreTags.LIFE_CYCLE_OBSERVER, + defaultScope: BindingScope.SINGLETON, + }).apply(asLifeCycleObserver); + this.add(binding); + return binding; + } } /** diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index 7e84a09efed2..e06f28096105 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -11,6 +11,7 @@ import { Provider, } from '@loopback/context'; import {Application, ControllerClass} from './application'; +import {LifeCycleObserver} from './lifecycle'; import {Server} from './server'; /** @@ -67,6 +68,8 @@ export interface Component { [name: string]: Constructor; }; + lifeCycleObservers?: Constructor[]; + /** * An array of bindings to be aded to the application context. For example, * ```ts @@ -126,4 +129,10 @@ export function mountComponent(app: Application, component: Component) { app.server(component.servers[serverKey], serverKey); } } + + if (component.lifeCycleObservers) { + for (const observer of component.lifeCycleObservers) { + app.lifeCycleObserver(observer); + } + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5381aeaa6ee8..87e4b81ac6fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,8 @@ export * from './server'; export * from './application'; export * from './component'; export * from './keys'; +export * from './lifecycle'; +export * from './lifecycle-registry'; // Re-export public Core API coming from dependencies export * from '@loopback/context'; diff --git a/packages/core/src/keys.ts b/packages/core/src/keys.ts index a7a8e0563fa0..6cd42c8388d4 100644 --- a/packages/core/src/keys.ts +++ b/packages/core/src/keys.ts @@ -5,6 +5,10 @@ import {BindingKey} from '@loopback/context'; import {Application, ApplicationMetadata, ControllerClass} from './application'; +import { + LifeCycleObserverOptions, + LifeCycleObserverRegistry, +} from './lifecycle-registry'; /** * Namespace for core binding keys @@ -74,6 +78,21 @@ export namespace CoreBindings { * context */ export const CONTROLLER_CURRENT = BindingKey.create('controller.current'); + + export const LIFE_CYCLE_OBSERVERS = 'lifeCycleObservers'; + /** + * Binding key for life cycle observer options + */ + export const LIFE_CYCLE_OBSERVER_REGISTRY = BindingKey.create< + LifeCycleObserverRegistry + >('lifeCycleObserver.registry'); + + /** + * Binding key for life cycle observer options + */ + export const LIFE_CYCLE_OBSERVER_OPTIONS = BindingKey.create< + LifeCycleObserverOptions + >('lifeCycleObserver.options'); } export namespace CoreTags { @@ -91,4 +110,14 @@ export namespace CoreTags { * Binding tag for controllers */ export const CONTROLLER = 'controller'; + + /** + * Binding tag for life cycle observers + */ + export const LIFE_CYCLE_OBSERVER = 'lifeCycleObserver'; + + /** + * Binding tag for group name of life cycle observers + */ + export const LIFE_CYCLE_OBSERVER_GROUP = 'lifeCycleObserverGroup'; } diff --git a/packages/core/src/lifecycle-registry.ts b/packages/core/src/lifecycle-registry.ts new file mode 100644 index 000000000000..9e89ee928bd8 --- /dev/null +++ b/packages/core/src/lifecycle-registry.ts @@ -0,0 +1,246 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Binding, ContextView, inject} from '@loopback/context'; +import {CoreBindings, CoreTags} from './keys'; +import {LifeCycleObserver, lifeCycleObserverFilter} from './lifecycle'; +import debugFactory = require('debug'); +const debug = debugFactory('loopback:core:lifecycle'); + +/** + * A group of life cycle observers + */ +export type LifeCycleObserverGroup = { + /** + * Observer group name + */ + group: string; + /** + * Bindings for observers within the group + */ + bindings: Readonly>[]; +}; + +export type LifeCycleObserverOptions = { + /** + * Control the order of observer groups for notifications. For example, + * with `['datasource', 'server']`, the observers in `datasource` group are + * notified before those in `server` group during `start`. Please note that + * observers are notified in the reverse order during `stop`. + */ + orderedGroups: string[]; + /** + * Notify observers of the same group in parallel, default to `true` + */ + parallel?: boolean; +}; + +export const DEFAULT_ORDERED_GROUPS = ['server']; + +/** + * A context-based registry for life cycle observers + */ +export class LifeCycleObserverRegistry implements LifeCycleObserver { + constructor( + @inject.view(lifeCycleObserverFilter) + protected readonly observersView: ContextView, + @inject(CoreBindings.LIFE_CYCLE_OBSERVER_OPTIONS, {optional: true}) + protected readonly options: LifeCycleObserverOptions = { + parallel: true, + orderedGroups: DEFAULT_ORDERED_GROUPS, + }, + ) {} + + setOrderedGroups(groups: string[]) { + this.options.orderedGroups = groups; + } + + /** + * Get observer groups ordered by the group + */ + public getObserverGroupsByOrder(): LifeCycleObserverGroup[] { + const bindings = this.observersView.bindings; + const groups = this.sortObserverBindingsByGroup(bindings); + if (debug.enabled) { + debug( + 'Observer groups: %j', + groups.map(g => ({ + group: g.group, + bindings: g.bindings.map(b => b.key), + })), + ); + } + return groups; + } + + /** + * Get the group for a given life cycle observer binding + * @param binding Life cycle observer binding + */ + protected getObserverGroup( + binding: Readonly>, + ): string { + // First check if there is an explicit group name in the tag + let group = binding.tagMap[CoreTags.LIFE_CYCLE_OBSERVER_GROUP]; + if (!group) { + // Fall back to a tag that matches one of the groups + group = this.options.orderedGroups.find(g => binding.tagMap[g] === g); + } + group = group || ''; + debug( + 'Binding %s is configured with observer group %s', + binding.key, + group, + ); + return group; + } + + /** + * Sort the life cycle observer bindings so that we can start/stop them + * in the right order. By default, we can start other observers before servers + * and stop them in the reverse order + * @param bindings Life cycle observer bindings + */ + protected sortObserverBindingsByGroup( + bindings: Readonly>[], + ) { + // Group bindings in a map + const groupMap: Map< + string, + Readonly>[] + > = new Map(); + for (const binding of bindings) { + const group = this.getObserverGroup(binding); + let bindingsInGroup = groupMap.get(group); + if (bindingsInGroup == null) { + bindingsInGroup = []; + groupMap.set(group, bindingsInGroup); + } + bindingsInGroup.push(binding); + } + // Create an array for group entries + const groups: LifeCycleObserverGroup[] = []; + for (const [group, bindingsInGroup] of groupMap) { + groups.push({group, bindings: bindingsInGroup}); + } + // Sort the groups + return groups.sort((g1, g2) => { + const i1 = this.options.orderedGroups.indexOf(g1.group); + const i2 = this.options.orderedGroups.indexOf(g2.group); + if (i1 !== -1 || i2 !== -1) { + // Honor the group order + return i1 - i2; + } else { + // Neither group is in the pre-defined order + // Use alphabetical order instead so that `1-group` is invoked before + // `2-group` + return g1.group < g2.group ? -1 : g1.group > g2.group ? 1 : 0; + } + }); + } + + /** + * Notify an observer group of the given event + * @param group A group of bindings for life cycle observers + * @param event Event name + */ + protected async notifyObservers( + observers: LifeCycleObserver[], + bindings: Readonly>[], + event: keyof LifeCycleObserver, + ) { + if (!this.options.parallel) { + let index = 0; + for (const observer of observers) { + debug( + 'Invoking %s observer for binding %s', + event, + bindings[index].key, + ); + index++; + await this.invokeObserver(observer, event); + } + return; + } + + // Parallel invocation + const notifiers = observers.map((observer, index) => { + debug('Invoking %s observer for binding %s', event, bindings[index].key); + return this.invokeObserver(observer, event); + }); + await Promise.all(notifiers); + } + + /** + * Invoke an observer for the given event + * @param observer A life cycle observer + * @param event Event name + */ + protected async invokeObserver( + observer: LifeCycleObserver, + event: keyof LifeCycleObserver, + ) { + if (typeof observer[event] === 'function') { + await observer[event]!(); + } + } + + /** + * Emit events to the observer groups + * @param events Event names + * @param groups Observer groups + */ + protected async notifyGroups( + events: (keyof LifeCycleObserver)[], + groups: LifeCycleObserverGroup[], + reverse = false, + ) { + const observers = await this.observersView.values(); + const bindings = this.observersView.bindings; + if (reverse) { + // Do not reverse the original `groups` in place + groups = [...groups].reverse(); + } + for (const group of groups) { + const observersForGroup: LifeCycleObserver[] = []; + const bindingsInGroup = reverse + ? group.bindings.reverse() + : group.bindings; + for (const binding of bindingsInGroup) { + const index = bindings.indexOf(binding); + observersForGroup.push(observers[index]); + } + + for (const event of events) { + debug('Beginning notification %s of %s...', event); + await this.notifyObservers(observersForGroup, group.bindings, event); + debug('Finished notification %s of %s', event); + } + } + } + + /** + * Notify all life cycle observers by group of `start` + * + * @returns {Promise} + */ + public async start(): Promise { + debug('Starting the %s...'); + const groups = this.getObserverGroupsByOrder(); + await this.notifyGroups(['start'], groups); + } + + /** + * Notify all life cycle observers by group of `stop` + * + * @returns {Promise} + */ + public async stop(): Promise { + debug('Stopping the %s...'); + const groups = this.getObserverGroupsByOrder(); + // Stop in the reverse order + await this.notifyGroups(['stop'], groups, true); + } +} diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts new file mode 100644 index 000000000000..606473d4bc89 --- /dev/null +++ b/packages/core/src/lifecycle.ts @@ -0,0 +1,84 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + bind, + Binding, + BindingSpec, + Constructor, + ValueOrPromise, +} from '@loopback/context'; +import {CoreTags} from './keys'; + +/** + * Observers to handle life cycle start/stop events + */ +export interface LifeCycleObserver { + /** + * The method to be invoked during `start` + */ + start?(): ValueOrPromise; + /** + * The method to be invoked during `stop` + */ + stop?(): ValueOrPromise; +} + +const lifeCycleMethods: (keyof LifeCycleObserver)[] = ['start', 'stop']; + +/** + * Test if an object implements LifeCycleObserver + * @param obj An object + */ +export function isLifeCycleObserver(obj: { + [name: string]: unknown; +}): obj is LifeCycleObserver { + return lifeCycleMethods.some(m => typeof obj[m] === 'function'); +} + +/** + * Test if a class implements LifeCycleObserver + * @param ctor A class + */ +export function isLifeCycleObserverClass( + ctor: Constructor, +): ctor is Constructor { + return ctor.prototype && isLifeCycleObserver(ctor.prototype); +} + +/** + * A `BindingTemplate` function to configure the binding as life cycle observer + * by tagging it with `CoreTags.LIFE_CYCLE_OBSERVER`. + * + * @param binding Binding object + */ +export function asLifeCycleObserver(binding: Binding) { + return binding.tag(CoreTags.LIFE_CYCLE_OBSERVER); +} + +/** + * Find all life cycle observer bindings. By default, a binding tagged with + * `CoreTags.LIFE_CYCLE_OBSERVER`. It's used as `BindingFilter`. + */ +export function lifeCycleObserverFilter(binding: Binding): boolean { + return binding.tagMap[CoreTags.LIFE_CYCLE_OBSERVER] != null; +} + +/** + * Sugar decorator to mark a class as life cycle observer + * @param group Optional observer group name + * @param specs Optional bindings specs + */ +export function lifeCycleObserver(group = '', ...specs: BindingSpec[]) { + return bind( + asLifeCycleObserver, + { + tags: { + [CoreTags.LIFE_CYCLE_OBSERVER_GROUP]: group, + }, + }, + ...specs, + ); +} diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 615988257f67..09a800fb036d 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -3,6 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {LifeCycleObserver} from './lifecycle'; + /** * Defines the requirements to implement a Server for LoopBack applications: * start() : Promise @@ -15,18 +17,9 @@ * @export * @interface Server */ -export interface Server { +export interface Server extends LifeCycleObserver { /** * Tells whether the server is listening for connections or not */ readonly listening: boolean; - - /** - * Start the server - */ - start(): Promise; - /** - * Stop the server - */ - stop(): Promise; }