UNPKG

11.3 kBPlain TextView Raw
1// Copyright IBM Corp. and LoopBack contributors 2018,2020. All Rights Reserved.
2// Node module: @loopback/context
3// This file is licensed under the MIT License.
4// License text available at https://opensource.org/licenses/MIT
5
6import {DecoratorFactory} from '@loopback/metadata';
7import debugModule from 'debug';
8import {Binding} from './binding';
9import {BindingSelector} from './binding-filter';
10import {Context} from './context';
11import {Injection, InjectionMetadata} from './inject';
12import {BoundValue, tryWithFinally, ValueOrPromise} from './value-promise';
13
14const debugSession = debugModule('loopback:context:resolver:session');
15const getTargetName = DecoratorFactory.getTargetName;
16
17/**
18 * A function to be executed with the resolution session
19 */
20export type ResolutionAction = (
21 session: ResolutionSession,
22) => ValueOrPromise<BoundValue>;
23
24/**
25 * Wrapper for bindings tracked by resolution sessions
26 */
27export interface BindingElement {
28 type: 'binding';
29 value: Readonly<Binding>;
30}
31
32/**
33 * Wrapper for injections tracked by resolution sessions
34 */
35export interface InjectionElement {
36 type: 'injection';
37 value: Readonly<Injection>;
38}
39
40export interface InjectionDescriptor {
41 targetName: string;
42 bindingSelector: BindingSelector;
43 metadata: InjectionMetadata;
44}
45
46/**
47 * Binding or injection elements tracked by resolution sessions
48 */
49export type ResolutionElement = BindingElement | InjectionElement;
50
51/**
52 * Type guard for binding elements
53 * @param element - A resolution element
54 */
55function isBinding(
56 element: ResolutionElement | undefined,
57): element is BindingElement {
58 return element != null && element.type === 'binding';
59}
60
61/**
62 * Type guard for injection elements
63 * @param element - A resolution element
64 */
65function isInjection(
66 element: ResolutionElement | undefined,
67): element is InjectionElement {
68 return element != null && element.type === 'injection';
69}
70
71/**
72 * Object to keep states for a session to resolve bindings and their
73 * dependencies within a context
74 */
75export class ResolutionSession {
76 /**
77 * A stack of bindings for the current resolution session. It's used to track
78 * the path of dependency resolution and detect circular dependencies.
79 */
80 readonly stack: ResolutionElement[] = [];
81
82 /**
83 * Fork the current session so that a new one with the same stack can be used
84 * in parallel or future resolutions, such as multiple method arguments,
85 * multiple properties, or a getter function
86 * @param session - The current session
87 */
88 static fork(session?: ResolutionSession): ResolutionSession | undefined {
89 if (session === undefined) return undefined;
90 const copy = new ResolutionSession();
91 copy.stack.push(...session.stack);
92 return copy;
93 }
94
95 /**
96 * Run the given action with the given binding and session
97 * @param action - A function to do some work with the resolution session
98 * @param binding - The current binding
99 * @param session - The current resolution session
100 */
101 static runWithBinding(
102 action: ResolutionAction,
103 binding: Readonly<Binding>,
104 session = new ResolutionSession(),
105 ) {
106 // Start to resolve a binding within the session
107 session.pushBinding(binding);
108 return tryWithFinally(
109 () => action(session),
110 () => session.popBinding(),
111 );
112 }
113
114 /**
115 * Run the given action with the given injection and session
116 * @param action - A function to do some work with the resolution session
117 * @param binding - The current injection
118 * @param session - The current resolution session
119 */
120 static runWithInjection(
121 action: ResolutionAction,
122 injection: Readonly<Injection>,
123 session = new ResolutionSession(),
124 ) {
125 session.pushInjection(injection);
126 return tryWithFinally(
127 () => action(session),
128 () => session.popInjection(),
129 );
130 }
131
132 /**
133 * Describe the injection for debugging purpose
134 * @param injection - Injection object
135 */
136 static describeInjection(
137 injection: Readonly<Injection>,
138 ): InjectionDescriptor {
139 const name = getTargetName(
140 injection.target,
141 injection.member,
142 injection.methodDescriptorOrParameterIndex,
143 );
144 return {
145 targetName: name,
146 bindingSelector: injection.bindingSelector,
147 metadata: injection.metadata,
148 };
149 }
150
151 /**
152 * Push the injection onto the session
153 * @param injection - Injection The current injection
154 */
155 pushInjection(injection: Readonly<Injection>) {
156 /* istanbul ignore if */
157 if (debugSession.enabled) {
158 debugSession(
159 'Enter injection:',
160 ResolutionSession.describeInjection(injection),
161 );
162 }
163 this.stack.push({type: 'injection', value: injection});
164 /* istanbul ignore if */
165 if (debugSession.enabled) {
166 debugSession('Resolution path:', this.getResolutionPath());
167 }
168 }
169
170 /**
171 * Pop the last injection
172 */
173 popInjection() {
174 const top = this.stack.pop();
175 if (!isInjection(top)) {
176 throw new Error('The top element must be an injection');
177 }
178
179 const injection = top.value;
180 /* istanbul ignore if */
181 if (debugSession.enabled) {
182 debugSession(
183 'Exit injection:',
184 ResolutionSession.describeInjection(injection),
185 );
186 debugSession('Resolution path:', this.getResolutionPath() || '<empty>');
187 }
188 return injection;
189 }
190
191 /**
192 * Getter for the current injection
193 */
194 get currentInjection(): Readonly<Injection> | undefined {
195 for (let i = this.stack.length - 1; i >= 0; i--) {
196 const element = this.stack[i];
197 if (isInjection(element)) return element.value;
198 }
199 return undefined;
200 }
201
202 /**
203 * Getter for the current binding
204 */
205 get currentBinding(): Readonly<Binding> | undefined {
206 for (let i = this.stack.length - 1; i >= 0; i--) {
207 const element = this.stack[i];
208 if (isBinding(element)) return element.value;
209 }
210 return undefined;
211 }
212
213 /**
214 * Enter the resolution of the given binding. If
215 * @param binding - Binding
216 */
217 pushBinding(binding: Readonly<Binding>) {
218 /* istanbul ignore if */
219 if (debugSession.enabled) {
220 debugSession('Enter binding:', binding.toJSON());
221 }
222
223 if (this.stack.find(i => isBinding(i) && i.value === binding)) {
224 const msg =
225 `Circular dependency detected: ` +
226 `${this.getResolutionPath()} --> ${binding.key}`;
227 debugSession(msg);
228 throw new Error(msg);
229 }
230 this.stack.push({type: 'binding', value: binding});
231 /* istanbul ignore if */
232 if (debugSession.enabled) {
233 debugSession('Resolution path:', this.getResolutionPath());
234 }
235 }
236
237 /**
238 * Exit the resolution of a binding
239 */
240 popBinding(): Readonly<Binding> {
241 const top = this.stack.pop();
242 if (!isBinding(top)) {
243 throw new Error('The top element must be a binding');
244 }
245 const binding = top.value;
246 /* istanbul ignore if */
247 if (debugSession.enabled) {
248 debugSession('Exit binding:', binding?.toJSON());
249 debugSession('Resolution path:', this.getResolutionPath() || '<empty>');
250 }
251 return binding;
252 }
253
254 /**
255 * Getter for bindings on the stack
256 */
257 get bindingStack(): Readonly<Binding>[] {
258 return this.stack.filter(isBinding).map(e => e.value);
259 }
260
261 /**
262 * Getter for injections on the stack
263 */
264 get injectionStack(): Readonly<Injection>[] {
265 return this.stack.filter(isInjection).map(e => e.value);
266 }
267
268 /**
269 * Get the binding path as `bindingA --> bindingB --> bindingC`.
270 */
271 getBindingPath() {
272 return this.stack.filter(isBinding).map(describe).join(' --> ');
273 }
274
275 /**
276 * Get the injection path as `injectionA --> injectionB --> injectionC`.
277 */
278 getInjectionPath() {
279 return this.injectionStack
280 .map(i => ResolutionSession.describeInjection(i).targetName)
281 .join(' --> ');
282 }
283
284 /**
285 * Get the resolution path including bindings and injections, for example:
286 * `bindingA --> @ClassA[0] --> bindingB --> @ClassB.prototype.prop1
287 * --> bindingC`.
288 */
289 getResolutionPath() {
290 return this.stack.map(describe).join(' --> ');
291 }
292
293 toString() {
294 return this.getResolutionPath();
295 }
296}
297
298function describe(e: ResolutionElement) {
299 switch (e.type) {
300 case 'injection':
301 return '@' + ResolutionSession.describeInjection(e.value).targetName;
302 case 'binding':
303 return e.value.key;
304 }
305}
306
307/**
308 * Options for binding/dependency resolution
309 */
310export interface ResolutionOptions {
311 /**
312 * A session to track bindings and injections
313 */
314 session?: ResolutionSession;
315
316 /**
317 * A boolean flag to indicate if the dependency is optional. If it's set to
318 * `true` and the binding is not bound in a context, the resolution
319 * will return `undefined` instead of throwing an error.
320 */
321 optional?: boolean;
322
323 /**
324 * A boolean flag to control if a proxy should be created to apply
325 * interceptors for the resolved value. It's only honored for bindings backed
326 * by a class.
327 */
328 asProxyWithInterceptors?: boolean;
329}
330
331/**
332 * Resolution options or session
333 */
334export type ResolutionOptionsOrSession = ResolutionOptions | ResolutionSession;
335
336/**
337 * Normalize ResolutionOptionsOrSession to ResolutionOptions
338 * @param optionsOrSession - resolution options or session
339 */
340export function asResolutionOptions(
341 optionsOrSession?: ResolutionOptionsOrSession,
342): ResolutionOptions {
343 // backwards compatibility
344 if (optionsOrSession instanceof ResolutionSession) {
345 return {session: optionsOrSession};
346 }
347 return optionsOrSession ?? {};
348}
349
350/**
351 * Contextual metadata for resolution
352 */
353export interface ResolutionContext<T = unknown> {
354 /**
355 * The context for resolution
356 */
357 readonly context: Context;
358 /**
359 * The binding to be resolved
360 */
361 readonly binding: Readonly<Binding<T>>;
362 /**
363 * The options used for resolution
364 */
365 readonly options: ResolutionOptions;
366}
367
368/**
369 * Error for context binding resolutions and dependency injections
370 */
371export class ResolutionError extends Error {
372 constructor(
373 message: string,
374 readonly resolutionCtx: Partial<ResolutionContext>,
375 ) {
376 super(ResolutionError.buildMessage(message, resolutionCtx));
377 this.name = ResolutionError.name;
378 }
379
380 private static buildDetails(resolutionCtx: Partial<ResolutionContext>) {
381 return {
382 context: resolutionCtx.context?.name ?? '',
383 binding: resolutionCtx.binding?.key ?? '',
384 resolutionPath: resolutionCtx.options?.session?.getResolutionPath() ?? '',
385 };
386 }
387
388 /**
389 * Build the error message for the resolution to include more contextual data
390 * @param reason - Cause of the error
391 * @param resolutionCtx - Resolution context
392 */
393 private static buildMessage(
394 reason: string,
395 resolutionCtx: Partial<ResolutionContext>,
396 ) {
397 const info = this.describeResolutionContext(resolutionCtx);
398 const message = `${reason} (${info})`;
399 return message;
400 }
401
402 private static describeResolutionContext(
403 resolutionCtx: Partial<ResolutionContext>,
404 ) {
405 const details = ResolutionError.buildDetails(resolutionCtx);
406 const items: string[] = [];
407 for (const [name, val] of Object.entries(details)) {
408 if (val !== '') {
409 items.push(`${name}: ${val}`);
410 }
411 }
412 return items.join(', ');
413 }
414}