diff --git a/docs/site/Booting-an-Application.md b/docs/site/Booting-an-Application.md index f988f05d9b36..3deab972b02c 100644 --- a/docs/site/Booting-an-Application.md +++ b/docs/site/Booting-an-Application.md @@ -287,3 +287,44 @@ Used to discover the artifacts supported by the `Booter` based on convention. **load** Used to bind the discovered artifacts to the Application. + +### Boot an application using component + +For a complex project, we may break it down into multiple LoopBack applications, +each of which has controllers, datasources, services, repositories, and other +artifacts. How do we compose these sub applications into the main application? +The component application booter can be created to support this use case. + +1. Create a component for the sub-application: + +```ts +import {createComponentApplicationBooterBinding} from '@loopback/boot'; +import {Component} from '@loopback/core'; + +export class SubAppComponent implements Component { + bindings = [ + createComponentApplicationBooterBinding( + new SubApp(), /* an optional binding filter */, + ), + ]; +} +``` + +2. Mount the sub-application as a component to the main application: + +```ts +const mainApp = new MainApp(); + +// This can be done in the constructor of `MainApp` too. Make sure the component +// is registered before calling `app.boot()`. +mainApp.component(SubAppComponent); + +// Boot the main application. It will invoke the component application booter +// to add artifacts from the `SubApp`. +await mainApp.boot(); +``` + +A binding filter function can be provided to select what bindings from the +component application should be added to the main application. The booter skips +bindings that exist in the component application before `boot`. It does not +override locked bindings in the main application. diff --git a/packages/boot/src/__tests__/acceptance/component-application.booter.acceptance.ts b/packages/boot/src/__tests__/acceptance/component-application.booter.acceptance.ts new file mode 100644 index 000000000000..a11d49b1adcb --- /dev/null +++ b/packages/boot/src/__tests__/acceptance/component-application.booter.acceptance.ts @@ -0,0 +1,113 @@ +// Copyright IBM Corp. 2019,2020. 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 {Application, Component} from '@loopback/core'; +import {expect, givenHttpServerConfig, TestSandbox} from '@loopback/testlab'; +import {resolve} from 'path'; +import { + BootBindings, + BootMixin, + createComponentApplicationBooterBinding, +} from '../..'; +import {BooterApp} from '../fixtures/application'; + +describe('component application booter acceptance tests', () => { + let app: BooterApp; + const sandbox = new TestSandbox(resolve(__dirname, '../../.sandbox'), { + // We intentionally use this flag so that `dist/application.js` can keep + // its relative path to satisfy import statements + subdir: false, + }); + + beforeEach('reset sandbox', () => sandbox.reset()); + beforeEach(getApp); + + it('binds artifacts booted from the component application', async () => { + class BooterAppComponent implements Component { + bindings = [createComponentApplicationBooterBinding(app)]; + } + + const mainApp = new MainApp(); + mainApp.component(BooterAppComponent); + const appBindingsBeforeBoot = mainApp.find( + // Exclude boot related bindings + binding => + ![ + BootBindings.BOOT_OPTIONS.key, + BootBindings.PROJECT_ROOT.key, + BootBindings.BOOTSTRAPPER_KEY.key, + ].includes(binding.key), + ); + await mainApp.boot(); + const controllers = mainApp.find('controllers.*').map(b => b.key); + expect(controllers).to.eql([ + 'controllers.ArtifactOne', + 'controllers.ArtifactTwo', + ]); + + // Assert main app bindings before boot are not overridden + const appBindingsAfterBoot = mainApp.find(binding => + appBindingsBeforeBoot.includes(binding), + ); + expect(appBindingsAfterBoot.map(b => b.key)).to.eql( + appBindingsBeforeBoot.map(b => b.key), + ); + }); + + it('binds artifacts booted from the component application by filter', async () => { + class BooterAppComponent implements Component { + bindings = [ + createComponentApplicationBooterBinding(app, binding => { + return binding.key === 'controllers.ArtifactOne'; + }), + ]; + } + + const mainApp = new MainApp(); + mainApp.component(BooterAppComponent); + await mainApp.boot(); + const controllers = mainApp.find('controllers.*').map(b => b.key); + expect(controllers).to.eql(['controllers.ArtifactOne']); + }); + + it('does not override locked bindings', async () => { + class BooterAppComponent implements Component { + bindings = [createComponentApplicationBooterBinding(app)]; + } + + const mainApp = new MainApp(); + const lockedBinding = mainApp + .bind('controllers.ArtifactTwo') + .to('-locked-') + .lock(); + mainApp.component(BooterAppComponent); + await mainApp.boot(); + const current = mainApp.getBinding('controllers.ArtifactTwo', { + optional: true, + }); + expect(current).to.be.exactly(lockedBinding); + }); + + class MainApp extends BootMixin(Application) { + constructor() { + super(); + this.projectRoot = __dirname; + } + } + + async function getApp() { + await sandbox.copyFile(resolve(__dirname, '../fixtures/package.json')); + await sandbox.copyFile(resolve(__dirname, '../fixtures/application.js')); + await sandbox.copyFile( + resolve(__dirname, '../fixtures/multiple.artifact.js'), + 'controllers/multiple.controller.js', + ); + + const MyApp = require(resolve(sandbox.path, 'application.js')).BooterApp; + app = new MyApp({ + rest: givenHttpServerConfig(), + }); + } +}); diff --git a/packages/boot/src/booters/component-application.booter.ts b/packages/boot/src/booters/component-application.booter.ts new file mode 100644 index 000000000000..dedc94234d51 --- /dev/null +++ b/packages/boot/src/booters/component-application.booter.ts @@ -0,0 +1,113 @@ +// Copyright IBM Corp. 2020. 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 { + Application, + Binding, + BindingFilter, + Constructor, + CoreBindings, + createBindingFromClass, + inject, +} from '@loopback/core'; +import debugFactory from 'debug'; +import {BootBindings} from '../keys'; +import {Bootable, Booter, booter} from '../types'; + +const debug = debugFactory('loopback:boot:booter:component-application'); + +/** + * Create a booter that boots the component application. Bindings that exist + * in the component application before `boot` are skipped. Locked bindings in + * the main application will not be overridden. + * + * @param componentApp - The application exposing a component + * @param filter Binding filter to selected bindings to be added + */ +export function createBooterForComponentApplication( + componentApp: Application & Bootable, + filter: BindingFilter = () => true, +): Constructor { + /** + * A booter to boot artifacts for the component application + */ + @booter('componentApplications') + class ComponentApplicationBooter implements Booter { + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) private mainApp: Application, + ) {} + + async load() { + const bootBindingKeys = [ + BootBindings.BOOT_OPTIONS.key, + BootBindings.PROJECT_ROOT.key, + BootBindings.BOOTSTRAPPER_KEY.key, + ]; + /** + * List all bindings before boot + */ + let bindings = componentApp.find(() => true); + const bindingsBeforeBoot = new Set(bindings); + // Boot the component application + await componentApp.boot(); + /** + * Add bindings from the component application to the main application + */ + bindings = componentApp.find(filter); + for (const binding of bindings) { + // Exclude boot related bindings + if (bootBindingKeys.includes(binding.key)) continue; + + // Exclude bindings from the app before boot + if (bindingsBeforeBoot.has(binding)) { + debug( + 'Skipping binding %s that exists before booting %s', + binding.key, + componentApp.name, + ); + continue; + } + + // Do not override locked bindings + const locked = this.mainApp.find(binding.key).some(b => b.isLocked); + if (locked) { + debug( + 'Skipping binding %s from %s - locked in %s', + binding.key, + componentApp.name, + this.mainApp.name, + ); + continue; + } + + debug( + 'Adding binding from %s to %s', + componentApp.name, + this.mainApp.name, + binding, + ); + this.mainApp.add(binding as Binding); + } + } + } + return ComponentApplicationBooter; +} + +/** + * Create a binding to register a booter that boots the component application. + * Bindings that exist in the component application before `boot` are skipped. + * Locked bindings in the main application will not be overridden. + * + * @param componentApp - The application exposing a component + * @param filter Binding filter to selected bindings to be added + */ +export function createComponentApplicationBooterBinding( + componentApp: Application & Bootable, + filter?: BindingFilter, +) { + return createBindingFromClass( + createBooterForComponentApplication(componentApp, filter), + ); +} diff --git a/packages/boot/src/booters/index.ts b/packages/boot/src/booters/index.ts index 36ee79804e06..4fe98be2e9de 100644 --- a/packages/boot/src/booters/index.ts +++ b/packages/boot/src/booters/index.ts @@ -6,6 +6,7 @@ export * from './application-metadata.booter'; export * from './base-artifact.booter'; export * from './booter-utils'; +export * from './component-application.booter'; export * from './controller.booter'; export * from './datasource.booter'; export * from './interceptor.booter';