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 Apr 9, 2018
1 parent 685195c commit 979a428
Show file tree
Hide file tree
Showing 5 changed files with 418 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 @@ -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';
Expand Down
177 changes: 177 additions & 0 deletions packages/context/src/injectable.ts
Original file line number Diff line number Diff line change
@@ -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<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;
}
54 changes: 54 additions & 0 deletions packages/context/test/acceptance/injectable.feature.md
Original file line number Diff line number Diff line change
@@ -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<Logger> {
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<Logger> {
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.
Loading

0 comments on commit 979a428

Please sign in to comment.