From 06823bb41de4231f28586cb82f291ad97ef15868 Mon Sep 17 00:00:00 2001 From: Taranveer Virk Date: Sat, 30 Dec 2017 01:17:02 -0500 Subject: [PATCH 1/2] feat: implement convention based boot --- package.json | 1 + packages/boot/.gitignore | 3 + packages/boot/.npmrc | 1 + packages/boot/LICENSE | 25 +++ packages/boot/README.md | 109 ++++++++++ packages/boot/docs.json | 14 ++ packages/boot/index.d.ts | 6 + packages/boot/index.js | 6 + packages/boot/index.ts | 6 + packages/boot/package.json | 46 +++++ packages/boot/src/index.ts | 8 + packages/boot/src/mixins/boot.mixin.ts | 152 ++++++++++++++ packages/boot/src/tscofig.build.json | 8 + packages/boot/src/types.ts | 9 + packages/boot/src/utils.ts | 113 ++++++++++ .../boot/test/acceptance/boot.acceptance.ts | 47 +++++ .../controllers/admin/admin.controller.ts | 15 ++ .../fakeApp/controllers/hello.controller.ts | 24 +++ packages/boot/test/fakeApp/index.ts | 43 ++++ .../fakeApp/repositories/test.repository.ts | 6 + .../boot/test/integration/boot.integration.ts | 124 +++++++++++ .../boot/test/unit/mixins/boot.mixin.unit.ts | 134 ++++++++++++ packages/boot/test/unit/utils.unit.ts | 194 ++++++++++++++++++ packages/repository/src/repository-mixin.ts | 11 + 24 files changed, 1105 insertions(+) create mode 100644 packages/boot/.gitignore create mode 100644 packages/boot/.npmrc create mode 100644 packages/boot/LICENSE create mode 100644 packages/boot/README.md create mode 100644 packages/boot/docs.json create mode 100644 packages/boot/index.d.ts create mode 100644 packages/boot/index.js create mode 100644 packages/boot/index.ts create mode 100644 packages/boot/package.json create mode 100644 packages/boot/src/index.ts create mode 100644 packages/boot/src/mixins/boot.mixin.ts create mode 100644 packages/boot/src/tscofig.build.json create mode 100644 packages/boot/src/types.ts create mode 100644 packages/boot/src/utils.ts create mode 100644 packages/boot/test/acceptance/boot.acceptance.ts create mode 100644 packages/boot/test/fakeApp/controllers/admin/admin.controller.ts create mode 100644 packages/boot/test/fakeApp/controllers/hello.controller.ts create mode 100644 packages/boot/test/fakeApp/index.ts create mode 100644 packages/boot/test/fakeApp/repositories/test.repository.ts create mode 100644 packages/boot/test/integration/boot.integration.ts create mode 100644 packages/boot/test/unit/mixins/boot.mixin.unit.ts create mode 100644 packages/boot/test/unit/utils.unit.ts diff --git a/package.json b/package.json index a204654345e6..11574e1f4970 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "build:current": "lerna run --loglevel=silent build:current", "pretest": "npm run build:current", "test": "node packages/build/bin/run-nyc npm run mocha", + "test:clean": "npm run clean && npm run build:current && npm run test", "mocha": "node packages/build/bin/select-dist mocha --opts test/mocha.opts \"packages/*/DIST/test/**/*.js\"", "posttest": "npm run lint" }, 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/LICENSE b/packages/boot/LICENSE new file mode 100644 index 000000000000..a59160dc82d1 --- /dev/null +++ b/packages/boot/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2017. 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..0bc56a63f8a3 --- /dev/null +++ b/packages/boot/README.md @@ -0,0 +1,109 @@ +# @loopback/boot + +Boot package for LoopBack 4 to bootstrap convention based projects. + +## Overview + +This package provides the ability to bootstrap a LoopBack 4 project by +automatically automatically associating artifacts and configuration with an +application at runtime. + +The package is currently consumed as a [Mixin](http://loopback.io/doc/en/lb4/Mixin.html). + +It will automatically find all [Controller](http://loopback.io/doc/en/lb4/Controllers.html) +classes by searching through all files in `controllers` directory ending in +`.controller.js` and bind them to the application using `this.controller()`. + +Other Mixins can support automatic Booting by overriding the `boot()` method. +See example in [Advanced use](#advanced-use). + +## Installation + +```sh +npm i --save @loopback/boot +``` + +## Basic use + +Using `@loopback/boot` is simple. It is a Mixin and should be added to your +application Class as shown below. + +```ts +class BootedApplication extends BootMixin(Application) { + constructor(options?: ApplicationConfig) { + super(options); + } +} +``` + +### Configuration + +Configuration options for boot can be set via the `ApplicationConfig` object +(`options`). The following options are supported: + +|Name|Default|Description| +|-|-|-| +|`boot.rootDir`|`process.cwd()`|The root directory for the project. All other paths are resolved relative to this path.| +|`boot.controllerDirs`|`controllers`|String or Array of directory paths to search in for controllers. Paths may be relative to `rootDir` or absolute.| +|`boot.controllerExts`|`controller.js`|String or Array of file extensions to consider as controller files.| + +*Example* +```ts +app = new BootedApplication({ + rest: { + port: 3000 + }, + boot: { + rootDir: '/absolute/path/to/my/app/root', + controllerDirs: ['controllers', 'ctrls'], + controllerExts: ['controller.js', '.ctrl.js'], + }, +}); + +## Advanced use + +An extension developer can support the **boot** process by writing a mixin and +overriding the `boot()` function as shown below. If a user is using +`@loopback/boot`, it will automatically boot your artifact as well. + +```ts +function TestMixin>(superClass: T) { + return class extends superClass { + constructor(...args: any[]) { + super(...args); + } + + async boot() { + // Your custom config + const repoDir = this.options.boot.repoDir || 'repositories'; + // We call the convenience method to boot class artifacts + await this.bootClassArtifacts(repoDir, 'repository.js', 'testMixinRepo'); + + // IMPORTANT: This line must be added so all other artifacts can be booted + // automatically and regardless of the order of the Mixins. + if (super.boot) await super.boot(); + } + }; +} +``` + +## Related resources + +**Coming Soon** Link to Boot Docs. + +## 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..a0606113f114 --- /dev/null +++ b/packages/boot/docs.json @@ -0,0 +1,14 @@ +{ + "content": [ + "index.ts", + "src/index.ts", + "src/types.ts", + "src/utils.ts", + "src/mixins/boot.mixin.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..8b35d41b8aa3 --- /dev/null +++ b/packages/boot/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2013,2017. 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..7cc2692a3f95 --- /dev/null +++ b/packages/boot/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2013,2017. 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/src'); diff --git a/packages/boot/index.ts b/packages/boot/index.ts new file mode 100644 index 000000000000..08d545c7564b --- /dev/null +++ b/packages/boot/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2013,2017. 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..a446e20efd68 --- /dev/null +++ b/packages/boot/package.json @@ -0,0 +1,46 @@ +{ + "name": "@loopback/boot", + "version": "4.0.0-alpha.1", + "description": "Boot handler for LoopBack 4 apps", + "engines": { + "node": ">=8" + }, + "main": "index.js", + "scripts": { + "acceptance": "mocha --opts ../../test/mocha.opts dist/test/acceptance/**/*.js", + "build": "lb-tsc", + "build:current": "lb-tsc", + "build:apidocs": "lb-apidocs", + "clean": "lb-clean loopback-boot*.tgz dist package api-docs", + "prepare": "npm run build", + "pretest": "npm run clean && npm run build", + "test": "mocha --opts ../../test/mocha.opts dist/test/", + "unit": "mocha --opts ../../test/mocha.opts dist/test/unit/", + "verify": "npm pack && tar xf loopback-boot*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "license": "MIT", + "keywords": ["LoopBack", "Boot"], + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "api-docs", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + }, + "devDependencies": { + "@loopback/build": "^4.0.0-alpha.7", + "@loopback/core": "^4.0.0-alpha.26", + "@loopback/rest": "^4.0.0-alpha.15", + "@loopback/testlab": "^4.0.0-alpha.17" + }, + "dependencies": { + "@loopback/context": "^4.0.0-alpha.23", + "debug": "^3.1.0" + } +} diff --git a/packages/boot/src/index.ts b/packages/boot/src/index.ts new file mode 100644 index 000000000000..2d6e5b748911 --- /dev/null +++ b/packages/boot/src/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2013,2017. 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 './mixins/boot.mixin'; +export * from './utils'; +export * from './types'; diff --git a/packages/boot/src/mixins/boot.mixin.ts b/packages/boot/src/mixins/boot.mixin.ts new file mode 100644 index 000000000000..b0c794fe38ed --- /dev/null +++ b/packages/boot/src/mixins/boot.mixin.ts @@ -0,0 +1,152 @@ +// Copyright IBM Corp. 2013,2017. 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 {discoverArtifactFiles} from '../utils'; +import {resolve} from 'path'; +import {Constructor} from '@loopback/context'; +import {StringOrArr} from '../types'; +const debug = require('debug')('@loopback/boot:mixin'); + +/** + * A mixin class for Application that boots it. Boot automatically + * binds controllers based on convention based naming before starting + * the Application. + * + * ```ts + * class MyApp extends BootMixin(Application) {} + * ``` + */ +// tslint:disable-next-line:no-any +export function BootMixin>(superClass: T) { + return class extends superClass { + // tslint:disable-next-line:no-any + constructor(...args: any[]) { + super(...args); + + // Create Options Object / Init Defaults if non-existent! + if (!this.options) this.options = {}; + if (!this.options.boot) { + this.options.boot = {}; + } + + // Set `dist/src` as rootDir (as a relative path) if not set by user + if (!this.options.boot.rootDir) { + this.options.boot.rootDir = 'dist/src'; + } + + // Resolve to Absolute path! This is done here so user can provide + // relative path or absolute path for rootDir. + this.options.boot.rootDir = resolve(this.options.boot.rootDir); + } + + /** + * Default function to boot Application. Boot is the process of discovering + * and binding artifacts automatically to a given key. + */ + async boot() { + this.bootControllers(); + // This allows boot to work regardless of Mixin order + if (super.boot) await super.boot(); + } + + /** + * Override the start function to boot before running start! + */ + async start() { + await this.boot(); + super.start(); + } + + /** + * Boot controllers automatically by finding them in given directories + * and binding them using this.controller() function. + */ + bootControllers() { + // Default folder to look in (relative to rootDir) + const controllerDirs = this.options.boot.controllerDirs || 'controllers'; + + // If custom extensions aren't passed, set default + const controllerExts = + this.options.boot.controllerExts || 'controller.js'; + + // Controllers are Classes so this method can be used to discover + // and boot Controllers + this.bootClassArtifacts(controllerDirs, controllerExts, this.controller); + } + + /** + * If an artifact is + * + * @param dirs List of paths to search for Artifacts. (may be relative to + * rootDir / basePath OR absolute paths) + * @param exts List of extensions files discovered should match + * @param prefixOrFunc String prefix if using a `bind.toClass` or a function + * for custom binding logic + * @param nested Should nested directories in dirs be searched for Artifacts + * @param basePath Override rootDir (for prefixing to relative paths) + */ + bootClassArtifacts( + dirs: StringOrArr, + exts: StringOrArr, + prefixOrFunc: string | Function, + nested?: boolean, + basePath?: string, + ) { + basePath = basePath || this.options.boot.rootDir; + const files = discoverArtifactFiles(dirs, exts, nested, basePath); + if (typeof prefixOrFunc === 'string') { + this.bindClassArtifacts(files, prefixOrFunc); + } else { + this.bindClassArtifactsUsingFunction(files, prefixOrFunc); + } + } + + /** + * Find classes in a list of files and bind them as a Class (to Application + * Context) by generating a key using a prefix. Key is: prefix.className + * + * @param files List of files (absolute paths) to look for Classes in + * @param prefix Prefix to generate the binding key (prefix.className) + */ + bindClassArtifacts(files: string[], prefix: string) { + files.forEach(file => { + try { + const ctrl = require(file); + const classes: string[] = Object.keys(ctrl); + + classes.forEach((cls: string) => + // prettier-ignore + this.bind(`${prefix}.${cls}`).toClass(ctrl[cls]).tag(prefix), + ); + } catch (err) { + debug( + `bindClassArtifacts: Skipping file: ${file} because error: ${err}`, + ); + } + }); + } + + /** + * Find classes in a list of files (absolute paths) and call the given + * function with the Classes in the files given. + * @param files List of files (absolute paths) to look for Classes in + * @param fn Function to pass the Class in to so it can handle "booting" it + */ + bindClassArtifactsUsingFunction(files: string[], fn: Function) { + files.forEach(file => { + try { + const ctrl = require(file); + const classes: string[] = Object.keys(ctrl); + + classes.forEach((cls: string) => fn.call(this, ctrl[cls])); + } catch (err) { + debug( + `bindClassArtifactsUsingFunction: Skipping file: ${file} because error: ${err}`, + ); + } + }); + } + }; +} diff --git a/packages/boot/src/tscofig.build.json b/packages/boot/src/tscofig.build.json new file mode 100644 index 000000000000..855e02848b35 --- /dev/null +++ b/packages/boot/src/tscofig.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/boot/src/types.ts b/packages/boot/src/types.ts new file mode 100644 index 000000000000..f393ba915298 --- /dev/null +++ b/packages/boot/src/types.ts @@ -0,0 +1,9 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * String or String List type + */ +export type StringOrArr = string | string[]; diff --git a/packages/boot/src/utils.ts b/packages/boot/src/utils.ts new file mode 100644 index 000000000000..e182a66c3402 --- /dev/null +++ b/packages/boot/src/utils.ts @@ -0,0 +1,113 @@ +// Copyright IBM Corp. 2013,2017. 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 {statSync, readdirSync} from 'fs'; +import {join, resolve} from 'path'; +import {StringOrArr} from './types'; +const debug = require('debug')('@loopback/boot:utils'); + +/** + * Takes an absolute directory path and returns all files / folders in it. Can optionally + * traverse nested folders. + * + * @param d Absolute path of directory to read + * @param nested Read nested directories in the given directory or not. Defaults to true. + */ +// tslint:disable-next-line:no-any +export function readFolder(d: string, nested: boolean = true): any { + // If not reading nested directories, read the current one and return + if (!nested) { + try { + let files = readdirSync(d).map(f => join(d, f)); + return files; + } catch (err) { + debug(`Skipping ${d} in if(!recursive) because error: ${err}`); + return []; + } + } else { + try { + // Recursively read nested directories + if (statSync(d).isDirectory()) { + return readdirSync(d).map(f => readFolder(join(d, f), nested)); + } else { + return d; + } + } catch (err) { + debug(`Skipping ${d} in else because error: ${err}`); + return []; + } + } +} + +/** + * Takes an array which may contain nested arrays and flattens them into a single array + * + * @param arr Nested array of elements to flatten + */ +// tslint:disable-next-line:no-any +export function flatten(arr: any): any { + return arr.reduce( + // tslint:disable-next-line:no-any + (flat: any, item: any) => + flat.concat(Array.isArray(item) ? flatten(item) : item), + [], + ); +} + +/** + * Filters a list of strings to look for particular extension endings. + * + * @param arr A list of items to filter + * @param exts A list of extensions to look in arr + */ +export function filterExts(arr: string[], exts: string[]): string[] { + if (exts && exts.length === 0) return arr; + return arr.filter(item => { + let include = false; + exts.forEach(ext => { + if (item.indexOf(ext) === item.length - ext.length) { + include = true; + } + }); + + if (include) return item; + }); +} + +/** + * Normalize a string / list to be a list + * + * @param str String or string array to normalize + */ +export function normalizeToArray(str: string | string[]): string[] { + if (typeof str === 'string') return [str]; + return str; +} + +/** + * Take a list of directories to search for artifact files in and filter + * the list based on the list of extensions. Can optionally check nested folders. + * If paths are relative, a rootDir path can be given to resolve absolute paths. + * + * @param dirs List of directories to search for artifacts in + * @param exts List of extensions to filter files with artifacts + * @param nested Checked nested folders recursively + * @param basePath Base path for relative paths in dirs + */ +export function discoverArtifactFiles( + dirs: StringOrArr, + exts: StringOrArr = [], + nested?: boolean, + basePath?: string, +): string[] { + dirs = normalizeToArray(dirs); + exts = normalizeToArray(exts); + let files: string[] = []; + dirs.forEach(d => { + if (basePath) d = resolve(basePath, d); + files.push(readFolder(d, nested)); + }); + return filterExts(flatten(files), exts); +} diff --git a/packages/boot/test/acceptance/boot.acceptance.ts b/packages/boot/test/acceptance/boot.acceptance.ts new file mode 100644 index 000000000000..eefa3bc54f8f --- /dev/null +++ b/packages/boot/test/acceptance/boot.acceptance.ts @@ -0,0 +1,47 @@ +// Copyright IBM Corp. 2013,2017. 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 {BootTestApplication} from '../fakeApp'; +import {RestServer} from '@loopback/rest'; + +describe('@loopback/boot acceptance', () => { + let rootDir: string; + let app: BootTestApplication; + + before(getRootDir); + beforeEach(getApp); + afterEach(stopApp); + + it('booted controllers / app works as expected', async () => { + await app.start(); + + const server: RestServer = await app.getServer(RestServer); + const client: Client = createClientForHandler(server.handleHttp); + + await client.get('/').expect(200, 'Hello World'); + await client.get('/world').expect(200, 'Hi from world controller'); + await client.get('/admin').expect(200, 'Hello Admin'); + }); + + function getRootDir() { + rootDir = + process.cwd().indexOf('packages') > -1 + ? 'dist/test/fakeApp/' + : 'packages/boot/dist/test/fakeApp/'; + } + + function getApp() { + app = new BootTestApplication({boot: {rootDir: rootDir}}); + } + + async function stopApp() { + try { + await app.stop(); + } catch (err) { + console.log(`Stopping the app threw an error: ${err}`); + } + } +}); diff --git a/packages/boot/test/fakeApp/controllers/admin/admin.controller.ts b/packages/boot/test/fakeApp/controllers/admin/admin.controller.ts new file mode 100644 index 000000000000..e882961c330d --- /dev/null +++ b/packages/boot/test/fakeApp/controllers/admin/admin.controller.ts @@ -0,0 +1,15 @@ +// Copyright IBM Corp. 2013,2017. 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/rest'; + +export class AdminController { + constructor() {} + + @get('/admin') + admin() { + return 'Hello Admin'; + } +} diff --git a/packages/boot/test/fakeApp/controllers/hello.controller.ts b/packages/boot/test/fakeApp/controllers/hello.controller.ts new file mode 100644 index 000000000000..132edc312e12 --- /dev/null +++ b/packages/boot/test/fakeApp/controllers/hello.controller.ts @@ -0,0 +1,24 @@ +// Copyright IBM Corp. 2013,2017. 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/rest'; + +export class HelloController { + constructor() {} + + @get('/') + hello() { + return 'Hello World'; + } +} + +export class WorldController { + constructor() {} + + @get('/world') + world() { + return 'Hi from world controller'; + } +} diff --git a/packages/boot/test/fakeApp/index.ts b/packages/boot/test/fakeApp/index.ts new file mode 100644 index 000000000000..7a67018aa014 --- /dev/null +++ b/packages/boot/test/fakeApp/index.ts @@ -0,0 +1,43 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Application, ApplicationConfig} from '@loopback/core'; +import {RestComponent} from '@loopback/rest'; + +import {BootMixin} from '../..'; + +export class BootTestApplication extends BootMixin(Application) { + constructor(options?: ApplicationConfig) { + // Allow options to replace the defined components array, if desired. + options = Object.assign( + {}, + { + components: [RestComponent], + rest: { + port: 3333, + }, + }, + options, + ); + super(options); + } +} + +export class NormalApplication extends Application { + constructor(options?: ApplicationConfig) { + // Allow options to replace the defined components array, if desired. + options = Object.assign( + {}, + { + components: [RestComponent], + rest: { + port: 3333, + }, + }, + options, + ); + super(options); + } +} diff --git a/packages/boot/test/fakeApp/repositories/test.repository.ts b/packages/boot/test/fakeApp/repositories/test.repository.ts new file mode 100644 index 000000000000..d8af36f6d672 --- /dev/null +++ b/packages/boot/test/fakeApp/repositories/test.repository.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2013,2017. 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 class TestRepository {} diff --git a/packages/boot/test/integration/boot.integration.ts b/packages/boot/test/integration/boot.integration.ts new file mode 100644 index 000000000000..31d20e94552e --- /dev/null +++ b/packages/boot/test/integration/boot.integration.ts @@ -0,0 +1,124 @@ +// Copyright IBM Corp. 2013,2017. 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, Client, createClientForHandler} from '@loopback/testlab'; +import {NormalApplication} from '../fakeApp'; +import {Constructor, Binding} from '@loopback/context'; +import {BootMixin} from '../..'; +import {RestServer} from '@loopback/rest'; +import { + HelloController, + WorldController, +} from '../fakeApp/controllers/hello.controller'; +import {AdminController} from '../fakeApp/controllers/admin/admin.controller'; +import {TestRepository} from '../fakeApp/repositories/test.repository'; + +describe('@loopback/boot integration', () => { + class BootWithTestMixin extends TestMixin(BootMixin(NormalApplication)) {} + class BootWithTestMixinReverse extends BootMixin( + TestMixin(NormalApplication), + ) {} + + let rootDir: string; + + // Using any type for app so we can use the same stopApp function! + // tslint:disable-next-line:no-any + let app: any; + let client: Client; + + before(getRootDir); + afterEach(stopApp); + + it('booted controllers / app works as expected with TestMixin(BootMixin())', async () => { + app = new BootWithTestMixin({boot: {rootDir: rootDir}}); + const expectedCtrls = [ + 'controllers.AdminController', + 'controllers.HelloController', + 'controllers.WorldController', + ]; + const expectedRepos = ['testMixinRepo.TestRepository']; + + await app.start(); + const server: RestServer = await app.getServer(RestServer); + client = createClientForHandler(server.handleHttp); + + checkBindingsArr('controllers', expectedCtrls); + checkBindingInstance('controllers.HelloController', HelloController); + checkBindingInstance('controllers.WorldController', WorldController); + checkBindingInstance('controllers.AdminController', AdminController); + await checkClientResponses(); + checkBindingsArr('testMixinRepo', expectedRepos); + checkBindingInstance('testMixinRepo.TestRepository', TestRepository); + }); + + it('booted controllers / app works as expected with BootMixin(TestMixin())', async () => { + app = new BootWithTestMixinReverse({boot: {rootDir: rootDir}}); + const expectedCtrls = [ + 'controllers.AdminController', + 'controllers.HelloController', + 'controllers.WorldController', + ]; + const expectedRepos = ['testMixinRepo.TestRepository']; + + await app.start(); + const server: RestServer = await app.getServer(RestServer); + client = createClientForHandler(server.handleHttp); + + checkBindingsArr('controllers', expectedCtrls); + checkBindingInstance('controllers.HelloController', HelloController); + checkBindingInstance('controllers.WorldController', WorldController); + checkBindingInstance('controllers.AdminController', AdminController); + await checkClientResponses(); + checkBindingsArr('testMixinRepo', expectedRepos); + checkBindingInstance('testMixinRepo.TestRepository', TestRepository); + }); + + async function checkClientResponses() { + await client.get('/').expect(200, 'Hello World'); + await client.get('/world').expect(200, 'Hi from world controller'); + await client.get('/admin').expect(200, 'Hello Admin'); + } + + function checkBindingInstance(key: string, inst: Constructor<{}>) { + const binding = app.getSync(key); + expect(binding).to.be.instanceOf(inst); + } + + function checkBindingsArr(prefix: string, expected: string[]) { + const bindings = app.find(`${prefix}.*`).map((b: Binding) => b.key); + expect(bindings.sort()).to.eql(expected.sort()); + } + + function getRootDir() { + rootDir = + process.cwd().indexOf('packages') > -1 + ? 'dist/test/fakeApp/' + : 'packages/boot/dist/test/fakeApp/'; + } + + async function stopApp() { + try { + await app.stop(); + } catch (err) { + console.log(`Stopping the app threw an error: ${err}`); + } + } +}); + +// tslint:disable-next-line:no-any +function TestMixin>(superClass: T) { + return class extends superClass { + // tslint:disable-next-line:no-any + constructor(...args: any[]) { + super(...args); + } + + async boot() { + const repoDir = this.options.boot.repoDir || 'repositories'; + await this.bootClassArtifacts(repoDir, 'repository.js', 'testMixinRepo'); + if (super.boot) await super.boot(); + } + }; +} diff --git a/packages/boot/test/unit/mixins/boot.mixin.unit.ts b/packages/boot/test/unit/mixins/boot.mixin.unit.ts new file mode 100644 index 000000000000..0fdcc4f28584 --- /dev/null +++ b/packages/boot/test/unit/mixins/boot.mixin.unit.ts @@ -0,0 +1,134 @@ +// Copyright IBM Corp. 2013,2017. 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 {BootTestApplication} from '../../fakeApp'; +import { + HelloController, + WorldController, +} from '../../fakeApp/controllers/hello.controller'; +import {AdminController} from '../../fakeApp/controllers/admin/admin.controller'; +import {TestRepository} from '../../fakeApp/repositories/test.repository'; +import {Constructor} from '@loopback/context'; +import {resolve} from 'path'; + +describe('BootMixin()', () => { + let myApp: BootTestApplication; + let rootDir: string; + + before(getRootDir); + beforeEach(getApplication); + afterEach(stopApp); + + describe('basic function presence tests', () => { + it('has .boot() function', () => { + expect(myApp.boot).to.be.a.Function(); + }); + + it('has .bootControllers() function', () => { + expect(myApp.bootControllers).to.be.a.Function(); + }); + + it('has .bootClassArtifacts() function', () => { + expect(myApp.bootClassArtifacts).to.be.a.Function(); + }); + + it('has .bindClassArtifacts() function', () => { + expect(myApp.bindClassArtifacts).to.be.a.Function(); + }); + + it('has .bindClassArtifactsUsingFunction() function', () => { + expect(myApp.bindClassArtifactsUsingFunction).to.be.a.Function(); + }); + }); + + it('binds controllers automatically', async () => { + const expected = [ + 'controllers.AdminController', + 'controllers.HelloController', + 'controllers.WorldController', + ]; + + await myApp.start(); + + checkBindingsArr('controllers', expected); + checkBindingInstance('controllers.HelloController', HelloController); + checkBindingInstance('controllers.WorldController', WorldController); + checkBindingInstance('controllers.AdminController', AdminController); + }); + + it('allows other class artifacts to be booted using prefix', () => { + const expected = ['repositories.TestRepository']; + + myApp.bootClassArtifacts('repositories', '.repository.js', 'repositories'); + + checkBindingsArr('repositories', expected); + checkBindingInstance('repositories.TestRepository', TestRepository); + }); + + it('allows other class artifacts to be booted using function', () => { + const expected = ['repositoryUsingFunc.TestRepository']; + function bindRepoClass(inst: Constructor<{}>) { + myApp.bind(`repositoryUsingFunc.${inst.name}`).toClass(inst); + } + + myApp.bootClassArtifacts('repositories', '.repository.js', bindRepoClass); + + checkBindingsArr('repositoryUsingFunc', expected); + checkBindingInstance('repositoryUsingFunc.TestRepository', TestRepository); + }); + + it('discovers classes in given files and binds to a given prefix', () => { + const files = [resolve(rootDir, 'repositories/test.repository.js')]; + const expected = ['repoPrefix.TestRepository']; + + myApp.bindClassArtifacts(files, 'repoPrefix'); + + checkBindingsArr('repoPrefix', expected); + checkBindingInstance('repoPrefix.TestRepository', TestRepository); + }); + + it('discovers classes in given files and calls function to bind', () => { + const files = [resolve(rootDir, 'repositories/test.repository.js')]; + const expected = ['repoFunc.TestRepository']; + function bindFunc(inst: Constructor<{}>) { + myApp.bind(`repoFunc.${inst.name}`).toClass(inst); + } + + myApp.bindClassArtifactsUsingFunction(files, bindFunc); + + checkBindingsArr('repoFunc', expected); + checkBindingInstance('repoFunc.TestRepository', TestRepository); + }); + + function checkBindingInstance(key: string, inst: Constructor<{}>) { + const binding = myApp.getSync(key); + expect(binding).to.be.instanceOf(inst); + } + + function checkBindingsArr(prefix: string, expected: string[]) { + const bindings = myApp.find(`${prefix}.*`).map(b => b.key); + expect(bindings.sort()).to.eql(expected.sort()); + } + + function getRootDir() { + rootDir = + process.cwd().indexOf('packages') > -1 + ? 'dist/test/fakeApp/' + : 'packages/boot/dist/test/fakeApp/'; + } + + function getApplication() { + myApp = new BootTestApplication({boot: {rootDir: rootDir}}); + } + + async function stopApp() { + try { + await myApp.stop(); + } catch (err) { + console.log(`Stopping the app threw an error: ${err}`); + } + } +}); diff --git a/packages/boot/test/unit/utils.unit.ts b/packages/boot/test/unit/utils.unit.ts new file mode 100644 index 000000000000..9a73b55df804 --- /dev/null +++ b/packages/boot/test/unit/utils.unit.ts @@ -0,0 +1,194 @@ +// Copyright IBM Corp. 2013,2017. 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 { + readFolder, + flatten, + filterExts, + normalizeToArray, + discoverArtifactFiles, +} from '../..'; +import {resolve} from 'path'; + +describe('utils', () => { + let rootDir: string; + + describe('readFolder()', () => { + // tslint:disable-next-line:no-any + let expected: any; + + before(getRootDir); + + it('reads a folder and nested folders by default', () => { + expected = [ + resolve(rootDir, 'index.js'), + resolve(rootDir, 'index.js.map'), + resolve(rootDir, 'index.d.ts'), + [ + [ + resolve(rootDir, 'controllers/admin/admin.controller.d.ts'), + resolve(rootDir, 'controllers/admin/admin.controller.js'), + resolve(rootDir, 'controllers/admin/admin.controller.js.map'), + ], + resolve(rootDir, 'controllers/hello.controller.d.ts'), + resolve(rootDir, 'controllers/hello.controller.js'), + resolve(rootDir, 'controllers/hello.controller.js.map'), + ], + [ + resolve(rootDir, 'repositories/test.repository.d.ts'), + resolve(rootDir, 'repositories/test.repository.js'), + resolve(rootDir, 'repositories/test.repository.js.map'), + ], + ]; + + const files: string[] = readFolder(resolve(rootDir)); + expect(files.sort()).to.be.eql(expected.sort()); + }); + + it('reads a folder and ignored nested folders', () => { + expected = [ + resolve(rootDir, 'controllers'), + resolve(rootDir, 'index.js'), + resolve(rootDir, 'index.js.map'), + resolve(rootDir, 'index.d.ts'), + resolve(rootDir, 'repositories'), + ]; + const files: string[] = readFolder(resolve(rootDir), false); + expect(files.sort()).to.be.eql(expected.sort()); + }); + + it('handles invalid folder name', () => { + const files: string[] = readFolder(resolve(rootDir, 'non-existent')); + expect(files).to.be.a.Array(); + expect(files).to.have.lengthOf(0); + }); + }); + + describe('flatten()', () => { + it('flattens an array of arrays', () => { + const input = ['a', ['b', 'c'], 'd', ['e', ['f']]]; + const expected: string[] = ['a', 'b', 'c', 'd', 'e', 'f']; + const actual: string[] = flatten(input); + expect(actual.sort()).to.be.eql(expected.sort()); + }); + }); + + describe('filterExts()', () => { + const input = ['a.ts', 'b.ts', 'a.js', 'b.js', 'a.js.map', '.b.js.map']; + it('filters an array for matching extension', () => { + const filter: string[] = ['.ts']; + const expected: string[] = ['a.ts', 'b.ts']; + const result: string[] = filterExts(input, filter); + expect(result.sort()).to.be.eql(expected.sort()); + }); + + it('filters an array for matching extensions', () => { + const filter: string[] = ['.ts', '.js']; + const expected: string[] = ['a.ts', 'b.ts', 'a.js', 'b.js']; + const result: string[] = filterExts(input, filter); + expect(result.sort()).to.be.eql(expected.sort()); + }); + + it('returns input when no filter is applied', () => { + const result: string[] = filterExts(input, []); + expect(result.sort()).to.be.eql(input.sort()); + }); + }); + + describe('normalizeToArray()', () => { + it('converts a string to an array', () => { + const result: string[] = normalizeToArray('hello'); + expect(result).to.be.an.Array(); + expect(result).to.be.eql(['hello']); + }); + + it('returns an array given an array', () => { + const input: string[] = ['hello']; + const result: string[] = normalizeToArray(input); + expect(result).to.be.eql(input); + }); + }); + + describe('discoverArtifactFiles()', () => { + before(getRootDir); + + it('discovers correct artifact files in nested folders with Array input', () => { + const dirs: string[] = [resolve(rootDir)]; + const exts: string[] = ['.controller.js']; + const expected: string[] = [ + resolve(rootDir, 'controllers/hello.controller.js'), + resolve(rootDir, 'controllers/admin/admin.controller.js'), + ]; + const result: string[] = discoverArtifactFiles(dirs, exts); + expect(result.sort()).to.be.eql(expected.sort()); + }); + + it('discovers correct artifact files in nested folders with String input', () => { + const expected: string[] = [ + resolve(rootDir, 'controllers/hello.controller.js'), + resolve(rootDir, 'controllers/admin/admin.controller.js'), + ]; + const result: string[] = discoverArtifactFiles( + resolve(rootDir), + '.controller.js', + ); + expect(result.sort()).to.be.eql(expected.sort()); + }); + + it('discovers correct artifact files and ignores nested folders', () => { + const dirs: string[] = [resolve(rootDir)]; + const exts: string[] = ['.controller.js']; + const expected: string[] = []; + const result: string[] = discoverArtifactFiles(dirs, exts, false); + expect(result).to.be.eql(expected); + }); + + it('discovers correct artifact files and ignores nested folders', () => { + const expected: string[] = [ + resolve(rootDir, 'controllers/hello.controller.js'), + ]; + const result: string[] = discoverArtifactFiles( + resolve(rootDir, 'controllers'), + '.controller.js', + false, + ); + expect(result).to.be.eql(expected); + }); + + it('discovers correct artifact files when given relative paths with a basePath', () => { + const expected: string[] = [ + resolve(rootDir, 'controllers/hello.controller.js'), + resolve(rootDir, 'controllers/admin/admin.controller.js'), + ]; + const result: string[] = discoverArtifactFiles( + 'controllers', + '.controller.js', + true, + rootDir, + ); + expect(result.sort()).to.be.eql(expected.sort()); + }); + + it('discovers correct artifact files in multiple dirs', () => { + const dirs: string[] = ['controllers', 'repositories']; + const exts: string[] = ['.controller.js', '.repository.js']; + const expected: string[] = [ + resolve(rootDir, 'controllers/hello.controller.js'), + resolve(rootDir, 'controllers/admin/admin.controller.js'), + resolve(rootDir, 'repositories/test.repository.js'), + ]; + const result: string[] = discoverArtifactFiles(dirs, exts, true, rootDir); + expect(result.sort()).to.be.eql(expected.sort()); + }); + }); + + function getRootDir(): void { + rootDir = + process.cwd().indexOf('packages') > -1 + ? 'dist/test/fakeApp/' + : 'packages/boot/dist/test/fakeApp/'; + } +}); diff --git a/packages/repository/src/repository-mixin.ts b/packages/repository/src/repository-mixin.ts index 5ec8b2e0b9b4..8dafcef03338 100644 --- a/packages/repository/src/repository-mixin.ts +++ b/packages/repository/src/repository-mixin.ts @@ -113,5 +113,16 @@ export function RepositoryMixin>(superClass: T) { } } } + + /** + * If @loopback/boot is enabled, find and mount all repositories + * automatically at boot time. + */ + async boot() { + const repoDir = this.options.boot.repoDir || 'repositories'; + const repoExt = this.options.boot.repoExt || 'repository.js'; + await this.bootClassArtifacts(repoDir, repoExt, this.repository); + if (super.boot) await super.boot(); + } }; } From cb03b8f8d270713bec6f32416b36ced15f84eeae Mon Sep 17 00:00:00 2001 From: Kevin Delisle Date: Tue, 2 Jan 2018 12:08:59 -0500 Subject: [PATCH 2/2] fix(boot): Allow mixin arrays You can pass an optional 2nd parameter which contains an array of mixin classes that will be applied to the class in order. --- packages/boot/src/mixins/boot.mixin.ts | 13 +++++++++++-- packages/boot/test/integration/boot.integration.ts | 2 +- packages/boot/tsconfig.json | 12 ++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 packages/boot/tsconfig.json diff --git a/packages/boot/src/mixins/boot.mixin.ts b/packages/boot/src/mixins/boot.mixin.ts index b0c794fe38ed..2299d1392f3d 100644 --- a/packages/boot/src/mixins/boot.mixin.ts +++ b/packages/boot/src/mixins/boot.mixin.ts @@ -19,8 +19,17 @@ const debug = require('debug')('@loopback/boot:mixin'); * ``` */ // tslint:disable-next-line:no-any -export function BootMixin>(superClass: T) { - return class extends superClass { +export function BootMixin>( + superClass: T, + mixins?: Function[], +) { + let finalClass = superClass; + if (mixins) { + mixins.forEach(mixin => { + finalClass = mixin(finalClass); + }); + } + return class extends finalClass { // tslint:disable-next-line:no-any constructor(...args: any[]) { super(...args); diff --git a/packages/boot/test/integration/boot.integration.ts b/packages/boot/test/integration/boot.integration.ts index 31d20e94552e..6ac34fcdc7e7 100644 --- a/packages/boot/test/integration/boot.integration.ts +++ b/packages/boot/test/integration/boot.integration.ts @@ -16,7 +16,7 @@ import {AdminController} from '../fakeApp/controllers/admin/admin.controller'; import {TestRepository} from '../fakeApp/repositories/test.repository'; describe('@loopback/boot integration', () => { - class BootWithTestMixin extends TestMixin(BootMixin(NormalApplication)) {} + class BootWithTestMixin extends BootMixin(NormalApplication, [TestMixin]) {} class BootWithTestMixinReverse extends BootMixin( TestMixin(NormalApplication), ) {} diff --git a/packages/boot/tsconfig.json b/packages/boot/tsconfig.json new file mode 100644 index 000000000000..5d395fc57680 --- /dev/null +++ b/packages/boot/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../build/config/tsconfig.common.json", + "include": [ + "src", + "test" + ], + "exclude": [ + "node_modules/**", + "packages/*/node_modules/**", + "**/*.d.ts" + ] +}