@loopback/context
Version:
Facilities to manage artifacts and their dependencies in your Node.js applications. The module exposes TypeScript/JavaScript APIs and decorators to register artifacts, declare dependencies, and resolve artifacts by keys. It also serves as an IoC container
798 lines (759 loc) • 23.2 kB
text/typescript
// Copyright IBM Corp. and LoopBack contributors 2017,2020. 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 {
DecoratorFactory,
InspectionOptions,
MetadataAccessor,
MetadataInspector,
MetadataMap,
ParameterDecoratorFactory,
PropertyDecoratorFactory,
Reflector,
} from '@loopback/metadata';
import {Binding, BindingTag} from './binding';
import {
BindingFilter,
BindingSelector,
filterByTag,
isBindingAddress,
isBindingTagFilter,
} from './binding-filter';
import {BindingAddress, BindingKey} from './binding-key';
import {BindingComparator} from './binding-sorter';
import {BindingCreationPolicy, Context} from './context';
import {ContextView, createViewGetter} from './context-view';
import {JSONObject} from './json-types';
import {ResolutionOptions, ResolutionSession} from './resolution-session';
import {BoundValue, Constructor, ValueOrPromise} from './value-promise';
const INJECT_PARAMETERS_KEY = MetadataAccessor.create<
Injection,
ParameterDecorator
>('inject:parameters');
const INJECT_PROPERTIES_KEY = MetadataAccessor.create<
Injection,
PropertyDecorator
>('inject:properties');
// A key to cache described argument injections
const INJECT_METHODS_KEY = MetadataAccessor.create<Injection, MethodDecorator>(
'inject:methods',
);
// TODO(rfeng): We may want to align it with `ValueFactory` interface that takes
// an argument of `ResolutionContext`.
/**
* A function to provide resolution of injected values.
*
* @example
* ```ts
* const resolver: ResolverFunction = (ctx, injection, session) {
* return session.currentBinding?.key;
* }
* ```
*/
export interface ResolverFunction {
(
ctx: Context,
injection: Readonly<Injection>,
session: ResolutionSession,
): ValueOrPromise<BoundValue>;
}
/**
* An object to provide metadata for `@inject`
*/
export interface InjectionMetadata extends Omit<ResolutionOptions, 'session'> {
/**
* Name of the decorator function, such as `@inject` or `@inject.setter`.
* It's usually set by the decorator implementation.
*/
decorator?: string;
/**
* Optional comparator for matched bindings
*/
bindingComparator?: BindingComparator;
/**
* Other attributes
*/
[attribute: string]: BoundValue;
}
/**
* Descriptor for an injection point
*/
export interface Injection<ValueType = BoundValue> {
target: Object;
member?: string;
methodDescriptorOrParameterIndex?:
| TypedPropertyDescriptor<ValueType>
| number;
bindingSelector: BindingSelector<ValueType>; // Binding selector
metadata: InjectionMetadata; // Related metadata
resolve?: ResolverFunction; // A custom resolve function
}
/**
* A decorator to annotate method arguments for automatic injection
* by LoopBack IoC container.
*
* @example
* Usage - Typescript:
*
* ```ts
* class InfoController {
* @inject('authentication.user') public userName: string;
*
* constructor(@inject('application.name') public appName: string) {
* }
* // ...
* }
* ```
*
* Usage - JavaScript:
*
* - TODO(bajtos)
*
* @param bindingSelector - What binding to use in order to resolve the value of the
* decorated constructor parameter or property.
* @param metadata - Optional metadata to help the injection
* @param resolve - Optional function to resolve the injection
*
*/
export function inject(
bindingSelector: BindingSelector,
metadata?: InjectionMetadata,
resolve?: ResolverFunction,
) {
if (typeof bindingSelector === 'function' && !resolve) {
resolve = resolveValuesByFilter;
}
const injectionMetadata = Object.assign({decorator: '@inject'}, metadata);
if (injectionMetadata.bindingComparator && !resolve) {
throw new Error('Binding comparator is only allowed with a binding filter');
}
if (!bindingSelector && typeof resolve !== 'function') {
throw new Error(
'A non-empty binding selector or resolve function is required for @inject',
);
}
return function markParameterOrPropertyAsInjected(
target: Object,
member: string | undefined,
methodDescriptorOrParameterIndex?:
| TypedPropertyDescriptor<BoundValue>
| number,
) {
if (typeof methodDescriptorOrParameterIndex === 'number') {
// The decorator is applied to a method parameter
// Please note propertyKey is `undefined` for constructor
const paramDecorator: ParameterDecorator =
ParameterDecoratorFactory.createDecorator<Injection>(
INJECT_PARAMETERS_KEY,
{
target,
member,
methodDescriptorOrParameterIndex,
bindingSelector,
metadata: injectionMetadata,
resolve,
},
// Do not deep clone the spec as only metadata is mutable and it's
// shallowly cloned
{cloneInputSpec: false, decoratorName: injectionMetadata.decorator},
);
paramDecorator(target, member!, methodDescriptorOrParameterIndex);
} else if (member) {
// Property or method
if (target instanceof Function) {
throw new Error(
'@inject is not supported for a static property: ' +
DecoratorFactory.getTargetName(target, member),
);
}
if (methodDescriptorOrParameterIndex) {
// Method
throw new Error(
'@inject cannot be used on a method: ' +
DecoratorFactory.getTargetName(
target,
member,
methodDescriptorOrParameterIndex,
),
);
}
const propDecorator: PropertyDecorator =
PropertyDecoratorFactory.createDecorator<Injection>(
INJECT_PROPERTIES_KEY,
{
target,
member,
methodDescriptorOrParameterIndex,
bindingSelector,
metadata: injectionMetadata,
resolve,
},
// Do not deep clone the spec as only metadata is mutable and it's
// shallowly cloned
{cloneInputSpec: false, decoratorName: injectionMetadata.decorator},
);
propDecorator(target, member!);
} else {
// It won't happen here as `@inject` is not compatible with ClassDecorator
/* istanbul ignore next */
throw new Error(
'@inject can only be used on a property or a method parameter',
);
}
};
}
/**
* The function injected by `@inject.getter(bindingSelector)`. It can be used
* to fetch bound value(s) from the underlying binding(s). The return value will
* be an array if the `bindingSelector` is a `BindingFilter` function.
*/
export type Getter<T> = () => Promise<T>;
export namespace Getter {
/**
* Convert a value into a Getter returning that value.
* @param value
*/
export function fromValue<T>(value: T): Getter<T> {
return () => Promise.resolve(value);
}
}
/**
* The function injected by `@inject.setter(bindingKey)`. It sets the underlying
* binding to a constant value using `binding.to(value)`.
*
* @example
*
* ```ts
* setterFn('my-value');
* ```
* @param value - The value for the underlying binding
*/
export type Setter<T> = (value: T) => void;
/**
* Metadata for `@inject.binding`
*/
export interface InjectBindingMetadata extends InjectionMetadata {
/**
* Controls how the underlying binding is resolved/created
*/
bindingCreation?: BindingCreationPolicy;
}
export namespace inject {
/**
* Inject a function for getting the actual bound value.
*
* This is useful when implementing Actions, where
* the action is instantiated for Sequence constructor, but some
* of action's dependencies become bound only after other actions
* have been executed by the sequence.
*
* See also `Getter<T>`.
*
* @param bindingSelector - The binding key or filter we want to eventually get
* value(s) from.
* @param metadata - Optional metadata to help the injection
*/
export const getter = function injectGetter(
bindingSelector: BindingSelector<unknown>,
metadata?: InjectionMetadata,
) {
metadata = Object.assign({decorator: '@inject.getter'}, metadata);
return inject(
bindingSelector,
metadata,
isBindingAddress(bindingSelector)
? resolveAsGetter
: resolveAsGetterByFilter,
);
};
/**
* Inject a function for setting (binding) the given key to a given
* value. (Only static/constant values are supported, it's not possible
* to bind a key to a class or a provider.)
*
* This is useful e.g. when implementing Actions that are contributing
* new Elements.
*
* See also `Setter<T>`.
*
* @param bindingKey - The key of the value we want to set.
* @param metadata - Optional metadata to help the injection
*/
export const setter = function injectSetter(
bindingKey: BindingAddress,
metadata?: InjectBindingMetadata,
) {
metadata = Object.assign({decorator: '@inject.setter'}, metadata);
return inject(bindingKey, metadata, resolveAsSetter);
};
/**
* Inject the binding object for the given key. This is useful if a binding
* needs to be set up beyond just a constant value allowed by
* `@inject.setter`. The injected binding is found or created based on the
* `metadata.bindingCreation` option. See `BindingCreationPolicy` for more
* details.
*
* @example
*
* ```ts
* class MyAuthAction {
* @inject.binding('current-user', {
* bindingCreation: BindingCreationPolicy.ALWAYS_CREATE,
* })
* private userBinding: Binding<UserProfile>;
*
* async authenticate() {
* this.userBinding.toDynamicValue(() => {...});
* }
* }
* ```
*
* @param bindingKey - Binding key
* @param metadata - Metadata for the injection
*/
export const binding = function injectBinding(
bindingKey?: string | BindingKey<unknown>,
metadata?: InjectBindingMetadata,
) {
metadata = Object.assign({decorator: '@inject.binding'}, metadata);
return inject(bindingKey ?? '', metadata, resolveAsBinding);
};
/**
* Inject an array of values by a tag pattern string or regexp
*
* @example
* ```ts
* class AuthenticationManager {
* constructor(
* @inject.tag('authentication.strategy') public strategies: Strategy[],
* ) {}
* }
* ```
* @param bindingTag - Tag name, regex or object
* @param metadata - Optional metadata to help the injection
*/
export const tag = function injectByTag(
bindingTag: BindingTag | RegExp,
metadata?: InjectionMetadata,
) {
metadata = Object.assign(
{decorator: '@inject.tag', tag: bindingTag},
metadata,
);
return inject(filterByTag(bindingTag), metadata);
};
/**
* Inject matching bound values by the filter function
*
* @example
* ```ts
* class MyControllerWithView {
* @inject.view(filterByTag('foo'))
* view: ContextView<string[]>;
* }
* ```
* @param bindingFilter - A binding filter function
* @param metadata
*/
export const view = function injectContextView(
bindingFilter: BindingFilter,
metadata?: InjectionMetadata,
) {
metadata = Object.assign({decorator: '@inject.view'}, metadata);
return inject(bindingFilter, metadata, resolveAsContextView);
};
/**
* Inject the context object.
*
* @example
* ```ts
* class MyProvider {
* constructor(@inject.context() private ctx: Context) {}
* }
* ```
*/
export const context = function injectContext() {
return inject('', {decorator: '@inject.context'}, (ctx: Context) => ctx);
};
}
/**
* Assert the target type inspected from TypeScript for injection to be the
* expected type. If the types don't match, an error is thrown.
* @param injection - Injection information
* @param expectedType - Expected type
* @param expectedTypeName - Name of the expected type to be used in the error
* @returns The name of the target
*/
export function assertTargetType(
injection: Readonly<Injection>,
expectedType: Function,
expectedTypeName?: string,
) {
const targetName = ResolutionSession.describeInjection(injection).targetName;
const targetType = inspectTargetType(injection);
if (targetType && targetType !== expectedType) {
expectedTypeName = expectedTypeName ?? expectedType.name;
throw new Error(
`The type of ${targetName} (${targetType.name}) is not ${expectedTypeName}`,
);
}
return targetName;
}
/**
* Resolver for `@inject.getter`
* @param ctx
* @param injection
* @param session
*/
function resolveAsGetter(
ctx: Context,
injection: Readonly<Injection>,
session: ResolutionSession,
) {
assertTargetType(injection, Function, 'Getter function');
const bindingSelector = injection.bindingSelector as BindingAddress;
const options: ResolutionOptions = {
// https://github.com/loopbackio/loopback-next/issues/9041
// We should start with a new session for `getter` resolution to avoid
// possible circular dependencies
session: undefined,
...injection.metadata,
};
return function getter() {
return ctx.get(bindingSelector, options);
};
}
/**
* Resolver for `@inject.setter`
* @param ctx
* @param injection
*/
function resolveAsSetter(ctx: Context, injection: Injection) {
const targetName = assertTargetType(injection, Function, 'Setter function');
const bindingSelector = injection.bindingSelector;
if (!isBindingAddress(bindingSelector)) {
throw new Error(
`@inject.setter (${targetName}) does not allow BindingFilter.`,
);
}
if (bindingSelector === '') {
throw new Error('Binding key is not set for @inject.setter');
}
// No resolution session should be propagated into the setter
return function setter(value: unknown) {
const binding = findOrCreateBindingForInjection(ctx, injection);
binding!.to(value);
};
}
function resolveAsBinding(
ctx: Context,
injection: Injection,
session: ResolutionSession,
) {
const targetName = assertTargetType(injection, Binding);
const bindingSelector = injection.bindingSelector;
if (!isBindingAddress(bindingSelector)) {
throw new Error(
`@inject.binding (${targetName}) does not allow BindingFilter.`,
);
}
return findOrCreateBindingForInjection(ctx, injection, session);
}
function findOrCreateBindingForInjection(
ctx: Context,
injection: Injection<unknown>,
session?: ResolutionSession,
) {
if (injection.bindingSelector === '') return session?.currentBinding;
const bindingCreation =
injection.metadata &&
(injection.metadata as InjectBindingMetadata).bindingCreation;
const binding: Binding<unknown> = ctx.findOrCreateBinding(
injection.bindingSelector as BindingAddress,
bindingCreation,
);
return binding;
}
/**
* Check if constructor injection should be applied to the base class
* of the given target class
*
* @param targetClass - Target class
*/
function shouldSkipBaseConstructorInjection(targetClass: Object) {
// FXIME(rfeng): We use the class definition to check certain patterns
const classDef = targetClass.toString();
return (
/*
* See https://github.com/loopbackio/loopback-next/issues/2946
* A class decorator can return a new constructor that mixes in
* additional properties/methods.
*
* @example
* ```ts
* class extends baseConstructor {
* // The constructor calls `super(...arguments)`
* constructor() {
* super(...arguments);
* }
* classProperty = 'a classProperty';
* classFunction() {
* return 'a classFunction';
* }
* };
* ```
*
* We check the following pattern:
* ```ts
* constructor() {
* super(...arguments);
* }
* ```
*/
!classDef.match(
/\s+constructor\s*\(\s*\)\s*\{\s*super\(\.\.\.arguments\)/,
) &&
/*
* See https://github.com/loopbackio/loopback-next/issues/1565
*
* @example
* ```ts
* class BaseClass {
* constructor(@inject('foo') protected foo: string) {}
* // ...
* }
*
* class SubClass extends BaseClass {
* // No explicit constructor is present
*
* @inject('bar')
* private bar: number;
* // ...
* };
*
*/
classDef.match(/\s+constructor\s*\([^\)]*\)\s+\{/m)
);
}
/**
* Return an array of injection objects for parameters
* @param target - The target class for constructor or static methods,
* or the prototype for instance methods
* @param method - Method name, undefined for constructor
*/
export function describeInjectedArguments(
target: Object,
method?: string,
): Readonly<Injection>[] {
method = method ?? '';
// Try to read from cache
const cache =
MetadataInspector.getAllMethodMetadata<Readonly<Injection>[]>(
INJECT_METHODS_KEY,
target,
{
ownMetadataOnly: true,
},
) ?? {};
let meta: Readonly<Injection>[] = cache[method];
if (meta) return meta;
// Build the description
const options: InspectionOptions = {};
if (method === '') {
if (shouldSkipBaseConstructorInjection(target)) {
options.ownMetadataOnly = true;
}
} else if (Object.prototype.hasOwnProperty.call(target, method)) {
// The method exists in the target, no injections on the super method
// should be honored
options.ownMetadataOnly = true;
}
meta =
MetadataInspector.getAllParameterMetadata<Readonly<Injection>>(
INJECT_PARAMETERS_KEY,
target,
method,
options,
) ?? [];
// Cache the result
cache[method] = meta;
MetadataInspector.defineMetadata<MetadataMap<Readonly<Injection>[]>>(
INJECT_METHODS_KEY,
cache,
target,
);
return meta;
}
/**
* Inspect the target type for the injection to find out the corresponding
* JavaScript type
* @param injection - Injection information
*/
export function inspectTargetType(injection: Readonly<Injection>) {
if (typeof injection.methodDescriptorOrParameterIndex === 'number') {
const designType = MetadataInspector.getDesignTypeForMethod(
injection.target,
injection.member!,
);
return designType?.parameterTypes?.[
injection.methodDescriptorOrParameterIndex as number
];
}
return MetadataInspector.getDesignTypeForProperty(
injection.target,
injection.member!,
);
}
/**
* Resolve an array of bound values matching the filter function for `@inject`.
* @param ctx - Context object
* @param injection - Injection information
* @param session - Resolution session
*/
function resolveValuesByFilter(
ctx: Context,
injection: Readonly<Injection>,
session: ResolutionSession,
) {
assertTargetType(injection, Array);
const bindingFilter = injection.bindingSelector as BindingFilter;
const view = new ContextView(
ctx,
bindingFilter,
injection.metadata.bindingComparator,
);
return view.resolve(session);
}
/**
* Resolve to a getter function that returns an array of bound values matching
* the filter function for `@inject.getter`.
*
* @param ctx - Context object
* @param injection - Injection information
* @param session - Resolution session
*/
function resolveAsGetterByFilter(
ctx: Context,
injection: Readonly<Injection>,
session: ResolutionSession,
) {
assertTargetType(injection, Function, 'Getter function');
const bindingFilter = injection.bindingSelector as BindingFilter;
return createViewGetter(
ctx,
bindingFilter,
injection.metadata.bindingComparator,
session,
);
}
/**
* Resolve to an instance of `ContextView` by the binding filter function
* for `@inject.view`
* @param ctx - Context object
* @param injection - Injection information
*/
function resolveAsContextView(ctx: Context, injection: Readonly<Injection>) {
assertTargetType(injection, ContextView);
const bindingFilter = injection.bindingSelector as BindingFilter;
const view = new ContextView(
ctx,
bindingFilter,
injection.metadata.bindingComparator,
);
view.open();
return view;
}
/**
* Return a map of injection objects for properties
* @param target - The target class for static properties or
* prototype for instance properties.
*/
export function describeInjectedProperties(
target: Object,
): MetadataMap<Readonly<Injection>> {
const metadata =
MetadataInspector.getAllPropertyMetadata<Readonly<Injection>>(
INJECT_PROPERTIES_KEY,
target,
) ?? {};
return metadata;
}
/**
* Inspect injections for a binding created with `toClass` or `toProvider`
* @param binding - Binding object
*/
export function inspectInjections(binding: Readonly<Binding<unknown>>) {
const json: JSONObject = {};
const ctor = binding.valueConstructor ?? binding.providerConstructor;
if (ctor == null) return json;
const constructorInjections = describeInjectedArguments(ctor, '').map(
inspectInjection,
);
if (constructorInjections.length) {
json.constructorArguments = constructorInjections;
}
const propertyInjections = describeInjectedProperties(ctor.prototype);
const properties: JSONObject = {};
for (const p in propertyInjections) {
properties[p] = inspectInjection(propertyInjections[p]);
}
if (Object.keys(properties).length) {
json.properties = properties;
}
return json;
}
/**
* Inspect an injection
* @param injection - Injection information
*/
function inspectInjection(injection: Readonly<Injection<unknown>>) {
const injectionInfo = ResolutionSession.describeInjection(injection);
const descriptor: JSONObject = {};
if (injectionInfo.targetName) {
descriptor.targetName = injectionInfo.targetName;
}
if (isBindingAddress(injectionInfo.bindingSelector)) {
// Binding key
descriptor.bindingKey = injectionInfo.bindingSelector.toString();
} else if (isBindingTagFilter(injectionInfo.bindingSelector)) {
// Binding tag filter
descriptor.bindingTagPattern = JSON.parse(
JSON.stringify(injectionInfo.bindingSelector.bindingTagPattern),
);
} else {
// Binding filter function
descriptor.bindingFilter =
injectionInfo.bindingSelector?.name ?? '<function>';
}
// Inspect metadata
if (injectionInfo.metadata) {
if (
injectionInfo.metadata.decorator &&
injectionInfo.metadata.decorator !== '@inject'
) {
descriptor.decorator = injectionInfo.metadata.decorator;
}
if (injectionInfo.metadata.optional) {
descriptor.optional = injectionInfo.metadata.optional;
}
}
return descriptor;
}
/**
* Check if the given class has `@inject` or other decorations that map to
* `@inject`.
*
* @param cls - Class with possible `@inject` decorations
*/
export function hasInjections(cls: Constructor<unknown>): boolean {
return (
MetadataInspector.getClassMetadata(INJECT_PARAMETERS_KEY, cls) != null ||
Reflector.getMetadata(INJECT_PARAMETERS_KEY.toString(), cls.prototype) !=
null ||
MetadataInspector.getAllPropertyMetadata(
INJECT_PROPERTIES_KEY,
cls.prototype,
) != null
);
}