diff --git a/docs/site/Context.md b/docs/site/Context.md index b038f747ef41..92d8e8a1131a 100644 --- a/docs/site/Context.md +++ b/docs/site/Context.md @@ -229,3 +229,74 @@ class HelloController { These "sugar" decorators allow you to quickly build up your application without having to code up all the additional logic by simply giving LoopBack hints (in the form of metadata) to your intent. + +## Context view + +Bindings in a context can come and go. It's often desirable for an artifact +(especially an extension point) to keep track of other artifacts (extensions). +For example, the `RestServer` needs to know routes contributed by `controller` +classes or other handlers. Such routes can be added or removed after the +`RestServer` starts. When a controller is added after the application starts, +new routes are bound into the application context. Ideally, the `RestServer` +should be able to pick up these new routes without restarting. + +To support the dynamic tracking of such artifacts registered within a context +chain, we introduce `ContextEventListener` interface and `ContextView` class +that can be used to watch a list of bindings matching certain criteria depicted +by a `BindingFilter` function. + +```ts +import {Context, ContextView} from '@loopback/context'; + +// Set up a context chain +const appCtx = new Context('app'); +const serverCtx = new Context(appCtx, 'server'); // server -> app + +// Define a binding filter to select bindings with tag `controller` +const controllerFilter = binding => binding.tagMap.controller != null; + +// Watch for bindings with tag `controller` +const view = serverCtx.watch(controllerFilter); + +// No controllers yet +await view.values(); // returns [] + +// Bind Controller1 to server context +serverCtx + .bind('controllers.Controller1') + .toClass(Controller1) + .tag('controller'); + +// Resolve to an instance of Controller1 +await view.values(); // returns [an instance of Controller1]; + +// Bind Controller2 to app context +appCtx + .bind('controllers.Controller2') + .toClass(Controller2) + .tag('controller'); + +// Resolve to an instance of Controller1 and an instance of Controller2 +await view.values(); // returns [an instance of Controller1, an instance of Controller2]; + +// Unbind Controller2 +appCtx.unbind('controllers.Controller2'); + +// No more instance of Controller2 +await view.values(); // returns [an instance of Controller1]; +``` + +The key benefit of `ContextView` is that it caches resolved values until context +bindings matching the filter function are added/removed. For most cases, we +don't have to pay the penalty to find/resolve per request. + +To fully leverage the live list of extensions, an extension point such as +`RoutingTable` should either keep a pointer to an instance of `ContextView` +corresponding to all `routes` (extensions) in the context chain and use the +`values()` function to match again the live `routes` per request or implement +itself as a `ContextEventListener` to rebuild the routes upon changes of routes +in the context with `listen()`. + +If your dependency needs to follow the context for values from bindings matching +a filter, use [`@inject.view`](Decorators_inject.md#@inject.view) for dependency +injection. diff --git a/docs/site/Decorators_inject.md b/docs/site/Decorators_inject.md index 928441583fb3..e87131f13306 100644 --- a/docs/site/Decorators_inject.md +++ b/docs/site/Decorators_inject.md @@ -6,7 +6,12 @@ sidebar: lb4_sidebar permalink: /doc/en/lb4/Decorators_inject.html --- -## Dependency Injection Decorator +## Dependency Injection Decorators + +### @inject + +Syntax: +`@inject(bindingSelector: BindingSelector, metadata?: InjectionMetadata)`. `@inject` is a decorator to annotate class properties or constructor arguments for automatic injection by LoopBack's IoC container. @@ -65,13 +70,28 @@ export class WidgetController { } ``` +The `@inject` decorator now also accepts a binding filter function so that an +array of values can be injected. If the target type is not `Array`, an error +will be thrown. + +```ts +class MyControllerWithValues { + constructor( + @inject(binding => binding.tagNames.includes('foo')) + public values: string[], + ) {} +} +``` + A few variants of `@inject` are provided to declare special forms of -dependencies: +dependencies. + +### @inject.getter -- `@inject.getter`: inject a getter function that returns a promise of the bound - value of the key +`@inject.getter` injects a getter function that returns a promise of the bound +value of the key. -Syntax: `@inject.getter(bindingKey: string)`. +Syntax: `@inject.getter(bindingSelector: BindingSelector)`. ```ts import {inject, Getter} from '@loopback/context'; @@ -92,7 +112,19 @@ export class HelloController { } ``` -- `@inject.setter`: inject a setter function to set the bound value of the key +`@inject.getter` also allows the getter function to return an array of values +from bindings that match a filter function. + +```ts +class MyControllerWithGetter { + @inject.getter(filterByTag('prime')) + getter: Getter; +} +``` + +### @inject.setter + +`@inject.setter` injects a setter function to set the bound value of the key. Syntax: `@inject.setter(bindingKey: string)`. @@ -111,10 +143,12 @@ export class HelloController { } ``` -- `@inject.tag`: inject an array of values by a pattern or regexp to match - binding tags +### @inject.tag + +`@inject.tag` injects an array of values by a pattern or regexp to match binding +tags. -Syntax: `@inject.tag(tag: string | RegExp)`. +Syntax: `@inject.tag(tag: BindingTag | RegExp)`. ```ts class Store { @@ -135,7 +169,47 @@ const store = ctx.getSync('store'); console.log(store.locations); // ['San Francisco', 'San Jose'] ``` -- `@inject.context`: inject the current context +### @inject.view + +`@inject.view` injects a `ContextView` to track a list of bound values matching +a filter function. + +```ts +import {inject} from '@loopback/context'; +import {DataSource} from '@loopback/repository'; + +export class DataSourceTracker { + constructor( + @inject.view(filterByTag('datasource')) + private dataSources: ContextView, + ) {} + + async listDataSources(): Promise { + // Use the Getter function to resolve data source instances + return await this.dataSources.values(); + } +} +``` + +In the example above, `filterByTag` is a helper function that creates a filter +function that matches a given tag. You can define your own filter functions, +such as: + +```ts +export class DataSourceTracker { + constructor( + @inject.view(binding => binding.tagNames.includes('datasource')) + private dataSources: ContextView, + ) {} +} +``` + +The `@inject.view` decorator takes a `BindingFilter` function. It can only be +applied to a property or method parameter of `ContextView` type. + +### @inject.context + +`@inject.context` injects the current context. Syntax: `@inject.context()`. diff --git a/docs/site/Dependency-injection.md b/docs/site/Dependency-injection.md index 1bbd339438a1..fa40c738dbc1 100644 --- a/docs/site/Dependency-injection.md +++ b/docs/site/Dependency-injection.md @@ -50,7 +50,7 @@ important when testing code interacting with external services like a database or an OAuth2 provider. Instead of making expensive network requests, the test can provide a lightweight implementation returning pre-defined responses. -## Configuring what to inject +## Configure what to inject Now that we write a class that gets the dependencies injected, you are probably wondering where are these values going to be injected from and how to configure @@ -239,6 +239,18 @@ export class MyController { } ``` +## Additional `inject.*` decorators + +There are a few special decorators from the `inject` namespace. + +- [`@inject.getter`](Decorators_inject.md#@inject.getter) +- [`@inject.setter`](Decorators_inject.md#@inject.setter) +- [`@inject.context`](Decorators_inject.md#@inject.context) +- [`@inject.tag`](Decorators_inject.md#@inject.tag) +- [`@inject.view`](Decorators_inject.md#@inject.view) + +See [Inject decorators](Decorators_inject.md) for more details. + ## Circular dependencies LoopBack can detect circular dependencies and report the path which leads to the diff --git a/packages/context/package.json b/packages/context/package.json index 5df62eefa0fb..accfc379c8d7 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -21,6 +21,7 @@ "dependencies": { "@loopback/metadata": "^1.0.5", "debug": "^4.0.1", + "p-event": "^2.2.0", "uuid": "^3.2.1" }, "devDependencies": { diff --git a/packages/context/src/context-observer.ts b/packages/context/src/context-observer.ts new file mode 100644 index 000000000000..c53b39d273d1 --- /dev/null +++ b/packages/context/src/context-observer.ts @@ -0,0 +1,52 @@ +// 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 {Binding} from './binding'; +import {BindingFilter} from './binding-filter'; +import {ValueOrPromise} from './value-promise'; +import {Context} from './context'; + +/** + * Context event types. We support `bind` and `unbind` for now but + * keep it open for new types + */ +export type ContextEventType = 'bind' | 'unbind' | string; + +/** + * Observers of context bind/unbind events + */ +export interface ContextObserver { + /** + * An optional filter function to match bindings. If not present, the listener + * will be notified of all binding events. + */ + filter?: BindingFilter; + + /** + * Listen on `bind`, `unbind`, or other events + * @param eventType Context event type + * @param binding The binding as event source + */ + observe( + eventType: ContextEventType, + binding: Readonly>, + context: Context, + ): ValueOrPromise; +} + +/** + * Subscription of context events. It's modeled after + * https://github.com/tc39/proposal-observable. + */ +export interface Subscription { + /** + * unsubscribe + */ + unsubscribe(): void; + /** + * Is the subscription closed? + */ + closed: boolean; +} diff --git a/packages/context/src/context-view.ts b/packages/context/src/context-view.ts new file mode 100644 index 000000000000..771bfb0e12e5 --- /dev/null +++ b/packages/context/src/context-view.ts @@ -0,0 +1,131 @@ +// 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 * as debugFactory from 'debug'; +import {promisify} from 'util'; +import {Binding} from './binding'; +import {BindingFilter} from './binding-filter'; +import {Context} from './context'; +import { + ContextObserver, + ContextEventType, + Subscription, +} from './context-observer'; +import {Getter} from './inject'; +import {ResolutionSession} from './resolution-session'; +import {resolveList, ValueOrPromise} from './value-promise'; +const debug = debugFactory('loopback:context:view'); +const nextTick = promisify(process.nextTick); + +/** + * `ContextView` provides a view for a given context chain to maintain a live + * list of matching bindings and their resolved values within the context + * hierarchy. + * + * This class is the key utility to implement dynamic extensions for extension + * points. For example, the RestServer can react to `controller` bindings even + * they are added/removed/updated after the application starts. + * + */ +export class ContextView implements ContextObserver { + protected _cachedBindings: Readonly>[] | undefined; + protected _cachedValues: T[] | undefined; + private _subscription: Subscription | undefined; + + constructor( + protected readonly context: Context, + public readonly filter: BindingFilter, + ) {} + + /** + * Start listening events from the context + */ + open() { + debug('Start listening on changes of context %s', this.context.name); + return (this._subscription = this.context.subscribe(this)); + } + + /** + * Stop listening events from the context + */ + close() { + debug('Stop listening on changes of context %s', this.context.name); + if (!this._subscription || this._subscription.closed) return; + this._subscription.unsubscribe(); + this._subscription = undefined; + } + + /** + * Get the list of matched bindings. If they are not cached, it tries to find + * them from the context. + */ + get bindings(): Readonly>[] { + debug('Reading bindings'); + if (this._cachedBindings == null) { + this._cachedBindings = this.findBindings(); + } + return this._cachedBindings; + } + + /** + * Find matching bindings and refresh the cache + */ + protected findBindings() { + debug('Finding matching bindings'); + this._cachedBindings = this.context.find(this.filter); + return this._cachedBindings; + } + + /** + * Listen on `bind` or `unbind` and invalidate the cache + */ + observe(event: ContextEventType, binding: Readonly>) { + this.reset(); + } + + /** + * Reset the view by invalidating its cache + */ + reset() { + debug('Invalidating cache'); + this._cachedBindings = undefined; + this._cachedValues = undefined; + } + + /** + * Resolve values for the matching bindings + * @param session Resolution session + */ + resolve(session?: ResolutionSession): ValueOrPromise { + debug('Resolving values'); + // We don't cache values with this method as it returns `ValueOrPromise` + // for inject `resolve` function to allow `getSync` but `this._cachedValues` + // expects `T[]` + return resolveList(this.bindings, b => { + return b.getValue(this.context, ResolutionSession.fork(session)); + }); + } + + /** + * Get the list of resolved values. If they are not cached, it tries to find + * and resolve them. + */ + async values(): Promise { + debug('Reading values'); + // Wait for the next tick so that context event notification can be emitted + await nextTick(); + if (this._cachedValues == null) { + this._cachedValues = await this.resolve(); + } + return this._cachedValues; + } + + /** + * As a `Getter` function + */ + asGetter(): Getter { + return () => this.values(); + } +} diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index aa862b015886..aea240670574 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -4,20 +4,30 @@ // License text available at https://opensource.org/licenses/MIT import * as debugModule from 'debug'; +import {EventEmitter} from 'events'; import {v1 as uuidv1} from 'uuid'; import {ValueOrPromise} from '.'; import {Binding, BindingTag} from './binding'; +import {BindingFilter, filterByKey, filterByTag} from './binding-filter'; import {BindingAddress, BindingKey} from './binding-key'; +import {ContextView} from './context-view'; +import { + ContextEventType, + ContextObserver, + Subscription, +} from './context-observer'; import {ResolutionOptions, ResolutionSession} from './resolution-session'; import {BoundValue, getDeepProperty, isPromiseLike} from './value-promise'; -import {BindingFilter, filterByKey, filterByTag} from './binding-filter'; + +// FIXME: `@types/p-event` is out of date against `p-event@2.2.0` +const pEvent = require('p-event'); const debug = debugModule('loopback:context'); /** * Context provides an implementation of Inversion of Control (IoC) container */ -export class Context { +export class Context extends EventEmitter { /** * Name of the context */ @@ -34,16 +44,90 @@ export class Context { protected _parent?: Context; /** - * Create a new context + * A list of registered context observers + */ + protected readonly observers: Set = new Set(); + + /** + * Internal counter for pending events which observers have not processed yet + */ + private pendingEvents = 0; + + /** + * Create a new context. For example, + * ```ts + * // Create a new root context, let the framework to create a unique name + * const rootCtx = new Context(); + * + * // Create a new child context inheriting bindings from `rootCtx` + * const childCtx = new Context(rootCtx); + * + * // Create another root context called "application" + * const appCtx = new Context('application'); + * + * // Create a new child context called "request" and inheriting bindings + * // from `appCtx` + * const reqCtx = new Context(appCtx, 'request'); + * ``` * @param _parent The optional parent context + * @param name Name of the context, if not provided, a `uuid` will be + * generated as the name */ constructor(_parent?: Context | string, name?: string) { + super(); if (typeof _parent === 'string') { name = _parent; _parent = undefined; } this._parent = _parent; this.name = name || uuidv1(); + + this.setupEventHandlers(); + } + + /** + * Set up an internal listener to notify registered observers asynchronously + * upon `bind` and `unbind` events + */ + private setupEventHandlers() { + // Ideally p-event should allow multiple event types in an iterator + this.observeEvent('bind'); + this.observeEvent('unbind'); + } + + /** + * Listen on context events and notify observers + * @param eventType Context event type + */ + private async observeEvent(eventType: ContextEventType) { + this.on(eventType, () => { + // Track pending events + this.pendingEvents++; + }); + // Create an async iterator from the given event type + const bindings: AsyncIterable>> = pEvent.iterator( + this, + eventType, + ); + for await (const binding of bindings) { + try { + await this.notifyObservers(eventType, binding); + this.pendingEvents--; + this.emit('idle'); + } catch (err) { + this.pendingEvents--; + this.emit('error', err); + } + } + } + + /** + * Wait until event notification is idle + */ + protected async waitForIdle() { + const count = this.pendingEvents; + if (count === 0) return; + await pEvent.multiple(this, 'idle', {count}); } /** @@ -72,14 +156,21 @@ export class Context { debug('Adding binding: %s', key); } + let existingBinding: Binding | undefined; const keyExists = this.registry.has(key); if (keyExists) { - const existingBinding = this.registry.get(key); + existingBinding = this.registry.get(key); const bindingIsLocked = existingBinding && existingBinding.isLocked; if (bindingIsLocked) throw new Error(`Cannot rebind key "${key}" to a locked binding`); } this.registry.set(key, binding); + if (existingBinding !== binding) { + if (existingBinding != null) { + this.emit('unbind', existingBinding); + } + this.emit('bind', binding); + } return this; } @@ -96,10 +187,78 @@ export class Context { unbind(key: BindingAddress): boolean { key = BindingKey.validate(key); const binding = this.registry.get(key); + // If not found, return `false` if (binding == null) return false; if (binding && binding.isLocked) throw new Error(`Cannot unbind key "${key}" of a locked binding`); - return this.registry.delete(key); + this.registry.delete(key); + this.emit('unbind', binding); + return true; + } + + /** + * Add a context event observer to the context chain, including its ancestors + * @param observer Context event observer + */ + subscribe(observer: ContextObserver): Subscription { + let ctx: Context | undefined = this; + while (ctx != null) { + ctx.observers.add(observer); + ctx = ctx._parent; + } + return new ContextSubscription(this, observer); + } + + /** + * Remove the context event observer from the context chain + * @param observer Context event observer + */ + unsubscribe(observer: ContextObserver) { + let ctx: Context | undefined = this; + while (ctx != null) { + ctx.observers.delete(observer); + ctx = ctx._parent; + } + } + + /** + * Check if an observer is subscribed to this context + * @param observer Context observer + */ + isSubscribed(observer: ContextObserver) { + return this.observers.has(observer); + } + + /** + * Create a view of the context chain with the given binding filter + * @param filter A function to match bindings + */ + createView(filter: BindingFilter) { + const view = new ContextView(this, filter); + view.open(); + return view; + } + + /** + * Publish an event to the registered observers. Please note the + * notification is queued and performed asynchronously so that we allow fluent + * APIs such as `ctx.bind('key').to(...).tag(...);` and give observers the + * fully populated binding. + * + * @param eventType Event names: `bind` or `unbind` + * @param binding Binding bound or unbound + */ + protected async notifyObservers( + eventType: ContextEventType, + binding: Readonly>, + ) { + if (this.observers.size === 0) return; + + for (const observer of this.observers) { + if (!observer.filter || observer.filter(binding)) { + await observer.observe(eventType, binding, this); + } + } } /** @@ -137,7 +296,7 @@ export class Context { } /** - * Find bindings using the key pattern + * Find bindings using a key pattern or filter function * @param pattern A filter function, a regexp or a wildcard pattern with * optional `*` and `?`. Find returns such bindings where the key matches * the provided pattern. @@ -451,3 +610,24 @@ export class Context { return json; } } + +/** + * An implementation of `Subscription` interface for context events + */ +class ContextSubscription implements Subscription { + constructor( + protected context: Context, + protected observer: ContextObserver, + ) {} + + private _closed = false; + + unsubscribe() { + this.context.unsubscribe(this.observer); + this._closed = true; + } + + get closed() { + return this._closed; + } +} diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 4a83b7c580ef..106924e0b991 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -10,6 +10,8 @@ export * from './binding-inspector'; export * from './binding-key'; export * from './binding-filter'; export * from './context'; +export * from './context-observer'; +export * from './context-view'; export * from './inject'; export * from './keys'; export * from './provider'; diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index b9f2173b137b..6af664ae81ed 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -4,18 +4,21 @@ // License text available at https://opensource.org/licenses/MIT import { - MetadataInspector, DecoratorFactory, + InspectionOptions, + MetadataAccessor, + MetadataInspector, + MetadataMap, ParameterDecoratorFactory, PropertyDecoratorFactory, - MetadataMap, - MetadataAccessor, - InspectionOptions, } from '@loopback/metadata'; -import {BoundValue, ValueOrPromise, resolveList} from './value-promise'; +import {BindingTag} from './binding'; +import {BindingFilter, filterByTag} from './binding-filter'; +import {BindingAddress} from './binding-key'; import {Context} from './context'; -import {BindingKey, BindingAddress} from './binding-key'; +import {ContextView} from './context-view'; import {ResolutionSession} from './resolution-session'; +import {BoundValue, ValueOrPromise} from './value-promise'; const PARAMETERS_KEY = MetadataAccessor.create( 'inject:parameters', @@ -54,6 +57,13 @@ export interface InjectionMetadata { [attribute: string]: BoundValue; } +/** + * Select binding(s) by key or a filter function + */ +export type BindingSelector = + | BindingAddress + | BindingFilter; + /** * Descriptor for an injection point */ @@ -64,7 +74,7 @@ export interface Injection { | TypedPropertyDescriptor | number; - bindingKey: BindingAddress; // Binding key + bindingSelector: BindingSelector; // Binding selector metadata?: InjectionMetadata; // Related metadata resolve?: ResolverFunction; // A custom resolve function } @@ -89,17 +99,20 @@ export interface Injection { * * - TODO(bajtos) * - * @param bindingKey What binding to use in order to resolve the value of the + * @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( - bindingKey: string | BindingKey, + bindingSelector: BindingSelector, metadata?: InjectionMetadata, resolve?: ResolverFunction, ) { + if (typeof bindingSelector === 'function' && !resolve) { + resolve = resolveValuesByFilter; + } metadata = Object.assign({decorator: '@inject'}, metadata); return function markParameterOrPropertyAsInjected( target: Object, @@ -119,7 +132,7 @@ export function inject( target, member, methodDescriptorOrParameterIndex, - bindingKey, + bindingSelector, metadata, resolve, }, @@ -155,7 +168,7 @@ export function inject( target, member, methodDescriptorOrParameterIndex, - bindingKey, + bindingSelector, metadata, resolve, }, @@ -175,7 +188,7 @@ export function inject( } /** - * The function injected by `@inject.getter(key)`. + * The function injected by `@inject.getter(bindingSelector)`. */ export type Getter = () => Promise; @@ -205,15 +218,16 @@ export namespace inject { * * See also `Getter`. * - * @param bindingKey The key of the value we want to eventually get. + * @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( - bindingKey: BindingAddress, + bindingSelector: BindingSelector, metadata?: InjectionMetadata, ) { metadata = Object.assign({decorator: '@inject.getter'}, metadata); - return inject(bindingKey, metadata, resolveAsGetter); + return inject(bindingSelector, metadata, resolveAsGetter); }; /** @@ -248,18 +262,38 @@ export namespace inject { * ) {} * } * ``` - * @param bindingTag Tag name or regex + * @param bindingTag Tag name, regex or object * @param metadata Optional metadata to help the injection */ - export const tag = function injectTag( - bindingTag: string | RegExp, + export const tag = function injectByTag( + bindingTag: BindingTag | RegExp, metadata?: InjectionMetadata, ) { metadata = Object.assign( {decorator: '@inject.tag', tag: bindingTag}, metadata, ); - return inject('', metadata, resolveByTag); + return inject(filterByTag(bindingTag), metadata); + }; + + /** + * Inject matching bound values by the filter function + * + * ```ts + * class MyControllerWithView { + * @inject.view(filterByTag('foo')) + * view: ContextView; + * } + * ``` + * @param bindingFilter A binding filter function + * @param metadata + */ + export const view = function injectByFilter( + bindingFilter: BindingFilter, + metadata?: InjectionMetadata, + ) { + metadata = Object.assign({decorator: '@inject.view'}, metadata); + return inject(bindingFilter, metadata, resolveAsContextView); }; /** @@ -277,15 +311,33 @@ export namespace inject { }; } +function isBindingAddress( + bindingSelector: BindingSelector, +): bindingSelector is BindingAddress { + return typeof bindingSelector !== 'function'; +} + function resolveAsGetter( ctx: Context, injection: Readonly, session?: ResolutionSession, ) { + const targetType = inspectTargetType(injection); + if (targetType && targetType !== Function) { + const targetName = ResolutionSession.describeInjection(injection)! + .targetName; + throw new Error( + `The type of ${targetName} (${targetType.name}) is not a Getter function`, + ); + } + const bindingSelector = injection.bindingSelector; + if (!isBindingAddress(bindingSelector)) { + return resolveAsGetterByFilter(ctx, injection, session); + } // We need to clone the session for the getter as it will be resolved later session = ResolutionSession.fork(session); return function getter() { - return ctx.get(injection.bindingKey, { + return ctx.get(bindingSelector, { session, optional: injection.metadata && injection.metadata.optional, }); @@ -293,9 +345,22 @@ function resolveAsGetter( } function resolveAsSetter(ctx: Context, injection: Injection) { + const targetType = inspectTargetType(injection); + const targetName = ResolutionSession.describeInjection(injection)!.targetName; + if (targetType && targetType !== Function) { + throw new Error( + `The type of ${targetName} (${targetType.name}) is not a Setter function`, + ); + } + const bindingSelector = injection.bindingSelector; + if (!isBindingAddress(bindingSelector)) { + throw new Error( + `@inject.setter for (${targetType.name}) does not allow BindingFilter`, + ); + } // No resolution session should be propagated into the setter - return function setter(value: BoundValue) { - ctx.bind(injection.bindingKey).to(value); + return function setter(value: unknown) { + ctx.bind(bindingSelector).to(value); }; } @@ -331,19 +396,99 @@ export function describeInjectedArguments( return meta || []; } -function resolveByTag( +/** + * Inspect the target type + * @param injection + */ +function inspectTargetType(injection: Readonly) { + let type = MetadataInspector.getDesignTypeForProperty( + injection.target, + injection.member!, + ); + if (type) { + return type; + } + const designType = MetadataInspector.getDesignTypeForMethod( + injection.target, + injection.member!, + ); + type = + designType.parameterTypes[ + injection.methodDescriptorOrParameterIndex as number + ]; + return type; +} + +/** + * 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, + session?: ResolutionSession, +) { + const targetType = inspectTargetType(injection); + + if (targetType !== Array) { + const targetName = ResolutionSession.describeInjection(injection)! + .targetName; + throw new Error( + `The type of ${targetName} (${targetType.name}) is not Array`, + ); + } + + const bindingFilter = injection.bindingSelector as BindingFilter; + const view = new ContextView(ctx, bindingFilter); + 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, + session?: ResolutionSession, +) { + const bindingFilter = injection.bindingSelector as BindingFilter; + const view = new ContextView(ctx, bindingFilter); + view.open(); + return view.asGetter(); +} + +/** + * Resolve to an instance of `ContextView` by the binding filter function + * for `@inject.view` + * @param ctx Context object + * @param injection Injection information + * @param session Resolution session + */ +function resolveAsContextView( ctx: Context, injection: Readonly, session?: ResolutionSession, ) { - const tag: string | RegExp = injection.metadata!.tag; - const bindings = ctx.findByTag(tag); + const targetType = inspectTargetType(injection); + if (targetType && targetType !== ContextView) { + const targetName = ResolutionSession.describeInjection(injection)! + .targetName; + throw new Error( + `The type of ${targetName} (${targetType.name}) is not ContextView`, + ); + } - return resolveList(bindings, b => { - // We need to clone the session so that resolution of multiple bindings - // can be tracked in parallel - return b.getValue(ctx, ResolutionSession.fork(session)); - }); + const bindingFilter = injection.bindingSelector as BindingFilter; + const view = new ContextView(ctx, bindingFilter); + view.open(); + return view; } /** diff --git a/packages/context/src/resolution-session.ts b/packages/context/src/resolution-session.ts index 0a682c02f5b8..c3738d3f38c9 100644 --- a/packages/context/src/resolution-session.ts +++ b/packages/context/src/resolution-session.ts @@ -169,7 +169,7 @@ export class ResolutionSession { ); return { targetName: name, - bindingKey: injection.bindingKey, + bindingKey: injection.bindingSelector, // Cast to Object so that we don't have to expose InjectionMetadata metadata: injection.metadata as Object, }; diff --git a/packages/context/src/resolver.ts b/packages/context/src/resolver.ts index e1394ecaf85f..f4a7656d6459 100644 --- a/packages/context/src/resolver.ts +++ b/packages/context/src/resolver.ts @@ -5,6 +5,7 @@ import {DecoratorFactory} from '@loopback/metadata'; import {Context} from './context'; +import {BindingAddress} from './binding-key'; import { BoundValue, Constructor, @@ -100,7 +101,8 @@ function resolve( return injection.resolve(ctx, injection, s); } else { // Default to resolve the value from the context by binding key - return ctx.getValueOrPromise(injection.bindingKey, { + const key = injection.bindingSelector as BindingAddress; + return ctx.getValueOrPromise(key, { session: s, // If the `optional` flag is set for the injection, the resolution // will return `undefined` instead of throwing an error @@ -174,7 +176,10 @@ export function resolveInjectedArguments( // The `val` argument is not used as the resolver only uses `injectedArgs` // and `extraArgs` to return the new value const injection = ix < injectedArgs.length ? injectedArgs[ix] : undefined; - if (injection == null || (!injection.bindingKey && !injection.resolve)) { + if ( + injection == null || + (!injection.bindingSelector && !injection.resolve) + ) { if (nonInjectedIndex < extraArgs.length) { // Set the argument from the non-injected list return extraArgs[nonInjectedIndex++]; @@ -265,7 +270,7 @@ export function resolveInjectedProperties( const injectedProperties = describeInjectedProperties(constructor.prototype); return resolveMap(injectedProperties, (injection, p) => { - if (!injection.bindingKey && !injection.resolve) { + if (!injection.bindingSelector && !injection.resolve) { const name = getTargetName(constructor, p); throw new Error( `Cannot resolve injected property ${name}: ` + diff --git a/packages/context/test/acceptance/class-level-bindings.acceptance.ts b/packages/context/test/acceptance/class-level-bindings.acceptance.ts index 3a195ec3b77c..e5dd06edc372 100644 --- a/packages/context/test/acceptance/class-level-bindings.acceptance.ts +++ b/packages/context/test/acceptance/class-level-bindings.acceptance.ts @@ -181,6 +181,32 @@ describe('Context bindings - Injecting dependencies of classes', () => { return expect(getter()).to.be.fulfilledWith('data'); }); + it('reports an error if @inject.getter has a non-function target', async () => { + ctx.bind('key').to('value'); + + class Store { + constructor(@inject.getter('key') public getter: string) {} + } + + ctx.bind('store').toClass(Store); + expect(() => ctx.getSync('store')).to.throw( + 'The type of Store.constructor[0] (String) is not a Getter function', + ); + }); + + it('reports an error if @inject.setter has a non-function target', async () => { + ctx.bind('key').to('value'); + + class Store { + constructor(@inject.setter('key') public setter: object) {} + } + + ctx.bind('store').toClass(Store); + expect(() => ctx.getSync('store')).to.throw( + 'The type of Store.constructor[0] (Object) is not a Setter function', + ); + }); + it('injects a nested property', async () => { class TestComponent { constructor(@inject('config#test') public config: string) {} diff --git a/packages/context/test/acceptance/context-view.acceptance.ts b/packages/context/test/acceptance/context-view.acceptance.ts new file mode 100644 index 000000000000..d199d879abb1 --- /dev/null +++ b/packages/context/test/acceptance/context-view.acceptance.ts @@ -0,0 +1,185 @@ +import {expect} from '@loopback/testlab'; +import { + Binding, + filterByTag, + Context, + ContextObserver, + ContextEventType, + ContextView, + Getter, + inject, +} from '../..'; + +let app: Context; +let server: Context; + +describe('ContextView', () => { + let contextView: ContextView; + beforeEach(givenViewForControllers); + + it('watches matching bindings', async () => { + // We have server: 1, app: 2 + expect(await getControllers()).to.eql(['1', '2']); + server.unbind('controllers.1'); + // Now we have app: 2 + expect(await getControllers()).to.eql(['2']); + app.unbind('controllers.2'); + // All controllers are gone from the context chain + expect(await getControllers()).to.eql([]); + // Add a new controller - server: 3 + givenController(server, '3'); + expect(await getControllers()).to.eql(['3']); + }); + + function givenViewForControllers() { + givenServerWithinAnApp(); + contextView = server.createView(filterByTag('controller')); + givenController(server, '1'); + givenController(app, '2'); + } + + function givenController(_ctx: Context, _name: string) { + class MyController { + name = _name; + } + _ctx + .bind(`controllers.${_name}`) + .toClass(MyController) + .tag('controller'); + } + + async function getControllers() { + // tslint:disable-next-line:no-any + return (await contextView.values()).map((v: any) => v.name); + } +}); + +describe('@inject.* - injects a live collection of matching bindings', async () => { + beforeEach(givenPrimeNumbers); + + class MyControllerWithGetter { + @inject.getter(filterByTag('prime')) + getter: Getter; + } + + class MyControllerWithValues { + constructor( + @inject(filterByTag('prime')) + public values: number[], + ) {} + } + + class MyControllerWithView { + @inject.view(filterByTag('prime')) + view: ContextView; + } + + it('injects as getter', async () => { + server.bind('my-controller').toClass(MyControllerWithGetter); + const inst = await server.get('my-controller'); + const getter = inst.getter; + expect(await getter()).to.eql([3, 5]); + // Add a new binding that matches the filter + givenPrime(server, 7); + // The getter picks up the new binding + expect(await getter()).to.eql([3, 7, 5]); + }); + + it('injects as values', async () => { + server.bind('my-controller').toClass(MyControllerWithValues); + const inst = await server.get('my-controller'); + expect(inst.values).to.eql([3, 5]); + }); + + it('injects as a view', async () => { + server.bind('my-controller').toClass(MyControllerWithView); + const inst = await server.get('my-controller'); + const view = inst.view; + expect(await view.values()).to.eql([3, 5]); + // Add a new binding that matches the filter + // Add a new binding that matches the filter + givenPrime(server, 7); + // The view picks up the new binding + expect(await view.values()).to.eql([3, 7, 5]); + server.unbind('prime.7'); + expect(await view.values()).to.eql([3, 5]); + }); + + function givenPrimeNumbers() { + givenServerWithinAnApp(); + givenPrime(server, 3); + givenPrime(app, 5); + } + + function givenPrime(ctx: Context, p: number) { + ctx + .bind(`prime.${p}`) + .to(p) + .tag('prime'); + } +}); + +describe('ContextEventListener', () => { + let contextListener: MyListenerForControllers; + beforeEach(givenControllerListener); + + it('receives notifications of matching binding events', async () => { + const controllers = await getControllers(); + // We have server: 1, app: 2 + // NOTE: The controllers are not guaranteed to be ['1', '2'] as the events + // are emitted by two context objects and they are processed asynchronously + expect(controllers).to.containEql('1'); + expect(controllers).to.containEql('2'); + server.unbind('controllers.1'); + // Now we have app: 2 + expect(await getControllers()).to.eql(['2']); + app.unbind('controllers.2'); + // All controllers are gone from the context chain + expect(await getControllers()).to.eql([]); + // Add a new controller - server: 3 + givenController(server, '3'); + expect(await getControllers()).to.eql(['3']); + }); + + class MyListenerForControllers implements ContextObserver { + controllers: Set = new Set(); + filter = filterByTag('controller'); + observe(event: ContextEventType, binding: Readonly>) { + if (event === 'bind') { + this.controllers.add(binding.tagMap.name); + } else if (event === 'unbind') { + this.controllers.delete(binding.tagMap.name); + } + } + } + + function givenControllerListener() { + givenServerWithinAnApp(); + contextListener = new MyListenerForControllers(); + server.subscribe(contextListener); + givenController(server, '1'); + givenController(app, '2'); + } + + function givenController(ctx: Context, controllerName: string) { + class MyController { + name = controllerName; + } + ctx + .bind(`controllers.${controllerName}`) + .toClass(MyController) + .tag('controller', {name: controllerName}); + } + + async function getControllers() { + return new Promise(resolve => { + // Wrap it inside `setImmediate` to make the events are triggered + setImmediate(() => resolve(Array.from(contextListener.controllers))); + }); + } +}); + +function givenServerWithinAnApp() { + app = new Context('app'); + server = new Context(app, 'server'); +} diff --git a/packages/context/test/unit/context-view.unit.ts b/packages/context/test/unit/context-view.unit.ts new file mode 100644 index 000000000000..801feac0aa87 --- /dev/null +++ b/packages/context/test/unit/context-view.unit.ts @@ -0,0 +1,116 @@ +// 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 {expect} from '@loopback/testlab'; +import {Binding, BindingScope, filterByTag, Context, ContextView} from '../..'; + +describe('ContextView', () => { + let app: Context; + let server: Context; + + let bindings: Binding[]; + let contextView: ContextView; + + beforeEach(givenContextView); + + it('tracks bindings', () => { + expect(contextView.bindings).to.eql(bindings); + }); + + it('resolves bindings', async () => { + expect(await contextView.resolve()).to.eql(['BAR', 'FOO']); + expect(await contextView.values()).to.eql(['BAR', 'FOO']); + }); + + it('resolves bindings as a getter', async () => { + expect(await contextView.asGetter()()).to.eql(['BAR', 'FOO']); + }); + + it('reloads bindings after reset', async () => { + contextView.reset(); + const abcBinding = server + .bind('abc') + .to('ABC') + .tag('abc'); + const xyzBinding = server + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(contextView.bindings).to.containEql(xyzBinding); + // `abc` does not have the matching tag + expect(contextView.bindings).to.not.containEql(abcBinding); + expect(await contextView.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); + + it('reloads bindings if context bindings are added', async () => { + const abcBinding = server + .bind('abc') + .to('ABC') + .tag('abc'); + const xyzBinding = server + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(contextView.bindings).to.containEql(xyzBinding); + // `abc` does not have the matching tag + expect(contextView.bindings).to.not.containEql(abcBinding); + expect(await contextView.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); + + it('reloads bindings if context bindings are removed', async () => { + server.unbind('bar'); + expect(await contextView.values()).to.eql(['FOO']); + }); + + it('reloads bindings if context bindings are rebound', async () => { + server.bind('bar').to('BAR'); // No more tagged with `foo` + expect(await contextView.values()).to.eql(['FOO']); + }); + + it('reloads bindings if parent context bindings are added', async () => { + const xyzBinding = app + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(contextView.bindings).to.containEql(xyzBinding); + expect(await contextView.values()).to.eql(['BAR', 'FOO', 'XYZ']); + }); + + it('reloads bindings if parent context bindings are removed', async () => { + app.unbind('foo'); + expect(await contextView.values()).to.eql(['BAR']); + }); + + it('stops watching', async () => { + expect(await contextView.values()).to.eql(['BAR', 'FOO']); + contextView.close(); + app.unbind('foo'); + expect(await contextView.values()).to.eql(['BAR', 'FOO']); + }); + + function givenContextView() { + bindings = []; + givenContext(); + contextView = server.createView(filterByTag('foo')); + } + + function givenContext() { + app = new Context('app'); + server = new Context(app, 'server'); + bindings.push( + server + .bind('bar') + .toDynamicValue(() => Promise.resolve('BAR')) + .tag('foo', 'bar') + .inScope(BindingScope.SINGLETON), + ); + bindings.push( + app + .bind('foo') + .to('FOO') + .tag('foo', 'bar'), + ); + } +}); diff --git a/packages/context/test/unit/context.unit.ts b/packages/context/test/unit/context.unit.ts index e0896a8c9265..7985c1257e6f 100644 --- a/packages/context/test/unit/context.unit.ts +++ b/packages/context/test/unit/context.unit.ts @@ -4,15 +4,19 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; +import {promisify} from 'util'; import { - Context, Binding, + BindingKey, BindingScope, BindingType, + Context, + ContextObserver, isPromiseLike, - BindingKey, } from '../..'; +const setImmediateAsync = promisify(setImmediate); + /** * Create a subclass of context so that we can access parents and registry * for assertions @@ -25,6 +29,13 @@ class TestContext extends Context { const map = new Map(this.registry); return map; } + + /** + * Wait until the context event queue is empty or an error is thrown + */ + waitUntilObserversNotified(): Promise { + return this.waitForIdle(); + } } describe('Context constructor', () => { @@ -61,7 +72,7 @@ describe('Context constructor', () => { }); describe('Context', () => { - let ctx: Context; + let ctx: TestContext; beforeEach('given a context', createContext); describe('bind', () => { @@ -681,7 +692,155 @@ describe('Context', () => { }); }); + describe('observer subscription', () => { + let nonMatchingObserver: ContextObserver; + + beforeEach(givenNonMatchingObserver); + + it('subscribes observers', () => { + ctx.subscribe(nonMatchingObserver); + expect(ctx.isSubscribed(nonMatchingObserver)).to.true(); + }); + + it('unsubscribes observers', () => { + ctx.subscribe(nonMatchingObserver); + expect(ctx.isSubscribed(nonMatchingObserver)).to.true(); + ctx.unsubscribe(nonMatchingObserver); + expect(ctx.isSubscribed(nonMatchingObserver)).to.false(); + }); + + it('allows subscription.unsubscribe()', () => { + const subscription = ctx.subscribe(nonMatchingObserver); + expect(ctx.isSubscribed(nonMatchingObserver)).to.true(); + subscription.unsubscribe(); + expect(ctx.isSubscribed(nonMatchingObserver)).to.false(); + expect(subscription.closed).to.be.true(); + }); + + it('registers observers on context chain', () => { + const childCtx = new Context(ctx, 'child'); + childCtx.subscribe(nonMatchingObserver); + expect(childCtx.isSubscribed(nonMatchingObserver)).to.true(); + expect(ctx.isSubscribed(nonMatchingObserver)).to.true(); + }); + + it('un-registers observers on context chain', () => { + const childCtx = new Context(ctx, 'child'); + childCtx.subscribe(nonMatchingObserver); + expect(childCtx.isSubscribed(nonMatchingObserver)).to.true(); + expect(ctx.isSubscribed(nonMatchingObserver)).to.true(); + childCtx.unsubscribe(nonMatchingObserver); + expect(childCtx.isSubscribed(nonMatchingObserver)).to.false(); + expect(ctx.isSubscribed(nonMatchingObserver)).to.false(); + }); + + function givenNonMatchingObserver() { + nonMatchingObserver = { + filter: binding => false, + observe: (event, binding) => {}, + }; + } + }); + + describe('event notification', () => { + const events: string[] = []; + let nonMatchingObserverCalled = false; + + beforeEach(givenObservers); + + it('emits one bind event to matching observers', async () => { + ctx.bind('foo').to('foo-value'); + await ctx.waitUntilObserversNotified(); + expect(events).to.eql(['1:foo:foo-value:bind', '2:foo:foo-value:bind']); + expect(nonMatchingObserverCalled).to.be.false(); + }); + + it('emits multiple bind events to matching observers', async () => { + ctx.bind('foo').to('foo-value'); + ctx.bind('xyz').to('xyz-value'); + await ctx.waitUntilObserversNotified(); + expect(events).to.eql([ + '1:foo:foo-value:bind', + '2:foo:foo-value:bind', + '1:xyz:xyz-value:bind', + '2:xyz:xyz-value:bind', + ]); + }); + + it('emits unbind event to matching observers', async () => { + ctx.bind('foo').to('foo-value'); + await ctx.waitUntilObserversNotified(); + ctx.unbind('foo'); + await ctx.waitUntilObserversNotified(); + expect(events).to.eql([ + '1:foo:foo-value:bind', + '2:foo:foo-value:bind', + '1:foo:foo-value:unbind', + '2:foo:foo-value:unbind', + ]); + expect(nonMatchingObserverCalled).to.be.false(); + }); + + it('does not trigger observers if affected binding is the same', async () => { + const binding = ctx.bind('foo').to('foo-value'); + await ctx.waitUntilObserversNotified(); + expect(events).to.eql(['1:foo:foo-value:bind', '2:foo:foo-value:bind']); + ctx.add(binding); + await ctx.waitUntilObserversNotified(); + expect(events).to.eql(['1:foo:foo-value:bind', '2:foo:foo-value:bind']); + }); + + it('reports error if an observer fails', () => { + ctx.bind('bar').to('bar-value'); + return expect(ctx.waitUntilObserversNotified()).to.be.rejectedWith( + 'something wrong', + ); + }); + + function givenObservers() { + nonMatchingObserverCalled = false; + events.splice(0, events.length); + // An observer does not match the criteria + const nonMatchingObserver: ContextObserver = { + filter: binding => false, + observe: (event, binding) => { + nonMatchingObserverCalled = true; + }, + }; + // A sync observer matches the criteria + const matchingObserver: ContextObserver = { + observe: (event, binding, context) => { + // Make sure the binding is configured with value + // when the observer is notified + const val = binding.getValue(context); + events.push(`1:${binding.key}:${val}:${event}`); + }, + }; + // An async observer matches the criteria + const matchingAsyncObserver: ContextObserver = { + filter: binding => true, + observe: async (event, binding, context) => { + await setImmediateAsync(); + const val = binding.getValue(context); + events.push(`2:${binding.key}:${val}:${event}`); + }, + }; + // An async observer matches the criteria that throws an error + const matchingAsyncObserverWithError: ContextObserver = { + filter: binding => binding.key === 'bar', + observe: async () => { + await setImmediateAsync(); + throw new Error('something wrong'); + }, + }; + ctx.subscribe(nonMatchingObserver); + ctx.subscribe(matchingObserver); + ctx.subscribe(matchingAsyncObserver); + ctx.subscribe(matchingAsyncObserverWithError); + } + }); + function createContext() { - ctx = new Context(); + ctx = new TestContext(); } }); diff --git a/packages/context/test/unit/inject-view.unit.ts b/packages/context/test/unit/inject-view.unit.ts new file mode 100644 index 000000000000..eaee9e89400c --- /dev/null +++ b/packages/context/test/unit/inject-view.unit.ts @@ -0,0 +1,160 @@ +// 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 {expect} from '@loopback/testlab'; +import { + Binding, + BindingScope, + filterByTag, + Context, + ContextView, + Getter, + inject, +} from '../..'; + +describe('@inject.view', async () => { + let ctx: Context; + beforeEach(() => { + ctx = givenContext(); + }); + + class MyControllerWithGetter { + @inject.view(filterByTag('foo')) + getter: Getter; + } + + class MyControllerWithValues { + constructor( + @inject.view(filterByTag('foo')) + public values: string[], + ) {} + } + + class MyControllerWithTracker { + @inject.view(filterByTag('foo')) + view: ContextView; + } + + it('reports error if the target type (Getter) is not ContextView', async () => { + ctx.bind('my-controller').toClass(MyControllerWithGetter); + await expect( + ctx.get('my-controller'), + ).to.be.rejectedWith( + 'The type of MyControllerWithGetter.prototype.getter (Function) is not ContextView', + ); + }); + + it('reports error if the target type (string[]) is not ContextView', async () => { + ctx.bind('my-controller').toClass(MyControllerWithValues); + await expect( + ctx.get('my-controller'), + ).to.be.rejectedWith( + 'The type of MyControllerWithValues.constructor[0] (Array) is not ContextView', + ); + }); + + it('injects as a view', async () => { + ctx.bind('my-controller').toClass(MyControllerWithTracker); + const inst = await ctx.get('my-controller'); + expect(inst.view).to.be.instanceOf(ContextView); + expect(await inst.view.values()).to.eql(['BAR', 'FOO']); + // Add a new binding that matches the filter + ctx + .bind('xyz') + .to('XYZ') + .tag('foo'); + // The view picks up the new binding + expect(await inst.view.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); +}); + +describe('@inject with filter function', async () => { + let ctx: Context; + beforeEach(() => { + ctx = givenContext(); + }); + + class MyControllerWithGetter { + @inject.getter(filterByTag('foo')) + getter: Getter; + } + + class MyControllerWithValues { + constructor( + @inject(filterByTag('foo')) + public values: string[], + ) {} + } + + class MyControllerWithView { + @inject(filterByTag('foo')) + view: ContextView; + } + + class MyControllerWithGetter2 { + @inject(filterByTag('foo')) + getter: Getter; + } + + it('injects as getter', async () => { + ctx.bind('my-controller').toClass(MyControllerWithGetter); + const inst = await ctx.get('my-controller'); + const getter = inst.getter; + expect(getter).to.be.a.Function(); + expect(await getter()).to.eql(['BAR', 'FOO']); + // Add a new binding that matches the filter + ctx + .bind('xyz') + .to('XYZ') + .tag('foo'); + // The getter picks up the new binding + expect(await getter()).to.eql(['BAR', 'XYZ', 'FOO']); + }); + + it('injects as values', async () => { + ctx.bind('my-controller').toClass(MyControllerWithValues); + const inst = await ctx.get('my-controller'); + expect(inst.values).to.eql(['BAR', 'FOO']); + }); + + it('refuses to inject as a view', async () => { + ctx.bind('my-controller').toClass(MyControllerWithView); + await expect( + ctx.get('my-controller'), + ).to.be.rejectedWith( + 'The type of MyControllerWithView.prototype.view' + + ' (ContextView) is not Array', + ); + }); + + it('refuses to inject as a getter', async () => { + ctx.bind('my-controller').toClass(MyControllerWithGetter2); + await expect( + ctx.get('my-controller'), + ).to.be.rejectedWith( + 'The type of MyControllerWithGetter2.prototype.getter' + + ' (Function) is not Array', + ); + }); +}); + +function givenContext(bindings: Binding[] = []) { + const parent = new Context('app'); + const ctx = new Context(parent, 'server'); + bindings.push( + ctx + .bind('bar') + .toDynamicValue(() => Promise.resolve('BAR')) + .tag('foo', 'bar') + .inScope(BindingScope.SINGLETON), + ); + bindings.push( + parent + .bind('foo') + .to('FOO') + .tag('foo', 'bar'), + ); + return ctx; +} diff --git a/packages/context/test/unit/inject.unit.ts b/packages/context/test/unit/inject.unit.ts index 75c4a3dd74d2..1b1eec921cea 100644 --- a/packages/context/test/unit/inject.unit.ts +++ b/packages/context/test/unit/inject.unit.ts @@ -25,7 +25,7 @@ describe('function argument injection', () => { } const meta = describeInjectedArguments(TestClass); - expect(meta.map(m => m.bindingKey)).to.deepEqual(['foo']); + expect(meta.map(m => m.bindingSelector)).to.deepEqual(['foo']); }); it('can retrieve information about injected method arguments', () => { @@ -35,7 +35,7 @@ describe('function argument injection', () => { } const meta = describeInjectedArguments(TestClass.prototype, 'test'); - expect(meta.map(m => m.bindingKey)).to.deepEqual(['foo']); + expect(meta.map(m => m.bindingSelector)).to.deepEqual(['foo']); }); it('can retrieve information about injected static method arguments', () => { @@ -44,7 +44,7 @@ describe('function argument injection', () => { } const meta = describeInjectedArguments(TestClass, 'test'); - expect(meta.map(m => m.bindingKey)).to.deepEqual(['foo']); + expect(meta.map(m => m.bindingSelector)).to.deepEqual(['foo']); }); it('returns an empty array when no ctor arguments are decorated', () => { @@ -63,7 +63,7 @@ describe('function argument injection', () => { class SubTestClass extends TestClass {} const meta = describeInjectedArguments(SubTestClass); - expect(meta.map(m => m.bindingKey)).to.deepEqual(['foo']); + expect(meta.map(m => m.bindingSelector)).to.deepEqual(['foo']); }); it('supports inheritance with overriding constructor', () => { @@ -77,7 +77,7 @@ describe('function argument injection', () => { } } const meta = describeInjectedArguments(SubTestClass); - expect(meta.map(m => m.bindingKey)).to.deepEqual(['bar']); + expect(meta.map(m => m.bindingSelector)).to.deepEqual(['bar']); }); it('supports inheritance with overriding constructor - no args', () => { @@ -91,7 +91,7 @@ describe('function argument injection', () => { } } const meta = describeInjectedArguments(SubTestClass); - expect(meta.map(m => m.bindingKey)).to.deepEqual([]); + expect(meta.map(m => m.bindingSelector)).to.deepEqual([]); }); }); @@ -112,7 +112,7 @@ describe('property injection', () => { } const meta = describeInjectedProperties(TestClass.prototype); - expect(meta.foo.bindingKey).to.eql('foo'); + expect(meta.foo.bindingSelector).to.eql('foo'); }); it('returns an empty object when no properties are decorated', () => { @@ -152,7 +152,7 @@ describe('property injection', () => { class SubTestClass extends TestClass {} const meta = describeInjectedProperties(SubTestClass.prototype); - expect(meta.foo.bindingKey).to.eql('foo'); + expect(meta.foo.bindingSelector).to.eql('foo'); }); it('supports inheritance with overriding property', () => { @@ -167,10 +167,10 @@ describe('property injection', () => { } const base = describeInjectedProperties(TestClass.prototype); - expect(base.foo.bindingKey).to.eql('foo'); + expect(base.foo.bindingSelector).to.eql('foo'); const sub = describeInjectedProperties(SubTestClass.prototype); - expect(sub.foo.bindingKey).to.eql('bar'); + expect(sub.foo.bindingSelector).to.eql('bar'); }); it('supports inherited and own properties', () => { @@ -184,8 +184,8 @@ describe('property injection', () => { bar: string; } const meta = describeInjectedProperties(SubTestClass.prototype); - expect(meta.foo.bindingKey).to.eql('foo'); - expect(meta.bar.bindingKey).to.eql('bar'); + expect(meta.foo.bindingSelector).to.eql('foo'); + expect(meta.bar.bindingSelector).to.eql('bar'); }); it('does not clone metadata deeply', () => { diff --git a/packages/context/test/unit/resolution-session.unit.ts b/packages/context/test/unit/resolution-session.unit.ts index 73c248804666..2b507e0554c9 100644 --- a/packages/context/test/unit/resolution-session.unit.ts +++ b/packages/context/test/unit/resolution-session.unit.ts @@ -14,7 +14,7 @@ describe('ResolutionSession', () => { function givenInjection(): Injection { return { target: MyController, - bindingKey: 'b', + bindingSelector: 'b', methodDescriptorOrParameterIndex: 0, }; } diff --git a/packages/context/test/unit/resolver.unit.ts b/packages/context/test/unit/resolver.unit.ts index f66522ce7f88..292744630c2b 100644 --- a/packages/context/test/unit/resolver.unit.ts +++ b/packages/context/test/unit/resolver.unit.ts @@ -5,12 +5,13 @@ import {expect} from '@loopback/testlab'; import { + BindingAddress, Context, + Getter, inject, + Injection, instantiateClass, invokeMethod, - Injection, - Getter, ResolutionSession, } from '../..'; @@ -82,7 +83,7 @@ describe('constructor injection', () => { @inject('foo', {x: 'bar'}, (c: Context, injection: Injection) => { const barKey = injection.metadata && injection.metadata.x; const b = c.getSync(barKey); - const f = c.getSync(injection.bindingKey); + const f = c.getSync(injection.bindingSelector as BindingAddress); return f + ':' + b; }) public fooBar: string, @@ -377,7 +378,7 @@ describe('property injection', () => { @inject('foo', {x: 'bar'}, (c: Context, injection: Injection) => { const barKey = injection.metadata && injection.metadata.x; const b = c.getSync(barKey); - const f = c.getSync(injection.bindingKey); + const f = c.getSync(injection.bindingSelector as BindingAddress); return f + ':' + b; }) public fooBar: string; @@ -586,7 +587,7 @@ function customDecorator(def: Object) { return inject('foo', def, (c: Context, injection: Injection) => { const barKey = injection.metadata && injection.metadata.x; const b = c.getSync(barKey); - const f = c.getSync(injection.bindingKey); + const f = c.getSync(injection.bindingSelector as BindingAddress); return f + ':' + b; }); } @@ -595,7 +596,7 @@ function customAsyncDecorator(def: Object) { return inject('foo', def, async (c: Context, injection: Injection) => { const barKey = injection.metadata && injection.metadata.x; const b = await c.get(barKey); - const f = await c.get(injection.bindingKey); + const f = await c.get(injection.bindingSelector as BindingAddress); return f + ':' + b; }); }