Skip to content

Commit

Permalink
feat(context): add @injectable to decorate bindable classes
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed Feb 21, 2018
1 parent 7d83523 commit 1c8cf6d
Show file tree
Hide file tree
Showing 4 changed files with 353 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/context/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/context/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ export {Binding, BindingScope, BindingType} from './binding';
export {Context} from './context';
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';
Expand Down
166 changes: 166 additions & 0 deletions packages/context/src/injectable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// 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
* - class (default)
*/
type?: string;
/**
* 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<Provider<any>>) => 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<InjectableMetadata>(
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<object>,
): 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<Provider<any>>);
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;
}
179 changes: 179 additions & 0 deletions packages/context/test/unit/injectable.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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',
]);
});
});

0 comments on commit 1c8cf6d

Please sign in to comment.