diff --git a/docs/site/Binding.md b/docs/site/Binding.md index 7ae4efb0131f..68d2b01636fb 100644 --- a/docs/site/Binding.md +++ b/docs/site/Binding.md @@ -179,6 +179,56 @@ const serverBinding = new Binding('servers.RestServer1'); serverBinding.apply(serverTemplate); ``` +### Configure binding attributes for a class + +Classes can be discovered and bound to the application context during `boot`. In +addition to conventions, it's often desirable to allow certain binding +attributes, such as scope and tags, to be specified as metadata for the class. +When the class is bound, these attributes are honored to create a binding. You +can use `@bind` decorator to configure how to bind a class. + +```ts +import {bind, BindingScope} from '@loopback/context'; + +// @bind() accepts scope and tags +@bind({ + scope: BindingScope.SINGLETON, + tags: ['service'], +}) +export class MyService {} + +// @binding.provider is a shortcut for a provider class +@bind.provider({ + tags: { + key: 'my-date-provider', + }, +}) +export class MyDateProvider implements Provider { + value() { + return new Date(); + } +} + +@bind({ + tags: ['controller', {name: 'my-controller'}], +}) +export class MyController {} + +// @bind() can take one or more binding template functions +@bind(binding => binding.tag('controller', {name: 'your-controller'}) +export class YourController {} +``` + +Then a binding can be created by inspecting the class, + +```ts +import {createBindingFromClass} from '@loopback/context'; + +const ctx = new Context(); +const binding = createBindingFromClass(MyService); +ctx.add(binding); +``` + ### Encoding value types in binding keys String keys for bindings do not help enforce the value type. Consider the diff --git a/packages/context/docs.json b/packages/context/docs.json index b0e75f4447d2..a88745216461 100644 --- a/packages/context/docs.json +++ b/packages/context/docs.json @@ -7,6 +7,7 @@ "src/index.ts", "src/inject.ts", "src/value-promise.ts", + "src/bind-decorator.ts", "src/provider.ts", "src/resolution-session.ts", "src/resolver.ts" diff --git a/packages/context/src/binding-decorator.ts b/packages/context/src/binding-decorator.ts new file mode 100644 index 000000000000..3c2cf907f0e9 --- /dev/null +++ b/packages/context/src/binding-decorator.ts @@ -0,0 +1,105 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ClassDecoratorFactory} from '@loopback/metadata'; +import { + asBindingTemplate, + asProvider, + BindingMetadata, + BindingSpec, + BINDING_METADATA_KEY, + isProviderClass, + asClassOrProvider, + removeNameAndKeyTags, +} from './binding-inspector'; +import {Provider} from './provider'; +import {Constructor} from './value-promise'; + +/** + * Decorator factory for `@bind` + */ +class BindDecoratorFactory extends ClassDecoratorFactory { + mergeWithInherited(inherited: BindingMetadata, target: Function) { + if (inherited) { + return { + templates: [ + ...inherited.templates, + removeNameAndKeyTags, + ...this.spec.templates, + ], + target: this.spec.target, + }; + } else { + this.withTarget(this.spec, target); + return this.spec; + } + } + + withTarget(spec: BindingMetadata, target: Function) { + spec.target = target as Constructor; + return spec; + } +} + +/** + * Decorate a class with binding configuration + * + * @example + * ```ts + * @bind((binding) => {binding.inScope(BindingScope.SINGLETON).tag('controller')} + * ) + * export class MyController { + * } + * ``` + * + * @param specs A list of binding scope/tags or template functions to + * configure the binding + */ +export function bind(...specs: BindingSpec[]): ClassDecorator { + const templateFunctions = specs.map(t => { + if (typeof t === 'function') { + return t; + } else { + return asBindingTemplate(t); + } + }); + + return (target: Function) => { + const cls = target as Constructor; + const spec: BindingMetadata = { + templates: [asClassOrProvider(cls), ...templateFunctions], + target: cls, + }; + + const decorator = BindDecoratorFactory.createDecorator( + BINDING_METADATA_KEY, + spec, + ); + decorator(target); + }; +} + +export namespace bind { + /** + * `@bind.provider` to denote a provider class + * + * A list of binding scope/tags or template functions to configure the binding + */ + export function provider( + ...specs: BindingSpec[] + ): ((target: Constructor>) => void) { + return (target: Constructor>) => { + if (!isProviderClass(target)) { + throw new Error(`Target ${target} is not a Provider`); + } + bind( + // Set up the default for providers + asProvider(target), + // Call other template functions + ...specs, + )(target); + }; + } +} diff --git a/packages/context/src/binding-inspector.ts b/packages/context/src/binding-inspector.ts new file mode 100644 index 000000000000..cddcc7d6ab2a --- /dev/null +++ b/packages/context/src/binding-inspector.ts @@ -0,0 +1,287 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {MetadataAccessor, MetadataInspector} from '@loopback/metadata'; +import {Binding, BindingScope, BindingTag, BindingTemplate} from './binding'; +import {BindingAddress} from './binding-key'; +import {ContextTags} from './keys'; +import {Provider} from './provider'; +import {Constructor} from './value-promise'; + +/** + * Binding metadata from `@bind` + */ +export type BindingMetadata = { + /** + * An array of template functions to configure a binding + */ + templates: BindingTemplate[]; + /** + * The target class where binding metadata is decorated + */ + target: Constructor; +}; + +/** + * Metadata key for binding metadata + */ +export const BINDING_METADATA_KEY = MetadataAccessor.create< + BindingMetadata, + ClassDecorator +>('binding.metadata'); + +/** + * An object to configure binding scope and tags + */ +export type BindingScopeAndTags = { + scope?: BindingScope; + tags?: BindingTag | BindingTag[]; +}; + +/** + * Specification of parameters for `@bind()` + */ +export type BindingSpec = BindingTemplate | BindingScopeAndTags; + +/** + * Check if a class implements `Provider` interface + * @param cls A class + */ +export function isProviderClass( + cls: Constructor, +): cls is Constructor> { + return cls && typeof cls.prototype.value === 'function'; +} + +/** + * A factory function to create a template function to bind the target class + * as a `Provider`. + * @param target Target provider class + */ +export function asProvider( + target: Constructor>, +): BindingTemplate { + return binding => + binding.toProvider(target).tag(ContextTags.PROVIDER, { + [ContextTags.TYPE]: ContextTags.PROVIDER, + }); +} + +/** + * A factory function to create a template function to bind the target class + * as a class or `Provider`. + * @param target Target class, which can be an implementation of `Provider` + */ +export function asClassOrProvider( + target: Constructor, +): BindingTemplate { + // Add a template to bind to a class or provider + return binding => { + if (isProviderClass(target)) { + asProvider(target)(binding); + } else { + binding.toClass(target); + } + }; +} + +/** + * Convert binding scope and tags as a template function + * @param scopeAndTags Binding scope and tags + */ +export function asBindingTemplate( + scopeAndTags: BindingScopeAndTags, +): BindingTemplate { + return binding => { + if (scopeAndTags.scope) { + binding.inScope(scopeAndTags.scope); + } + if (scopeAndTags.tags) { + if (Array.isArray(scopeAndTags.tags)) { + binding.tag(...scopeAndTags.tags); + } else { + binding.tag(scopeAndTags.tags); + } + } + }; +} + +/** + * Get binding metadata for a class + * @param target The target class + */ +export function getBindingMetadata( + target: Function, +): BindingMetadata | undefined { + return MetadataInspector.getClassMetadata( + BINDING_METADATA_KEY, + target, + ); +} + +/** + * A binding template function to delete `name` and `key` tags + */ +export function removeNameAndKeyTags(binding: Binding) { + if (binding.tagMap) { + delete binding.tagMap.name; + delete binding.tagMap.key; + } +} + +/** + * Get the binding template for a class with binding metadata + * + * @param cls A class with optional `@bind` + */ +export function bindingTemplateFor(cls: Constructor): BindingTemplate { + const spec = getBindingMetadata(cls); + const templateFunctions = (spec && spec.templates) || [ + asClassOrProvider(cls), + ]; + return binding => { + for (const t of templateFunctions) { + binding.apply(t); + } + if (spec && spec.target !== cls) { + // Remove name/key tags inherited from base classes + binding.apply(removeNameAndKeyTags); + } + }; +} + +/** + * Mapping artifact types to binding key namespaces (prefixes). For example: + * ```ts + * { + * repository: 'repositories' + * } + * ``` + */ +export type TypeNamespaceMapping = {[name: string]: string}; + +export const DEFAULT_TYPE_NAMESPACES: TypeNamespaceMapping = { + class: 'classes', + provider: 'providers', +}; + +/** + * Options to customize the binding created from a class + */ +export type BindingFromClassOptions = { + /** + * Binding key + */ + key?: BindingAddress; + /** + * Artifact type, such as `server`, `controller`, `repository` or `service` + */ + type?: string; + /** + * Artifact name, such as `my-rest-server` and `my-controller` + */ + name?: string; + /** + * Namespace for the binding key, such as `servers` and `controllers` + */ + namespace?: string; + /** + * Mapping artifact type to binding key namespaces + */ + typeNamespaceMapping?: TypeNamespaceMapping; +}; + +/** + * Create a binding from a class with decorated metadata + * @param cls A class + * @param options Options to customize the binding key + */ +export function createBindingFromClass( + cls: Constructor, + options: BindingFromClassOptions = {}, +): Binding { + const templateFn = bindingTemplateFor(cls); + let key = options.key; + if (!key) { + key = buildBindingKey(cls, options); + } + const binding = Binding.bind(key).apply(templateFn); + if (options.name) { + binding.tag({name: options.name}); + } + if (options.type) { + binding.tag({type: options.type}, options.type); + } + return binding; +} + +/** + * Find/infer binding key namespace for a type + * @param type Artifact type, such as `controller`, `datasource`, or `server` + * @param typeNamespaces An object mapping type names to namespaces + */ +function getNamespace(type: string, typeNamespaces = DEFAULT_TYPE_NAMESPACES) { + if (type in typeNamespaces) { + return typeNamespaces[type]; + } else { + // Return the plural form + return `${type}s`; + } +} + +/** + * Build the binding key for a class with optional binding metadata. + * The binding key is resolved in the following steps: + * + * 1. Check `options.key`, if it exists, return it + * 2. Check if the binding metadata has `key` tag, if yes, return its tag value + * 3. Identify `namespace` and `name` to form the binding key as + * `.`. + * - namespace + * - `options.namespace` + * - Map `options.type` or `type` tag value to a namespace, for example, + * 'controller` to 'controller'. + * - name + * - `options.name` + * - `name` tag value + * - the class name + * + * @param cls A class to be bound + * @param options Options to customize how to build the key + */ +function buildBindingKey( + cls: Constructor, + options: BindingFromClassOptions = {}, +) { + const templateFn = bindingTemplateFor(cls); + // Create a temporary binding + const bindingTemplate = new Binding('template').apply(templateFn); + // Is there a `key` tag? + let key: string = options.key || bindingTemplate.tagMap[ContextTags.KEY]; + if (key) return key; + + let namespace = options.namespace; + if (!namespace) { + const namespaces = Object.assign( + {}, + DEFAULT_TYPE_NAMESPACES, + options.typeNamespaceMapping, + ); + // Derive the key from type + name + let type = options.type || bindingTemplate.tagMap[ContextTags.TYPE]; + if (!type) { + type = + bindingTemplate.tagNames.find(t => namespaces[t] != null) || + ContextTags.CLASS; + } + namespace = getNamespace(type, namespaces); + } + + const name = + options.name || bindingTemplate.tagMap[ContextTags.NAME] || cls.name; + key = `${namespace}.${name}`; + + return key; +} diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index 1bcf47d2d46a..038633a4c14f 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -118,6 +118,16 @@ export enum BindingType { // tslint:disable-next-line:no-any export type TagMap = MapObject; +/** + * Binding tag can be a simple name or name/value pairs + */ +export type BindingTag = TagMap | string; + +/** + * A function as the template to configure bindings + */ +export type BindingTemplate = (binding: Binding) => void; + /** * Binding represents an entry in the `Context`. Each binding has a key and a * corresponding value getter. @@ -269,7 +279,7 @@ export class Binding { * * ``` */ - tag(...tags: (string | TagMap)[]): this { + tag(...tags: BindingTag[]): this { for (const t of tags) { if (typeof t === 'string') { this.tagMap[t] = t; @@ -438,7 +448,7 @@ export class Binding { * ``` * @param templateFn A function to configure the binding */ - apply(templateFn: (binding: Binding) => void): this { + apply(templateFn: BindingTemplate): this { templateFn(this); return this; } diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index b512d58630ac..706bc4e36808 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -3,15 +3,14 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Binding, TagMap} from './binding'; -import {BindingKey, BindingAddress} from './binding-key'; -import {isPromiseLike, getDeepProperty, BoundValue} from './value-promise'; -import {ResolutionOptions, ResolutionSession} from './resolution-session'; - -import {v1 as uuidv1} from 'uuid'; - import * as debugModule from 'debug'; +import {v1 as uuidv1} from 'uuid'; import {ValueOrPromise} from '.'; +import {Binding, BindingTag} from './binding'; +import {BindingAddress, BindingKey} from './binding-key'; +import {ResolutionOptions, ResolutionSession} from './resolution-session'; +import {BoundValue, getDeepProperty, isPromiseLike} from './value-promise'; + const debug = debugModule('loopback:context'); /** @@ -207,7 +206,7 @@ export class Context { * `{name: 'my-controller'}` */ findByTag( - tagFilter: string | RegExp | TagMap, + tagFilter: BindingTag | RegExp, ): Readonly>[] { if (typeof tagFilter === 'string' || tagFilter instanceof RegExp) { const regexp = diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index c4f9e3bb7fae..ca4671786bf5 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -4,29 +4,14 @@ // License text available at https://opensource.org/licenses/MIT export * from '@loopback/metadata'; - -export { - isPromiseLike, - BoundValue, - Constructor, - ValueOrPromise, - MapObject, - resolveList, - resolveMap, - resolveUntil, - transformValueOrPromise, - tryWithFinally, - getDeepProperty, -} from './value-promise'; - -export {Binding, BindingScope, BindingType, TagMap} from './binding'; - -export {Context} from './context'; -export {BindingKey, BindingAddress} from './binding-key'; -export {ResolutionSession} from './resolution-session'; -export {inject, Setter, Getter, Injection, InjectionMetadata} from './inject'; -export {Provider} from './provider'; - -export {instantiateClass, invokeMethod} from './resolver'; -// internals for testing -export {describeInjectedArguments, describeInjectedProperties} from './inject'; +export * from './binding'; +export * from './binding-decorator'; +export * from './binding-inspector'; +export * from './binding-key'; +export * from './context'; +export * from './inject'; +export * from './keys'; +export * from './provider'; +export * from './resolution-session'; +export * from './resolver'; +export * from './value-promise'; diff --git a/packages/context/src/keys.ts b/packages/context/src/keys.ts new file mode 100644 index 000000000000..4b82165b2837 --- /dev/null +++ b/packages/context/src/keys.ts @@ -0,0 +1,22 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export namespace ContextTags { + export const CLASS = 'class'; + export const PROVIDER = 'provider'; + + /** + * Type of the artifact + */ + export const TYPE = 'type'; + /** + * Name of the artifact + */ + export const NAME = 'name'; + /** + * Binding key for the artifact + */ + export const KEY = 'key'; +} diff --git a/packages/context/test/acceptance/bind-decorator.acceptance.ts b/packages/context/test/acceptance/bind-decorator.acceptance.ts new file mode 100644 index 000000000000..8ff8cf4c947a --- /dev/null +++ b/packages/context/test/acceptance/bind-decorator.acceptance.ts @@ -0,0 +1,70 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {Context, bind, BindingScope, Provider} from '../..'; +import {createBindingFromClass} from '../../src'; + +describe('@bind - customize classes with binding attributes', () => { + @bind({ + scope: BindingScope.SINGLETON, + tags: ['service'], + }) + class MyService {} + + @bind.provider({ + tags: { + key: 'my-date-provider', + }, + }) + class MyDateProvider implements Provider { + value() { + return new Date(); + } + } + + @bind({ + tags: ['controller', {name: 'my-controller', type: 'controller'}], + }) + class MyController {} + + const discoveredClasses = [MyService, MyDateProvider, MyController]; + + it('allows discovery of classes to be bound', () => { + const ctx = new Context(); + discoveredClasses.forEach(c => { + const binding = createBindingFromClass(c); + if (binding.tagMap.controller) { + ctx.add(binding); + } + }); + expect(ctx.findByTag('controller').map(b => b.key)).eql([ + 'controllers.my-controller', + ]); + expect(ctx.find().map(b => b.key)).eql(['controllers.my-controller']); + }); + + it('allows binding attributes to be customized', () => { + const ctx = new Context(); + discoveredClasses.forEach(c => { + const binding = createBindingFromClass(c, { + typeNamespaceMapping: { + controller: 'controllers', + service: 'services', + }, + }); + ctx.add(binding); + }); + expect(ctx.findByTag('provider').map(b => b.key)).eql(['my-date-provider']); + expect(ctx.getBinding('services.MyService').scope).to.eql( + BindingScope.SINGLETON, + ); + expect(ctx.find().map(b => b.key)).eql([ + 'services.MyService', + 'my-date-provider', + 'controllers.my-controller', + ]); + }); +}); diff --git a/packages/context/test/acceptance/binding-decorator.feature.md b/packages/context/test/acceptance/binding-decorator.feature.md new file mode 100644 index 000000000000..1bfe0428ec3b --- /dev/null +++ b/packages/context/test/acceptance/binding-decorator.feature.md @@ -0,0 +1,52 @@ +# Feature: @bind for classes representing various artifacts + +- In order to automatically bind classes for various artifacts to a context +- As a developer +- I want to decorate my classes to provide more metadata +- So that the bootstrapper can bind them to a context according to the metadata + +## Scenario: Add metadata to a class to facilitate automatic binding + +When the bootstrapper discovers a file under `controllers` folder, it tries to +bind the exported constructs to the context automatically. + +For example: + +controllers/log-controller.ts + +```ts +export class LogController {} + +export const LOG_LEVEL = 'info'; +export class LogProvider implements Provider { + value() { + return msg => console.log(msg); + } +} +``` + +There are three exported entries from `log-controller.ts` and the bootstrapper +does not have enough information to bind them to the context correctly. For +example, it's impossible for the bootstrapper to infer that `LogProvider` is a +provider so that the class can be bound using +`ctx.bind('providers.LogProvider').toProvider(LogProvider)`. + +Developers can help the bootstrapper by decorating these classes with `@bind`. + +```ts +@bind({tags: ['log']}) +export class LogController {} + +export const LOG_LEVEL = 'info'; + +@bind({type: 'provider', tags: ['log']}) +export class LogProvider implements Provider { + value() { + return msg => console.log(msg); + } +} +``` + +Please note that we don't intend to use `@bind` to help the bootstrapper +discover such artifacts. The purpose of `@bind` is to allow developers to +provide more metadata on how the class should be bound. diff --git a/packages/context/test/unit/binding-decorator.test.ts b/packages/context/test/unit/binding-decorator.test.ts new file mode 100644 index 000000000000..1125cfec315f --- /dev/null +++ b/packages/context/test/unit/binding-decorator.test.ts @@ -0,0 +1,166 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + bind, + BindingScope, + BindingScopeAndTags, + Constructor, + Binding, + bindingTemplateFor, + BindingTemplate, + Provider, +} from '../..'; + +describe('@bind', () => { + const scopeAndTags = { + tags: {rest: 'rest'}, + scope: BindingScope.SINGLETON, + }; + + it('decorates a class', () => { + const spec = { + tags: ['rest'], + scope: BindingScope.SINGLETON, + }; + + @bind(spec) + class MyController {} + + expect(inspectScopeAndTags(MyController)).to.eql(scopeAndTags); + }); + + it('allows inheritance for certain tags and scope', () => { + const spec = { + tags: ['rest'], + scope: BindingScope.SINGLETON, + }; + + @bind(spec) + class MyController {} + + class MySubController extends MyController {} + + expect(inspectScopeAndTags(MySubController)).to.eql(scopeAndTags); + }); + + it('ignores `name` and `key` from base class', () => { + const spec = { + tags: [ + 'rest', + { + name: 'my-controller', + key: 'controllers.my-controller', + }, + ], + scope: BindingScope.SINGLETON, + }; + + @bind(spec) + class MyController {} + + @bind() + class MySubController extends MyController {} + + const result = inspectScopeAndTags(MySubController); + expect(result).to.containEql(scopeAndTags); + expect(result.tags).to.not.containEql({ + name: 'my-controller', + }); + expect(result.tags).to.not.containEql({ + key: 'controllers.my-controller', + }); + }); + + it('accepts template functions', () => { + const spec: BindingTemplate = binding => { + binding.tag('rest').inScope(BindingScope.SINGLETON); + }; + + @bind(spec) + class MyController {} + + expect(inspectScopeAndTags(MyController)).to.eql(scopeAndTags); + }); + + it('accepts multiple scope/tags', () => { + @bind({tags: 'rest'}, {scope: BindingScope.SINGLETON}) + class MyController {} + + expect(inspectScopeAndTags(MyController)).to.eql(scopeAndTags); + }); + + it('accepts multiple template functions', () => { + @bind( + binding => binding.tag('rest'), + binding => binding.inScope(BindingScope.SINGLETON), + ) + class MyController {} + + expect(inspectScopeAndTags(MyController)).to.eql(scopeAndTags); + }); + + it('accepts both template functions and tag/scopes', () => { + const spec: BindingTemplate = binding => { + binding.tag('rest').inScope(BindingScope.SINGLETON); + }; + + @bind(spec, {tags: [{name: 'my-controller'}]}) + class MyController {} + + expect(inspectScopeAndTags(MyController)).to.eql({ + tags: {rest: 'rest', name: 'my-controller'}, + scope: BindingScope.SINGLETON, + }); + }); + + it('decorates a provider classes', () => { + const spec = { + tags: ['rest'], + scope: BindingScope.CONTEXT, + }; + + @bind.provider(spec) + class MyProvider implements Provider { + value() { + return 'my-value'; + } + } + + expect(inspectScopeAndTags(MyProvider)).to.eql({ + tags: {rest: 'rest', type: 'provider', provider: 'provider'}, + scope: BindingScope.CONTEXT, + }); + }); + + it('recognizes provider classes', () => { + const spec = { + tags: ['rest', {type: 'provider'}], + scope: BindingScope.CONTEXT, + }; + + @bind(spec) + class MyProvider implements Provider { + value() { + return 'my-value'; + } + } + + expect(inspectScopeAndTags(MyProvider)).to.eql({ + tags: {rest: 'rest', type: 'provider', provider: 'provider'}, + scope: BindingScope.CONTEXT, + }); + }); + + function inspectScopeAndTags(cls: Constructor): BindingScopeAndTags { + const templateFn = bindingTemplateFor(cls); + const bindingTemplate = new Binding('template').apply(templateFn); + return { + scope: bindingTemplate.scope, + tags: bindingTemplate.tagMap, + }; + } +}); diff --git a/packages/context/test/unit/binding-inspector.test.ts b/packages/context/test/unit/binding-inspector.test.ts new file mode 100644 index 000000000000..fec6fba1a602 --- /dev/null +++ b/packages/context/test/unit/binding-inspector.test.ts @@ -0,0 +1,156 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + bind, + BindingScope, + BindingScopeAndTags, + Constructor, + Context, + createBindingFromClass, + Provider, +} from '../..'; + +describe('createBindingFromClass()', () => { + it('inspects classes', () => { + const spec: BindingScopeAndTags = { + tags: {type: 'controller', name: 'my-controller', rest: 'rest'}, + scope: BindingScope.SINGLETON, + }; + + @bind(spec) + class MyController {} + + const ctx = new Context(); + const binding = givenBinding(MyController, ctx); + + expect(binding.scope).to.eql(spec.scope); + expect(binding.tagMap).to.containDeep({ + name: 'my-controller', + type: 'controller', + rest: 'rest', + }); + ctx.add(binding); + expect(ctx.getSync(binding.key)).to.be.instanceof(MyController); + }); + + it('inspects classes without @bind', () => { + class MyController {} + + const ctx = new Context(); + const binding = givenBinding(MyController, ctx); + + ctx.add(binding); + expect(ctx.getSync(binding.key)).to.be.instanceof(MyController); + }); + + it('inspects provider classes', () => { + const spec = { + tags: ['rest', {type: 'provider'}], + scope: BindingScope.CONTEXT, + }; + + @bind.provider(spec) + class MyProvider implements Provider { + value() { + return 'my-value'; + } + } + + const ctx = new Context(); + const binding = givenBinding(MyProvider, ctx); + + expect(binding.key).to.eql('providers.MyProvider'); + expect(binding.scope).to.eql(spec.scope); + expect(binding.tagMap).to.containDeep({ + type: 'provider', + provider: 'provider', + rest: 'rest', + }); + expect(ctx.getSync(binding.key)).to.eql('my-value'); + }); + + it('recognizes provider classes', () => { + const spec = { + tags: ['rest', {type: 'provider'}], + scope: BindingScope.CONTEXT, + }; + + @bind(spec) + class MyProvider implements Provider { + value() { + return 'my-value'; + } + } + + const ctx = new Context(); + const binding = givenBinding(MyProvider, ctx); + + expect(binding.key).to.eql('providers.MyProvider'); + expect(binding.scope).to.eql(spec.scope); + expect(binding.tagMap).to.containDeep({ + type: 'provider', + provider: 'provider', + rest: 'rest', + }); + expect(ctx.getSync(binding.key)).to.eql('my-value'); + }); + + it('recognizes provider classes without @bind', () => { + class MyProvider implements Provider { + value() { + return 'my-value'; + } + } + + const ctx = new Context(); + const binding = givenBinding(MyProvider, ctx); + + expect(ctx.getSync(binding.key)).to.eql('my-value'); + }); + + it('honors the binding key', () => { + const spec: BindingScopeAndTags = { + tags: { + type: 'controller', + key: 'controllers.my', + name: 'my-controller', + }, + }; + + @bind(spec) + class MyController {} + + const binding = givenBinding(MyController); + + expect(binding.key).to.eql('controllers.my'); + + expect(binding.tagMap).to.eql({ + name: 'my-controller', + type: 'controller', + key: 'controllers.my', + }); + }); + + it('defaults type to class', () => { + const spec: BindingScopeAndTags = {}; + + @bind(spec) + class MyClass {} + + const binding = givenBinding(MyClass); + expect(binding.key).to.eql('classes.MyClass'); + }); + + function givenBinding( + cls: Constructor, + ctx: Context = new Context(), + ) { + const binding = createBindingFromClass(cls); + ctx.add(binding); + return binding; + } +}); diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index 75693f44c8a1..5c8480762213 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -3,10 +3,16 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Context, Binding, BindingScope, Constructor} from '@loopback/context'; -import {Server} from './server'; +import { + Binding, + BindingScope, + Constructor, + Context, + createBindingFromClass, +} from '@loopback/context'; import {Component, mountComponent} from './component'; import {CoreBindings} from './keys'; +import {Server} from './server'; /** * Application is the container for various types of artifacts, such as @@ -40,10 +46,13 @@ export class Application extends Context { * ``` */ controller(controllerCtor: ControllerClass, name?: string): Binding { - name = name || controllerCtor.name; - return this.bind(`controllers.${name}`) - .toClass(controllerCtor) - .tag('controller'); + const binding = createBindingFromClass(controllerCtor, { + name, + namespace: 'controllers', + type: 'controller', + }); + this.add(binding); + return binding; } /** @@ -66,13 +75,14 @@ export class Application extends Context { public server( ctor: Constructor, name?: string, - ): Binding { - const suffix = name || ctor.name; - const key = `${CoreBindings.SERVERS}.${suffix}`; - return this.bind(key) - .toClass(ctor) - .tag('server') - .inScope(BindingScope.SINGLETON); + ): Binding { + const binding = createBindingFromClass(ctor, { + name, + namespace: CoreBindings.SERVERS, + type: 'server', + }).inScope(BindingScope.SINGLETON); + this.add(binding); + return binding; } /** @@ -182,15 +192,16 @@ export class Application extends Context { * ``` */ public component(componentCtor: Constructor, name?: string) { - name = name || componentCtor.name; - const componentKey = `components.${name}`; - this.bind(componentKey) - .toClass(componentCtor) - .inScope(BindingScope.SINGLETON) - .tag('component'); + const binding = createBindingFromClass(componentCtor, { + name, + namespace: 'components', + type: 'component', + }).inScope(BindingScope.SINGLETON); + this.add(binding); // Assuming components can be synchronously instantiated - const instance = this.getSync(componentKey); + const instance = this.getSync(binding.key); mountComponent(this, instance); + return binding; } /** diff --git a/packages/repository/src/mixins/repository.mixin.ts b/packages/repository/src/mixins/repository.mixin.ts index 9f6ea634b6c7..08ea7fb5f55a 100644 --- a/packages/repository/src/mixins/repository.mixin.ts +++ b/packages/repository/src/mixins/repository.mixin.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {BindingScope, Binding} from '@loopback/context'; +import {BindingScope, Binding, createBindingFromClass} from '@loopback/context'; import {Application} from '@loopback/core'; import * as debugFactory from 'debug'; import {Class} from '../common-types'; @@ -38,7 +38,7 @@ export function RepositoryMixin>(superClass: T) { /** * Add a repository to this application. * - * @param repo The repository to add. + * @param repoClass The repository to add. * * ```ts * @@ -63,11 +63,17 @@ export function RepositoryMixin>(superClass: T) { * ``` */ // tslint:disable-next-line:no-any - repository(repo: Class>): void { - const repoKey = `repositories.${repo.name}`; - this.bind(repoKey) - .toClass(repo) - .tag('repository'); + repository>( + repoClass: Class, + name?: string, + ): Binding { + const binding = createBindingFromClass(repoClass, { + name, + namespace: 'repositories', + type: 'repository', + }).inScope(BindingScope.SINGLETON); + this.add(binding); + return binding; } /** @@ -101,24 +107,24 @@ export function RepositoryMixin>(superClass: T) { * } * ``` */ - dataSource( - dataSource: Class | juggler.DataSource, + dataSource( + dataSource: Class | D, name?: string, - ) { + ): Binding { // We have an instance of if (dataSource instanceof juggler.DataSource) { const key = `datasources.${name || dataSource.name}`; - this.bind(key) + return this.bind(key) .to(dataSource) .tag('datasource'); } else if (typeof dataSource === 'function') { - const key = `datasources.${name || - dataSource.dataSourceName || - dataSource.name}`; - this.bind(key) - .toClass(dataSource) - .tag('datasource') - .inScope(BindingScope.SINGLETON); + const binding = createBindingFromClass(dataSource, { + name: name || dataSource.dataSourceName, + namespace: 'datasources', + type: 'datasource', + }).inScope(BindingScope.SINGLETON); + this.add(binding); + return binding; } else { throw new Error('not a valid DataSource.'); } @@ -144,8 +150,8 @@ export function RepositoryMixin>(superClass: T) { * app.component(ProductComponent); * ``` */ - public component(component: Class<{}>) { - super.component(component); + public component(component: Class, name?: string) { + super.component(component, name); this.mountComponentRepositories(component); } @@ -156,7 +162,7 @@ export function RepositoryMixin>(superClass: T) { * * @param component The component to mount repositories of */ - mountComponentRepositories(component: Class<{}>) { + mountComponentRepositories(component: Class) { const componentKey = `components.${component.name}`; const compInstance = this.getSync(componentKey); @@ -214,15 +220,18 @@ export function RepositoryMixin>(superClass: T) { */ export interface ApplicationWithRepositories extends Application { // tslint:disable-next-line:no-any - repository(repo: Class): void; + repository>( + repo: Class, + name?: string, + ): Binding; // tslint:disable-next-line:no-any getRepository>(repo: Class): Promise; - dataSource( - dataSource: Class | juggler.DataSource, + dataSource( + dataSource: Class | D, name?: string, - ): void; - component(component: Class<{}>): void; - mountComponentRepositories(component: Class<{}>): void; + ): Binding; + component(component: Class, name?: string): Binding; + mountComponentRepositories(component: Class): void; migrateSchema(options?: SchemaMigrationOptions): Promise; } @@ -269,7 +278,9 @@ export class RepositoryMixinDoc { * ``` */ // tslint:disable-next-line:no-any - repository(repo: Class>): void {} + repository(repo: Class>): Binding { + throw new Error(); + } /** * Retrieve the repository instance from the given Repository class @@ -305,7 +316,9 @@ export class RepositoryMixinDoc { dataSource( dataSource: Class | juggler.DataSource, name?: string, - ) {} + ): Binding { + throw new Error(); + } /** * Add a component to this application. Also mounts @@ -327,7 +340,9 @@ export class RepositoryMixinDoc { * app.component(ProductComponent); * ``` */ - public component(component: Class<{}>) {} + public component(component: Class<{}>): Binding { + throw new Error(); + } /** * Get an instance of a component and mount all it's diff --git a/packages/service-proxy/src/mixins/service.mixin.ts b/packages/service-proxy/src/mixins/service.mixin.ts index e3dcf15e9255..756efeb717ed 100644 --- a/packages/service-proxy/src/mixins/service.mixin.ts +++ b/packages/service-proxy/src/mixins/service.mixin.ts @@ -3,7 +3,12 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Provider} from '@loopback/context'; +import { + Provider, + createBindingFromClass, + BindingScope, + Binding, +} from '@loopback/context'; import {Application} from '@loopback/core'; /** @@ -49,7 +54,7 @@ export function ServiceMixin>(superClass: T) { * * export class GeocoderServiceProvider implements Provider { * constructor( - * @inject('datasources.geocoder') + * @inject('services.geocoder') * protected dataSource: juggler.DataSource = new GeocoderDataSource(), * ) {} * @@ -61,12 +66,18 @@ export function ServiceMixin>(superClass: T) { * app.serviceProvider(GeocoderServiceProvider); * ``` */ - serviceProvider(provider: Class>): void { - const serviceName = provider.name.replace(/Provider$/, ''); - const repoKey = `services.${serviceName}`; - this.bind(repoKey) - .toProvider(provider) - .tag('service'); + serviceProvider( + provider: Class>, + name?: string, + ): Binding { + const serviceName = name || provider.name.replace(/Provider$/, ''); + const binding = createBindingFromClass(provider, { + name: serviceName, + namespace: 'services', + type: 'service', + }).inScope(BindingScope.SINGLETON); + this.add(binding); + return binding; } /** @@ -89,8 +100,8 @@ export function ServiceMixin>(superClass: T) { * app.component(ProductComponent); * ``` */ - public component(component: Class<{}>) { - super.component(component); + public component(component: Class, name?: string) { + super.component(component, name); this.mountComponentServices(component); } @@ -101,7 +112,7 @@ export function ServiceMixin>(superClass: T) { * * @param component The component to mount services of */ - mountComponentServices(component: Class<{}>) { + mountComponentServices(component: Class) { const componentKey = `components.${component.name}`; const compInstance = this.getSync(componentKey); @@ -119,8 +130,8 @@ export function ServiceMixin>(superClass: T) { */ export interface ApplicationWithServices extends Application { // tslint:disable-next-line:no-any - serviceProvider(provider: Class>): void; - component(component: Class<{}>): void; + serviceProvider(provider: Class>, name?: string): Binding; + component(component: Class<{}>, name?: string): Binding; mountComponentServices(component: Class<{}>): void; } @@ -163,7 +174,9 @@ export class ServiceMixinDoc { * app.serviceProvider(GeocoderServiceProvider); * ``` */ - serviceProvider(provider: Class>): void {} + serviceProvider(provider: Class>): Binding { + throw new Error(); + } /** * Add a component to this application. Also mounts @@ -185,7 +198,9 @@ export class ServiceMixinDoc { * app.component(ProductComponent); * ``` */ - public component(component: Class<{}>) {} + public component(component: Class): Binding { + throw new Error(); + } /** * Get an instance of a component and mount all it's @@ -194,5 +209,5 @@ export class ServiceMixinDoc { * * @param component The component to mount services of */ - mountComponentServices(component: Class<{}>) {} + mountComponentServices(component: Class) {} }