From d8883088acc0ea6f94faaae614701bdda32f4153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 28 Aug 2018 16:48:39 +0200 Subject: [PATCH 1/4] feat(testlab): expose "sandbox.path" property Allow TestSandbox consumers to access the path of the sandbox directory. Clean up the code by moving "validateInst" check into "path" getter, thus avoiding the need to explicitly call "validateInst" in every public API method. --- packages/testlab/src/test-sandbox.ts | 32 +++++++++++----------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/testlab/src/test-sandbox.ts b/packages/testlab/src/test-sandbox.ts index 2138614c17a6..2aafbb379ddf 100644 --- a/packages/testlab/src/test-sandbox.ts +++ b/packages/testlab/src/test-sandbox.ts @@ -20,7 +20,16 @@ import { */ export class TestSandbox { // Path of the TestSandbox - private path: string; + private _path?: string; + + public get path(): string { + if (!this._path) { + throw new Error( + `TestSandbox instance was deleted. Create a new instance.`, + ); + } + return this._path; + } /** * Will create a directory if it doesn't already exist. If it exists, you @@ -30,26 +39,14 @@ export class TestSandbox { */ constructor(path: string) { // resolve ensures path is absolute / makes it absolute (relative to cwd()) - this.path = resolve(path); + this._path = resolve(path); ensureDirSync(this.path); } - /** - * This function ensures a valid instance is being used for operations. - */ - private validateInst() { - if (!this.path) { - throw new Error( - `TestSandbox instance was deleted. Create a new instance.`, - ); - } - } - /** * Returns the path of the TestSandbox */ getPath(): string { - this.validateInst(); return this.path; } @@ -57,8 +54,6 @@ export class TestSandbox { * Resets the TestSandbox. (Remove all files in it). */ async reset(): Promise { - this.validateInst(); - // Decache files from require's cache so future tests aren't affected incase // a file is recreated in sandbox with the same file name but different // contents after resetting the sandbox. @@ -75,9 +70,8 @@ export class TestSandbox { * Deletes the TestSandbox. */ async delete(): Promise { - this.validateInst(); await remove(this.path); - delete this.path; + delete this._path; } /** @@ -86,7 +80,6 @@ export class TestSandbox { * @param dir Name of directory to create (relative to TestSandbox path) */ async mkdir(dir: string): Promise { - this.validateInst(); await ensureDir(resolve(this.path, dir)); } @@ -101,7 +94,6 @@ export class TestSandbox { * (relative to TestSandbox). Original filename used if not specified. */ async copyFile(src: string, dest?: string): Promise { - this.validateInst(); dest = dest ? resolve(this.path, dest) : resolve(this.path, parse(src).base); From 5c879590713436db0c877ddc12dddacb43bae621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 28 Aug 2018 16:51:11 +0200 Subject: [PATCH 2/4] feat(boot): add debug logs for better troubleshooting Make it easier to troubleshoot the situation when a booter is not recognizing artifact files and/or classes exported by those files. To make debug logs easy to read, absolute paths are converted to project-relative paths in debug logs. This change required a refactoring of Booter design, where "projectRoot" becomes a required constructor argument. While making these changes, I changed "options" to be a required constructor argument too, and chaged both "options" and "projectRoot" to readonly properties. --- .../boot/src/booters/base-artifact.booter.ts | 43 +++++++++++++++++-- packages/boot/src/booters/booter-utils.ts | 13 +++++- .../boot/src/booters/controller.booter.ts | 10 +++-- .../boot/src/booters/datasource.booter.ts | 10 +++-- .../boot/src/booters/repository.booter.ts | 10 +++-- .../unit/booters/base-artifact.booter.unit.ts | 38 +++++++++------- .../test/unit/booters/booter-utils.unit.ts | 8 ++-- 7 files changed, 96 insertions(+), 36 deletions(-) diff --git a/packages/boot/src/booters/base-artifact.booter.ts b/packages/boot/src/booters/base-artifact.booter.ts index ec653dc6c4f9..da244ea59968 100644 --- a/packages/boot/src/booters/base-artifact.booter.ts +++ b/packages/boot/src/booters/base-artifact.booter.ts @@ -4,8 +4,12 @@ // License text available at https://opensource.org/licenses/MIT import {Constructor} from '@loopback/context'; +import * as debugFactory from 'debug'; +import * as path from 'path'; +import {ArtifactOptions, Booter} from '../interfaces'; import {discoverFiles, loadClassesFromFiles} from './booter-utils'; -import {Booter, ArtifactOptions} from '../interfaces'; + +const debug = debugFactory('loopback:boot:base-artifact-booter'); /** * This class serves as a base class for Booters which follow a pattern of @@ -35,14 +39,27 @@ export class BaseArtifactBooter implements Booter { /** * Options being used by the Booter. */ - options: ArtifactOptions; - projectRoot: string; + readonly options: ArtifactOptions; + readonly projectRoot: string; dirs: string[]; extensions: string[]; glob: string; discovered: string[]; classes: Array>; + constructor(projectRoot: string, options: ArtifactOptions) { + this.projectRoot = projectRoot; + this.options = options; + } + + /** + * Get the name of the artifact loaded by this booter, e.g. "Controller". + * Subclasses can override the default logic based on the class name. + */ + get artifactName(): string { + return this.constructor.name.replace(/Booter$/, ''); + } + /** * Configure the Booter by initializing the 'dirs', 'extensions' and 'glob' * properties. @@ -78,7 +95,25 @@ export class BaseArtifactBooter implements Booter { * 'discovered' property. */ async discover() { + debug( + 'Discovering %s artifacts in %j using glob %j', + this.artifactName, + this.projectRoot, + this.glob, + ); + this.discovered = await discoverFiles(this.glob, this.projectRoot); + + if (debug.enabled) { + debug( + 'Artifact files found: %s', + JSON.stringify( + this.discovered.map(f => path.relative(this.projectRoot, f)), + null, + 2, + ), + ); + } } /** @@ -90,6 +125,6 @@ export class BaseArtifactBooter implements Booter { * and then process the artifact classes as appropriate. */ async load() { - this.classes = loadClassesFromFiles(this.discovered); + this.classes = loadClassesFromFiles(this.discovered, this.projectRoot); } } diff --git a/packages/boot/src/booters/booter-utils.ts b/packages/boot/src/booters/booter-utils.ts index db5024ce85b5..2504efa95ac8 100644 --- a/packages/boot/src/booters/booter-utils.ts +++ b/packages/boot/src/booters/booter-utils.ts @@ -4,9 +4,13 @@ // License text available at https://opensource.org/licenses/MIT import {Constructor} from '@loopback/context'; +import * as debugFactory from 'debug'; +import * as path from 'path'; import {promisify} from 'util'; const glob = promisify(require('glob')); +const debug = debugFactory('loopback:boot:booter-utils'); + /** * Returns all files matching the given glob pattern relative to root * @@ -42,16 +46,23 @@ export function isClass(target: any): target is Constructor { * @param files An array of string of absolute file paths * @returns {Constructor<{}>[]} An array of Class constructors from a file */ -export function loadClassesFromFiles(files: string[]): Constructor<{}>[] { +export function loadClassesFromFiles( + files: string[], + projectRootDir: string, +): Constructor<{}>[] { const classes: Array> = []; for (const file of files) { + debug('Loading artifact file %j', path.relative(projectRootDir, file)); const moduleObj = require(file); // WORKAROUND: use `for in` instead of Object.values(). // See https://github.com/nodejs/node/issues/20278 for (const k in moduleObj) { const exported = moduleObj[k]; if (isClass(exported)) { + debug(' add %s (class %s)', k, exported.name); classes.push(exported); + } else { + debug(' skip non-class %s', k); } } } diff --git a/packages/boot/src/booters/controller.booter.ts b/packages/boot/src/booters/controller.booter.ts index b5af53d91cef..8953bc227758 100644 --- a/packages/boot/src/booters/controller.booter.ts +++ b/packages/boot/src/booters/controller.booter.ts @@ -22,13 +22,15 @@ import {BootBindings} from '../keys'; export class ControllerBooter extends BaseArtifactBooter { constructor( @inject(CoreBindings.APPLICATION_INSTANCE) public app: Application, - @inject(BootBindings.PROJECT_ROOT) public projectRoot: string, + @inject(BootBindings.PROJECT_ROOT) 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); + super( + projectRoot, + // Set Controller Booter Options if passed in via bootConfig + Object.assign({}, ControllerDefaults, controllerConfig), + ); } /** diff --git a/packages/boot/src/booters/datasource.booter.ts b/packages/boot/src/booters/datasource.booter.ts index 665a626164a1..5529cc3e040d 100644 --- a/packages/boot/src/booters/datasource.booter.ts +++ b/packages/boot/src/booters/datasource.booter.ts @@ -28,13 +28,15 @@ export class DataSourceBooter extends BaseArtifactBooter { constructor( @inject(CoreBindings.APPLICATION_INSTANCE) public app: ApplicationWithRepositories, - @inject(BootBindings.PROJECT_ROOT) public projectRoot: string, + @inject(BootBindings.PROJECT_ROOT) projectRoot: string, @inject(`${BootBindings.BOOT_OPTIONS}#datasources`) public datasourceConfig: ArtifactOptions = {}, ) { - super(); - // Set DataSource Booter Options if passed in via bootConfig - this.options = Object.assign({}, DataSourceDefaults, datasourceConfig); + super( + projectRoot, + // Set DataSource Booter Options if passed in via bootConfig + Object.assign({}, DataSourceDefaults, datasourceConfig), + ); } /** diff --git a/packages/boot/src/booters/repository.booter.ts b/packages/boot/src/booters/repository.booter.ts index a8c2ce841fbf..caf8e914a758 100644 --- a/packages/boot/src/booters/repository.booter.ts +++ b/packages/boot/src/booters/repository.booter.ts @@ -25,13 +25,15 @@ export class RepositoryBooter extends BaseArtifactBooter { constructor( @inject(CoreBindings.APPLICATION_INSTANCE) public app: ApplicationWithRepositories, - @inject(BootBindings.PROJECT_ROOT) public projectRoot: string, + @inject(BootBindings.PROJECT_ROOT) projectRoot: string, @inject(`${BootBindings.BOOT_OPTIONS}#repositories`) public repositoryOptions: ArtifactOptions = {}, ) { - super(); - // Set Repository Booter Options if passed in via bootConfig - this.options = Object.assign({}, RepositoryDefaults, repositoryOptions); + super( + projectRoot, + // Set Repository Booter Options if passed in via bootConfig + Object.assign({}, RepositoryDefaults, repositoryOptions), + ); } /** diff --git a/packages/boot/test/unit/booters/base-artifact.booter.unit.ts b/packages/boot/test/unit/booters/base-artifact.booter.unit.ts index 9ba05542f2b9..68f38e92bb88 100644 --- a/packages/boot/test/unit/booters/base-artifact.booter.unit.ts +++ b/packages/boot/test/unit/booters/base-artifact.booter.unit.ts @@ -6,35 +6,38 @@ import {BaseArtifactBooter} from '../../../index'; import {expect} from '@loopback/testlab'; import {resolve} from 'path'; +import {ArtifactOptions} from '../../../src'; describe('base-artifact booter unit tests', () => { - let booterInst: BaseArtifactBooter; - - beforeEach(getBaseBooter); + const TEST_OPTIONS = { + dirs: ['test', 'test2'], + extensions: ['.test.js', 'test2.js'], + nested: false, + }; 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}; + const booterInst = givenBaseBooter({ + 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 booterInst = givenBaseBooter(); 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 booterInst = givenBaseBooter( + Object.assign({}, TEST_OPTIONS, {nested: true}), + ); const expected = '/@(test|test2)/**/*@(.test.js|test2.js)'; await booterInst.configure(); expect(booterInst.glob).to.equal(expected); @@ -42,7 +45,9 @@ describe('base-artifact booter unit tests', () => { it(`sets 'glob' pattern to options.glob if present`, async () => { const expected = '/**/*.glob'; - booterInst.options = Object.assign({}, options, {glob: expected}); + const booterInst = givenBaseBooter( + Object.assign({}, TEST_OPTIONS, {glob: expected}), + ); await booterInst.configure(); expect(booterInst.glob).to.equal(expected); }); @@ -50,7 +55,7 @@ describe('base-artifact booter unit tests', () => { describe('discover()', () => { it(`sets 'discovered' property`, async () => { - booterInst.projectRoot = __dirname; + const booterInst = givenBaseBooter(); // Fake glob pattern so we get an empty array booterInst.glob = '/abc.xyz'; await booterInst.discover(); @@ -60,6 +65,7 @@ describe('base-artifact booter unit tests', () => { describe('load()', () => { it(`sets 'classes' property to Classes from a file`, async () => { + const booterInst = givenBaseBooter(); booterInst.discovered = [ resolve(__dirname, '../../fixtures/multiple.artifact.js'), ]; @@ -69,7 +75,7 @@ describe('base-artifact booter unit tests', () => { }); }); - async function getBaseBooter() { - booterInst = new BaseArtifactBooter(); + function givenBaseBooter(options?: ArtifactOptions) { + return new BaseArtifactBooter(__dirname, options || TEST_OPTIONS); } }); diff --git a/packages/boot/test/unit/booters/booter-utils.unit.ts b/packages/boot/test/unit/booters/booter-utils.unit.ts index 734b2d4a30e4..cf53d63f33d2 100644 --- a/packages/boot/test/unit/booters/booter-utils.unit.ts +++ b/packages/boot/test/unit/booters/booter-utils.unit.ts @@ -67,7 +67,7 @@ describe('booter-utils unit tests', () => { const files = [resolve(SANDBOX_PATH, 'multiple.artifact.js')]; const NUM_CLASSES = 2; // Number of classes in above file - const classes = loadClassesFromFiles(files); + const classes = loadClassesFromFiles(files, sandbox.path); expect(classes).to.have.lengthOf(NUM_CLASSES); expect(classes[0]).to.be.a.Function(); expect(classes[1]).to.be.a.Function(); @@ -79,14 +79,16 @@ describe('booter-utils unit tests', () => { ); const files = [resolve(SANDBOX_PATH, 'empty.artifact.js')]; - const classes = loadClassesFromFiles(files); + const classes = loadClassesFromFiles(files, sandbox.path); 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.throw(/Cannot find module/); + expect(() => loadClassesFromFiles(files, sandbox.path)).to.throw( + /Cannot find module/, + ); }); }); }); From 9b8c8b727bebd73367c9ee2285e392759742e9f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 28 Aug 2018 16:57:37 +0200 Subject: [PATCH 3/4] feat(boot): implement Service booter --- packages/boot/README.md | 24 +++++ packages/boot/docs.json | 1 + packages/boot/package.json | 1 + packages/boot/src/boot.component.ts | 14 ++- packages/boot/src/booters/index.ts | 3 +- packages/boot/src/booters/service.booter.ts | 82 ++++++++++++++ packages/boot/test/fixtures/application.ts | 5 +- .../test/fixtures/service-class.artifact.ts | 10 ++ .../fixtures/service-provider.artifact.ts | 28 +++++ .../integration/service.booter.integration.ts | 49 +++++++++ .../boot/test/unit/boot.component.unit.ts | 8 ++ .../unit/booters/datasource.booter.unit.ts | 2 +- .../test/unit/booters/service.booter.unit.ts | 101 ++++++++++++++++++ 13 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 packages/boot/src/booters/service.booter.ts create mode 100644 packages/boot/test/fixtures/service-class.artifact.ts create mode 100644 packages/boot/test/fixtures/service-provider.artifact.ts create mode 100644 packages/boot/test/integration/service.booter.integration.ts create mode 100644 packages/boot/test/unit/booters/service.booter.unit.ts diff --git a/packages/boot/README.md b/packages/boot/README.md index 9b2f35255f4d..3b0b4f371985 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -48,6 +48,7 @@ List of Options available on BootOptions Object. | `controllers` | `ArtifactOptions` | ControllerBooter convention options | | `repositories` | `ArtifactOptions` | RepositoryBooter convention options | | `datasources` | `ArtifactOptions` | DataSourceBooter convention options | +| `services` | `ArtifactOptions` | ServiceBooter convention options | ### ArtifactOptions @@ -159,6 +160,29 @@ Available options on the `datasources` object on `BootOptions` are as follows: | `nested` | `boolean` | `true` | Look in nested directories in `dirs` for DataSource artifacts | | `glob` | `string` | | A `glob` pattern string. This takes precedence over above 3 options (which are used to make a glob pattern). | +### ServiceBooter + +#### Description + +Discovers and binds Service providers using `app.serviceProvider()` (Application +must use `ServiceMixin` from `@loopback/service-proxy`). + +#### Options + +The options for this can be passed via `BootOptions` when calling +`app.boot(options: BootOptions)`. + +The options for this are passed in a `services` object on `BootOptions`. + +Available options on the `services` object on `BootOptions` are as follows: + +| Options | Type | Default | Description | +| ------------ | -------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ | +| `dirs` | `string \| string[]` | `['repositories']` | Paths relative to projectRoot to look in for Service artifacts | +| `extensions` | `string \| string[]` | `['.repository.js']` | File extensions to match for Service artifacts | +| `nested` | `boolean` | `true` | Look in nested directories in `dirs` for Service artifacts | +| `glob` | `string` | | A `glob` pattern string. This takes precedence over above 3 options (which are used to make a glob pattern). | + ## Contributions - [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) diff --git a/packages/boot/docs.json b/packages/boot/docs.json index c2180c24f099..e69aee6a5427 100644 --- a/packages/boot/docs.json +++ b/packages/boot/docs.json @@ -6,6 +6,7 @@ "src/booters/controller.booter.ts", "src/booters/datasource.booter.ts", "src/booters/repository.booter.ts", + "src/booters/service.booter.ts", "src/booters/index.ts", "src/mixins/boot.mixin.ts", "src/mixins/index.ts", diff --git a/packages/boot/package.json b/packages/boot/package.json index cc4850eda2af..a30fdbeb9800 100644 --- a/packages/boot/package.json +++ b/packages/boot/package.json @@ -30,6 +30,7 @@ "@loopback/core": "^0.11.5", "@loopback/dist-util": "^0.3.6", "@loopback/repository": "^0.15.1", + "@loopback/service-proxy": "^0.7.1", "@types/debug": "0.0.30", "@types/glob": "^5.0.35", "debug": "^3.1.0", diff --git a/packages/boot/src/boot.component.ts b/packages/boot/src/boot.component.ts index e13430864d3b..ec43376b922b 100644 --- a/packages/boot/src/boot.component.ts +++ b/packages/boot/src/boot.component.ts @@ -6,7 +6,12 @@ import {Bootstrapper} from './bootstrapper'; import {Component, Application, CoreBindings} from '@loopback/core'; import {inject, BindingScope} from '@loopback/context'; -import {ControllerBooter, RepositoryBooter, DataSourceBooter} from './booters'; +import { + ControllerBooter, + RepositoryBooter, + DataSourceBooter, + ServiceBooter, +} from './booters'; import {BootBindings} from './keys'; /** @@ -17,7 +22,12 @@ import {BootBindings} from './keys'; 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, RepositoryBooter, DataSourceBooter]; + booters = [ + ControllerBooter, + RepositoryBooter, + ServiceBooter, + DataSourceBooter, + ]; /** * diff --git a/packages/boot/src/booters/index.ts b/packages/boot/src/booters/index.ts index f31c44a32175..fcf21cce42fd 100644 --- a/packages/boot/src/booters/index.ts +++ b/packages/boot/src/booters/index.ts @@ -6,5 +6,6 @@ export * from './base-artifact.booter'; export * from './booter-utils'; export * from './controller.booter'; -export * from './repository.booter'; export * from './datasource.booter'; +export * from './repository.booter'; +export * from './service.booter'; diff --git a/packages/boot/src/booters/service.booter.ts b/packages/boot/src/booters/service.booter.ts new file mode 100644 index 000000000000..ecb924cf8311 --- /dev/null +++ b/packages/boot/src/booters/service.booter.ts @@ -0,0 +1,82 @@ +// 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} from '@loopback/core'; +import {ApplicationWithServices} from '@loopback/service-proxy'; +import {inject, Provider, Constructor} from '@loopback/context'; +import {ArtifactOptions} from '../interfaces'; +import {BaseArtifactBooter} from './base-artifact.booter'; +import {BootBindings} from '../keys'; + +type ServiceProviderClass = Constructor>; + +/** + * A class that extends BaseArtifactBooter to boot the 'DataSource' artifact type. + * Discovered DataSources are bound using `app.controller()`. + * + * Supported phases: configure, discover, load + * + * @param app Application instance + * @param projectRoot Root of User Project relative to which all paths are resolved + * @param [bootConfig] DataSource Artifact Options Object + */ +export class ServiceBooter extends BaseArtifactBooter { + serviceProviders: ServiceProviderClass[]; + + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) + public app: ApplicationWithServices, + @inject(BootBindings.PROJECT_ROOT) projectRoot: string, + @inject(`${BootBindings.BOOT_OPTIONS}#services`) + public serviceConfig: ArtifactOptions = {}, + ) { + super( + projectRoot, + // Set DataSource Booter Options if passed in via bootConfig + Object.assign({}, ServiceDefaults, serviceConfig), + ); + } + + /** + * Uses super method to get a list of Artifact classes. Boot each file by + * creating a DataSourceConstructor and binding it to the application class. + */ + async load() { + await super.load(); + + this.serviceProviders = this.classes.filter(isServiceProvider); + + /** + * If Service providers were discovered, we need to make sure ServiceMixin + * was used (so we have `app.serviceProvider()`) to perform the binding of a + * Service provider class. + */ + if (this.serviceProviders.length > 0) { + if (!this.app.serviceProvider) { + console.warn( + 'app.serviceProvider() function is needed for ServiceBooter. You can add ' + + 'it to your Application using ServiceMixin from @loopback/service-proxy.', + ); + } else { + this.serviceProviders.forEach(cls => { + this.app.serviceProvider(cls as Constructor>); + }); + } + } + } +} + +/** + * Default ArtifactOptions for DataSourceBooter. + */ +export const ServiceDefaults: ArtifactOptions = { + dirs: ['services'], + extensions: ['.service.js'], + nested: true, +}; + +function isServiceProvider(cls: Constructor<{}>): cls is ServiceProviderClass { + return /Provider$/.test(cls.name); +} diff --git a/packages/boot/test/fixtures/application.ts b/packages/boot/test/fixtures/application.ts index 567096bff60a..3d370abaac04 100644 --- a/packages/boot/test/fixtures/application.ts +++ b/packages/boot/test/fixtures/application.ts @@ -6,9 +6,12 @@ import {ApplicationConfig} from '@loopback/core'; import {RepositoryMixin} from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; +import {ServiceMixin} from '@loopback/service-proxy'; import {BootMixin} from '../../index'; -export class BooterApp extends RepositoryMixin(BootMixin(RestApplication)) { +export class BooterApp extends BootMixin( + ServiceMixin(RepositoryMixin(RestApplication)), +) { constructor(options?: ApplicationConfig) { super(options); this.projectRoot = __dirname; diff --git a/packages/boot/test/fixtures/service-class.artifact.ts b/packages/boot/test/fixtures/service-class.artifact.ts new file mode 100644 index 000000000000..25861df0d1f8 --- /dev/null +++ b/packages/boot/test/fixtures/service-class.artifact.ts @@ -0,0 +1,10 @@ +// 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 class GreetingService { + greet(whom: string = 'world') { + return Promise.resolve(`Hello ${whom}`); + } +} diff --git a/packages/boot/test/fixtures/service-provider.artifact.ts b/packages/boot/test/fixtures/service-provider.artifact.ts new file mode 100644 index 000000000000..b48c28b7d7f4 --- /dev/null +++ b/packages/boot/test/fixtures/service-provider.artifact.ts @@ -0,0 +1,28 @@ +// 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 {Provider} from '@loopback/core'; + +export interface GeoPoint { + lat: number; + lng: number; +} + +export interface GeocoderService { + geocode(address: string): Promise; +} + +// A dummy service instance to make unit testing easier +const GeocoderSingleton: GeocoderService = { + geocode(address: string) { + return Promise.resolve({lat: 0, lng: 0}); + }, +}; + +export class GeocoderServiceProvider implements Provider { + value(): Promise { + return Promise.resolve(GeocoderSingleton); + } +} diff --git a/packages/boot/test/integration/service.booter.integration.ts b/packages/boot/test/integration/service.booter.integration.ts new file mode 100644 index 000000000000..f42585486c3a --- /dev/null +++ b/packages/boot/test/integration/service.booter.integration.ts @@ -0,0 +1,49 @@ +// 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 {BooterApp} from '../fixtures/application'; + +describe('service booter integration tests', () => { + const SANDBOX_PATH = resolve(__dirname, '../../.sandbox'); + const sandbox = new TestSandbox(SANDBOX_PATH); + + const SERVICES_PREFIX = 'services'; + const SERVICES_TAG = 'service'; + + let app: BooterApp; + + beforeEach('reset sandbox', () => sandbox.reset()); + beforeEach(getApp); + + it('boots services when app.boot() is called', async () => { + const expectedBindings = [ + `${SERVICES_PREFIX}.GeocoderService`, + // greeting service is skipped - service classes are not supported (yet) + ]; + + await app.boot(); + + const bindings = app.findByTag(SERVICES_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/service-provider.artifact.js'), + 'services/geocoder.service.js', + ); + + await sandbox.copyFile( + resolve(__dirname, '../fixtures/service-class.artifact.js'), + 'services/greeting.service.js', + ); + + const MyApp = require(resolve(SANDBOX_PATH, 'application.js')).BooterApp; + app = new MyApp(); + } +}); diff --git a/packages/boot/test/unit/boot.component.unit.ts b/packages/boot/test/unit/boot.component.unit.ts index ae24503c5751..4bfdff3aee02 100644 --- a/packages/boot/test/unit/boot.component.unit.ts +++ b/packages/boot/test/unit/boot.component.unit.ts @@ -12,6 +12,7 @@ import { BootMixin, RepositoryBooter, DataSourceBooter, + ServiceBooter, } from '../../'; describe('boot.component unit tests', () => { @@ -47,6 +48,13 @@ describe('boot.component unit tests', () => { expect(booterInst).to.be.an.instanceOf(DataSourceBooter); }); + it('ServiceBooter is bound as a booter by default', async () => { + const booterInst = await app.get( + `${BootBindings.BOOTER_PREFIX}.ServiceBooter`, + ); + expect(booterInst).to.be.an.instanceOf(ServiceBooter); + }); + function getApp() { app = new BootableApp(); app.bind(BootBindings.PROJECT_ROOT).to(__dirname); diff --git a/packages/boot/test/unit/booters/datasource.booter.unit.ts b/packages/boot/test/unit/booters/datasource.booter.unit.ts index c82d99df9468..4d278b06cff9 100644 --- a/packages/boot/test/unit/booters/datasource.booter.unit.ts +++ b/packages/boot/test/unit/booters/datasource.booter.unit.ts @@ -29,7 +29,7 @@ describe('datasource booter unit tests', () => { beforeEach(createStub); afterEach(restoreStub); - it('gives a wanring if called on an app without RepositoryMixin', async () => { + it('gives a warning if called on an app without RepositoryMixin', async () => { const normalApp = new Application(); await sandbox.copyFile( resolve(__dirname, '../../fixtures/datasource.artifact.js'), diff --git a/packages/boot/test/unit/booters/service.booter.unit.ts b/packages/boot/test/unit/booters/service.booter.unit.ts new file mode 100644 index 000000000000..1abab13412da --- /dev/null +++ b/packages/boot/test/unit/booters/service.booter.unit.ts @@ -0,0 +1,101 @@ +// 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, sinon} from '@loopback/testlab'; +import {resolve} from 'path'; +import {ApplicationWithServices, ServiceMixin} from '@loopback/service-proxy'; +import {ServiceBooter, ServiceDefaults} from '../../../src'; +import {Application} from '@loopback/core'; + +describe('service booter unit tests', () => { + const SANDBOX_PATH = resolve(__dirname, '../../../.sandbox'); + const sandbox = new TestSandbox(SANDBOX_PATH); + + const SERVICES_PREFIX = 'services'; + const SERVICES_TAG = 'service'; + + class AppWithRepo extends ServiceMixin(Application) {} + + let app: AppWithRepo; + let stub: sinon.SinonStub; + + beforeEach('reset sandbox', () => sandbox.reset()); + beforeEach(getApp); + beforeEach(createStub); + afterEach(restoreStub); + + it('gives a warning if called on an app without RepositoryMixin', async () => { + const normalApp = new Application(); + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/service-provider.artifact.js'), + ); + + const booterInst = new ServiceBooter( + normalApp as ApplicationWithServices, + SANDBOX_PATH, + ); + + booterInst.discovered = [ + resolve(SANDBOX_PATH, 'service-provider.artifact.js'), + ]; + await booterInst.load(); + + sinon.assert.calledOnce(stub); + sinon.assert.calledWith( + stub, + 'app.serviceProvider() function is needed for ServiceBooter. You can add ' + + 'it to your Application using ServiceMixin from @loopback/service-proxy.', + ); + }); + + it(`uses ServiceDefaults for 'options' if none are given`, () => { + const booterInst = new ServiceBooter(app, SANDBOX_PATH); + expect(booterInst.options).to.deepEqual(ServiceDefaults); + }); + + it('overrides defaults with provided options and uses defaults for the rest', () => { + const options = { + dirs: ['test'], + extensions: ['.ext1'], + }; + const expected = Object.assign({}, options, { + nested: ServiceDefaults.nested, + }); + + const booterInst = new ServiceBooter(app, SANDBOX_PATH, options); + expect(booterInst.options).to.deepEqual(expected); + }); + + it('binds services during the load phase', async () => { + const expected = [`${SERVICES_PREFIX}.GeocoderService`]; + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/service-provider.artifact.js'), + ); + const booterInst = new ServiceBooter(app, SANDBOX_PATH); + const NUM_CLASSES = 1; // 1 class in above file. + + booterInst.discovered = [ + resolve(SANDBOX_PATH, 'service-provider.artifact.js'), + ]; + await booterInst.load(); + + const services = app.findByTag(SERVICES_TAG); + const keys = services.map(binding => binding.key); + expect(keys).to.have.lengthOf(NUM_CLASSES); + expect(keys.sort()).to.eql(expected.sort()); + }); + + function getApp() { + app = new AppWithRepo(); + } + + function restoreStub() { + stub.restore(); + } + + function createStub() { + stub = sinon.stub(console, 'warn'); + } +}); From cc0e10876f46c3c4802869bc73d8933220b0b14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 30 Aug 2018 09:40:04 +0200 Subject: [PATCH 4/4] fixup! finish the changes - address review comments - use ServiceBooter in example apps - update tutorial pages - update docs --- docs/site/Booting-an-Application.md | 27 +++++++ docs/site/sidebars/lb4_sidebar.yml | 4 - ...soap-calculator-tutorial-add-controller.md | 3 +- ...ap-calculator-tutorial-register-service.md | 80 ------------------- .../soap-calculator-tutorial-run-and-test.md | 3 +- .../soap-calculator-tutorial-scaffolding.md | 13 +-- docs/site/todo-tutorial-geocoding-service.md | 35 +------- examples/soap-calculator/src/application.ts | 8 -- examples/todo/src/application.ts | 9 --- packages/boot/README.md | 4 + packages/boot/src/booters/service.booter.ts | 4 +- .../test/fixtures/service-class.artifact.ts | 2 + 12 files changed, 50 insertions(+), 142 deletions(-) delete mode 100644 docs/site/soap-calculator-tutorial-register-service.md diff --git a/docs/site/Booting-an-Application.md b/docs/site/Booting-an-Application.md index ea308cf760c7..b4332ddfa535 100644 --- a/docs/site/Booting-an-Application.md +++ b/docs/site/Booting-an-Application.md @@ -237,6 +237,33 @@ The `datasources` object support the following options: | `nested` | `boolean` | `true` | Look in nested directories in `dirs` for DataSource artifacts | | `glob` | `string` | | A `glob` pattern string. This takes precendence over above 3 options (which are used to make a glob pattern). | +### Service Booter + +#### Description + +Discovers and binds Service providers using `app.serviceProvider()` (Application +must use `ServiceMixin` from `@loopback/service-proxy`). + +**IMPORTANT:** For a class to be recognized by `ServiceBooter` as a service +provider, its name must end with `Provider` suffix and its prototype must have a +`value()` method. + +#### Options + +The options for this can be passed via `BootOptions` when calling +`app.boot(options: BootOptions)`. + +The options for this are passed in a `services` object on `BootOptions`. + +Available options on the `services` object on `BootOptions` are as follows: + +| Options | Type | Default | Description | +| ------------ | -------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ | +| `dirs` | `string \| string[]` | `['repositories']` | Paths relative to projectRoot to look in for Service artifacts | +| `extensions` | `string \| string[]` | `['.repository.js']` | File extensions to match for Service artifacts | +| `nested` | `boolean` | `true` | Look in nested directories in `dirs` for Service artifacts | +| `glob` | `string` | | A `glob` pattern string. This takes precedence over above 3 options (which are used to make a glob pattern). | + ### Custom Booters A custom Booter can be written as a Class that implements the `Booter` diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index b17c6c6c756d..9556a8a32b3d 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -95,10 +95,6 @@ children: url: soap-calculator-tutorial-add-controller.html output: 'web, pdf' - - title: 'Register the Service' - url: soap-calculator-tutorial-register-service.html - output: 'web, pdf' - - title: 'Run and Test it' url: soap-calculator-tutorial-run-and-test.html output: 'web, pdf' diff --git a/docs/site/soap-calculator-tutorial-add-controller.md b/docs/site/soap-calculator-tutorial-add-controller.md index c71823a32f44..a14793452ad5 100644 --- a/docs/site/soap-calculator-tutorial-add-controller.md +++ b/docs/site/soap-calculator-tutorial-add-controller.md @@ -193,4 +193,5 @@ the divide end point method. Previous step: [Add a Service](soap-calculator-tutorial-add-service.md) -Next step: [Register the Service](soap-calculator-tutorial-register-service.md) +Next step: +[Run and Test the Application](soap-calculator-tutorial-run-and-test.md) diff --git a/docs/site/soap-calculator-tutorial-register-service.md b/docs/site/soap-calculator-tutorial-register-service.md deleted file mode 100644 index c41944becd12..000000000000 --- a/docs/site/soap-calculator-tutorial-register-service.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -lang: en -title: 'Register the Service' -keywords: LoopBack 4.0, LoopBack 4 -sidebar: lb4_sidebar -permalink: /doc/en/lb4/soap-calculator-tutorial-register-service.html ---- - -### Registering the service in the Application src/application.ts - -We need to add the **CalculatorService** service to the `src/application.ts` -file so that it is loaded automatically at boot time and the application knows -about its correspondent **key** to make it available in the **DI** _(Dependency -Injection)_. - -#### Importing the service and helper classes - -Add the following import statements after all the previous imports. - -```ts -import {ServiceMixin} from '@loopback/service-proxy'; -import {CalculatorServiceProvider} from './services/calculator.service'; -``` - -#### Applying `ServiceMixin` on our Application class - -Modify the inheritance chain of our Application class as follows: - -```ts -export class SoapCalculatorApplication extends BootMixin( - ServiceMixin(RepositoryMixin(RestApplication)), -) { - // (no changes in application constructor or methods) -} -``` - -#### Registering the Service and bind it to a key - -Let's continue by creating a method to register services used by our -application. Notice that we are using `this.serviceProvider` method contributed -by `ServiceMixin`, this method removes the suffix `Provider` from the class name -and uses the remaining string as the binding key. For our service provider -called `CalculatorServiceProvider`, the binding key becomes -**services.CalculatorService** and matches the -`@inject('services.CalculatorService')` decorator parameter we used in our -controller. - -**NOTE:** This will be the method for now until we place the autodiscover and -registration for services in the same way we do now for other artifacts in -**LB4**. - -```ts - setupServices() { - this.serviceProvider(CalculatorServiceProvider); - } -``` - -Finally call this `setupServices()` method from inside the Application -constructor after the `this.sequence(MySequence);` statement. - -```ts -//bind services -this.setupServices(); -``` - -**Note:** We could have achieved the above result by calling the following line -inside the setupServices() method, replacing the method provided by the mixin. -However, the mixin-provided method is more efficient when you need to register -multiple services, to keep the _keys_ standard. - -```ts -this.bind('services.CalculatorService').toProvider(CalculatorServiceProvider); -``` - -### Navigation - -Previous step: [Add a Controller](soap-calculator-tutorial-add-controller.md) - -Next step: -[Run and Test the Application](soap-calculator-tutorial-run-and-test.md) diff --git a/docs/site/soap-calculator-tutorial-run-and-test.md b/docs/site/soap-calculator-tutorial-run-and-test.md index 783120ccbc20..e07a68aae57a 100644 --- a/docs/site/soap-calculator-tutorial-run-and-test.md +++ b/docs/site/soap-calculator-tutorial-run-and-test.md @@ -66,5 +66,4 @@ options = Object.assign( ### Navigation -Previous step: -[Register the Service](soap-calculator-tutorial-register-service.md) +Previous step: [Add a Controller](soap-calculator-tutorial-add-controller.md) diff --git a/docs/site/soap-calculator-tutorial-scaffolding.md b/docs/site/soap-calculator-tutorial-scaffolding.md index d0bb07cc6531..011dad75c97f 100644 --- a/docs/site/soap-calculator-tutorial-scaffolding.md +++ b/docs/site/soap-calculator-tutorial-scaffolding.md @@ -12,12 +12,14 @@ Let's start by creating the initial application by running the following command: ```sh -lb4 app soap-calculator --enableRepository +lb4 app soap-calculator --enableRepository --enableServices ``` **Note:** The option **--enableRepository** instructs the **CLI** to include a -RepositoryMixin class in the application constructor which will be needed when -we create the datasource. +`RepositoryMixin` class in the application constructor which will be needed when +we create the datasource. The option **--enableServices** instructs the **CLI** +to include a `ServiceMixin` class in the application constructor which will be +needed to register our SOAP service client. **LB4** will ask you a few questions _(you can leave the default options)_. The description and the root directory are obvious. The class name referes to the @@ -31,8 +33,9 @@ application.ts file. ``` Next you will see a list of options for the build settings, if you did not -specify --enableRepository in the last command, then you will see it in this -list, make sure you enable the repository for the application. +specify `--enableRepository` and `--enableServices` in the last command, then +you will see them in this list, make sure you enable both the repository and the +services for the application. **Note:** Enable all options, unless you know what you are doing, see [The Getting Started guide](Getting-started.md) for more information. diff --git a/docs/site/todo-tutorial-geocoding-service.md b/docs/site/todo-tutorial-geocoding-service.md index 3da83c76488e..13e51fabbf49 100644 --- a/docs/site/todo-tutorial-geocoding-service.md +++ b/docs/site/todo-tutorial-geocoding-service.md @@ -94,12 +94,12 @@ docs here: [REST connector](/doc/en/lb3/REST-connector.html). Create a new directory `src/services` and add the following two new files: -- `src/geocoder.service.ts` defining TypeScript interfaces for Geocoder service - and implementing a service proxy provider. +- `src/services/geocoder.service.ts` defining TypeScript interfaces for Geocoder + service and implementing a service proxy provider. - `src/index.ts` providing a conventient access to all services via a single `import` statement. -#### src/geocoder.service.ts +#### src/services/geocoder.service.ts ```ts import {getService, juggler} from '@loopback/service-proxy'; @@ -140,35 +140,6 @@ export class GeocoderServiceProvider implements Provider { export * from './geocoder.service'; ``` -### Register the service for dependency injection - -Because `@loopback/boot` does not support loading of services yet (see -[issue #1439](https://github.com/strongloop/loopback-next/issues/1439)), we need -to add few code snippets to our Application class to take care of this task. - -#### src/application.ts - -```ts -import {ServiceMixin} from '@loopback/service-proxy'; - -export class TodoListApplication extends BootMixin( - ServiceMixin(RepositoryMixin(RestApplication)), -) { - constructor(options?: ApplicationConfig) { - super(options); - // etc., keep the existing code without changes - - // ADD THE FOLLOWING LINE AT THE END - this.setupServices(); - } - - // ADD THE FOLLOWING TWO METHODS - setupServices() { - this.serviceProvider(GeocoderServiceProvider); - } -} -``` - ### Enhance Todo model with location data Add two new properties to our Todo model: `remindAtAddress` and `remindAtGeo`. diff --git a/examples/soap-calculator/src/application.ts b/examples/soap-calculator/src/application.ts index f83914987231..0022f310d71d 100644 --- a/examples/soap-calculator/src/application.ts +++ b/examples/soap-calculator/src/application.ts @@ -4,7 +4,6 @@ import {RepositoryMixin} from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; import {ServiceMixin} from '@loopback/service-proxy'; import {MySequence} from './sequence'; -import {CalculatorServiceProvider} from './services/calculator.service'; export class SoapCalculatorApplication extends BootMixin( ServiceMixin(RepositoryMixin(RestApplication)), @@ -15,9 +14,6 @@ export class SoapCalculatorApplication extends BootMixin( // Set up the custom sequence this.sequence(MySequence); - //bind services - this.setupServices(); - this.projectRoot = __dirname; // Customize @loopback/boot Booter Conventions here this.bootOptions = { @@ -29,8 +25,4 @@ export class SoapCalculatorApplication extends BootMixin( }, }; } - - setupServices() { - this.serviceProvider(CalculatorServiceProvider); - } } diff --git a/examples/todo/src/application.ts b/examples/todo/src/application.ts index 8a5b428c5e99..154f2374baf0 100644 --- a/examples/todo/src/application.ts +++ b/examples/todo/src/application.ts @@ -9,7 +9,6 @@ import {RepositoryMixin} from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; import {ServiceMixin} from '@loopback/service-proxy'; import {MySequence} from './sequence'; -import {GeocoderServiceProvider} from './services'; export class TodoListApplication extends BootMixin( ServiceMixin(RepositoryMixin(RestApplication)), @@ -30,13 +29,5 @@ export class TodoListApplication extends BootMixin( nested: true, }, }; - - // TODO(bajtos) Services should be created and registered by @loopback/boot - // See https://github.com/strongloop/loopback-next/issues/1439 - this.setupServices(); - } - - setupServices() { - this.serviceProvider(GeocoderServiceProvider); } } diff --git a/packages/boot/README.md b/packages/boot/README.md index 3b0b4f371985..5bec4bf6e04a 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -167,6 +167,10 @@ Available options on the `datasources` object on `BootOptions` are as follows: Discovers and binds Service providers using `app.serviceProvider()` (Application must use `ServiceMixin` from `@loopback/service-proxy`). +**IMPORTANT:** For a class to be recognized by `ServiceBooter` as a service +provider, its name must end with `Provider` suffix and its prototype must have a +`value()` method. + #### Options The options for this can be passed via `BootOptions` when calling diff --git a/packages/boot/src/booters/service.booter.ts b/packages/boot/src/booters/service.booter.ts index ecb924cf8311..b897babb9e68 100644 --- a/packages/boot/src/booters/service.booter.ts +++ b/packages/boot/src/booters/service.booter.ts @@ -78,5 +78,7 @@ export const ServiceDefaults: ArtifactOptions = { }; function isServiceProvider(cls: Constructor<{}>): cls is ServiceProviderClass { - return /Provider$/.test(cls.name); + const hasSupportedName = cls.name.endsWith('Provider'); + const hasValueMethod = 'value' in cls.prototype; + return hasSupportedName && hasValueMethod; } diff --git a/packages/boot/test/fixtures/service-class.artifact.ts b/packages/boot/test/fixtures/service-class.artifact.ts index 25861df0d1f8..a15cbdcef2df 100644 --- a/packages/boot/test/fixtures/service-class.artifact.ts +++ b/packages/boot/test/fixtures/service-class.artifact.ts @@ -3,6 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +// NOTE(bajtos) At the moment, ServiceBooter recognizes only service providers. +// This class is used by tests to verify that non-provider classes are ignored. export class GreetingService { greet(whom: string = 'world') { return Promise.resolve(`Hello ${whom}`);