diff --git a/docs/site/Binding.md b/docs/site/Binding.md index 68d2b01636fb..5ba0cc0e1b0b 100644 --- a/docs/site/Binding.md +++ b/docs/site/Binding.md @@ -229,6 +229,28 @@ const binding = createBindingFromClass(MyService); ctx.add(binding); ``` +Please note `createBindingFromClass` also accepts an optional `options` +parameter of `BindingFromClassOptions` type with the following settings: + +- key: Binding key, such as `controllers.MyController` +- type: Artifact type, such as `server`, `controller`, `repository` or `service` +- name: Artifact name, such as `my-rest-server` and `my-controller`, default to + the name of the bound class +- namespace: Namespace for the binding key, such as `servers` and `controllers`. + If `key` does not exist, its value is calculated as `.`. +- typeNamespaceMapping: Mapping artifact type to binding key namespaces, such + as: + + ```ts + { + controller: 'controllers', + repository: 'repositories' + } + ``` + +- defaultScope: Default scope if the binding does not have an explicit scope + set. The `scope` from `@bind` of the bound class takes precedence. + ### Encoding value types in binding keys String keys for bindings do not help enforce the value type. Consider the diff --git a/packages/context/src/__tests__/acceptance/bind-decorator.acceptance.ts b/packages/context/src/__tests__/acceptance/bind-decorator.acceptance.ts index 878dc57b9208..76830a3a6146 100644 --- a/packages/context/src/__tests__/acceptance/bind-decorator.acceptance.ts +++ b/packages/context/src/__tests__/acceptance/bind-decorator.acceptance.ts @@ -72,4 +72,42 @@ describe('@bind - customize classes with binding attributes', () => { 'controllers.my-controller', ]); }); + + it('supports default binding scope in options', () => { + const binding = createBindingFromClass(MyController, { + defaultScope: BindingScope.SINGLETON, + }); + expect(binding.scope).to.equal(BindingScope.SINGLETON); + }); + + describe('binding scope', () => { + @bind({ + // Explicitly set the binding scope to be `SINGLETON` as the developer + // choose to implement the controller as a singleton without depending + // on request specific information + scope: BindingScope.SINGLETON, + }) + class MySingletonController {} + + it('allows singleton controller with @bind', () => { + const binding = createBindingFromClass(MySingletonController, { + type: 'controller', + }); + expect(binding.key).to.equal('controllers.MySingletonController'); + expect(binding.tagMap).to.containEql({controller: 'controller'}); + expect(binding.scope).to.equal(BindingScope.SINGLETON); + }); + + it('honors binding scope from @bind over defaultScope', () => { + let binding = createBindingFromClass(MySingletonController, { + defaultScope: BindingScope.TRANSIENT, + }); + expect(binding.scope).to.equal(BindingScope.SINGLETON); + }); + + it('honors binding scope from @bind', () => { + const binding = createBindingFromClass(MySingletonController); + expect(binding.scope).to.equal(BindingScope.SINGLETON); + }); + }); }); diff --git a/packages/context/src/__tests__/unit/binding.unit.ts b/packages/context/src/__tests__/unit/binding.unit.ts index 37af23450243..28cb6837a4d0 100644 --- a/packages/context/src/__tests__/unit/binding.unit.ts +++ b/packages/context/src/__tests__/unit/binding.unit.ts @@ -29,6 +29,11 @@ describe('Binding', () => { it('sets the binding lock state to unlocked by default', () => { expect(binding.isLocked).to.be.false(); }); + + it('leaves other states to `undefined` by default', () => { + expect(binding.type).to.be.undefined(); + expect(binding.valueConstructor).to.be.undefined(); + }); }); describe('lock', () => { @@ -76,7 +81,7 @@ describe('Binding', () => { }); describe('inScope', () => { - it('defaults the transient binding scope', () => { + it('defaults to `TRANSIENT` binding scope', () => { expect(binding.scope).to.equal(BindingScope.TRANSIENT); }); @@ -96,6 +101,19 @@ describe('Binding', () => { }); }); + describe('applyDefaultScope', () => { + it('sets the scope if not set', () => { + binding.applyDefaultScope(BindingScope.SINGLETON); + expect(binding.scope).to.equal(BindingScope.SINGLETON); + }); + + it('does not override the existing scope', () => { + binding.inScope(BindingScope.TRANSIENT); + binding.applyDefaultScope(BindingScope.SINGLETON); + expect(binding.scope).to.equal(BindingScope.TRANSIENT); + }); + }); + describe('to(value)', () => { it('returns the value synchronously', () => { binding.to('value'); diff --git a/packages/context/src/binding-inspector.ts b/packages/context/src/binding-inspector.ts index 4ac8685738da..cec36ab90b09 100644 --- a/packages/context/src/binding-inspector.ts +++ b/packages/context/src/binding-inspector.ts @@ -193,11 +193,20 @@ export type BindingFromClassOptions = { * Mapping artifact type to binding key namespaces */ typeNamespaceMapping?: TypeNamespaceMapping; + /** + * Default scope if the binding does not have an explicit scope + */ + defaultScope?: BindingScope; }; /** - * Create a binding from a class with decorated metadata - * @param cls A class + * Create a binding from a class with decorated metadata. The class is attached + * to the binding as follows: + * - `binding.toClass(cls)`: if `cls` is a plain class such as `MyController` + * - `binding.toProvider(cls)`: it `cls` is a value provider class with a + * prototype method `value()` + * + * @param cls A class. It can be either a plain class or a value provider class * @param options Options to customize the binding key */ export function createBindingFromClass( @@ -216,6 +225,9 @@ export function createBindingFromClass( if (options.type) { binding.tag({type: options.type}, options.type); } + if (options.defaultScope) { + binding.applyDefaultScope(options.defaultScope); + } return binding; } diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index cfbbd110d69b..3045bdee3f7c 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -141,18 +141,24 @@ export class Binding { /** * Map for tag name/value pairs */ - public readonly tagMap: TagMap = {}; + private _scope?: BindingScope; /** * Scope of the binding to control how the value is cached/shared */ - public scope: BindingScope = BindingScope.TRANSIENT; + public get scope(): BindingScope { + // Default to TRANSIENT if not set + return this._scope || BindingScope.TRANSIENT; + } + private _type?: BindingType; /** * Type of the binding value getter */ - public type: BindingType; + public get type(): BindingType | undefined { + return this._type; + } private _cache: WeakMap; private _getValue: ( @@ -160,11 +166,14 @@ export class Binding { session?: ResolutionSession, ) => ValueOrPromise; + private _valueConstructor?: Constructor; /** * For bindings bound via toClass, this property contains the constructor * function */ - public valueConstructor: Constructor; + public get valueConstructor(): Constructor | undefined { + return this._valueConstructor; + } constructor(key: string, public isLocked: boolean = false) { BindingKey.validate(key); @@ -190,6 +199,7 @@ export class Binding { // Cache the value at the current context this._cache.set(ctx, val); } + // Do not cache for `TRANSIENT` return val; }); } @@ -302,8 +312,24 @@ export class Binding { return Object.keys(this.tagMap); } + /** + * Set the binding scope + * @param scope Binding scope + */ inScope(scope: BindingScope): this { - this.scope = scope; + this._scope = scope; + return this; + } + + /** + * Apply default scope to the binding. It only changes the scope if it's not + * set yet + * @param scope Default binding scope + */ + applyDefaultScope(scope: BindingScope): this { + if (!this._scope) { + this._scope = scope; + } return this; } @@ -345,7 +371,7 @@ export class Binding { if (debug.enabled) { debug('Bind %s to constant:', this.key, value); } - this.type = BindingType.CONSTANT; + this._type = BindingType.CONSTANT; this._getValue = () => value; return this; } @@ -373,7 +399,7 @@ export class Binding { if (debug.enabled) { debug('Bind %s to dynamic value:', this.key, factoryFn); } - this.type = BindingType.DYNAMIC_VALUE; + this._type = BindingType.DYNAMIC_VALUE; this._getValue = ctx => factoryFn(); return this; } @@ -399,7 +425,7 @@ export class Binding { if (debug.enabled) { debug('Bind %s to provider %s', this.key, providerClass.name); } - this.type = BindingType.PROVIDER; + this._type = BindingType.PROVIDER; this._getValue = (ctx, session) => { const providerOrPromise = instantiateClass>( providerClass, @@ -423,9 +449,9 @@ export class Binding { if (debug.enabled) { debug('Bind %s to class %s', this.key, ctor.name); } - this.type = BindingType.CLASS; + this._type = BindingType.CLASS; this._getValue = (ctx, session) => instantiateClass(ctor, ctx!, session); - this.valueConstructor = ctor; + this._valueConstructor = ctor; return this; } diff --git a/packages/core/src/__tests__/unit/application.unit.ts b/packages/core/src/__tests__/unit/application.unit.ts index 61d978a899fe..6d6a6ec498d2 100644 --- a/packages/core/src/__tests__/unit/application.unit.ts +++ b/packages/core/src/__tests__/unit/application.unit.ts @@ -3,15 +3,17 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {expect} from '@loopback/testlab'; -import {Application, Server, Component, CoreBindings} from '../..'; import { - Context, - Constructor, + bind, Binding, - Provider, + BindingScope, + Constructor, + Context, inject, + Provider, } from '@loopback/context'; +import {expect} from '@loopback/testlab'; +import {Application, Component, CoreBindings, Server} from '../..'; describe('Application', () => { describe('controller binding', () => { @@ -24,6 +26,7 @@ describe('Application', () => { const binding = app.controller(MyController); expect(Array.from(binding.tagNames)).to.containEql('controller'); expect(binding.key).to.equal('controllers.MyController'); + expect(binding.scope).to.equal(BindingScope.TRANSIENT); expect(findKeysByTag(app, 'controller')).to.containEql(binding.key); }); @@ -34,6 +37,15 @@ describe('Application', () => { expect(findKeysByTag(app, 'controller')).to.containEql(binding.key); }); + it('binds a singleton controller', () => { + @bind({scope: BindingScope.SINGLETON}) + class MySingletonController {} + + const binding = app.controller(MySingletonController); + expect(binding.scope).to.equal(BindingScope.SINGLETON); + expect(findKeysByTag(app, 'controller')).to.containEql(binding.key); + }); + function givenApp() { app = new Application(); } @@ -47,7 +59,8 @@ describe('Application', () => { beforeEach(givenApp); it('binds a component', () => { - app.component(MyComponent); + const binding = app.component(MyComponent); + expect(binding.scope).to.equal(BindingScope.SINGLETON); expect(findKeysByTag(app, 'component')).to.containEql( 'components.MyComponent', ); @@ -60,6 +73,14 @@ describe('Application', () => { ); }); + it('binds a transient component', () => { + @bind({scope: BindingScope.TRANSIENT}) + class MyTransientComponent {} + + const binding = app.component(MyTransientComponent); + expect(binding.scope).to.equal(BindingScope.TRANSIENT); + }); + it('binds controllers from a component', () => { class MyController {} @@ -133,16 +154,26 @@ describe('Application', () => { }); describe('server binding', () => { + let app: Application; + beforeEach(givenApplication); + it('defaults to constructor name', async () => { - const app = new Application(); const binding = app.server(FakeServer); + expect(binding.scope).to.equal(BindingScope.SINGLETON); expect(Array.from(binding.tagNames)).to.containEql('server'); const result = await app.getServer(FakeServer.name); expect(result.constructor.name).to.equal(FakeServer.name); }); + it('binds a server with a different scope than SINGLETON', async () => { + @bind({scope: BindingScope.TRANSIENT}) + class TransientServer extends FakeServer {} + + const binding = app.server(TransientServer); + expect(binding.scope).to.equal(BindingScope.TRANSIENT); + }); + it('allows custom name', async () => { - const app = new Application(); const name = 'customName'; app.server(FakeServer, name); const result = await app.getServer(name); @@ -150,7 +181,6 @@ describe('Application', () => { }); it('allows binding of multiple servers as an array', async () => { - const app = new Application(); const bindings = app.servers([FakeServer, AnotherServer]); expect(Array.from(bindings[0].tagNames)).to.containEql('server'); expect(Array.from(bindings[1].tagNames)).to.containEql('server'); @@ -159,6 +189,10 @@ describe('Application', () => { const AnotherResult = await app.getServer(AnotherServer); expect(AnotherResult.constructor.name).to.equal(AnotherServer.name); }); + + function givenApplication() { + app = new Application(); + } }); describe('start', () => { diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index 5c8480762213..15064c24ae7a 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -50,6 +50,7 @@ export class Application extends Context { name, namespace: 'controllers', type: 'controller', + defaultScope: BindingScope.TRANSIENT, }); this.add(binding); return binding; @@ -80,7 +81,8 @@ export class Application extends Context { name, namespace: CoreBindings.SERVERS, type: 'server', - }).inScope(BindingScope.SINGLETON); + defaultScope: BindingScope.SINGLETON, + }); this.add(binding); return binding; } @@ -196,7 +198,8 @@ export class Application extends Context { name, namespace: 'components', type: 'component', - }).inScope(BindingScope.SINGLETON); + defaultScope: BindingScope.SINGLETON, + }); this.add(binding); // Assuming components can be synchronously instantiated const instance = this.getSync(binding.key); diff --git a/packages/repository/src/__tests__/unit/mixins/repository.mixin.unit.ts b/packages/repository/src/__tests__/unit/mixins/repository.mixin.unit.ts index fac8c16c4eaa..3b45f21784d5 100644 --- a/packages/repository/src/__tests__/unit/mixins/repository.mixin.unit.ts +++ b/packages/repository/src/__tests__/unit/mixins/repository.mixin.unit.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Application, BindingScope, Component} from '@loopback/core'; +import {Application, BindingScope, Component, bind} from '@loopback/core'; import {expect, sinon} from '@loopback/testlab'; import { Class, @@ -32,6 +32,16 @@ describe('RepositoryMixin', () => { expectNoteRepoToBeBound(myApp); }); + it('binds singleton repository from app.repository()', () => { + @bind({scope: BindingScope.SINGLETON}) + class SingletonNoteRepo extends NoteRepo {} + + const myApp = new AppWithRepoMixin(); + + const binding = myApp.repository(SingletonNoteRepo); + expect(binding.scope).to.equal(BindingScope.SINGLETON); + }); + it('mixed class has .getRepository()', () => { const myApp = new AppWithRepoMixin(); expect(typeof myApp.getRepository).to.eql('function'); diff --git a/packages/repository/src/mixins/repository.mixin.ts b/packages/repository/src/mixins/repository.mixin.ts index 2a3fe4ac1e29..e9a3fec8c40b 100644 --- a/packages/repository/src/mixins/repository.mixin.ts +++ b/packages/repository/src/mixins/repository.mixin.ts @@ -71,6 +71,7 @@ export function RepositoryMixin>(superClass: T) { name, namespace: 'repositories', type: 'repository', + defaultScope: BindingScope.TRANSIENT, }); this.add(binding); return binding; @@ -122,7 +123,8 @@ export function RepositoryMixin>(superClass: T) { name: name || dataSource.dataSourceName, namespace: 'datasources', type: 'datasource', - }).inScope(BindingScope.SINGLETON); + defaultScope: BindingScope.SINGLETON, + }); this.add(binding); return binding; } else { diff --git a/packages/rest/src/__tests__/acceptance/bootstrapping/rest.acceptance.ts b/packages/rest/src/__tests__/acceptance/bootstrapping/rest.acceptance.ts index eb1cc9f5e614..19ea7a8f24ea 100644 --- a/packages/rest/src/__tests__/acceptance/bootstrapping/rest.acceptance.ts +++ b/packages/rest/src/__tests__/acceptance/bootstrapping/rest.acceptance.ts @@ -24,11 +24,12 @@ describe('Bootstrapping with RestComponent', () => { // At this moment, the Sequence is not ready to be resolved // as RestBindings.Http.CONTEXT is not bound const binding = server.getBinding(RestBindings.SEQUENCE); - expect(binding.valueConstructor.name).to.equal('UserDefinedSequence'); + expect(binding.valueConstructor).to.equal(UserDefinedSequence); }); + class UserDefinedSequence extends DefaultSequence {} + async function givenAppWithUserDefinedSequence() { - class UserDefinedSequence extends DefaultSequence {} app = new Application({ rest: { sequence: UserDefinedSequence, diff --git a/packages/service-proxy/src/__tests__/unit/mixin/service.mixin.unit.ts b/packages/service-proxy/src/__tests__/unit/mixin/service.mixin.unit.ts index f242432e80a2..5bd6f1caba9f 100644 --- a/packages/service-proxy/src/__tests__/unit/mixin/service.mixin.unit.ts +++ b/packages/service-proxy/src/__tests__/unit/mixin/service.mixin.unit.ts @@ -3,7 +3,13 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Application, Component, Provider, BindingScope} from '@loopback/core'; +import { + Application, + bind, + BindingScope, + Component, + Provider, +} from '@loopback/core'; import {expect} from '@loopback/testlab'; import {Class, ServiceMixin} from '../../../'; @@ -23,6 +29,15 @@ describe('ServiceMixin', () => { await expectGeocoderToBeBound(myApp); }); + it('binds singleton service from app.serviceProvider()', async () => { + @bind({scope: BindingScope.SINGLETON}) + class SingletonGeocoderServiceProvider extends GeocoderServiceProvider {} + const myApp = new AppWithServiceMixin(); + + const binding = myApp.serviceProvider(SingletonGeocoderServiceProvider); + expect(binding.scope).to.equal(BindingScope.SINGLETON); + }); + it('binds a component without services', () => { class EmptyTestComponent {}