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 9b2f35255f4d..5bec4bf6e04a 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,33 @@ 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`). + +**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). | + ## 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/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/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/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/src/booters/service.booter.ts b/packages/boot/src/booters/service.booter.ts new file mode 100644 index 000000000000..b897babb9e68 --- /dev/null +++ b/packages/boot/src/booters/service.booter.ts @@ -0,0 +1,84 @@ +// 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 { + const hasSupportedName = cls.name.endsWith('Provider'); + const hasValueMethod = 'value' in cls.prototype; + return hasSupportedName && hasValueMethod; +} 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..a15cbdcef2df --- /dev/null +++ b/packages/boot/test/fixtures/service-class.artifact.ts @@ -0,0 +1,12 @@ +// 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 + +// 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}`); + } +} 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/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/, + ); }); }); }); 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'); + } +}); 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);