diff --git a/packages/context/docs.json b/packages/context/docs.json index e7a416007f39..f63fd49c2b0d 100644 --- a/packages/context/docs.json +++ b/packages/context/docs.json @@ -5,6 +5,7 @@ "src/context.ts", "src/index.ts", "src/inject.ts", + "src/injectable.ts", "src/is-promise.ts", "src/provider.ts", "src/reflect.ts", diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index f1b524ef2776..2383e84c6332 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -23,6 +23,13 @@ export {Context} from './context'; export {BindingKey, BindingAddress} from './BindingKey'; export {ResolutionSession} from './resolution-session'; export {inject, Setter, Getter, Injection, InjectionMetadata} from './inject'; +export { + injectable, + provider, + InjectableMetadata, + getInjectableMetadata, + bindInjectable, +} from './injectable'; export {Provider} from './provider'; export {instantiateClass, invokeMethod} from './resolver'; diff --git a/packages/context/src/injectable.ts b/packages/context/src/injectable.ts new file mode 100644 index 000000000000..75469c9d7ec8 --- /dev/null +++ b/packages/context/src/injectable.ts @@ -0,0 +1,177 @@ +// 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 {MetadataInspector, ClassDecoratorFactory} from '@loopback/metadata'; + +import {BindingScope, Binding} from './binding'; +import {Context} from './context'; +import {Constructor} from './value-promise'; +import {Provider} from './provider'; + +const INJECTABLE_CLASS_KEY = 'injectable'; + +/** + * Metadata for an injectable class + */ +export interface InjectableMetadata { + /** + * Type of the artifact. Valid values are: + * - controller + * - repository + * - component + * - provider + * - server + * - model + * - dataSource + * - class (default) + */ + type?: + | 'controller' + | 'repository' + | 'component' + | 'provider' + | 'server' + | 'model' + | 'dataSource' + | 'class' + | string; // Still allow string for extensibility + /** + * Name of the artifact, default to the class name + */ + name?: string; + /** + * Binding key, default to `${type}.${name}` + */ + key?: string; + /** + * Optional tags for the binding + */ + tags?: string[]; + /** + * Binding scope + */ + scope?: BindingScope; +} + +class InjectableDecoratorFactory extends ClassDecoratorFactory< + InjectableMetadata +> { + mergeWithInherited(inherited: InjectableMetadata, target: Function) { + const spec = super.mergeWithInherited(inherited, target); + if (!this.spec.name) { + delete spec.name; + } + return spec; + } +} +/** + * Mark a class to be injectable or bindable for context based dependency + * injection. + * + * @example + * ```ts + * @injectable({ + * type: 'controller', + * name: 'my-controller', + * scope: BindingScope.SINGLETON}, + * ) + * export class MyController { + * } + * ``` + * + * @param spec The metadata for bindings + */ +export function injectable(spec: InjectableMetadata = {}) { + return InjectableDecoratorFactory.createDecorator(INJECTABLE_CLASS_KEY, spec); +} + +/** + * `@provider` as a shortcut of `@injectable({type: 'provider'}) + * @param spec + */ +export function provider( + spec: InjectableMetadata = {}, +): // tslint:disable-next-line:no-any +((target: Constructor>) => void) { + return injectable(Object.assign(spec, {type: 'provider'})); +} + +/** + * Get the metadata for an injectable class + * @param target The target class + */ +export function getInjectableMetadata( + target: Function, +): InjectableMetadata | undefined { + return MetadataInspector.getClassMetadata( + INJECTABLE_CLASS_KEY, + target, + { + ownMetadataOnly: true, + }, + ); +} + +export const TYPE_NAMESPACES: {[name: string]: string} = { + controller: 'controllers', + repository: 'repositories', + model: 'models', + dataSource: 'dataSources', + server: 'servers', + class: 'classes', + provider: 'providers', +}; + +function getNamespace(type: string) { + if (type in TYPE_NAMESPACES) { + return TYPE_NAMESPACES[type]; + } else { + return `${type}s`; + } +} + +/** + * Bind the injectable class to a given context + * @param ctx The context + * @param cls The target class + */ +export function bindInjectable( + ctx: Context, + cls: Constructor, +): Binding { + const spec = getInjectableMetadata(cls); + if (spec === undefined) { + throw new Error( + `Target class ${cls.name} is not decorated with @injectable`, + ); + } + const type = spec.type || 'class'; + const name = spec.name || cls.name; + // Default binding key is ${plural form of the type}.${name} + const key = spec.key || `${getNamespace(type)}.${name}`; + const binding = ctx.bind(key); + switch (type) { + case 'provider': + // tslint:disable-next-line:no-any + binding.toProvider(cls as Constructor>); + break; + default: + binding.toClass(cls); + } + // Set tags if present + if (spec.tags) { + binding.tag(spec.tags); + } + // Set some tags for the metadata + binding + .tag(`name:${name}`) + .tag(`type:${type}`) + .tag(`${type}:${name}`); + // Set scope if present + if (spec.scope) { + binding.inScope(spec.scope); + } + return binding; +} diff --git a/packages/context/test/acceptance/injectable.feature.md b/packages/context/test/acceptance/injectable.feature.md new file mode 100644 index 000000000000..14d838e7680a --- /dev/null +++ b/packages/context/test/acceptance/injectable.feature.md @@ -0,0 +1,54 @@ +# Feature: @injectable 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 +`@injectable`. + +```ts +@injectable({tags: ['log']}) +export class LogController { +} + +export const LOG_LEVEL = 'info'; + +@injectable({type: 'provider', tags: ['log']}) +export class LogProvider implements Provider { + value() { + return msg => console.log(msg); + } +} +``` + +Please note that we don't intend to use `@injectable` to help the bootstrapper +discover such artifacts. The purpose of `@injectable` is to allow developers to +provide more metadata on how the class should be bound. diff --git a/packages/context/test/unit/injectable.test.ts b/packages/context/test/unit/injectable.test.ts new file mode 100644 index 000000000000..5cf2b09b837b --- /dev/null +++ b/packages/context/test/unit/injectable.test.ts @@ -0,0 +1,179 @@ +// 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 { + injectable, + getInjectableMetadata, + BindingScope, + InjectableMetadata, + Context, + Provider, +} from '../..'; +import {bindInjectable, provider} from '../../src/injectable'; + +describe('@injectable', () => { + it('decorates a class', () => { + const spec: InjectableMetadata = { + type: 'controller', + name: 'my-controller', + tags: ['rest'], + scope: BindingScope.SINGLETON, + }; + + @injectable(spec) + class MyController {} + + expect(getInjectableMetadata(MyController)).to.eql(spec); + }); + + it('has to be on the target class', () => { + const spec: InjectableMetadata = { + type: 'controller', + name: 'my-controller', + tags: ['rest'], + scope: BindingScope.SINGLETON, + }; + + @injectable(spec) + class MyController {} + + class MySubController extends MyController {} + + expect(getInjectableMetadata(MySubController)).to.be.undefined(); + }); + + it('inherits attributes except name from the base class', () => { + const spec: InjectableMetadata = { + type: 'controller', + name: 'my-controller', + tags: ['rest'], + scope: BindingScope.SINGLETON, + }; + + @injectable(spec) + class MyController {} + + @injectable() + class MySubController extends MyController {} + + expect(getInjectableMetadata(MySubController)).to.eql({ + type: 'controller', + tags: ['rest'], + scope: BindingScope.SINGLETON, + }); + }); + + it('decorates a provider classes', () => { + const spec = { + type: 'provider', + tags: ['rest'], + scope: BindingScope.CONTEXT, + }; + + @provider(spec) + class MyProvider implements Provider { + value() { + return 'my-value'; + } + } + expect(getInjectableMetadata(MyProvider)).to.eql({ + type: 'provider', + tags: ['rest'], + scope: BindingScope.CONTEXT, + }); + }); +}); + +describe('bindInjectable()', () => { + it('binds injectable classes', () => { + const spec: InjectableMetadata = { + type: 'controller', + name: 'my-controller', + tags: ['rest'], + scope: BindingScope.SINGLETON, + }; + + @injectable(spec) + class MyController {} + + const ctx = new Context(); + const binding = bindInjectable(ctx, MyController); + expect(binding.key).to.eql('controllers.my-controller'); + expect(binding.scope).to.eql(spec.scope); + expect(Array.from(binding.tags)).to.containDeep(spec.tags); + expect(Array.from(binding.tags)).to.containDeep([ + 'name:my-controller', + 'type:controller', + 'controller:my-controller', + ]); + expect(ctx.getSync(binding.key)).to.be.instanceof(MyController); + }); + + it('binds injectable provider classes', () => { + const spec: InjectableMetadata = { + type: 'provider', + tags: ['rest'], + scope: BindingScope.CONTEXT, + }; + + @injectable(spec) + class MyProvider implements Provider { + value() { + return 'my-value'; + } + } + + const ctx = new Context(); + const binding = bindInjectable(ctx, MyProvider); + expect(binding.key).to.eql('providers.MyProvider'); + expect(binding.scope).to.eql(spec.scope); + expect(Array.from(binding.tags)).to.containDeep(spec.tags); + expect(Array.from(binding.tags)).to.containDeep([ + 'name:MyProvider', + 'type:provider', + 'provider:MyProvider', + ]); + expect(ctx.getSync(binding.key)).to.eql('my-value'); + }); + + it('honors the binding key', () => { + const spec: InjectableMetadata = { + type: 'controller', + key: 'controllers.my', + name: 'my-controller', + }; + + @injectable(spec) + class MyController {} + + const ctx = new Context(); + const binding = bindInjectable(ctx, MyController); + expect(binding.key).to.eql('controllers.my'); + + expect(Array.from(binding.tags)).to.containDeep([ + 'name:my-controller', + 'type:controller', + 'controller:my-controller', + ]); + }); + + it('defaults type to class', () => { + const spec: InjectableMetadata = {}; + + @injectable(spec) + class MyClass {} + + const ctx = new Context(); + const binding = bindInjectable(ctx, MyClass); + expect(binding.key).to.eql('classes.MyClass'); + + expect(Array.from(binding.tags)).to.containDeep([ + 'name:MyClass', + 'type:class', + 'class:MyClass', + ]); + }); +});