contexify
Version:
A TypeScript library providing a powerful dependency injection container with context-based IoC capabilities, inspired by LoopBack's Context system.
831 lines (790 loc) • 23.9 kB
text/typescript
import {
DecoratorFactory,
type InspectionOptions,
MetadataAccessor,
MetadataInspector,
type MetadataMap,
ParameterDecoratorFactory,
PropertyDecoratorFactory,
Reflector,
} from 'metarize';
import { Binding, type BindingTag } from '../binding/binding.js';
import {
type BindingFilter,
type BindingSelector,
filterByTag,
isBindingAddress,
isBindingTagFilter,
} from '../binding/binding-filter.js';
import type { BindingAddress, BindingKey } from '../binding/binding-key.js';
import type { BindingComparator } from '../binding/binding-sorter.js';
import type { BindingCreationPolicy, Context } from '../context/context.js';
import { ContextView, createViewGetter } from '../context/context-view.js';
import {
type ResolutionOptions,
ResolutionSession,
} from '../resolution/resolution-session.js';
import createDebugger from '../utils/debug.js';
import type { JSONObject } from '../utils/json-types.js';
import type {
BoundValue,
Constructor,
ValueOrPromise,
} from '../utils/value-promise.js';
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'
);
const debug = createDebugger('contexify:inject');
// 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 type 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 the 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>;
// eslint-disable-next-line @typescript-eslint/no-namespace
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;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
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: Constructor<unknown>,
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 as unknown as Constructor<unknown>,
'Getter function'
);
const bindingSelector = injection.bindingSelector as BindingAddress;
const options: ResolutionOptions = {
// 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 as unknown as Constructor<unknown>,
'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();
// Add length limit to prevent ReDoS attacks
const MAX_CLASS_DEF_LENGTH = 50000; // Reasonable maximum length
if (classDef.length > MAX_CLASS_DEF_LENGTH) {
/* istanbul ignore if */
if (debug.enabled) {
debug(
'Class definition too long, skipping regex check for %s',
targetClass.constructor?.name || 'unknown'
);
}
return false;
}
return (
/*
* Handle class decorators that return a new constructor
* 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);
* }
* ```
*/
// Use non-greedy quantifiers (*? and +?) to reduce backtracking
!classDef.match(
/\s+constructor\s*?\(\s*?\)\s*?\{\s*?super\(\.\.\.arguments\)/
) &&
/*
* Handle subclasses without explicit constructors
*
* @example
* ```ts
* class BaseClass {
* constructor(@inject('foo') protected foo: string) {}
* // ...
* }
*
* class SubClass extends BaseClass {
* // No explicit constructor is present
*
* @inject('bar')
* private bar: number;
* // ...
* };
*
*/
// Use non-greedy quantifiers and keep multiline flag
// eslint-disable-next-line no-useless-escape
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.hasOwn(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 as unknown as Constructor<unknown>,
'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
);
}