diff --git a/CODEOWNERS b/CODEOWNERS index e8fb812a9df1..028ba8a3e7be 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -5,6 +5,7 @@ * @bajtos @raymondfeng @kjdelisle packages/authentication/* @bajtos @kjdelisle +packages/boot/* @raymondfeng @virkt25 packages/build/* @bajtos @raymondfeng packages/cli/* @raymondfeng @kjdelisle @shimks packages/context/* @bajtos @raymondfeng @kjdelisle diff --git a/MONOREPO.md b/MONOREPO.md index cff408060d65..c7571a921023 100644 --- a/MONOREPO.md +++ b/MONOREPO.md @@ -13,6 +13,7 @@ The [loopback-next](https://github.com/strongloop/loopback-next) repository uses |[metadata](packages/metadata) |@loopback/metadata | Utilities to help developers implement TypeScript decorators, define/merge metadata, and inspect metadata | |[context](packages/context) |@loopback/context | Facilities to manage artifacts and their dependencies in your Node.js applications. The module exposes TypeScript/JavaScript APIs and decorators to register artifacts, declare dependencies, and resolve artifacts by keys. It also serves as an IoC container to support dependency injection. | |[core](packages/core) |@loopback/core | Define and implement core constructs such as Application and Component | +|[boot](packages/boot) |@loopback/boot | Convention based Bootstrapper and Booters | |[openapi-spec](packages/openapi-spec) |@loopback/openapi-spec | TypeScript type definitions for OpenAPI Spec/Swagger documents | |[openapi-spec-builder](packages/openapi-spec-builder) |@loopback/openapi-spec-builder | Builders to create OpenAPI (Swagger) specification documents in tests | |[openapi-v2](packages/openapi-v2) |@loopback/openapi-v2 | Decorators that annotate LoopBack artifacts with OpenAPI v2 (Swagger) metadata and utilities that transform LoopBack metadata to OpenAPI v2 (Swagger) specifications| diff --git a/packages/boot/.npmrc b/packages/boot/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/boot/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/boot/LICENSE b/packages/boot/LICENSE new file mode 100644 index 000000000000..bc33602fe0a5 --- /dev/null +++ b/packages/boot/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2018. All Rights Reserved. +Node module: @loopback/boot +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/boot/README.md b/packages/boot/README.md new file mode 100644 index 000000000000..f117767e3cfc --- /dev/null +++ b/packages/boot/README.md @@ -0,0 +1,97 @@ +# @loopback/boot + +A convention based project Bootstrapper and Booters for LoopBack Applications + +# Overview + +A Booter is a Class that can be bound to an Application and is called +to perform a task before the Application is started. A Booter may have multiple +phases to complete its task. + +An example task of a Booter may be to discover and bind all artifacts of a +given type. + +A Bootstrapper is needed to manage the Booters and execute them. This is provided +in this package. For ease of use, everything needed is packages using a BootMixin. +This Mixin will add convenience methods such as `boot` and `booter`, as well as +properties needed for Bootstrapper such as `projectRoot`. The Mixin also adds the +`BootComponent` to your `Application` which binds the `Bootstrapper` and default +`Booters` made available by this package. + +## Installation + +```shell +$ npm i @loopback/boot +``` + +## Basic Use + +```ts +import {Application} from '@loopback/core'; +import {BootMixin} from '@loopback/boot'; +class BootApp extends BootMixin(Application) {} + +const app = new BootApp(); +app.projectRoot = __dirname; +app.bootOptions = { + controlles: { + // Configure ControllerBooter Conventiones here. + } +} + +await app.boot(); +await app.start(); +``` + +### BootOptions +List of Options available on BootOptions Object. + +|Option|Type|Description| +|-|-|-| +|`controllers`|`ArtifactOptions`|ControllerBooter convention options| + +### ArtifactOptions + +**Add Table for ArtifactOptions** + +### BootExecOptions + +**Add Table for BootExecOptions** + +## Available Booters + +### ControllerBooter + +#### Description +Discovers and binds Controller Classes using `app.controller()`. + +#### Options +The Options for this can be passed via `BootOptions` when calling `app.boot(options:BootOptions)`. + +The options for this are passed in a `controllers` object on `BootOptions`. + +Available Options on the `controllers` object on `BootOptions` are as follows: + +|Options|Type|Default|Description| +|-|-|-|-| +|`dirs`|`string | string[]`|`['controllers']`|Paths relative to projectRoot to look in for Controller artifacts| +|`extensions`|`string | string[]`|`['.controller.js']`|File extensions to match for Controller artifacts| +|`nested`|`boolean`|`true`|Look in nested directories in `dirs` for Controller artifacts| +|`glob`|`string`||A `glob` pattern string. This takes precendence over above 3 options (which are used to make a glob pattern).| + +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/wiki/Contributing#guidelines) +- [Join the team](https://github.com/strongloop/loopback-next/issues/110) + +## Tests + +Run `npm test` from the root folder. + +## Contributors + +See [all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/packages/boot/docs.json b/packages/boot/docs.json new file mode 100644 index 000000000000..832f14a43b3a --- /dev/null +++ b/packages/boot/docs.json @@ -0,0 +1,16 @@ +{ + "content": [ + "index.ts", + "src/booters/base-artifact.booter.ts", + "src/booters/booter-utils.ts", + "src/booters/controller.booter.ts", + "src/booters/index.ts", + "src/boot.component.ts", + "src/boot.mixin.ts", + "src/bootstrapper.ts", + "src/index.ts", + "src/interfaces.ts", + "src/keys.ts" + ], + "codeSectionDepth": 4 +} diff --git a/packages/boot/index.d.ts b/packages/boot/index.d.ts new file mode 100644 index 000000000000..a298c01feb0e --- /dev/null +++ b/packages/boot/index.d.ts @@ -0,0 +1,6 @@ +// 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 + +export * from './dist'; diff --git a/packages/boot/index.js b/packages/boot/index.js new file mode 100644 index 000000000000..c43b6eb7fb20 --- /dev/null +++ b/packages/boot/index.js @@ -0,0 +1,6 @@ +// 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 + +module.exports = require('./dist'); diff --git a/packages/boot/index.ts b/packages/boot/index.ts new file mode 100644 index 000000000000..e1819c75d561 --- /dev/null +++ b/packages/boot/index.ts @@ -0,0 +1,6 @@ +// 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 + +export * from './src'; diff --git a/packages/boot/package.json b/packages/boot/package.json new file mode 100644 index 000000000000..9c25bf3780f7 --- /dev/null +++ b/packages/boot/package.json @@ -0,0 +1,58 @@ +{ + "name": "@loopback/boot", + "version": "4.0.0-alpha.1", + "description": "A collection of Booters for LoopBack 4 Applications", + "engines": { + "node": ">=8" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "acceptance": "lb-mocha \"DIST/test/acceptance/**/*.js\"", + "build": "npm run build:dist", + "build:current": "lb-tsc", + "build:dist": "lb-tsc es2017", + "build:apidocs": "lb-apidocs", + "clean": "lb-clean loopback-boot*.tgz dist package api-docs", + "prepublishOnly": "npm run build && npm run build:apidocs", + "pretest": "npm run build:current", + "integration": "lb-mocha \"DIST/test/integration/**/*.js\"", + "test": "lb-mocha \"DIST/test/unit/**/*.js\" \"DIST/test/integration/**/*.js\" \"DIST/test/acceptance/**/*.js\"", + "unit": "lb-mocha \"DIST/test/unit/**/*.js\"", + "verify": "npm pack && tar xf loopback-boot*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "dependencies": { + "@loopback/context": "^4.0.0-alpha.27", + "@loopback/core": "^4.0.0-alpha.29", + "@types/debug": "0.0.30", + "@types/glob": "^5.0.34", + "debug": "^3.1.0", + "glob": "^7.1.2" + }, + "devDependencies": { + "@loopback/build": "^4.0.0-alpha.10", + "@loopback/openapi-v2": "^4.0.0-alpha.3", + "@loopback/rest": "^4.0.0-alpha.18", + "@loopback/testlab": "^4.0.0-alpha.20" + }, + "files": [ + "README.md", + "index.js", + "index.js.map", + "index.d.ts", + "dist/src", + "dist/index.js", + "dist/index.js.map", + "dist/index.d.ts", + "api-docs", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} diff --git a/packages/boot/src/boot.component.ts b/packages/boot/src/boot.component.ts new file mode 100644 index 000000000000..407f7be32383 --- /dev/null +++ b/packages/boot/src/boot.component.ts @@ -0,0 +1,33 @@ +// 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 {Bootstrapper} from './bootstrapper'; +import {Component, Application, CoreBindings} from '@loopback/core'; +import {inject, BindingScope} from '@loopback/context'; +import {ControllerBooter} from './booters'; +import {BootBindings} from './keys'; + +/** + * BootComponent is used to export the default list of Booter's made + * available by this module as well as bind the BootStrapper to the app so it + * can be used to run the Booters. + */ +export class BootComponent implements Component { + // Export a list of default booters in the component so they get bound + // automatically when this component is mounted. + booters = [ControllerBooter]; + + /** + * + * @param app Application instance + */ + constructor(@inject(CoreBindings.APPLICATION_INSTANCE) app: Application) { + // Bound as a SINGLETON so it can be cached as it has no state + app + .bind(BootBindings.BOOTSTRAPPER_KEY) + .toClass(Bootstrapper) + .inScope(BindingScope.SINGLETON); + } +} diff --git a/packages/boot/src/boot.mixin.ts b/packages/boot/src/boot.mixin.ts new file mode 100644 index 000000000000..3b6d4674987b --- /dev/null +++ b/packages/boot/src/boot.mixin.ts @@ -0,0 +1,142 @@ +// 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, Binding, BindingScope, Context} from '@loopback/context'; +import {Booter, BootOptions, Bootable} from './interfaces'; +import {BootComponent} from './boot.component'; +import {Bootstrapper} from './bootstrapper'; +import {BootBindings} from './keys'; + +// Binding is re-exported as Binding / Booter types are needed when consuming +// BootMixin and this allows a user to import them from the same package (UX!) +export {Binding}; + +/** + * Mixin for @loopback/boot. This Mixin provides the following: + * - Implements the Bootable Interface as follows. + * - Add a `projectRoot` property to the Class + * - Adds an optional `bootOptions` property to the Class that can be used to + * store the Booter conventions. + * - Adds the `BootComponent` to the Class (which binds the Bootstrapper and default Booters) + * - Provides the `boot()` convenience method to call Bootstrapper.boot() + * - Provides the `booter()` convenience method to bind a Booter(s) to the Application + * - Override `component()` to call `mountComponentBooters` + * - Adds `mountComponentBooters` which binds Booters to the application from `component.booters[]` + * + * ******************** NOTE ******************** + * Trying to constrain the type of this Mixin (or any Mixin) will cause errors. + * For example, constraining this Mixin to type Application require all types using by + * Application to be imported (including it's dependencies such as ResolutionSession). + * Another issue was that if a Mixin that is type constrained is used with another Mixin + * that is not, it will result in an error. + * Example (class MyApp extends BootMixin(RepositoryMixin(Application))) {}; + ********************* END OF NOTE ******************** + */ +// tslint:disable-next-line:no-any +export function BootMixin>(superClass: T) { + return class extends superClass implements Bootable { + projectRoot: string; + bootOptions?: BootOptions; + + // tslint:disable-next-line:no-any + constructor(...args: any[]) { + super(...args); + this.component(BootComponent); + + // We Dynamically bind the Project Root and Boot Options so these values can + // be used to resolve an instance of the Bootstrapper (as they are dependencies) + this.bind(BootBindings.PROJECT_ROOT).toDynamicValue( + () => this.projectRoot, + ); + this.bind(BootBindings.BOOT_OPTIONS).toDynamicValue( + () => this.bootOptions, + ); + } + + /** + * Convenience method to call bootstrapper.boot() by resolving bootstrapper + */ + async boot(): Promise { + // Get a instance of the BootStrapper + const bootstrapper: Bootstrapper = await this.get( + BootBindings.BOOTSTRAPPER_KEY, + ); + + await bootstrapper.boot(); + } + + /** + * Given a N number of Booter Classes, this method binds them using the + * prefix and tag expected by the Bootstrapper. + * + * @param booterCls Booter classes to bind to the Application + * + * ```ts + * app.booters(MyBooter, MyOtherBooter) + * ``` + */ + booters(...booterCls: Constructor[]): Binding[] { + // tslint:disable-next-line:no-any + return booterCls.map(cls => _bindBooter((this), cls)); + } + + /** + * Override to ensure any Booter's on a Component are also mounted. + * + * @param component The component to add. + * + * ```ts + * + * export class ProductComponent { + * booters = [ControllerBooter, RepositoryBooter]; + * providers = { + * [AUTHENTICATION_STRATEGY]: AuthStrategy, + * [AUTHORIZATION_ROLE]: Role, + * }; + * }; + * + * app.component(ProductComponent); + * ``` + */ + public component(component: Constructor<{}>) { + super.component(component); + this.mountComponentBooters(component); + } + + /** + * Get an instance of a component and mount all it's + * booters. This function is intended to be used internally + * by component() + * + * @param component The component to mount booters of + */ + mountComponentBooters(component: Constructor<{}>) { + const componentKey = `components.${component.name}`; + const compInstance = this.getSync(componentKey); + + if (compInstance.booters) { + this.booters(...compInstance.booters); + } + } + }; +} + +/** + * Method which binds a given Booter to a given Context with the Prefix and + * Tags expected by the Bootstrapper + * + * @param ctx The Context to bind the Booter Class + * @param booterCls Booter class to be bound + */ +export function _bindBooter( + ctx: Context, + booterCls: Constructor, +): Binding { + return ctx + .bind(`${BootBindings.BOOTER_PREFIX}.${booterCls.name}`) + .toClass(booterCls) + .inScope(BindingScope.CONTEXT) + .tag(BootBindings.BOOTER_TAG); +} diff --git a/packages/boot/src/booters/base-artifact.booter.ts b/packages/boot/src/booters/base-artifact.booter.ts new file mode 100644 index 000000000000..a3777c97673c --- /dev/null +++ b/packages/boot/src/booters/base-artifact.booter.ts @@ -0,0 +1,95 @@ +// 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} from '@loopback/context'; +import {discoverFiles, loadClassesFromFiles} from './booter-utils'; +import {Booter, ArtifactOptions} from '../interfaces'; + +/** + * This class serves as a base class for Booters which follow a pattern of + * configure, discover files in a folder(s) using explicit folder / extensions + * or a glob pattern and lastly identifying exported classes from such files and + * performing an action on such files such as binding them. + * + * Any Booter extending this base class is expected to + * + * 1. Set the 'options' property to a object of ArtifactOptions type. (Each extending + * class should provide defaults for the ArtifactOptions and use Object.assign to merge + * the properties with user provided Options). + * 2. Provide it's own logic for 'load' after calling 'await super.load()' to + * actually boot the Artifact classes. + * + * Currently supports the following boot phases: configure, discover, load + * + * @property options Options being used by the Booter + * @property projectRoot Project root relative to which all other paths are resovled + * @property dirs Directories to look for an artifact in + * @property extensions File extensions to look for to match an artifact (this is a convention based booter) + * @property glob Glob pattern to use to discover artifacts. Takes precedence over (dirs + extensions) + * @property discovered List of files discovered by the Booter that matched artifact requirements + * @property classes List of exported classes discovered in the files + */ +export class BaseArtifactBooter implements Booter { + /** + * Options being used by the Booter. + */ + options: ArtifactOptions; + projectRoot: string; + dirs: string[]; + extensions: string[]; + glob: string; + discovered: string[]; + classes: Array>; + + /** + * Configure the Booter by initializing the 'dirs', 'extensions' and 'glob' + * properties. + * + * NOTE: All properties are configured even if all aren't used. + */ + async configure() { + this.dirs = this.options.dirs + ? Array.isArray(this.options.dirs) + ? this.options.dirs + : [this.options.dirs] + : []; + + this.extensions = this.options.extensions + ? Array.isArray(this.options.extensions) + ? this.options.extensions + : [this.options.extensions] + : []; + + const joinedDirs = this.dirs.join('|'); + const joinedExts = this.extensions.join('|'); + + this.glob = this.options.glob + ? this.options.glob + : `/@(${joinedDirs})/${ + this.options.nested ? '**/*' : '*' + }@(${joinedExts})`; + } + + /** + * Discover files based on the 'glob' property relative to the 'projectRoot'. + * Discovered artifact files matching the pattern are saved to the + * 'discovered' property. + */ + async discover() { + this.discovered = await discoverFiles(this.glob, this.projectRoot); + } + + /** + * Filters the exports of 'discovered' files to only be Classes (in case + * function / types are exported) as an artifact is a Class. The filtered + * artifact Classes are saved in the 'classes' property. + * + * NOTE: Booters extending this class should call this method (await super.load()) + * and then process the artifact classes as appropriate. + */ + async load() { + this.classes = await loadClassesFromFiles(this.discovered); + } +} diff --git a/packages/boot/src/booters/booter-utils.ts b/packages/boot/src/booters/booter-utils.ts new file mode 100644 index 000000000000..a7ca3c6ebabc --- /dev/null +++ b/packages/boot/src/booters/booter-utils.ts @@ -0,0 +1,58 @@ +// 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} from '@loopback/context'; +import {promisify} from 'util'; +const glob = promisify(require('glob')); + +/** + * Returns all files matching the given glob pattern relative to root + * + * @param pattern A glob pattern + * @param root Root folder to start searching for matching files + * @returns {string[]} Array of discovered files + */ +export async function discoverFiles( + pattern: string, + root: string, +): Promise { + return await glob(pattern, {root: root}); +} + +/** + * Given a function, returns true if it is a class, false otherwise. + * + * @param target The function to check if it's a class or not. + * @returns {boolean} True if target is a class. False otherwise. + */ +export function isClass(target: Constructor<{}>): boolean { + return ( + typeof target === 'function' && target.toString().indexOf('class') === 0 + ); +} + +/** + * Returns an Array of Classes from given files. Works by requiring the file, + * identifying the exports from the file by getting the keys of the file + * and then testing each exported member to see if it's a class or not. + * + * @param files An array of string of absolute file paths + * @returns {Promise>>} An array of Class Construtors from a file + */ +export async function loadClassesFromFiles( + files: string[], +): Promise>> { + const classes: Array> = []; + files.forEach(file => { + const data = require(file); + Object.keys(data).forEach(cls => { + if (isClass(data[cls])) { + classes.push(data[cls]); + } + }); + }); + + return classes; +} diff --git a/packages/boot/src/booters/controller.booter.ts b/packages/boot/src/booters/controller.booter.ts new file mode 100644 index 000000000000..3fae6b26dc23 --- /dev/null +++ b/packages/boot/src/booters/controller.booter.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 {CoreBindings, Application} from '@loopback/core'; +import {inject} from '@loopback/context'; +import {ArtifactOptions} from '../interfaces'; +import {BaseArtifactBooter} from './base-artifact.booter'; +import {BootBindings} from '../keys'; + +/** + * A class that extends BaseArtifactBooter to boot the 'Controller' artifact type. + * Discovered controllers 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] Controller Artifact Options Object + */ +export class ControllerBooter extends BaseArtifactBooter { + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) public app: Application, + @inject(BootBindings.PROJECT_ROOT) public projectRoot: string, + @inject(`${BootBindings.BOOT_OPTIONS}#controllers`) + public controllerConfig: ArtifactOptions = {}, + ) { + super(); + // Set Controller Booter Options if passed in via bootConfig + this.options = Object.assign({}, ControllerDefaults, controllerConfig); + } + + /** + * Uses super method to get a list of Artifact classes. Boot each class by + * binding it to the application using `app.controller(controller);`. + */ + async load() { + await super.load(); + this.classes.forEach(cls => { + this.app.controller(cls); + }); + } +} + +/** + * Default ArtifactOptions for a ControllerBooter. + */ +export const ControllerDefaults: ArtifactOptions = { + dirs: ['controllers'], + extensions: ['.controller.js'], + nested: true, +}; diff --git a/packages/boot/src/booters/index.ts b/packages/boot/src/booters/index.ts new file mode 100644 index 000000000000..d0dc48ab2bda --- /dev/null +++ b/packages/boot/src/booters/index.ts @@ -0,0 +1,8 @@ +// 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 + +export * from './base-artifact.booter'; +export * from './booter-utils'; +export * from './controller.booter'; diff --git a/packages/boot/src/bootstrapper.ts b/packages/boot/src/bootstrapper.ts new file mode 100644 index 000000000000..dbd79c3ea709 --- /dev/null +++ b/packages/boot/src/bootstrapper.ts @@ -0,0 +1,131 @@ +// 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 {resolve} from 'path'; +import {Context, inject, resolveList} from '@loopback/context'; +import {CoreBindings, Application} from '@loopback/core'; +import { + BootOptions, + BootExecutionOptions, + BOOTER_PHASES, + Bootable, +} from './interfaces'; +import {BootBindings} from './keys'; +import {_bindBooter} from './boot.mixin'; + +import * as debugModule from 'debug'; +const debug = debugModule('loopback:boot:bootstrapper'); + +/** + * The Bootstrapper class provides the `boot` function that is responsible for + * finding and executing the Booters in an application based on given options. + * + * NOTE: Bootstrapper should be bound as a SINGLETON so it can be cached as + * it does not maintain any state of it's own. + * + * @param app Appliaction instance + * @param projectRoot The root directory of the project, relative to which all other paths are resolved + * @param [bootOptions] The BootOptions describing the conventions to be used by various Booters + */ +export class Bootstrapper { + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) + private app: Application & Bootable, + @inject(BootBindings.PROJECT_ROOT) private projectRoot: string, + @inject(BootBindings.BOOT_OPTIONS) private bootOptions: BootOptions = {}, + ) { + // Resolve path to projectRoot and re-bind + this.projectRoot = resolve(this.projectRoot); + app.bind(BootBindings.PROJECT_ROOT).to(this.projectRoot); + + // This is re-bound for testing reasons where this value may be passed directly + // and needs to be propogated to the Booters via DI + app.bind(BootBindings.BOOT_OPTIONS).to(this.bootOptions); + } + + /** + * Function is responsible for calling all registered Booter classes that + * are bound to the Application instance. Each phase of an instance must + * complete before the next phase is started. + * + * @param {BootExecutionOptions} execOptions Execution options for boot. These + * determine the phases and booters that are run. + * @param {Context} [ctx] Optional Context to use to resolve bindings. This is + * primarily useful when running app.boot() again but with different settings + * (in particular phases) such as 'start' / 'stop'. Using a returned Context from + * a previous boot call allows DI to retrieve the same instances of Booters previously + * used as they are bound using a CONTEXT scope. This is important as Booter instances + * may maintain state. + */ + async boot( + execOptions?: BootExecutionOptions, + ctx?: Context, + ): Promise { + const bootCtx = ctx || new Context(this.app); + + // Bind booters passed in as a part of BootOptions + // We use _bindBooter so this Class can be used without the Mixin + if (execOptions && execOptions.booters) { + execOptions.booters.forEach(booter => + // tslint:disable-next-line:no-any + _bindBooter(this.app, booter), + ); + } + + // Determine the phases to be run. If a user set a phases filter, those + // are selected otherwise we run the default phases (BOOTER_PHASES). + const phases = execOptions + ? execOptions.filter && execOptions.filter.phases + ? execOptions.filter.phases + : BOOTER_PHASES + : BOOTER_PHASES; + + // Find booters registered to the BOOTERS_TAG by getting the bindings + const bindings = bootCtx.findByTag(BootBindings.BOOTER_TAG); + + // Prefix length. +1 because of `.` => 'booters.' + const prefix_length = BootBindings.BOOTER_PREFIX.length + 1; + + // Names of all registered booters. + const defaultBooterNames = bindings.map(binding => + binding.key.slice(prefix_length), + ); + + // Determing the booters to be run. If a user set a booters filter (class + // names of booters that should be run), that is the value, otherwise it + // is all the registered booters by default. + const names = execOptions + ? execOptions.filter && execOptions.filter.booters + ? execOptions.filter.booters + : defaultBooterNames + : defaultBooterNames; + + // Filter bindings by names + const filteredBindings = bindings.filter(binding => + names.includes(binding.key.slice(prefix_length)), + ); + + // Resolve Booter Instances + const booterInsts = await resolveList(filteredBindings, binding => + bootCtx.get(binding.key), + ); + + // Run phases of booters + for (const phase of phases) { + for (const inst of booterInsts) { + const instName = inst.constructor.name; + if (inst[phase]) { + debug(`${instName} phase: ${phase} starting.`); + await inst[phase](); + debug(`${instName} phase: ${phase} complete.`); + } else { + debug(`${instName} phase: ${phase} not implemented.`); + } + } + } + + return bootCtx; + } +} diff --git a/packages/boot/src/index.ts b/packages/boot/src/index.ts new file mode 100644 index 000000000000..28ac74b3bed8 --- /dev/null +++ b/packages/boot/src/index.ts @@ -0,0 +1,11 @@ +// 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 + +export * from './booters'; +export * from './bootstrapper'; +export * from './boot.component'; +export * from './keys'; +export * from './boot.mixin'; +export * from './interfaces'; diff --git a/packages/boot/src/interfaces.ts b/packages/boot/src/interfaces.ts new file mode 100644 index 000000000000..7728f72b6299 --- /dev/null +++ b/packages/boot/src/interfaces.ts @@ -0,0 +1,111 @@ +// 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 {Constructor, Binding} from '@loopback/context'; + +/** + * Type definition for ArtifactOptions. These are the options supported by + * this Booter. + * + * @param dirs String / String Array of directories to check for artifacts. + * Paths must be relative. Defaults to ['controllers'] + * @param extensions String / String Array of file extensions to match artifact + * files in dirs. Defaults to ['.controller.js'] + * @param nested Boolean to control if artifact discovery should check nested + * folders or not. Default to true + * @param glob Optional. A `glob` string to use when searching for files. This takes + * precendence over other options. + */ +export type ArtifactOptions = { + dirs?: string | string[]; + extensions?: string | string[]; + nested?: boolean; + glob?: string; +}; + +/** + * Defines the requirements to implement a Booter for LoopBack applications: + * configure() : Promise + * discover() : Promise + * load(): Promise + * + * A Booter will run through the above methods in order. + * + * @export + * @interface Booter + */ +export interface Booter { + /** + * Configure phase of the Booter. It should set options / defaults in this phase. + */ + configure?(): Promise; + /** + * Discover phase of the Booter. It should search for artifacts in this phase. + */ + discover?(): Promise; + /** + * Load phase of the Booter. It should bind the artifacts in this phase. + */ + load?(): Promise; +} + +/** + * Export of an array of all the Booter phases supported by the interface + * above, in the order they should be run. + */ +export const BOOTER_PHASES = ['configure', 'discover', 'load']; + +/** + * Type Object for Options passed into .boot() + * + * @property projectRoot Root of project. All other artifacts are resolved relative to this + * @property booters An array of booters to bind to the application before running bootstrapper + * @property filter.booters An array of booters that should be run by the bootstrapper + * @property filter.phases An array of phases that should be run + */ +export type BootOptions = { + controllers?: ArtifactOptions; + /** + * Additional Properties + */ + // tslint:disable-next-line:no-any + [prop: string]: any; +}; + +export type BootExecutionOptions = { + /** + * Optional array of Booter Classes to bind to the application before running bootstrapper. + */ + booters?: Constructor[]; + /** + * Filter Object for Bootstrapper + */ + filter?: { + /** + * Names of booters that should be run by Bootstrapper + */ + booters?: string[]; + /** + * Names of phases that should be run by Bootstrapper + */ + phases?: string[]; + }; + /** + * Additional Properties + */ + // tslint:disable-next-line:no-any + [prop: string]: any; +}; + +/** + * Interface to describe the additions made available to an Application + * that uses BootMixin. + */ +export interface Bootable { + projectRoot: string; + bootOptions?: BootOptions; + boot(): Promise; + booters(...booterCls: Constructor[]): Binding[]; +} diff --git a/packages/boot/src/keys.ts b/packages/boot/src/keys.ts new file mode 100644 index 000000000000..ce2e334cfbc7 --- /dev/null +++ b/packages/boot/src/keys.ts @@ -0,0 +1,20 @@ +// 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 + +/** + * Namespace for core binding keys + */ +export namespace BootBindings { + /** + * Binding key for Boot configuration + */ + export const BOOT_OPTIONS = 'boot.options'; + export const PROJECT_ROOT = 'boot.project_root'; + + // Key for Binding the BootStrapper Class + export const BOOTSTRAPPER_KEY = 'application.bootstrapper'; + export const BOOTER_TAG = 'booter'; + export const BOOTER_PREFIX = 'booters'; +} diff --git a/packages/boot/test/acceptance/controller.booter.acceptance.ts b/packages/boot/test/acceptance/controller.booter.acceptance.ts new file mode 100644 index 000000000000..f0fdff1180d7 --- /dev/null +++ b/packages/boot/test/acceptance/controller.booter.acceptance.ts @@ -0,0 +1,64 @@ +// Copyright IBM Corp. 2013,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 {Client, createClientForHandler, TestSandbox} from '@loopback/testlab'; +import {RestServer} from '@loopback/rest'; +import {resolve} from 'path'; +import {ControllerBooterApp} from '../fixtures/application'; + +describe('controller booter acceptance tests', () => { + let app: ControllerBooterApp; + const SANDBOX_PATH = resolve(__dirname, '../../.sandbox'); + const sandbox = new TestSandbox(SANDBOX_PATH); + + beforeEach(resetSandbox); + beforeEach(getApp); + + afterEach(stopApp); + + it('binds controllers using ControllerDefaults and REST endpoints work', async () => { + await app.boot(); + await app.start(); + + const server: RestServer = await app.getServer(RestServer); + const client: Client = createClientForHandler(server.handleHttp); + + // Default Controllers = /controllers with .controller.js ending (nested = true); + await client.get('/one').expect(200, 'ControllerOne.one()'); + await client.get('/two').expect(200, 'ControllerTwo.two()'); + }); + + async function getApp() { + await sandbox.copyFile(resolve(__dirname, '../fixtures/application.js')); + await sandbox.copyFile( + resolve(__dirname, '../fixtures/application.js.map'), + ); + await sandbox.copyFile( + resolve(__dirname, '../fixtures/multiple.artifact.js'), + 'controllers/multiple.controller.js', + ); + await sandbox.copyFile( + resolve(__dirname, '../fixtures/multiple.artifact.js.map'), + 'controllers/multiple.artifact.js.map', + ); + + const BooterApp = require(resolve(SANDBOX_PATH, 'application.js')) + .ControllerBooterApp; + + app = new BooterApp(); + } + + async function stopApp() { + try { + await app.stop(); + } catch (err) { + console.log(`Stopping the app threw an error: ${err}`); + } + } + + async function resetSandbox() { + await sandbox.reset(); + } +}); diff --git a/packages/boot/test/fixtures/application.ts b/packages/boot/test/fixtures/application.ts new file mode 100644 index 000000000000..50df018e3543 --- /dev/null +++ b/packages/boot/test/fixtures/application.ts @@ -0,0 +1,19 @@ +// 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 {RestApplication} from '@loopback/rest'; +import {ApplicationConfig} from '@loopback/core'; + +/* tslint:disable:no-unused-variable */ +// Binding and Booter imports are required to infer types for BootMixin! +import {BootMixin, Booter, Binding} from '../../index'; +/* tslint:enable:no-unused-variable */ + +export class ControllerBooterApp extends BootMixin(RestApplication) { + constructor(options?: ApplicationConfig) { + super(options); + this.projectRoot = __dirname; + } +} diff --git a/packages/boot/test/fixtures/empty.artifact.ts b/packages/boot/test/fixtures/empty.artifact.ts new file mode 100644 index 000000000000..b2c8233645ed --- /dev/null +++ b/packages/boot/test/fixtures/empty.artifact.ts @@ -0,0 +1,6 @@ +// 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 + +// THIS FILE IS INTENTIONALLY LEFT EMPTY! diff --git a/packages/boot/test/fixtures/multiple.artifact.ts b/packages/boot/test/fixtures/multiple.artifact.ts new file mode 100644 index 000000000000..2bf32f1f0124 --- /dev/null +++ b/packages/boot/test/fixtures/multiple.artifact.ts @@ -0,0 +1,24 @@ +// 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 {get} from '@loopback/openapi-v2'; + +export class ControllerOne { + @get('/one') + one() { + return 'ControllerOne.one()'; + } +} + +export class ControllerTwo { + @get('/two') + two() { + return 'ControllerTwo.two()'; + } +} + +export function hello() { + return 'hello world'; +} diff --git a/packages/boot/test/integration/controller.booter.integration.ts b/packages/boot/test/integration/controller.booter.integration.ts new file mode 100644 index 000000000000..805a8bd7ca0a --- /dev/null +++ b/packages/boot/test/integration/controller.booter.integration.ts @@ -0,0 +1,58 @@ +// 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 {expect, TestSandbox} from '@loopback/testlab'; +import {resolve} from 'path'; +import {ControllerBooterApp} from '../fixtures/application'; + +describe('controller booter integration tests', () => { + const SANDBOX_PATH = resolve(__dirname, '../../.sandbox'); + const sandbox = new TestSandbox(SANDBOX_PATH); + + // Remnants from Refactor -- need to add these to core + const CONTROLLERS_PREFIX = 'controllers'; + const CONTROLLERS_TAG = 'controller'; + + let app: ControllerBooterApp; + + beforeEach(resetSandbox); + beforeEach(getApp); + + it('boots controllers when app.boot() is called', async () => { + const expectedBindings = [ + `${CONTROLLERS_PREFIX}.ControllerOne`, + `${CONTROLLERS_PREFIX}.ControllerTwo`, + ]; + + await app.boot(); + + const bindings = app.findByTag(CONTROLLERS_TAG).map(b => b.key); + expect(bindings.sort()).to.eql(expectedBindings.sort()); + }); + + async function getApp() { + await sandbox.copyFile(resolve(__dirname, '../fixtures/application.js')); + await sandbox.copyFile( + resolve(__dirname, '../fixtures/application.js.map'), + ); + await sandbox.copyFile( + resolve(__dirname, '../fixtures/multiple.artifact.js'), + 'controllers/multiple.controller.js', + ); + await sandbox.copyFile( + resolve(__dirname, '../fixtures/multiple.artifact.js.map'), + 'controllers/multiple.artifact.js.map', + ); + + const BooterApp = require(resolve(SANDBOX_PATH, 'application.js')) + .ControllerBooterApp; + + app = new BooterApp(); + } + + async function resetSandbox() { + await sandbox.reset(); + } +}); diff --git a/packages/boot/test/unit/boot.component.unit.ts b/packages/boot/test/unit/boot.component.unit.ts new file mode 100644 index 000000000000..bf79b780e35d --- /dev/null +++ b/packages/boot/test/unit/boot.component.unit.ts @@ -0,0 +1,33 @@ +// 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 {expect} from '@loopback/testlab'; +import {Application} from '@loopback/core'; +import {BootBindings, Bootstrapper, ControllerBooter, BootMixin} from '../../'; + +describe('boot.component unit tests', () => { + class BootableApp extends BootMixin(Application) {} + + let app: BootableApp; + + beforeEach(getApp); + + it('binds BootStrapper class', async () => { + const bootstrapper = await app.get(BootBindings.BOOTSTRAPPER_KEY); + expect(bootstrapper).to.be.an.instanceOf(Bootstrapper); + }); + + it('ControllerBooter is bound as a booter by default', async () => { + const ctrlBooter = await app.get( + `${BootBindings.BOOTER_PREFIX}.ControllerBooter`, + ); + expect(ctrlBooter).to.be.an.instanceOf(ControllerBooter); + }); + + function getApp() { + app = new BootableApp(); + app.bind(BootBindings.PROJECT_ROOT).to(__dirname); + } +}); diff --git a/packages/boot/test/unit/boot.mixin.unit.ts b/packages/boot/test/unit/boot.mixin.unit.ts new file mode 100644 index 000000000000..c37d9141b5d0 --- /dev/null +++ b/packages/boot/test/unit/boot.mixin.unit.ts @@ -0,0 +1,85 @@ +// 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 {expect} from '@loopback/testlab'; +import {BootMixin, Booter, BootBindings} from '../../'; +import {Application} from '@loopback/core'; + +describe('BootMxiin unit tests', () => { + let app: AppWithBootMixin; + + beforeEach(getApp); + + it('mixes into the target class', () => { + expect(app.boot).to.be.a.Function(); + expect(app.booters).to.be.a.Function(); + }); + + it('adds BootComponent to target class', () => { + const boundComponent = app.find('components.*').map(b => b.key); + expect(boundComponent).to.containEql('components.BootComponent'); + }); + + it('binds booter from app.booters()', async () => { + app.booters(TestBooter); + const booter = await app.get(`${BootBindings.BOOTER_PREFIX}.TestBooter`); + expect(booter).to.be.an.instanceOf(TestBooter); + }); + + it('binds multiple booter classes from app.booters()', async () => { + app.booters(TestBooter, AnotherTestBooter); + const booter = await app.get(`${BootBindings.BOOTER_PREFIX}.TestBooter`); + expect(booter).to.be.an.instanceOf(TestBooter); + + const anotherBooter = await app.get( + `${BootBindings.BOOTER_PREFIX}.AnotherTestBooter`, + ); + expect(anotherBooter).to.be.an.instanceOf(AnotherTestBooter); + }); + + it('binds user defined component without a booter', async () => { + class EmptyTestComponent {} + + app.component(EmptyTestComponent); + const compInstance = await app.get('components.EmptyTestComponent'); + expect(compInstance).to.be.an.instanceOf(EmptyTestComponent); + }); + + it('binds a user defined component with a booter from .component()', async () => { + class TestComponent { + booters = [TestBooter]; + } + + app.component(TestComponent); + const compInstance = await app.get('components.TestComponent'); + expect(compInstance).to.be.an.instanceOf(TestComponent); + const booterInst = await app.get( + `${BootBindings.BOOTER_PREFIX}.TestBooter`, + ); + expect(booterInst).to.be.an.instanceOf(TestBooter); + }); + + class TestBooter implements Booter { + configured = false; + + async configure() { + this.configured = true; + } + } + + class AnotherTestBooter implements Booter { + discovered = false; + + async discover() { + this.discovered = true; + } + } + + class AppWithBootMixin extends BootMixin(Application) {} + + function getApp() { + app = new AppWithBootMixin(); + } +}); diff --git a/packages/boot/test/unit/booters/base-artifact.booter.unit.ts b/packages/boot/test/unit/booters/base-artifact.booter.unit.ts new file mode 100644 index 000000000000..9ba05542f2b9 --- /dev/null +++ b/packages/boot/test/unit/booters/base-artifact.booter.unit.ts @@ -0,0 +1,75 @@ +// 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 {BaseArtifactBooter} from '../../../index'; +import {expect} from '@loopback/testlab'; +import {resolve} from 'path'; + +describe('base-artifact booter unit tests', () => { + let booterInst: BaseArtifactBooter; + + beforeEach(getBaseBooter); + + describe('configure()', () => { + const options = { + dirs: ['test', 'test2'], + extensions: ['.test.js', 'test2.js'], + nested: false, + }; + + it(`sets 'dirs' / 'extensions' properties as an array if a string`, async () => { + booterInst.options = {dirs: 'test', extensions: '.test.js', nested: true}; + await booterInst.configure(); + expect(booterInst.dirs).to.be.eql(['test']); + expect(booterInst.extensions).to.be.eql(['.test.js']); + }); + + it(`creates and sets 'glob' pattern`, async () => { + booterInst.options = options; + const expected = '/@(test|test2)/*@(.test.js|test2.js)'; + await booterInst.configure(); + expect(booterInst.glob).to.equal(expected); + }); + + it(`creates and sets 'glob' pattern (nested)`, async () => { + booterInst.options = Object.assign({}, options, {nested: true}); + const expected = '/@(test|test2)/**/*@(.test.js|test2.js)'; + await booterInst.configure(); + expect(booterInst.glob).to.equal(expected); + }); + + it(`sets 'glob' pattern to options.glob if present`, async () => { + const expected = '/**/*.glob'; + booterInst.options = Object.assign({}, options, {glob: expected}); + await booterInst.configure(); + expect(booterInst.glob).to.equal(expected); + }); + }); + + describe('discover()', () => { + it(`sets 'discovered' property`, async () => { + booterInst.projectRoot = __dirname; + // Fake glob pattern so we get an empty array + booterInst.glob = '/abc.xyz'; + await booterInst.discover(); + expect(booterInst.discovered).to.eql([]); + }); + }); + + describe('load()', () => { + it(`sets 'classes' property to Classes from a file`, async () => { + booterInst.discovered = [ + resolve(__dirname, '../../fixtures/multiple.artifact.js'), + ]; + const NUM_CLASSES = 2; // Above file has 1 class in it. + await booterInst.load(); + expect(booterInst.classes).to.have.a.lengthOf(NUM_CLASSES); + }); + }); + + async function getBaseBooter() { + booterInst = new BaseArtifactBooter(); + } +}); diff --git a/packages/boot/test/unit/booters/booter-utils.unit.ts b/packages/boot/test/unit/booters/booter-utils.unit.ts new file mode 100644 index 000000000000..f81d42a8527d --- /dev/null +++ b/packages/boot/test/unit/booters/booter-utils.unit.ts @@ -0,0 +1,111 @@ +// 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 {discoverFiles, isClass, loadClassesFromFiles} from '../../../index'; +import {resolve} from 'path'; +import {TestSandbox, expect} from '@loopback/testlab'; + +describe('booter-utils unit tests', () => { + const SANDBOX_PATH = resolve(__dirname, '../../../.sandbox'); + const sandbox = new TestSandbox(SANDBOX_PATH); + + beforeEach(resetSandbox); + + describe('discoverFiles()', () => { + beforeEach(setupSandbox); + + it('discovers files matching a nested glob pattern', async () => { + const expected = [ + resolve(SANDBOX_PATH, 'empty.artifact.js'), + resolve(SANDBOX_PATH, 'nested/multiple.artifact.js'), + ]; + const glob = '/**/*.artifact.js'; + + const files = await discoverFiles(glob, SANDBOX_PATH); + expect(files.sort()).to.eql(expected.sort()); + }); + + it('discovers files matching a non-nested glob pattern', async () => { + const expected = [resolve(SANDBOX_PATH, 'empty.artifact.js')]; + const glob = '/*.artifact.js'; + + const files = await discoverFiles(glob, SANDBOX_PATH); + expect(files).to.eql(expected); + }); + + it('discovers no files for a unknown glob', async () => { + const glob = '/xyz'; + const files = await discoverFiles(glob, SANDBOX_PATH); + expect(files).to.be.eql([]); + }); + + async function setupSandbox() { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/empty.artifact.js'), + ); + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/empty.artifact.js.map'), + ); + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/multiple.artifact.js'), + 'nested/multiple.artifact.js', + ); + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/multiple.artifact.js.map'), + 'nested/multiple.artifact.js.map', + ); + } + }); + + describe('isClass()', () => { + it('returns true given a class', () => { + expect(isClass(class Thing {})).to.be.True(); + }); + }); + + describe('loadClassesFromFiles()', () => { + it('returns an array of classes from a file', async () => { + // Copying a test file to sandbox that contains a function and 2 classes + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/multiple.artifact.js'), + ); + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/multiple.artifact.js.map'), + ); + const files = [resolve(SANDBOX_PATH, 'multiple.artifact.js')]; + const NUM_CLASSES = 2; // Number of classes in above file + + const classes = await loadClassesFromFiles(files); + expect(classes).to.have.lengthOf(NUM_CLASSES); + expect(classes[0]).to.be.a.Function(); + expect(classes[1]).to.be.a.Function(); + }); + + it('returns an empty array given an empty file', async () => { + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/empty.artifact.js'), + ); + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/empty.artifact.js.map'), + ); + const files = [resolve(SANDBOX_PATH, 'empty.artifact.js')]; + + const classes = await loadClassesFromFiles(files); + expect(classes).to.be.an.Array(); + expect(classes).to.be.empty(); + }); + + it('throws an error given a non-existent file', async () => { + const files = [resolve(SANDBOX_PATH, 'fake.artifact.js')]; + expect(loadClassesFromFiles(files)).to.eventually.throw( + /Error: Cannot find module/, + ); + }); + }); + + async function resetSandbox() { + await sandbox.reset(); + } +}); diff --git a/packages/boot/test/unit/booters/controller.booter.unit.ts b/packages/boot/test/unit/booters/controller.booter.unit.ts new file mode 100644 index 000000000000..30558f8b0e5d --- /dev/null +++ b/packages/boot/test/unit/booters/controller.booter.unit.ts @@ -0,0 +1,69 @@ +// 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 {expect, TestSandbox} from '@loopback/testlab'; +import {Application} from '@loopback/core'; +import {ControllerBooter, ControllerDefaults} from '../../../index'; +import {resolve} from 'path'; + +describe('controller booter unit tests', () => { + const SANDBOX_PATH = resolve(__dirname, '../../../.sandbox'); + const sandbox = new TestSandbox(SANDBOX_PATH); + + const CONTROLLERS_PREFIX = 'controllers'; + const CONTROLLERS_TAG = 'controller'; + + let app: Application; + + beforeEach(resetSandbox); + beforeEach(getApp); + + it(`constructor uses ControllerDefaults for 'options' if none are given`, () => { + const booterInst = new ControllerBooter(app, SANDBOX_PATH); + expect(booterInst.options).to.deepEqual(ControllerDefaults); + }); + + it('overrides defaults with provided options and uses defaults for rest', () => { + const options = { + dirs: ['test', 'test2'], + extensions: ['.ext1', 'ext2'], + }; + const expected = Object.assign({}, options, { + nested: ControllerDefaults.nested, + }); + + const booterInst = new ControllerBooter(app, SANDBOX_PATH, options); + expect(booterInst.options).to.deepEqual(expected); + }); + + it('binds controllers during load phase', async () => { + const expected = [ + `${CONTROLLERS_PREFIX}.ControllerOne`, + `${CONTROLLERS_PREFIX}.ControllerTwo`, + ]; + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/multiple.artifact.js'), + ); + const booterInst = new ControllerBooter(app, SANDBOX_PATH); + const NUM_CLASSES = 2; // 2 classes in above file. + + // Load uses discovered property + booterInst.discovered = [resolve(SANDBOX_PATH, 'multiple.artifact.js')]; + await booterInst.load(); + + const ctrls = app.findByTag(CONTROLLERS_TAG); + const keys = ctrls.map(binding => binding.key); + expect(keys).to.have.lengthOf(NUM_CLASSES); + expect(keys.sort()).to.eql(expected.sort()); + }); + + function getApp() { + app = new Application(); + } + + async function resetSandbox() { + await sandbox.reset(); + } +}); diff --git a/packages/boot/test/unit/bootstrapper.unit.ts b/packages/boot/test/unit/bootstrapper.unit.ts new file mode 100644 index 000000000000..9d6e23c41073 --- /dev/null +++ b/packages/boot/test/unit/bootstrapper.unit.ts @@ -0,0 +1,100 @@ +// 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 {expect} from '@loopback/testlab'; +import {Application} from '@loopback/core'; +import {Bootstrapper, Booter, BootBindings, BootMixin} from '../../index'; + +describe('boot-strapper unit tests', () => { + class BootApp extends BootMixin(Application) {} + + let app: BootApp; + let bootstrapper: Bootstrapper; + const booterKey = `${BootBindings.BOOTER_PREFIX}.TestBooter`; + const booterKey2 = `${BootBindings.BOOTER_PREFIX}.TestBooter2`; + + beforeEach(getApplication); + beforeEach(getBootStrapper); + + it('finds and runs registered booters', async () => { + const ctx = await bootstrapper.boot(); + const booterInst = await ctx.get(booterKey); + expect(booterInst.configureCalled).to.be.True(); + expect(booterInst.loadCalled).to.be.True(); + }); + + it('binds booters passed in BootExecutionOptions', async () => { + const ctx = await bootstrapper.boot({booters: [TestBooter2]}); + const booterInst2 = await ctx.get(booterKey2); + expect(booterInst2).to.be.instanceof(TestBooter2); + expect(booterInst2.configureCalled).to.be.True(); + }); + + it('no booters run when BootOptions.filter.booters is []', async () => { + const ctx = await bootstrapper.boot({filter: {booters: []}}); + const booterInst = await ctx.get(booterKey); + expect(booterInst.configureCalled).to.be.False(); + expect(booterInst.loadCalled).to.be.False(); + }); + + it('only runs booters passed in via BootOptions.filter.booters', async () => { + const ctx = await bootstrapper.boot({ + booters: [TestBooter2], + filter: {booters: ['TestBooter2']}, + }); + const booterInst = await ctx.get(booterKey); + const booterInst2 = await ctx.get(booterKey2); + expect(booterInst.configureCalled).to.be.False(); + expect(booterInst.loadCalled).to.be.False(); + expect(booterInst2.configureCalled).to.be.True(); + }); + + it('only runs phases passed in via BootOptions.filter.phases', async () => { + const ctx = await bootstrapper.boot({filter: {phases: ['configure']}}); + const booterInst = await ctx.get(booterKey); + expect(booterInst.configureCalled).to.be.True(); + expect(booterInst.loadCalled).to.be.False(); + }); + + /** + * Sets 'app' as a new instance of Application. Registers TestBooter as a booter. + */ + function getApplication() { + app = new BootApp(); + app.booters(TestBooter); + } + + /** + * Sets 'bootstrapper' as a new instance of a Bootstrapper + */ + function getBootStrapper() { + bootstrapper = new Bootstrapper(app, __dirname); + } + + /** + * A TestBooter for testing purposes. Implements configure and load. + */ + class TestBooter implements Booter { + configureCalled = false; + loadCalled = false; + async configure() { + this.configureCalled = true; + } + + async load() { + this.loadCalled = true; + } + } + + /** + * A TestBooter for testing purposes. Implements configure. + */ + class TestBooter2 implements Booter { + configureCalled = false; + async configure() { + this.configureCalled = true; + } + } +}); diff --git a/packages/boot/tsconfig.build.json b/packages/boot/tsconfig.build.json new file mode 100644 index 000000000000..3ffcd508d23e --- /dev/null +++ b/packages/boot/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["index.ts", "src", "test"] +} diff --git a/packages/cli/generators/app/index.js b/packages/cli/generators/app/index.js index 5c97ff998a72..84a419d1d67b 100644 --- a/packages/cli/generators/app/index.js +++ b/packages/cli/generators/app/index.js @@ -15,10 +15,12 @@ module.exports = class extends ProjectGenerator { _setupGenerator() { this.projectType = 'application'; + this.option('applicationName', { type: String, description: 'Application name', }); + return super._setupGenerator(); } diff --git a/packages/cli/generators/app/templates/src/application.ts.ejs b/packages/cli/generators/app/templates/src/application.ts.ejs index aff1d0823908..2ec25ec71d00 100644 --- a/packages/cli/generators/app/templates/src/application.ts.ejs +++ b/packages/cli/generators/app/templates/src/application.ts.ejs @@ -1,13 +1,25 @@ -import {ApplicationConfig} from '@loopback/core'; +import {Application, ApplicationConfig, BootOptions} from '@loopback/core'; import {RestApplication, RestServer} from '@loopback/rest'; -import {PingController} from './controllers/ping.controller'; import {MySequence} from './sequence'; -export class <%= project.applicationName %> extends RestApplication { +/* tslint:disable:no-unused-variable */ +// Binding and Booter imports are required to infer types for BootMixin! +import {BootMixin, Booter, Binding} from '@loopback/boot'; +/* tslint:enable:no-unused-variable */ + +export class <%= project.applicationName %> extends BootMixin(RestApplication) { constructor(options?: ApplicationConfig) { super(options); - this.sequence(MySequence); - this.setupControllers(); + this.projectRoot = __dirname; + // Customize @loopback/boot Booter Conventions here + this.bootOptions = { + controllers: { + // Customize ControllerBooter Conventions here + dirs: ['controllers'], + extensions: ['.controller.js'], + nested: true, + } + } } async start() { @@ -18,8 +30,4 @@ export class <%= project.applicationName %> extends RestApplication { console.log(`Server is running at http://127.0.0.1:${port}`); console.log(`Try http://127.0.0.1:${port}/ping`); } - - setupControllers() { - this.controller(PingController); - } } diff --git a/packages/cli/generators/app/templates/src/index.ts.ejs b/packages/cli/generators/app/templates/src/index.ts.ejs index beaf7c162e2e..b0cb07b8e74f 100644 --- a/packages/cli/generators/app/templates/src/index.ts.ejs +++ b/packages/cli/generators/app/templates/src/index.ts.ejs @@ -7,6 +7,7 @@ export async function main(options?: ApplicationConfig) { const app = new <%= project.applicationName %>(options); try { + await app.boot(); await app.start(); } catch (err) { console.error(`Unable to start application: ${err}`); diff --git a/packages/cli/generators/project/templates/package.json.ejs b/packages/cli/generators/project/templates/package.json.ejs index 475ac79042e3..0bd0043a60fb 100644 --- a/packages/cli/generators/project/templates/package.json.ejs +++ b/packages/cli/generators/project/templates/package.json.ejs @@ -57,6 +57,7 @@ "dist" ], "dependencies": { + "@loopback/boot": ">=4.0.0-alpha.1", "@loopback/context": ">=4.0.0-alpha.18", <% if (project.projectType === 'application') { -%> "@loopback/core": ">=4.0.0-alpha.20", diff --git a/packages/cli/generators/project/templates/package.plain.json.ejs b/packages/cli/generators/project/templates/package.plain.json.ejs index 083522a368af..cdbf8cb8b3cd 100644 --- a/packages/cli/generators/project/templates/package.plain.json.ejs +++ b/packages/cli/generators/project/templates/package.plain.json.ejs @@ -57,6 +57,7 @@ "dist" ], "dependencies": { + "@loopback/boot": ">=4.0.0-alpha.1", "@loopback/context": ">=4.0.0-alpha.18", <% if (project.projectType === 'application') { -%> "@loopback/core": ">=4.0.0-alpha.20", diff --git a/packages/cli/lib/project-generator.js b/packages/cli/lib/project-generator.js index cd27e05ce4a0..bbb826de4e4c 100644 --- a/packages/cli/lib/project-generator.js +++ b/packages/cli/lib/project-generator.js @@ -12,6 +12,10 @@ module.exports = class ProjectGenerator extends BaseGenerator { // Note: arguments and options should be defined in the constructor. constructor(args, opts) { super(args, opts); + // The default list of build options available for a project + // This list gets shown to users to let them select the appropriate + // build settings for their project. + this.buildOptions = ['tslint', 'prettier', 'mocha', 'loopbackBuild']; } _setupGenerator() { @@ -80,15 +84,10 @@ module.exports = class ProjectGenerator extends BaseGenerator { setOptions() { this.projectInfo = {projectType: this.projectType}; - [ - 'name', - 'description', - 'outdir', - 'tslint', - 'prettier', - 'mocha', - 'loopbackBuild', - ].forEach(n => { + this.projectOptions = ['name', 'description', 'outdir'].concat( + this.buildOptions + ); + this.projectOptions.forEach(n => { if (this.options[n]) { this.projectInfo[n] = this.options[n]; } @@ -144,7 +143,7 @@ module.exports = class ProjectGenerator extends BaseGenerator { promptOptions() { if (this.shouldExit()) return false; const choices = []; - ['tslint', 'prettier', 'mocha', 'loopbackBuild'].forEach(f => { + this.buildOptions.forEach(f => { if (!this.options[f]) { choices.push({ name: 'Enable ' + f, diff --git a/packages/cli/test/app.js b/packages/cli/test/app.js index 920ed0421326..c7500db7c599 100644 --- a/packages/cli/test/app.js +++ b/packages/cli/test/app.js @@ -27,10 +27,11 @@ describe('app-generator specfic files', () => { assert.file('src/application.ts'); assert.fileContent( 'src/application.ts', - /class MyAppApplication extends RestApplication/ + /class MyAppApplication extends BootMixin\(RestApplication/ ); assert.fileContent('src/application.ts', /constructor\(/); assert.fileContent('src/application.ts', /async start\(/); + assert.fileContent('src/application.ts', /this.projectRoot = __dirname/); assert.file('src/index.ts'); assert.fileContent('src/index.ts', /new MyAppApplication/); diff --git a/packages/example-getting-started/package.json b/packages/example-getting-started/package.json index d8321f150df9..3220e66714f0 100644 --- a/packages/example-getting-started/package.json +++ b/packages/example-getting-started/package.json @@ -25,6 +25,7 @@ }, "license": "MIT", "dependencies": { + "@loopback/boot": "^4.0.0-alpha.1", "@loopback/context": "^4.0.0-alpha.32", "@loopback/core": "^4.0.0-alpha.34", "@loopback/openapi-spec": "^4.0.0-alpha.25", diff --git a/packages/example-getting-started/src/application.ts b/packages/example-getting-started/src/application.ts index eadb5bae3f15..d77e18c8d9b8 100644 --- a/packages/example-getting-started/src/application.ts +++ b/packages/example-getting-started/src/application.ts @@ -5,12 +5,14 @@ import {ApplicationConfig} from '@loopback/core'; import {RestApplication} from '@loopback/rest'; -import {TodoController} from './controllers'; import {TodoRepository} from './repositories'; import {db} from './datasources/db.datasource'; + /* tslint:disable:no-unused-variable */ +// Do not remove! // Class and Repository imports required to infer types in consuming code! -// Do not remove them! +// Binding and Booter imports are required to infer types for BootMixin! +import {BootMixin, Booter, Binding} from '@loopback/boot'; import { Class, Repository, @@ -18,11 +20,14 @@ import { RepositoryMixin, } from '@loopback/repository'; /* tslint:enable:no-unused-variable */ -export class TodoApplication extends RepositoryMixin(RestApplication) { + +export class TodoApplication extends BootMixin( + RepositoryMixin(RestApplication), +) { constructor(options?: ApplicationConfig) { super(options); + this.projectRoot = __dirname; this.setupRepositories(); - this.setupControllers(); } // Helper functions (just to keep things organized) @@ -39,9 +44,4 @@ export class TodoApplication extends RepositoryMixin(RestApplication) { this.bind('datasource').to(datasource); this.repository(TodoRepository); } - - setupControllers() { - // TODO(bajtos) Automate controller registration via @loopback/boot - this.controller(TodoController); - } } diff --git a/packages/example-getting-started/src/index.ts b/packages/example-getting-started/src/index.ts index 9d23ea75a801..6e0aeca118ef 100644 --- a/packages/example-getting-started/src/index.ts +++ b/packages/example-getting-started/src/index.ts @@ -9,6 +9,7 @@ import {RestServer} from '@loopback/rest'; export async function main() { const app = new TodoApplication(); try { + await app.boot(); await app.start(); } catch (err) { console.error(`Unable to start application: ${err}`); diff --git a/packages/example-getting-started/test/acceptance/application.test.ts b/packages/example-getting-started/test/acceptance/application.test.ts index 9a9d36012dbc..8df0dc04726d 100644 --- a/packages/example-getting-started/test/acceptance/application.test.ts +++ b/packages/example-getting-started/test/acceptance/application.test.ts @@ -15,6 +15,7 @@ describe('Application', () => { before(givenARestServer); before(givenTodoRepository); before(async () => { + await app.boot(); await app.start(); }); before(() => {