From 36a8eac74a4736611deee3f2d4147dcbddae88ef 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] 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'); + } +});