Skip to content

Commit 72b4190

Browse files
committed
feat(context): enable detection of circular dependencies
1 parent 98882ee commit 72b4190

File tree

5 files changed

+237
-27
lines changed

5 files changed

+237
-27
lines changed

packages/context/src/binding.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// License text available at https://opensource.org/licenses/MIT
55

66
import {Context} from './context';
7-
import {Constructor, instantiateClass} from './resolver';
7+
import {Constructor, instantiateClass, ResolutionSession} from './resolver';
88
import {isPromise} from './is-promise';
99
import {Provider} from './provider';
1010

@@ -147,7 +147,10 @@ export class Binding {
147147
public type: BindingType;
148148

149149
private _cache: BoundValue;
150-
private _getValue: (ctx?: Context) => BoundValue | Promise<BoundValue>;
150+
private _getValue: (
151+
ctx?: Context,
152+
session?: ResolutionSession,
153+
) => BoundValue | Promise<BoundValue>;
151154

152155
// For bindings bound via toClass, this property contains the constructor
153156
// function
@@ -224,8 +227,14 @@ export class Binding {
224227
* doSomething(result);
225228
* }
226229
* ```
230+
*
231+
* @param ctx Context for the resolution
232+
* @param session Optional session for binding and dependency resolution
227233
*/
228-
getValue(ctx: Context): BoundValue | Promise<BoundValue> {
234+
getValue(
235+
ctx: Context,
236+
session?: ResolutionSession,
237+
): BoundValue | Promise<BoundValue> {
229238
// First check cached value for non-transient
230239
if (this._cache !== undefined) {
231240
if (this.scope === BindingScope.SINGLETON) {
@@ -237,7 +246,22 @@ export class Binding {
237246
}
238247
}
239248
if (this._getValue) {
240-
const result = this._getValue(ctx);
249+
const resolutionSession = ResolutionSession.enterBinding(this, session);
250+
const result = this._getValue(ctx, resolutionSession);
251+
if (isPromise(result)) {
252+
if (result instanceof Promise) {
253+
result.catch(err => {
254+
resolutionSession.exit();
255+
return Promise.reject(err);
256+
});
257+
}
258+
result.then(val => {
259+
resolutionSession.exit();
260+
return val;
261+
});
262+
} else {
263+
resolutionSession.exit();
264+
}
241265
return this._cacheValue(ctx, result);
242266
}
243267
return Promise.reject(
@@ -347,10 +371,11 @@ export class Binding {
347371
*/
348372
public toProvider<T>(providerClass: Constructor<Provider<T>>): this {
349373
this.type = BindingType.PROVIDER;
350-
this._getValue = ctx => {
374+
this._getValue = (ctx, session) => {
351375
const providerOrPromise = instantiateClass<Provider<T>>(
352376
providerClass,
353377
ctx!,
378+
session,
354379
);
355380
if (isPromise(providerOrPromise)) {
356381
return providerOrPromise.then(p => p.value());
@@ -370,7 +395,7 @@ export class Binding {
370395
*/
371396
toClass<T>(ctor: Constructor<T>): this {
372397
this.type = BindingType.CLASS;
373-
this._getValue = ctx => instantiateClass(ctor, ctx!);
398+
this._getValue = (ctx, session) => instantiateClass(ctor, ctx!, session);
374399
this.valueConstructor = ctor;
375400
return this;
376401
}

packages/context/src/context.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import {Binding, BoundValue, ValueOrPromise} from './binding';
77
import {isPromise} from './is-promise';
8+
import {ResolutionSession} from './resolver';
89

910
/**
1011
* Context provides an implementation of Inversion of Control (IoC) container
@@ -143,9 +144,9 @@ export class Context {
143144
* (deeply) nested property to retrieve.
144145
* @returns A promise of the bound value.
145146
*/
146-
get(key: string): Promise<BoundValue> {
147+
get(key: string, session?: ResolutionSession): Promise<BoundValue> {
147148
try {
148-
return Promise.resolve(this.getValueOrPromise(key));
149+
return Promise.resolve(this.getValueOrPromise(key, session));
149150
} catch (err) {
150151
return Promise.reject(err);
151152
}
@@ -173,8 +174,8 @@ export class Context {
173174
* (deeply) nested property to retrieve.
174175
* @returns A promise of the bound value.
175176
*/
176-
getSync(key: string): BoundValue {
177-
const valueOrPromise = this.getValueOrPromise(key);
177+
getSync(key: string, session?: ResolutionSession): BoundValue {
178+
const valueOrPromise = this.getValueOrPromise(key, session);
178179

179180
if (isPromise(valueOrPromise)) {
180181
throw new Error(
@@ -227,13 +228,17 @@ export class Context {
227228
*
228229
* @param keyWithPath The binding key, optionally suffixed with a path to the
229230
* (deeply) nested property to retrieve.
231+
* @param session An object to keep states of the resolution
230232
* @returns The bound value or a promise of the bound value, depending
231233
* on how the binding was configured.
232234
* @internal
233235
*/
234-
getValueOrPromise(keyWithPath: string): ValueOrPromise<BoundValue> {
236+
getValueOrPromise(
237+
keyWithPath: string,
238+
session?: ResolutionSession,
239+
): ValueOrPromise<BoundValue> {
235240
const {key, path} = Binding.parseKeyWithPath(keyWithPath);
236-
const boundValue = this.getBinding(key).getValue(this);
241+
const boundValue = this.getBinding(key).getValue(this, session);
237242
if (path === undefined || path === '') {
238243
return boundValue;
239244
}

packages/context/src/inject.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
} from '@loopback/metadata';
1212
import {BoundValue, ValueOrPromise} from './binding';
1313
import {Context} from './context';
14+
import {isPromise} from './is-promise';
15+
import {ResolutionSession} from './resolver';
1416

1517
const PARAMETERS_KEY = 'inject:parameters';
1618
const PROPERTIES_KEY = 'inject:properties';
@@ -19,7 +21,11 @@ const PROPERTIES_KEY = 'inject:properties';
1921
* A function to provide resolution of injected values
2022
*/
2123
export interface ResolverFunction {
22-
(ctx: Context, injection: Injection): ValueOrPromise<BoundValue>;
24+
(
25+
ctx: Context,
26+
injection: Injection,
27+
session?: ResolutionSession,
28+
): ValueOrPromise<BoundValue>;
2329
}
2430

2531
/**
@@ -168,12 +174,14 @@ export namespace inject {
168174
}
169175

170176
function resolveAsGetter(ctx: Context, injection: Injection) {
177+
// No resolution session should be propagated into the getter
171178
return function getter() {
172179
return ctx.get(injection.bindingKey);
173180
};
174181
}
175182

176183
function resolveAsSetter(ctx: Context, injection: Injection) {
184+
// No resolution session should be propagated into the setter
177185
return function setter(value: BoundValue) {
178186
ctx.bind(injection.bindingKey).to(value);
179187
};

packages/context/src/resolver.ts

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// License text available at https://opensource.org/licenses/MIT
55

66
import {Context} from './context';
7-
import {BoundValue, ValueOrPromise} from './binding';
7+
import {BoundValue, ValueOrPromise, Binding} from './binding';
88
import {isPromise} from './is-promise';
99
import {
1010
describeInjectedArguments,
@@ -20,6 +20,68 @@ export type Constructor<T> =
2020
// tslint:disable-next-line:no-any
2121
new (...args: any[]) => T;
2222

23+
/**
24+
* Object to keep states for a session to resolve bindings and their
25+
* dependencies within a context
26+
*/
27+
export class ResolutionSession {
28+
/**
29+
* A stack of bindings for the current resolution session. It's used to track
30+
* the path of dependency resolution and detect circular dependencies.
31+
*/
32+
readonly bindings: Binding[] = [];
33+
34+
/**
35+
* Start to resolve a binding within the session
36+
* @param binding Binding
37+
* @param session Resolution session
38+
*/
39+
static enterBinding(
40+
binding: Binding,
41+
session?: ResolutionSession,
42+
): ResolutionSession {
43+
session = session || new ResolutionSession();
44+
session.enter(binding);
45+
return session;
46+
}
47+
48+
/**
49+
* Getter for the current binding
50+
*/
51+
get binding() {
52+
return this.bindings[this.bindings.length - 1];
53+
}
54+
55+
/**
56+
* Enter the resolution of the given binding. If
57+
* @param binding Binding
58+
*/
59+
enter(binding: Binding) {
60+
if (this.bindings.indexOf(binding) !== -1) {
61+
throw new Error(
62+
`Circular dependency detected for '${
63+
binding.key
64+
}' on path '${this.getBindingPath()}'`,
65+
);
66+
}
67+
this.bindings.push(binding);
68+
}
69+
70+
/**
71+
* Exit the resolution of a binding
72+
*/
73+
exit() {
74+
return this.bindings.pop();
75+
}
76+
77+
/**
78+
* Get the binding path as `bindingA->bindingB->bindingC`.
79+
*/
80+
getBindingPath() {
81+
return this.bindings.map(b => b.key).join('->');
82+
}
83+
}
84+
2385
/**
2486
* Create an instance of a class which constructor has arguments
2587
* decorated with `@inject`.
@@ -29,16 +91,19 @@ export type Constructor<T> =
2991
*
3092
* @param ctor The class constructor to call.
3193
* @param ctx The context containing values for `@inject` resolution
94+
* @param session Optional session for binding and dependency resolution
3295
* @param nonInjectedArgs Optional array of args for non-injected parameters
3396
*/
3497
export function instantiateClass<T>(
3598
ctor: Constructor<T>,
3699
ctx: Context,
100+
session?: ResolutionSession,
37101
// tslint:disable-next-line:no-any
38102
nonInjectedArgs?: any[],
39103
): T | Promise<T> {
40-
const argsOrPromise = resolveInjectedArguments(ctor, ctx, '');
41-
const propertiesOrPromise = resolveInjectedProperties(ctor, ctx);
104+
session = session || new ResolutionSession();
105+
const argsOrPromise = resolveInjectedArguments(ctor, '', ctx, session);
106+
const propertiesOrPromise = resolveInjectedProperties(ctor, ctx, session);
42107
let inst: T | Promise<T>;
43108
if (isPromise(argsOrPromise)) {
44109
// Instantiate the class asynchronously
@@ -72,14 +137,19 @@ export function instantiateClass<T>(
72137
* Resolve the value or promise for a given injection
73138
* @param ctx Context
74139
* @param injection Descriptor of the injection
140+
* @param session Optional session for binding and dependency resolution
75141
*/
76-
function resolve<T>(ctx: Context, injection: Injection): ValueOrPromise<T> {
142+
function resolve<T>(
143+
ctx: Context,
144+
injection: Injection,
145+
session?: ResolutionSession,
146+
): ValueOrPromise<T> {
77147
if (injection.resolve) {
78148
// A custom resolve function is provided
79-
return injection.resolve(ctx, injection);
149+
return injection.resolve(ctx, injection, session);
80150
}
81151
// Default to resolve the value from the context by binding key
82-
return ctx.getValueOrPromise(injection.bindingKey);
152+
return ctx.getValueOrPromise(injection.bindingKey, session);
83153
}
84154

85155
/**
@@ -92,16 +162,18 @@ function resolve<T>(ctx: Context, injection: Injection): ValueOrPromise<T> {
92162
*
93163
* @param target The class for constructor injection or prototype for method
94164
* injection
95-
* @param ctx The context containing values for `@inject` resolution
96165
* @param method The method name. If set to '', the constructor will
97166
* be used.
167+
* @param ctx The context containing values for `@inject` resolution
168+
* @param session Optional session for binding and dependency resolution
98169
* @param nonInjectedArgs Optional array of args for non-injected parameters
99170
*/
100171
export function resolveInjectedArguments(
101172
// tslint:disable-next-line:no-any
102173
target: any,
103-
ctx: Context,
104174
method: string,
175+
ctx: Context,
176+
session?: ResolutionSession,
105177
// tslint:disable-next-line:no-any
106178
nonInjectedArgs?: any[],
107179
): BoundValue[] | Promise<BoundValue[]> {
@@ -137,7 +209,7 @@ export function resolveInjectedArguments(
137209
}
138210
}
139211

140-
const valueOrPromise = resolve(ctx, injection);
212+
const valueOrPromise = resolve(ctx, injection, session);
141213
if (isPromise(valueOrPromise)) {
142214
if (!asyncResolvers) asyncResolvers = [];
143215
asyncResolvers.push(
@@ -173,8 +245,9 @@ export function invokeMethod(
173245
): ValueOrPromise<BoundValue> {
174246
const argsOrPromise = resolveInjectedArguments(
175247
target,
176-
ctx,
177248
method,
249+
ctx,
250+
undefined,
178251
nonInjectedArgs,
179252
);
180253
assert(typeof target[method] === 'function', `Method ${method} not found`);
@@ -189,11 +262,24 @@ export function invokeMethod(
189262

190263
export type KV = {[p: string]: BoundValue};
191264

265+
/**
266+
* Given a class with properties decorated with `@inject`,
267+
* return the map of properties resolved using the values
268+
* bound in `ctx`.
269+
270+
* The function returns an argument array when all dependencies were
271+
* resolved synchronously, or a Promise otherwise.
272+
*
273+
* @param constructor The class for which properties should be resolved.
274+
* @param ctx The context containing values for `@inject` resolution
275+
* @param session Optional session for binding and dependency resolution
276+
*/
192277
export function resolveInjectedProperties(
193-
fn: Function,
278+
constructor: Function,
194279
ctx: Context,
280+
session?: ResolutionSession,
195281
): KV | Promise<KV> {
196-
const injectedProperties = describeInjectedProperties(fn.prototype);
282+
const injectedProperties = describeInjectedProperties(constructor.prototype);
197283

198284
const properties: KV = {};
199285
let asyncResolvers: Promise<void>[] | undefined = undefined;
@@ -205,11 +291,12 @@ export function resolveInjectedProperties(
205291
const injection = injectedProperties[p];
206292
if (!injection.bindingKey && !injection.resolve) {
207293
throw new Error(
208-
`Cannot resolve injected property for class ${fn.name}: ` +
294+
`Cannot resolve injected property for class ${constructor.name}: ` +
209295
`The property ${p} was not decorated for dependency injection.`,
210296
);
211297
}
212-
const valueOrPromise = resolve(ctx, injection);
298+
299+
const valueOrPromise = resolve(ctx, injection, session);
213300
if (isPromise(valueOrPromise)) {
214301
if (!asyncResolvers) asyncResolvers = [];
215302
asyncResolvers.push(valueOrPromise.then(propertyResolver(p)));

0 commit comments

Comments
 (0)