diff --git a/packages/boot/.gitignore b/packages/boot/.gitignore new file mode 100644 index 000000000000..90a8d96cc3ff --- /dev/null +++ b/packages/boot/.gitignore @@ -0,0 +1,3 @@ +*.tgz +dist* +package 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/.travis.yml b/packages/boot/.travis.yml new file mode 100644 index 000000000000..1b88f4998103 --- /dev/null +++ b/packages/boot/.travis.yml @@ -0,0 +1,5 @@ +sudo: false +language: node_js +node_js: + - "6" + - "8" \ No newline at end of file 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..7ad169286529 --- /dev/null +++ b/packages/boot/README.md @@ -0,0 +1,84 @@ +# @loopback/boot + +A collection of 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 it's task. + +An example task of a Booter may be to discover and bind all artifacts of a +given type. + +## Installation + +```shell +$ npm i @loopback/boot +``` + +## Basic Use + +```ts +import {ControllerBooter} from '@loopback/boot'; +app.booter(ControllerBooter); // register booter +await app.boot(); // Booter gets run by the Application +``` + +## Available Booters + +### ControllerBooter + +#### Description +Discovers and binds Controller Classes using `app.controller()`. + +#### Options +The Options for this can be passed via `ApplicationConfig` in the Application +constructor or via `BootOptions` when calling `app.boot(options:BootOptions)`. + +The options for this are passed in a `controllers` object on `boot`. + +Available Options on the `boot.controllers` 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| + +#### Examples +**Via Application Config** +```ts +new Application({ + boot: { + projectRoot: '', + controllers: {...} + } +}); +``` + +**Via BootOptions** +```ts +app.boot({ + boot: { + projectRoot: '', + controllers: {...} + } +}); +``` + +## 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..a436c72c0726 --- /dev/null +++ b/packages/boot/docs.json @@ -0,0 +1,8 @@ +{ + "content": ["index.ts", "src/index.ts", "src/controller.booter.ts"], + "codeSectionDepth": 4, + "assets": { + "/": "/docs", + "/docs": "/docs" + } +} diff --git a/packages/boot/index.d.ts b/packages/boot/index.d.ts new file mode 100644 index 000000000000..018f07a6c91b --- /dev/null +++ b/packages/boot/index.d.ts @@ -0,0 +1,6 @@ +// 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 + +export * from './dist/src'; diff --git a/packages/boot/index.js b/packages/boot/index.js new file mode 100644 index 000000000000..82e928f4944d --- /dev/null +++ b/packages/boot/index.js @@ -0,0 +1,8 @@ +// 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 + +const nodeMajorVersion = +process.versions.node.split('.')[0]; +module.exports = + nodeMajorVersion >= 7 ? require('./dist/src') : require('./dist6/src'); diff --git a/packages/boot/index.ts b/packages/boot/index.ts new file mode 100644 index 000000000000..a6d685a85bd6 --- /dev/null +++ b/packages/boot/index.ts @@ -0,0 +1,6 @@ +// 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 + +export * from './src'; diff --git a/packages/boot/package.json b/packages/boot/package.json new file mode 100644 index 000000000000..5cea9d91f57c --- /dev/null +++ b/packages/boot/package.json @@ -0,0 +1,50 @@ +{ + "name": "@loopback/boot", + "version": "4.0.0-alpha.1", + "description": "A collection of Booters for LoopBack 4 Applications", + "engines": { + "node": ">=6" + }, + "scripts": { + "acceptance": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/acceptance/**/*.js'", + "build": "npm run build:dist && npm run build:dist6", + "build:current": "lb-tsc", + "build:dist": "lb-tsc es2017", + "build:dist6": "lb-tsc es2015", + "build:apidocs": "lb-apidocs", + "clean": "lb-clean loopback-core*.tgz dist dist6 package api-docs", + "prepare": "npm run build && npm run build:apidocs", + "pretest": "npm run build:current", + "integration": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/integration/**/*.js'", + "test": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/unit/**/*.js' 'DIST/test/integration/**/*.js' 'DIST/test/acceptance/**/*.js'", + "unit": "lb-dist mocha --opts ../../test/mocha.opts 'DIST/test/unit/**/*.js'", + "verify": "npm pack && tar xf loopback-core*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "license": "MIT", + "dependencies": { + "@loopback/context": "^4.0.0-alpha.24", + "@loopback/core": "^4.0.0-alpha.26", + "@types/glob": "^5.0.34", + "glob": "^7.1.2" + }, + "devDependencies": { + "@loopback/build": "^4.0.0-alpha.7", + "@loopback/openapi-v2": "^4.0.0-alpha.3", + "@loopback/rest": "^4.0.0-alpha.18", + "@loopback/testlab": "^4.0.0-alpha.17" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "dist6/src", + "api-docs", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} diff --git a/packages/boot/src/controller.booter.ts b/packages/boot/src/controller.booter.ts new file mode 100644 index 000000000000..9ad573350fb5 --- /dev/null +++ b/packages/boot/src/controller.booter.ts @@ -0,0 +1,129 @@ +// 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 {CoreBindings, Application, Booter, BootOptions} from '@loopback/core'; +import {inject} from '@loopback/context'; +import * as glob from 'glob'; + +/** + * ControllerBooter is a class that implements the Booter inferface. The purpose + * of this booter is to allow for convention based booting of `Controller` + * artifacts for LoopBack 4 Applications. + * + * It supports the following boot phases: config, discover, boot + */ +export class ControllerBooter implements Booter { + options: ControllerOptions; + projectRoot: string; + + /** + * + * @param app Application instance + * @param bootConfig Config options for boot + */ + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) public app: Application, + @inject(CoreBindings.BOOT_CONFIG) public bootConfig: BootOptions, + ) { + if (!bootConfig.controllers) bootConfig.controllers = {}; + this.options = bootConfig.controllers; + this.projectRoot = bootConfig.projectRoot; + } + + /** + * This phase is responsible for configuring this Booter. It converts + * options and assigns default values for missing values so ther phases + * don't have to perform checks / conversions. + */ + async config() { + this.options = Object.assign({}, ControllerDefaults, this.options); + if (typeof this.options.dirs === 'string') { + this.options.dirs = [this.options.dirs]; + } + if (typeof this.options.extensions === 'string') { + this.options.extensions = [this.options.extensions]; + } + } + + /** + * This phase is responsible for discovering artifact files based on the + * given parameters. Sets options.discovered to an array of discovered + * artifact files. + */ + async discover() { + const dirs = this.options.dirs; + const exts = this.options.extensions; + + // glob pattern + let pattern = `/@(${dirs.join('|')})/`; + pattern += this.options.nested ? '**/*' : '*'; + pattern += `@(${exts.join('|')})`; + + this.options.discovered = glob.sync(pattern, {root: this.projectRoot}); + } + + /** + * This phase is responsible for reading the discovered files, checking the + * Classes exported and binding them to the Application instance for use. + * + * It will skip any files it isn't able to load and throw an error containing + * a list of skipped files. Other files that were read will still be bound + * to the Application instance. + */ + async boot() { + const files: string[] = this.options.discovered; + let errFiles: string[] = []; + files.forEach(file => { + try { + const ctrl = require(file); + const classes: string[] = Object.keys(ctrl); + classes.forEach(cls => { + this.app.controller(ctrl[cls]); + }); + } catch (err) { + errFiles.push(file.substring(this.projectRoot.length)); + } + }); + + // Only throw 1 error. Allows user to catch it and ignore if needed + if (errFiles.length > 0) { + throw new Error( + `ControllerBooter failed to load the following files: ${JSON.stringify( + errFiles, + )}`, + ); + } + } +} + +/** + * Type definition for ControllerOptions. 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 discovered An array of discovered files. This is set by the + * discover phase. + */ +export type ControllerOptions = { + dirs: string | string[]; + extensions: string | string[]; + discovered: string[]; + nested: boolean; +}; + +/** + * Default values for ControllerOptions described above is no value is provided. + */ +export const ControllerDefaults: ControllerOptions = { + dirs: ['controllers'], + extensions: ['.controller.js'], + nested: true, + discovered: [], +}; diff --git a/packages/boot/src/index.ts b/packages/boot/src/index.ts new file mode 100644 index 000000000000..85b8efe5c790 --- /dev/null +++ b/packages/boot/src/index.ts @@ -0,0 +1,6 @@ +// 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 + +export * from './controller.booter'; diff --git a/packages/boot/test/acceptance/controller.booter.app.acceptance.ts b/packages/boot/test/acceptance/controller.booter.app.acceptance.ts new file mode 100644 index 000000000000..87e0bab4e752 --- /dev/null +++ b/packages/boot/test/acceptance/controller.booter.app.acceptance.ts @@ -0,0 +1,68 @@ +// 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} from '@loopback/testlab'; +import {ControllerBooterApp} from '../fixtures/booterApp/application'; +import {ApplicationConfig} from '@loopback/core'; +import {RestServer} from '@loopback/rest'; +import {ControllerBooter} from '../../index'; +// @ts-ignore +import {getCompilationTarget} from '@loopback/build/bin/utils'; + +describe('controller booter acceptance tests', () => { + let app: ControllerBooterApp; + let appConfig: ApplicationConfig; + let projectRoot: string; + + beforeEach(getProjectRoot); + beforeEach(getAppConfig); + beforeEach(getApp); + + afterEach(stopApp); + + it('binds controllers using ControllerDefaults and REST endpoints work', async () => { + app.booter(ControllerBooter); + 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('/nested').expect(200, 'NesterController.nester()'); + await client.get('/').expect(200, 'HelloController.hello()'); + await client.get('/one').expect(200, 'ControllerOne.one()'); + await client.get('/two').expect(200, 'ControllerOne.two()'); + }); + + function getProjectRoot() { + let dist = 'dist'; + if (getCompilationTarget() === 'es2015') dist = 'dist6'; + projectRoot = + process.cwd().indexOf('packages') > -1 + ? `${dist}/test/fixtures/booterApp` + : `packages/boot/${dist}/test/fixtures/booterApp`; + } + + function getAppConfig() { + appConfig = { + boot: { + projectRoot: projectRoot, + }, + }; + } + + function getApp() { + app = new ControllerBooterApp(appConfig); + } + + async function stopApp() { + try { + await app.stop(); + } catch (err) { + console.log(`Stopping the app threw an error: ${err}`); + } + } +}); diff --git a/packages/boot/test/fixtures/booterApp/application.ts b/packages/boot/test/fixtures/booterApp/application.ts new file mode 100644 index 000000000000..989eef4ffcdc --- /dev/null +++ b/packages/boot/test/fixtures/booterApp/application.ts @@ -0,0 +1,23 @@ +// 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 {RestApplication, RestServer} from '@loopback/rest'; +import {ApplicationConfig} from '@loopback/core'; +import {ControllerBooter} from '../../../src'; + +export class ControllerBooterApp extends RestApplication { + constructor(options?: ApplicationConfig) { + super(options); + this.booter(ControllerBooter); + } + + async start() { + const server = await this.getServer(RestServer); + const port = await server.get('rest.port'); + console.log(`Server is running at http://127.0.0.1:${port}`); + console.log(`Try http://127.0.0.1:${port}/ping`); + return await super.start(); + } +} diff --git a/packages/boot/test/fixtures/booterApp/controllers/another.ext.ctrl.ts b/packages/boot/test/fixtures/booterApp/controllers/another.ext.ctrl.ts new file mode 100644 index 000000000000..72ecd3f2b9ca --- /dev/null +++ b/packages/boot/test/fixtures/booterApp/controllers/another.ext.ctrl.ts @@ -0,0 +1,13 @@ +// 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 {get} from '@loopback/openapi-v2'; + +export class AnotherController { + @get('/another') + another() { + return 'AnotherController.another()'; + } +} diff --git a/packages/boot/test/fixtures/booterApp/controllers/empty.controller.ts b/packages/boot/test/fixtures/booterApp/controllers/empty.controller.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/boot/test/fixtures/booterApp/controllers/hello.controller.ts b/packages/boot/test/fixtures/booterApp/controllers/hello.controller.ts new file mode 100644 index 000000000000..5401773877f8 --- /dev/null +++ b/packages/boot/test/fixtures/booterApp/controllers/hello.controller.ts @@ -0,0 +1,13 @@ +// 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 {get} from '@loopback/openapi-v2'; + +export class HelloController { + @get('/') + hello() { + return 'HelloController.hello()'; + } +} diff --git a/packages/boot/test/fixtures/booterApp/controllers/nested/nested.controller.ts b/packages/boot/test/fixtures/booterApp/controllers/nested/nested.controller.ts new file mode 100644 index 000000000000..3837f5bc135e --- /dev/null +++ b/packages/boot/test/fixtures/booterApp/controllers/nested/nested.controller.ts @@ -0,0 +1,13 @@ +// 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 {get} from '@loopback/openapi-v2'; + +export class NestedController { + @get('/nested') + nested() { + return 'NesterController.nester()'; + } +} diff --git a/packages/boot/test/fixtures/booterApp/controllers/two.controller.ts b/packages/boot/test/fixtures/booterApp/controllers/two.controller.ts new file mode 100644 index 000000000000..c3f4b6d9729f --- /dev/null +++ b/packages/boot/test/fixtures/booterApp/controllers/two.controller.ts @@ -0,0 +1,20 @@ +// 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 {get} from '@loopback/openapi-v2'; + +export class ControllerOne { + @get('/one') + one() { + return 'ControllerOne.one()'; + } +} + +export class ControllerTwo { + @get('/two') + two() { + return 'ControllerOne.two()'; + } +} diff --git a/packages/boot/test/fixtures/booterApp/ctrl/multiple.folder.controller.ts b/packages/boot/test/fixtures/booterApp/ctrl/multiple.folder.controller.ts new file mode 100644 index 000000000000..6323eb8d43b9 --- /dev/null +++ b/packages/boot/test/fixtures/booterApp/ctrl/multiple.folder.controller.ts @@ -0,0 +1,13 @@ +// 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 {get} from '@loopback/openapi-v2'; + +export class MultipleFolderController { + @get('/multiple') + multiple() { + return 'MultipleFolderController.multiple()'; + } +} diff --git a/packages/boot/test/integration/controller.booter.app.integration.ts b/packages/boot/test/integration/controller.booter.app.integration.ts new file mode 100644 index 000000000000..7bffa8a524df --- /dev/null +++ b/packages/boot/test/integration/controller.booter.app.integration.ts @@ -0,0 +1,94 @@ +// 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 {expect} from '@loopback/testlab'; +import {CoreBindings, ApplicationConfig} from '@loopback/core'; +import {ControllerBooterApp} from '../fixtures/booterApp/application'; +import {ControllerBooter, ControllerDefaults} from '../../index'; +import {resolve} from 'path'; +// @ts-ignore +import {getCompilationTarget} from '@loopback/build/bin/utils'; + +describe('controller booter intengration tests', () => { + let app: ControllerBooterApp; + let projectRoot: string; + let appConfig: ApplicationConfig; + + beforeEach(getProjectRoot); + beforeEach(getAppConfig); + beforeEach(getApp); + + it('all functions run and work via app.boot() once bound', async () => { + const resolvedProjectRoot = resolve(projectRoot); + const expectedDiscoveredFiles = [ + `${resolve( + resolvedProjectRoot, + 'controllers/nested/nested.controller.js', + )}`, + `${resolve(resolvedProjectRoot, 'controllers/another.ext.ctrl.js')}`, + `${resolve(resolvedProjectRoot, 'controllers/empty.controller.js')}`, + `${resolve(resolvedProjectRoot, 'controllers/hello.controller.js')}`, + `${resolve(resolvedProjectRoot, 'controllers/two.controller.js')}`, + `${resolve(resolvedProjectRoot, 'ctrl/multiple.folder.controller.js')}`, + ]; + const expectedBindings = [ + `${CoreBindings.CONTROLLERS}.NestedController`, + `${CoreBindings.CONTROLLERS}.AnotherController`, + `${CoreBindings.CONTROLLERS}.HelloController`, + `${CoreBindings.CONTROLLERS}.ControllerOne`, + `${CoreBindings.CONTROLLERS}.ControllerTwo`, + `${CoreBindings.CONTROLLERS}.MultipleFolderController`, + ]; + + app.booter(ControllerBooter); + await app.boot(); + const booter = await app.get(`${CoreBindings.BOOTERS}.ControllerBooter`); + + // Check Config Phase Ran as expected + expect(booter.options.dirs.sort()).to.eql( + appConfig.boot.controllers.dirs.sort(), + ); + expect(booter.options.extensions.sort()).to.eql( + appConfig.boot.controllers.extensions.sort(), + ); + expect(booter.options.nested).to.equal(ControllerDefaults.nested); + + // Check Discovered Phase Ran as expected + expect(booter.options.discovered.sort()).to.eql( + expectedDiscoveredFiles.sort(), + ); + + // Check Boot Phase Ran as expected + const ctrlBindings = app + .findByTag(CoreBindings.CONTROLLERS_TAG) + .map(b => b.key); + expect(ctrlBindings.sort()).to.eql(expectedBindings.sort()); + }); + + function getApp() { + app = new ControllerBooterApp(appConfig); + } + + function getProjectRoot() { + let dist = 'dist'; + if (getCompilationTarget() === 'es2015') dist = 'dist6'; + projectRoot = + process.cwd().indexOf('packages') > -1 + ? `${dist}/test/fixtures/booterApp` + : `packages/boot/${dist}/test/fixtures/booterApp`; + } + + function getAppConfig() { + appConfig = { + boot: { + projectRoot: projectRoot, + controllers: { + dirs: ['controllers', 'ctrl'], + extensions: ['.ctrl.js', '.controller.js'], + }, + }, + }; + } +}); diff --git a/packages/boot/test/unit/controller.booter.unit.ts b/packages/boot/test/unit/controller.booter.unit.ts new file mode 100644 index 000000000000..1d5af9ec83be --- /dev/null +++ b/packages/boot/test/unit/controller.booter.unit.ts @@ -0,0 +1,400 @@ +// 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 {expect} from '@loopback/testlab'; +import {Application, BootOptions, CoreBindings} from '@loopback/core'; +import {ControllerBooter, ControllerDefaults} from '../../index'; +import {resolve} from 'path'; +// @ts-ignore +import {getCompilationTarget} from '@loopback/build/bin/utils'; + +describe('controller booter unit tests', () => { + let app: Application; + let projectRoot: string; + + beforeEach(getApp); + beforeEach(getProjectRoot); + + describe('ControllerBooter.config()', () => { + it('uses default values for controllerOptions when not present', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + }; + + const booter = new ControllerBooter(app, bootOptions); + await booter.config(); + validateConfig( + booter, + ControllerDefaults.dirs, + ControllerDefaults.extensions, + ControllerDefaults.nested, + ); + }); + + it('converts string options in controllerOptions to Array and overrides defaults', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: { + dirs: 'ctrl', + extensions: '.ctrl.js', + nested: false, + }, + }; + + const booter = new ControllerBooter(app, bootOptions); + await booter.config(); + validateConfig( + booter, + [bootOptions.controllers.dirs], + [bootOptions.controllers.extensions], + bootOptions.controllers.nested, + ); + }); + + it('overrides controllerOptions with those provided', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: { + dirs: ['ctrl1', 'ctrl2'], + extensions: ['.ctrl.js', '.controller.js'], + }, + }; + + const booter = new ControllerBooter(app, bootOptions); + await booter.config(); + validateConfig( + booter, + bootOptions.controllers.dirs, + bootOptions.controllers.extensions, + ControllerDefaults.nested, + ); + }); + + function validateConfig( + booter: ControllerBooter, + dirs: string[], + exts: string[], + nested: boolean, + ) { + expect(booter.options.dirs).to.have.eql(dirs); + expect(booter.options.extensions).to.eql(exts); + if (nested) { + expect(booter.options.nested).to.be.True(); + } else { + expect(booter.options.nested).to.be.False(); + } + expect(booter.projectRoot).to.equal(projectRoot); + } + }); + + describe('ControllerBooter.discover()', () => { + it('discovers files based on ControllerDefaults', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: ControllerDefaults, + }; + const expected = [ + `${resolve(projectRoot, 'controllers/empty.controller.js')}`, + `${resolve(projectRoot, 'controllers/hello.controller.js')}`, + `${resolve(projectRoot, 'controllers/two.controller.js')}`, + `${resolve(projectRoot, 'controllers/nested/nested.controller.js')}`, + ]; + + const booter = new ControllerBooter(app, bootOptions); + await booter.discover(); + expect(booter.options.discovered).to.be.an.Array(); + expect(booter.options.discovered.sort()).to.eql(expected.sort()); + }); + + it('discovers files without going into nested folders', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + nested: false, + }), + }; + const expected = [ + `${resolve(projectRoot, 'controllers/empty.controller.js')}`, + `${resolve(projectRoot, 'controllers/hello.controller.js')}`, + `${resolve(projectRoot, 'controllers/two.controller.js')}`, + ]; + + const booter = new ControllerBooter(app, bootOptions); + await booter.discover(); + expect(booter.options.discovered).to.be.an.Array(); + expect(booter.options.discovered.sort()).to.eql(expected.sort()); + }); + + it('discovers files of specified extensions', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + extensions: ['.ctrl.js'], + }), + }; + const expected = [ + `${resolve(projectRoot, 'controllers/another.ext.ctrl.js')}`, + ]; + + const booter = new ControllerBooter(app, bootOptions); + await booter.discover(); + expect(booter.options.discovered).to.be.an.Array(); + expect(booter.options.discovered.sort()).to.eql(expected.sort()); + }); + + it('discovers files in specified directory', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + dirs: ['ctrl'], + }), + }; + const expected = [ + `${resolve(projectRoot, 'ctrl/multiple.folder.controller.js')}`, + ]; + + const booter = new ControllerBooter(app, bootOptions); + await booter.discover(); + expect(booter.options.discovered).to.be.an.Array(); + expect(booter.options.discovered.sort()).to.eql(expected.sort()); + }); + + it('discovers files of multiple extensions', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + extensions: ['.ctrl.js', '.controller.js'], + }), + }; + const expected = [ + `${resolve(projectRoot, 'controllers/empty.controller.js')}`, + `${resolve(projectRoot, 'controllers/hello.controller.js')}`, + `${resolve(projectRoot, 'controllers/two.controller.js')}`, + `${resolve(projectRoot, 'controllers/another.ext.ctrl.js')}`, + `${resolve(projectRoot, 'controllers/nested/nested.controller.js')}`, + ]; + + const booter = new ControllerBooter(app, bootOptions); + await booter.discover(); + expect(booter.options.discovered).to.be.an.Array(); + expect(booter.options.discovered.sort()).to.eql(expected.sort()); + }); + + it('discovers files in multiple directories', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + dirs: ['ctrl', 'controllers'], + }), + }; + const expected = [ + `${resolve(projectRoot, 'controllers/empty.controller.js')}`, + `${resolve(projectRoot, 'controllers/hello.controller.js')}`, + `${resolve(projectRoot, 'controllers/two.controller.js')}`, + `${resolve(projectRoot, 'controllers/nested/nested.controller.js')}`, + `${resolve(projectRoot, 'ctrl/multiple.folder.controller.js')}`, + ]; + + const booter = new ControllerBooter(app, bootOptions); + await booter.discover(); + expect(booter.options.discovered).to.be.an.Array(); + expect(booter.options.discovered.sort()).to.eql(expected.sort()); + }); + + it('discovers no files in an empty directory', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + dirs: ['empty'], + }), + }; + const expected: string[] = []; + + const booter = new ControllerBooter(app, bootOptions); + await booter.discover(); + expect(booter.options.discovered).to.be.an.Array(); + expect(booter.options.discovered.sort()).to.eql(expected.sort()); + }); + + it('discovers no files of an invalid extension', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + extensions: ['.fake'], + }), + }; + const expected: string[] = []; + + const booter = new ControllerBooter(app, bootOptions); + await booter.discover(); + expect(booter.options.discovered).to.be.an.Array(); + expect(booter.options.discovered.sort()).to.eql(expected.sort()); + }); + + it('discovers no files in a non-existent directory', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + dirs: ['fake'], + }), + }; + const expected: string[] = []; + + const booter = new ControllerBooter(app, bootOptions); + await booter.discover(); + expect(booter.options.discovered).to.be.an.Array(); + expect(booter.options.discovered.sort()).to.eql(expected.sort()); + }); + }); + + describe('ControllerBooter.boot()', () => { + it('binds a controller from discovered file', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + discovered: [ + `${resolve(projectRoot, 'controllers/hello.controller.js')}`, + ], + }), + }; + const expected = [`${CoreBindings.CONTROLLERS}.HelloController`]; + + const booter = new ControllerBooter(app, bootOptions); + await booter.boot(); + const boundControllers = app + .findByTag(CoreBindings.CONTROLLERS_TAG) + .map(b => b.key); + expect(boundControllers.sort()).to.eql(expected.sort()); + }); + + it('binds controllers from multiple files', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + discovered: [ + `${resolve(projectRoot, 'controllers/hello.controller.js')}`, + `${resolve(projectRoot, 'controllers/another.ext.ctrl.js')}`, + `${resolve( + projectRoot, + 'controllers/nested/nested.controller.js', + )}`, + ], + }), + }; + const expected = [ + `${CoreBindings.CONTROLLERS}.HelloController`, + `${CoreBindings.CONTROLLERS}.AnotherController`, + `${CoreBindings.CONTROLLERS}.NestedController`, + ]; + + const booter = new ControllerBooter(app, bootOptions); + await booter.boot(); + const boundControllers = app + .findByTag(CoreBindings.CONTROLLERS_TAG) + .map(b => b.key); + expect(boundControllers.sort()).to.eql(expected.sort()); + }); + + it('binds multiple controllers from a file', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + discovered: [ + `${resolve(projectRoot, 'controllers/two.controller.js')}`, + ], + }), + }; + const expected = [ + `${CoreBindings.CONTROLLERS}.ControllerOne`, + `${CoreBindings.CONTROLLERS}.ControllerTwo`, + ]; + + const booter = new ControllerBooter(app, bootOptions); + await booter.boot(); + const boundControllers = app + .findByTag(CoreBindings.CONTROLLERS_TAG) + .map(b => b.key); + expect(boundControllers.sort()).to.eql(expected.sort()); + }); + + it('does not throw on an empty file', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + discovered: [ + `${resolve(projectRoot, 'controllers/empty.controller.js')}`, + ], + }), + }; + const expected: string[] = []; + + const booter = new ControllerBooter(app, bootOptions); + await booter.boot(); + const boundControllers = app + .findByTag(CoreBindings.CONTROLLERS_TAG) + .map(b => b.key); + expect(boundControllers.sort()).to.eql(expected.sort()); + }); + + it('throws an error on a non-existent file', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + discovered: [ + `${resolve(projectRoot, 'controllers/fake.controller.js')}`, + ], + }), + }; + + const booter = new ControllerBooter(app, bootOptions); + await expect(booter.boot()).to.be.rejectedWith( + 'ControllerBooter failed to load the following files: ["/controllers/fake.controller.js"]', + ); + }); + }); + + it('mounts other files even if one is non-existent', async () => { + const bootOptions: BootOptions = { + projectRoot: projectRoot, + controllers: Object.assign({}, ControllerDefaults, { + discovered: [ + `${resolve(projectRoot, 'controllers/hello.controller.js')}`, + `${resolve(projectRoot, 'controllers/fake.controller.js')}`, + `${resolve(projectRoot, 'controllers/two.controller.js')}`, + ], + }), + }; + const expected = [ + `${CoreBindings.CONTROLLERS}.ControllerOne`, + `${CoreBindings.CONTROLLERS}.ControllerTwo`, + `${CoreBindings.CONTROLLERS}.HelloController`, + ]; + + const booter = new ControllerBooter(app, bootOptions); + + await expect(booter.boot()).to.be.rejectedWith( + 'ControllerBooter failed to load the following files: ["/controllers/fake.controller.js"]', + ); + + const boundControllers = app + .findByTag(CoreBindings.CONTROLLERS_TAG) + .map(b => b.key); + expect(boundControllers.sort()).to.eql(expected.sort()); + }); + + function getApp() { + app = new Application(); + } + + function getProjectRoot() { + let dist = 'dist'; + if (getCompilationTarget() === 'es2015') dist = 'dist6'; + projectRoot = + process.cwd().indexOf('packages') > -1 + ? `${dist}/test/fixtures/booterApp` + : `packages/boot/${dist}/test/fixtures/booterApp`; + projectRoot = resolve(projectRoot); + } +}); diff --git a/packages/boot/tsconfig.build.json b/packages/boot/tsconfig.build.json new file mode 100644 index 000000000000..855e02848b35 --- /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": ["src", "test"] +} diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index 7fbb3c14b581..43dbb418de84 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -7,6 +7,7 @@ import {Context, Binding, BindingScope, Constructor} from '@loopback/context'; import {Server} from './server'; import {Component, mountComponent} from './component'; import {CoreBindings} from './keys'; +import {resolve} from 'path'; /** * Application is the container for various types of artifacts, such as @@ -61,9 +62,95 @@ export class Application extends Context { */ controller(controllerCtor: ControllerClass, name?: string): Binding { name = name || controllerCtor.name; - return this.bind(`controllers.${name}`) + return this.bind(`${CoreBindings.CONTROLLERS}.${name}`) .toClass(controllerCtor) - .tag('controller'); + .tag(CoreBindings.CONTROLLERS_TAG); + } + + /** + * Register a booter class with this application. + * + * @param booterCls {Function} The booter class (constructor function). + * @param {string=} name Optional booter name, defaults to the class name. + * @return {Binding} The newly created binding, you can use the reference to + * further modify the binding, e.g. lock the value to prevent further + * modifications. + * + * ```ts + * class MyBooter implements Booter {} + * app.booter(MyBooter); + * ``` + */ + booter(booterCls: Constructor, name?: string): Binding { + name = name || booterCls.name; + return this.bind(`${CoreBindings.BOOTERS}.${name}`) + .toClass(booterCls) + .tag(CoreBindings.BOOTERS_TAG) + .inScope(BindingScope.SINGLETON); + } + + /** + * Register an array of booter classes with this application. + * Each Booter added in this way will automatically be named based on the + * class constructor name with the "booters." prefix. + * + * If you wish to control the binding keys for particular booter instances, + * use the app.booter function instead. + * + * @param {Constructor[]} booterArr {Function} An array of Booter + * constructors. + * @return {Binding[]} An array of bindings for the registered Booter classes. + * + * ```ts + * app.booters([ControllerBooter, RepositoryBooter]); + * ``` + */ + booters(booterArr: Constructor[]): Binding[] { + return booterArr.map(booterCls => this.booter(booterCls)); + } + + /** + * 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 {BootOptions} bootOptions Options for boot. Bound for Booters to + * receive via Dependency Injection. + */ + async boot(bootOptions?: BootOptions) { + // Taranveer: Getting a might be undefined error here even though + // this.options is guaranteed to exist via Constructor. Adding this + // line to overcome error by tricking the Compiler. + if (!this.options) this.options = {}; + if (bootOptions) this.options.boot = bootOptions; + + // Make sure boot.projectRoot is set by user! + if (!this.options.boot || !this.options.boot.projectRoot) { + throw new Error( + `No projectRoot provided for boot. Please set options.boot.projectRoot.`, + ); + } + + // Resolve path to projectRoot + this.options.boot.projectRoot = resolve(this.options.boot.projectRoot); + + // Bind Boot Config for Booters + this.bind(CoreBindings.BOOT_CONFIG).to(this.options.boot); + + // Find Bindings and get instance + const bindings = this.findByTag(CoreBindings.BOOTERS_TAG); + let booterInsts = bindings.map(binding => this.getSync(binding.key)); + + // Run phases of booters + for (const phase of BOOT_PHASES) { + for (const inst of booterInsts) { + if (inst[phase]) { + await inst[phase](); + console.log(`${inst.constructor.name} phase: ${phase} complete.`); + } else { + console.log(`${inst.constructor.name} phase: ${phase} missing.`); + } + } + } } /** @@ -241,3 +328,22 @@ export interface ApplicationConfig { // tslint:disable-next-line:no-any export type ControllerClass = Constructor; + +/** + * A Booter class interface + */ +export interface Booter { + config?(): void; + discover?(): void; + boot?(): void; +} + +// An Array of Boot Phases available +export const BOOT_PHASES = ['config', 'discover', 'boot']; + +// Boot Options Type. Must provide a projectRoot! +export type BootOptions = { + projectRoot: string; + // tslint:disable-next-line:no-any + [prop: string]: any; +}; diff --git a/packages/core/src/keys.ts b/packages/core/src/keys.ts index cbae4ec89d53..439c79f263be 100644 --- a/packages/core/src/keys.ts +++ b/packages/core/src/keys.ts @@ -23,6 +23,15 @@ export namespace CoreBindings { */ export const SERVERS = 'servers'; + // Binding Constant prefixes / tags + export const BOOTERS = 'booters'; + export const BOOTERS_TAG = 'booter'; + export const CONTROLLERS = 'controllers'; + export const CONTROLLERS_TAG = 'controller'; + + // Binding Key for Boot Config + export const BOOT_CONFIG = 'application.config.boot'; + // controller /** * Binding key for the controller class resolved in the current request diff --git a/packages/core/test/unit/application.test.ts b/packages/core/test/unit/application.test.ts index 8610211cef9d..c8d14b814c8a 100644 --- a/packages/core/test/unit/application.test.ts +++ b/packages/core/test/unit/application.test.ts @@ -4,7 +4,13 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; -import {Application, Server, Component} from '../../index'; +import { + Application, + Server, + Component, + Booter, + CoreBindings, +} from '../../index'; import {Context, Constructor} from '@loopback/context'; describe('Application', () => { @@ -33,6 +39,133 @@ describe('Application', () => { } }); + describe('booter binding(s)', () => { + let app: Application; + let rootDir: string = process.cwd(); // Not used + class MyBooter implements Booter {} + class MyOtherBooter implements Booter {} + + beforeEach(givenApp); + + it('binds a booter', () => { + const binding = app.booter(MyBooter); + expect(Array.from(binding.tags)).to.containEql(CoreBindings.BOOTERS_TAG); + expect(binding.key).to.equal(`${CoreBindings.BOOTERS}.MyBooter`); + expect(findKeysByTag(app, CoreBindings.BOOTERS_TAG)).to.containEql( + binding.key, + ); + }); + + it('binds a botter with a custom name', () => { + const binding = app.booter(MyBooter, 'my-booter'); + expect(Array.from(binding.tags)).to.containEql(CoreBindings.BOOTERS_TAG); + expect(binding.key).to.equal(`${CoreBindings.BOOTERS}.my-booter`); + expect(findKeysByTag(app, CoreBindings.BOOTERS_TAG)).to.containEql( + binding.key, + ); + }); + + it('binds an array of booters', () => { + const bindings = app.booters([MyBooter, MyOtherBooter]); + bindings.forEach(binding => { + expect(Array.from(binding.tags)).to.containEql( + CoreBindings.BOOTERS_TAG, + ); + expect(findKeysByTag(app, CoreBindings.BOOTERS_TAG)).to.containEql( + binding.key, + ); + }); + expect(bindings[0].key).to.equal(`${CoreBindings.BOOTERS}.MyBooter`); + expect(bindings[1].key).to.equal(`${CoreBindings.BOOTERS}.MyOtherBooter`); + }); + + it('throws an error if rootDir is not set', async () => { + app.booter(TestBooter); + await expect(app.boot()).to.be.rejectedWith( + 'No projectRoot provided for boot. Please set options.boot.projectRoot.', + ); + }); + + describe('rootDir set via .boot(options)', () => { + beforeEach(givenApp); + + it('runs the boot phases of a Booter', async () => { + app.booter(TestBooter); + const booterInst = await app.get(`${CoreBindings.BOOTERS}.TestBooter`); + + verifyTestBooterBefore(booterInst); + await app.boot({projectRoot: rootDir}); + verifyTestBooterAfter(booterInst); + }); + + it('runs the boot phases of a Booter and ignores other / missing function', async () => { + app.booter(TestBooter2); + const booterInst = await app.get(`${CoreBindings.BOOTERS}.TestBooter2`); + + verifyTestBooter2Before(booterInst); + await app.boot({projectRoot: rootDir}); + verifyTestBooter2After(booterInst); + }); + }); + + describe('rootDir set via ApplicationConfig', () => { + let appWithRootDir: Application; + beforeEach(givenRootDirApp); + + it('runs the boot phases of a Booter', async () => { + appWithRootDir.booter(TestBooter); + const booterInst = await appWithRootDir.get( + `${CoreBindings.BOOTERS}.TestBooter`, + ); + + verifyTestBooterBefore(booterInst); + await appWithRootDir.boot(); + verifyTestBooterAfter(booterInst); + }); + + it('runs the boot phases of a Booter and ignores other / missing function', async () => { + appWithRootDir.booter(TestBooter2); + const booterInst = await appWithRootDir.get( + `${CoreBindings.BOOTERS}.TestBooter2`, + ); + + verifyTestBooter2Before(booterInst); + await appWithRootDir.boot(); + verifyTestBooter2After(booterInst); + }); + + function givenRootDirApp() { + appWithRootDir = new Application({boot: {projectRoot: rootDir}}); + } + }); + + function givenApp() { + app = new Application(); + } + + function verifyTestBooterBefore(inst: TestBooter) { + expect(inst.configRun).to.be.False(); + expect(inst.discoverRun).to.be.False(); + expect(inst.bootRun).to.be.False(); + } + + function verifyTestBooterAfter(inst: TestBooter) { + expect(inst.configRun).to.be.True(); + expect(inst.discoverRun).to.be.True(); + expect(inst.bootRun).to.be.True(); + } + + function verifyTestBooter2Before(inst: TestBooter2) { + expect(inst.configRun).to.be.False(); + expect(inst.randomRun).to.be.False(); + } + + function verifyTestBooter2After(inst: TestBooter2) { + expect(inst.configRun).to.be.True(); + expect(inst.randomRun).to.be.False(); + } + }); + describe('component binding', () => { let app: Application; class MyController {} @@ -147,3 +280,34 @@ class FakeServer extends Context implements Server { this.running = false; } } + +class TestBooter implements Booter { + configRun = false; + discoverRun = false; + bootRun = false; + + async config() { + this.configRun = true; + } + + async discover() { + this.discoverRun = true; + } + + async boot() { + this.bootRun = true; + } +} + +class TestBooter2 implements Booter { + configRun = false; + randomRun = false; + + async config() { + this.configRun = true; + } + + async random() { + this.randomRun = true; + } +}