Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/context/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 @@ -25,6 +25,13 @@ export {Context} from './context';
export {BindingKey, BindingAddress} from './binding-key';
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
176 changes: 176 additions & 0 deletions packages/context/src/injectable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// 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, MapObject} 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[] | MapObject;
/**
* 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 (Array.isArray(spec.tags)) {
binding.tag(...spec.tags);
} else if (spec.tags) {
binding.tag(spec.tags);
}
// Set some tags for the metadata
binding.tag({name, type, [type]: name});
// Set scope if present
if (spec.scope) {
binding.inScope(spec.scope);
}
return binding;
}
3 changes: 2 additions & 1 deletion packages/context/src/value-promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export type BoundValue = any;
*/
export type ValueOrPromise<T> = T | PromiseLike<T>;

export type MapObject<T> = {[name: string]: T};
// tslint:disable-next-line:no-any
export type MapObject<T = any> = {[name: string]: T};

/**
* Check whether a value is a Promise-like instance.
Expand Down
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