1 | // Copyright IBM Corp. and LoopBack contributors 2017,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 |
|
6 | import debugFactory, {Debugger} from 'debug';
|
7 | import {EventEmitter} from 'events';
|
8 | import {
|
9 | Binding,
|
10 | BindingInspectOptions,
|
11 | BindingScope,
|
12 | BindingTag,
|
13 | } from './binding';
|
14 | import {
|
15 | ConfigurationResolver,
|
16 | DefaultConfigurationResolver,
|
17 | } from './binding-config';
|
18 | import {
|
19 | BindingFilter,
|
20 | filterByKey,
|
21 | filterByTag,
|
22 | isBindingTagFilter,
|
23 | } from './binding-filter';
|
24 | import {BindingAddress, BindingKey} from './binding-key';
|
25 | import {BindingComparator} from './binding-sorter';
|
26 | import {ContextEvent, ContextEventListener} from './context-event';
|
27 | import {ContextEventObserver, ContextObserver} from './context-observer';
|
28 | import {ContextSubscriptionManager, Subscription} from './context-subscription';
|
29 | import {ContextTagIndexer} from './context-tag-indexer';
|
30 | import {ContextView} from './context-view';
|
31 | import {JSONObject} from './json-types';
|
32 | import {ContextBindings} from './keys';
|
33 | import {
|
34 | asResolutionOptions,
|
35 | ResolutionError,
|
36 | ResolutionOptions,
|
37 | ResolutionOptionsOrSession,
|
38 | ResolutionSession,
|
39 | } from './resolution-session';
|
40 | import {generateUniqueId} from './unique-id';
|
41 | import {
|
42 | BoundValue,
|
43 | Constructor,
|
44 | getDeepProperty,
|
45 | isPromiseLike,
|
46 | transformValueOrPromise,
|
47 | ValueOrPromise,
|
48 | } from './value-promise';
|
49 |
|
50 | /**
|
51 | * Context provides an implementation of Inversion of Control (IoC) container
|
52 | */
|
53 | export class Context extends EventEmitter {
|
54 | /**
|
55 | * Name of the context
|
56 | */
|
57 | readonly name: string;
|
58 |
|
59 | /**
|
60 | * Key to binding map as the internal registry
|
61 | */
|
62 | protected readonly registry: Map<string, Binding> = new Map();
|
63 |
|
64 | /**
|
65 | * Indexer for bindings by tag
|
66 | */
|
67 | protected readonly tagIndexer: ContextTagIndexer;
|
68 |
|
69 | /**
|
70 | * Manager for observer subscriptions
|
71 | */
|
72 | readonly subscriptionManager: ContextSubscriptionManager;
|
73 |
|
74 | /**
|
75 | * Parent context
|
76 | */
|
77 | protected _parent?: Context;
|
78 |
|
79 | /**
|
80 | * Configuration resolver
|
81 | */
|
82 | protected configResolver: ConfigurationResolver;
|
83 |
|
84 | /**
|
85 | * A debug function which can be overridden by subclasses.
|
86 | *
|
87 | * @example
|
88 | * ```ts
|
89 | * import debugFactory from 'debug';
|
90 | * const debug = debugFactory('loopback:context:application');
|
91 | * export class Application extends Context {
|
92 | * super('application');
|
93 | * this._debug = debug;
|
94 | * }
|
95 | * ```
|
96 | */
|
97 | protected _debug: Debugger;
|
98 |
|
99 | /**
|
100 | * Scope for binding resolution
|
101 | */
|
102 | scope: BindingScope = BindingScope.CONTEXT;
|
103 |
|
104 | /**
|
105 | * Create a new context.
|
106 | *
|
107 | * @example
|
108 | * ```ts
|
109 | * // Create a new root context, let the framework to create a unique name
|
110 | * const rootCtx = new Context();
|
111 | *
|
112 | * // Create a new child context inheriting bindings from `rootCtx`
|
113 | * const childCtx = new Context(rootCtx);
|
114 | *
|
115 | * // Create another root context called "application"
|
116 | * const appCtx = new Context('application');
|
117 | *
|
118 | * // Create a new child context called "request" and inheriting bindings
|
119 | * // from `appCtx`
|
120 | * const reqCtx = new Context(appCtx, 'request');
|
121 | * ```
|
122 | * @param _parent - The optional parent context
|
123 | * @param name - Name of the context. If not provided, a unique identifier
|
124 | * will be generated as the name.
|
125 | */
|
126 | constructor(_parent?: Context | string, name?: string) {
|
127 | super();
|
128 | // The number of listeners can grow with the number of child contexts
|
129 | // For example, each request can add a listener to the RestServer and the
|
130 | // listener is removed when the request processing is finished.
|
131 | // See https://github.com/loopbackio/loopback-next/issues/4363
|
132 | this.setMaxListeners(Infinity);
|
133 | if (typeof _parent === 'string') {
|
134 | name = _parent;
|
135 | _parent = undefined;
|
136 | }
|
137 | this._parent = _parent;
|
138 | this.name = name ?? this.generateName();
|
139 | this.tagIndexer = new ContextTagIndexer(this);
|
140 | this.subscriptionManager = new ContextSubscriptionManager(this);
|
141 | this._debug = debugFactory(this.getDebugNamespace());
|
142 | }
|
143 |
|
144 | /**
|
145 | * Get the debug namespace for the context class. Subclasses can override
|
146 | * this method to supply its own namespace.
|
147 | *
|
148 | * @example
|
149 | * ```ts
|
150 | * export class Application extends Context {
|
151 | * super('application');
|
152 | * }
|
153 | *
|
154 | * protected getDebugNamespace() {
|
155 | * return 'loopback:context:application';
|
156 | * }
|
157 | * ```
|
158 | */
|
159 | protected getDebugNamespace() {
|
160 | if (this.constructor === Context) return 'loopback:context';
|
161 | const name = this.constructor.name.toLowerCase();
|
162 | return `loopback:context:${name}`;
|
163 | }
|
164 |
|
165 | private generateName() {
|
166 | const id = generateUniqueId();
|
167 | if (this.constructor === Context) return id;
|
168 | return `${this.constructor.name}-${id}`;
|
169 | }
|
170 |
|
171 | /**
|
172 | * @internal
|
173 | * Getter for ContextSubscriptionManager
|
174 | */
|
175 | get parent() {
|
176 | return this._parent;
|
177 | }
|
178 |
|
179 | /**
|
180 | * Wrap the debug statement so that it always print out the context name
|
181 | * as the prefix
|
182 | * @param args - Arguments for the debug
|
183 | */
|
184 | protected debug(...args: unknown[]) {
|
185 | /* istanbul ignore if */
|
186 | if (!this._debug.enabled) return;
|
187 | const formatter = args.shift();
|
188 | if (typeof formatter === 'string') {
|
189 | this._debug(`[%s] ${formatter}`, this.name, ...args);
|
190 | } else {
|
191 | this._debug('[%s] ', this.name, formatter, ...args);
|
192 | }
|
193 | }
|
194 |
|
195 | /**
|
196 | * A strongly-typed method to emit context events
|
197 | * @param type Event type
|
198 | * @param event Context event
|
199 | */
|
200 | emitEvent<T extends ContextEvent>(type: string, event: T) {
|
201 | this.emit(type, event);
|
202 | }
|
203 |
|
204 | /**
|
205 | * Emit an `error` event
|
206 | * @param err Error
|
207 | */
|
208 | emitError(err: unknown) {
|
209 | this.emit('error', err);
|
210 | }
|
211 |
|
212 | /**
|
213 | * Create a binding with the given key in the context. If a locked binding
|
214 | * already exists with the same key, an error will be thrown.
|
215 | *
|
216 | * @param key - Binding key
|
217 | */
|
218 | bind<ValueType = BoundValue>(
|
219 | key: BindingAddress<ValueType>,
|
220 | ): Binding<ValueType> {
|
221 | const binding = new Binding<ValueType>(key.toString());
|
222 | this.add(binding);
|
223 | return binding;
|
224 | }
|
225 |
|
226 | /**
|
227 | * Add a binding to the context. If a locked binding already exists with the
|
228 | * same key, an error will be thrown.
|
229 | * @param binding - The configured binding to be added
|
230 | */
|
231 | add(binding: Binding<unknown>): this {
|
232 | const key = binding.key;
|
233 | this.debug('[%s] Adding binding: %s', key);
|
234 | let existingBinding: Binding | undefined;
|
235 | const keyExists = this.registry.has(key);
|
236 | if (keyExists) {
|
237 | existingBinding = this.registry.get(key);
|
238 | const bindingIsLocked = existingBinding?.isLocked;
|
239 | if (bindingIsLocked)
|
240 | throw new Error(`Cannot rebind key "${key}" to a locked binding`);
|
241 | }
|
242 | this.registry.set(key, binding);
|
243 | if (existingBinding !== binding) {
|
244 | if (existingBinding != null) {
|
245 | this.emitEvent('unbind', {
|
246 | binding: existingBinding,
|
247 | context: this,
|
248 | type: 'unbind',
|
249 | });
|
250 | }
|
251 | this.emitEvent('bind', {binding, context: this, type: 'bind'});
|
252 | }
|
253 | return this;
|
254 | }
|
255 |
|
256 | /**
|
257 | * Create a corresponding binding for configuration of the target bound by
|
258 | * the given key in the context.
|
259 | *
|
260 | * For example, `ctx.configure('controllers.MyController').to({x: 1})` will
|
261 | * create binding `controllers.MyController:$config` with value `{x: 1}`.
|
262 | *
|
263 | * @param key - The key for the binding to be configured
|
264 | */
|
265 | configure<ConfigValueType = BoundValue>(
|
266 | key: BindingAddress = '',
|
267 | ): Binding<ConfigValueType> {
|
268 | const bindingForConfig = Binding.configure<ConfigValueType>(key);
|
269 | this.add(bindingForConfig);
|
270 | return bindingForConfig;
|
271 | }
|
272 |
|
273 | /**
|
274 | * Get the value or promise of configuration for a given binding by key
|
275 | *
|
276 | * @param key - Binding key
|
277 | * @param propertyPath - Property path for the option. For example, `x.y`
|
278 | * requests for `<config>.x.y`. If not set, the `<config>` object will be
|
279 | * returned.
|
280 | * @param resolutionOptions - Options for the resolution.
|
281 | * - optional: if not set or set to `true`, `undefined` will be returned if
|
282 | * no corresponding value is found. Otherwise, an error will be thrown.
|
283 | */
|
284 | getConfigAsValueOrPromise<ConfigValueType>(
|
285 | key: BindingAddress,
|
286 | propertyPath?: string,
|
287 | resolutionOptions?: ResolutionOptions,
|
288 | ): ValueOrPromise<ConfigValueType | undefined> {
|
289 | this.setupConfigurationResolverIfNeeded();
|
290 | return this.configResolver.getConfigAsValueOrPromise(
|
291 | key,
|
292 | propertyPath,
|
293 | resolutionOptions,
|
294 | );
|
295 | }
|
296 |
|
297 | /**
|
298 | * Set up the configuration resolver if needed
|
299 | */
|
300 | protected setupConfigurationResolverIfNeeded() {
|
301 | if (!this.configResolver) {
|
302 | // First try the bound ConfigurationResolver to this context
|
303 | const configResolver = this.getSync<ConfigurationResolver>(
|
304 | ContextBindings.CONFIGURATION_RESOLVER,
|
305 | {
|
306 | optional: true,
|
307 | },
|
308 | );
|
309 | if (configResolver) {
|
310 | this.debug(
|
311 | 'Custom ConfigurationResolver is loaded from %s.',
|
312 | ContextBindings.CONFIGURATION_RESOLVER.toString(),
|
313 | );
|
314 | this.configResolver = configResolver;
|
315 | } else {
|
316 | // Fallback to DefaultConfigurationResolver
|
317 | this.debug('DefaultConfigurationResolver is used.');
|
318 | this.configResolver = new DefaultConfigurationResolver(this);
|
319 | }
|
320 | }
|
321 | return this.configResolver;
|
322 | }
|
323 |
|
324 | /**
|
325 | * Resolve configuration for the binding by key
|
326 | *
|
327 | * @param key - Binding key
|
328 | * @param propertyPath - Property path for the option. For example, `x.y`
|
329 | * requests for `<config>.x.y`. If not set, the `<config>` object will be
|
330 | * returned.
|
331 | * @param resolutionOptions - Options for the resolution.
|
332 | */
|
333 | async getConfig<ConfigValueType>(
|
334 | key: BindingAddress,
|
335 | propertyPath?: string,
|
336 | resolutionOptions?: ResolutionOptions,
|
337 | ): Promise<ConfigValueType | undefined> {
|
338 | return this.getConfigAsValueOrPromise<ConfigValueType>(
|
339 | key,
|
340 | propertyPath,
|
341 | resolutionOptions,
|
342 | );
|
343 | }
|
344 |
|
345 | /**
|
346 | * Resolve configuration synchronously for the binding by key
|
347 | *
|
348 | * @param key - Binding key
|
349 | * @param propertyPath - Property path for the option. For example, `x.y`
|
350 | * requests for `config.x.y`. If not set, the `config` object will be
|
351 | * returned.
|
352 | * @param resolutionOptions - Options for the resolution.
|
353 | */
|
354 | getConfigSync<ConfigValueType>(
|
355 | key: BindingAddress,
|
356 | propertyPath?: string,
|
357 | resolutionOptions?: ResolutionOptions,
|
358 | ): ConfigValueType | undefined {
|
359 | const valueOrPromise = this.getConfigAsValueOrPromise<ConfigValueType>(
|
360 | key,
|
361 | propertyPath,
|
362 | resolutionOptions,
|
363 | );
|
364 | if (isPromiseLike(valueOrPromise)) {
|
365 | const prop = propertyPath ? ` property ${propertyPath}` : '';
|
366 | throw new Error(
|
367 | `Cannot get config${prop} for ${key} synchronously: the value is a promise`,
|
368 | );
|
369 | }
|
370 | return valueOrPromise;
|
371 | }
|
372 |
|
373 | /**
|
374 | * Unbind a binding from the context. No parent contexts will be checked.
|
375 | *
|
376 | * @remarks
|
377 | * If you need to unbind a binding owned by a parent context, use the code
|
378 | * below:
|
379 | *
|
380 | * ```ts
|
381 | * const ownerCtx = ctx.getOwnerContext(key);
|
382 | * return ownerCtx != null && ownerCtx.unbind(key);
|
383 | * ```
|
384 | *
|
385 | * @param key - Binding key
|
386 | * @returns true if the binding key is found and removed from this context
|
387 | */
|
388 | unbind(key: BindingAddress): boolean {
|
389 | this.debug('Unbind %s', key);
|
390 | key = BindingKey.validate(key);
|
391 | const binding = this.registry.get(key);
|
392 | // If not found, return `false`
|
393 | if (binding == null) return false;
|
394 | if (binding?.isLocked)
|
395 | throw new Error(`Cannot unbind key "${key}" of a locked binding`);
|
396 | this.registry.delete(key);
|
397 | this.emitEvent('unbind', {binding, context: this, type: 'unbind'});
|
398 | return true;
|
399 | }
|
400 |
|
401 | /**
|
402 | * Add a context event observer to the context
|
403 | * @param observer - Context observer instance or function
|
404 | */
|
405 | subscribe(observer: ContextEventObserver): Subscription {
|
406 | return this.subscriptionManager.subscribe(observer);
|
407 | }
|
408 |
|
409 | /**
|
410 | * Remove the context event observer from the context
|
411 | * @param observer - Context event observer
|
412 | */
|
413 | unsubscribe(observer: ContextEventObserver): boolean {
|
414 | return this.subscriptionManager.unsubscribe(observer);
|
415 | }
|
416 |
|
417 | /**
|
418 | * Close the context: clear observers, stop notifications, and remove event
|
419 | * listeners from its parent context.
|
420 | *
|
421 | * @remarks
|
422 | * This method MUST be called to avoid memory leaks once a context object is
|
423 | * no longer needed and should be recycled. An example is the `RequestContext`,
|
424 | * which is created per request.
|
425 | */
|
426 | close() {
|
427 | this.debug('Closing context...');
|
428 | this.subscriptionManager.close();
|
429 | this.tagIndexer.close();
|
430 | }
|
431 |
|
432 | /**
|
433 | * Check if an observer is subscribed to this context
|
434 | * @param observer - Context observer
|
435 | */
|
436 | isSubscribed(observer: ContextObserver) {
|
437 | return this.subscriptionManager.isSubscribed(observer);
|
438 | }
|
439 |
|
440 | /**
|
441 | * Create a view of the context chain with the given binding filter
|
442 | * @param filter - A function to match bindings
|
443 | * @param comparator - A function to sort matched bindings
|
444 | * @param options - Resolution options
|
445 | */
|
446 | createView<T = unknown>(
|
447 | filter: BindingFilter,
|
448 | comparator?: BindingComparator,
|
449 | options?: Omit<ResolutionOptions, 'session'>,
|
450 | ) {
|
451 | const view = new ContextView<T>(this, filter, comparator, options);
|
452 | view.open();
|
453 | return view;
|
454 | }
|
455 |
|
456 | /**
|
457 | * Check if a binding exists with the given key in the local context without
|
458 | * delegating to the parent context
|
459 | * @param key - Binding key
|
460 | */
|
461 | contains(key: BindingAddress): boolean {
|
462 | key = BindingKey.validate(key);
|
463 | return this.registry.has(key);
|
464 | }
|
465 |
|
466 | /**
|
467 | * Check if a key is bound in the context or its ancestors
|
468 | * @param key - Binding key
|
469 | */
|
470 | isBound(key: BindingAddress): boolean {
|
471 | if (this.contains(key)) return true;
|
472 | if (this._parent) {
|
473 | return this._parent.isBound(key);
|
474 | }
|
475 | return false;
|
476 | }
|
477 |
|
478 | /**
|
479 | * Get the owning context for a binding or its key
|
480 | * @param keyOrBinding - Binding object or key
|
481 | */
|
482 | getOwnerContext(
|
483 | keyOrBinding: BindingAddress | Readonly<Binding<unknown>>,
|
484 | ): Context | undefined {
|
485 | let key: BindingAddress;
|
486 | if (keyOrBinding instanceof Binding) {
|
487 | key = keyOrBinding.key;
|
488 | } else {
|
489 | key = keyOrBinding as BindingAddress;
|
490 | }
|
491 | if (this.contains(key)) {
|
492 | if (keyOrBinding instanceof Binding) {
|
493 | // Check if the contained binding is the same
|
494 | if (this.registry.get(key.toString()) === keyOrBinding) {
|
495 | return this;
|
496 | }
|
497 | return undefined;
|
498 | }
|
499 | return this;
|
500 | }
|
501 | if (this._parent) {
|
502 | return this._parent.getOwnerContext(key);
|
503 | }
|
504 | return undefined;
|
505 | }
|
506 |
|
507 | /**
|
508 | * Get the context matching the scope
|
509 | * @param scope - Binding scope
|
510 | */
|
511 | getScopedContext(
|
512 | scope:
|
513 | | BindingScope.APPLICATION
|
514 | | BindingScope.SERVER
|
515 | | BindingScope.REQUEST,
|
516 | ): Context | undefined {
|
517 | if (this.scope === scope) return this;
|
518 | if (this._parent) {
|
519 | return this._parent.getScopedContext(scope);
|
520 | }
|
521 | return undefined;
|
522 | }
|
523 |
|
524 | /**
|
525 | * Locate the resolution context for the given binding. Only bindings in the
|
526 | * resolution context and its ancestors are visible as dependencies to resolve
|
527 | * the given binding
|
528 | * @param binding - Binding object
|
529 | */
|
530 | getResolutionContext(
|
531 | binding: Readonly<Binding<unknown>>,
|
532 | ): Context | undefined {
|
533 | let resolutionCtx: Context | undefined;
|
534 | switch (binding.scope) {
|
535 | case BindingScope.SINGLETON:
|
536 | // Use the owner context
|
537 | return this.getOwnerContext(binding.key);
|
538 | case BindingScope.TRANSIENT:
|
539 | case BindingScope.CONTEXT:
|
540 | // Use the current context
|
541 | return this;
|
542 | case BindingScope.REQUEST:
|
543 | resolutionCtx = this.getScopedContext(binding.scope);
|
544 | if (resolutionCtx != null) {
|
545 | return resolutionCtx;
|
546 | } else {
|
547 | // If no `REQUEST` scope exists in the chain, fall back to the current
|
548 | // context
|
549 | this.debug(
|
550 | 'No context is found for binding "%s (scope=%s)". Fall back to the current context.',
|
551 | binding.key,
|
552 | binding.scope,
|
553 | );
|
554 | return this;
|
555 | }
|
556 | default:
|
557 | // Use the scoped context
|
558 | return this.getScopedContext(binding.scope);
|
559 | }
|
560 | }
|
561 |
|
562 | /**
|
563 | * Check if this context is visible (same or ancestor) to the given one
|
564 | * @param ctx - Another context object
|
565 | */
|
566 | isVisibleTo(ctx: Context) {
|
567 | let current: Context | undefined = ctx;
|
568 | while (current != null) {
|
569 | if (current === this) return true;
|
570 | current = current._parent;
|
571 | }
|
572 | return false;
|
573 | }
|
574 |
|
575 | /**
|
576 | * Find bindings using a key pattern or filter function
|
577 | * @param pattern - A filter function, a regexp or a wildcard pattern with
|
578 | * optional `*` and `?`. Find returns such bindings where the key matches
|
579 | * the provided pattern.
|
580 | *
|
581 | * For a wildcard:
|
582 | * - `*` matches zero or more characters except `.` and `:`
|
583 | * - `?` matches exactly one character except `.` and `:`
|
584 | *
|
585 | * For a filter function:
|
586 | * - return `true` to include the binding in the results
|
587 | * - return `false` to exclude it.
|
588 | */
|
589 | find<ValueType = BoundValue>(
|
590 | pattern?: string | RegExp | BindingFilter,
|
591 | ): Readonly<Binding<ValueType>>[] {
|
592 | // Optimize if the binding filter is for tags
|
593 | if (typeof pattern === 'function' && isBindingTagFilter(pattern)) {
|
594 | return this._findByTagIndex(pattern.bindingTagPattern);
|
595 | }
|
596 |
|
597 | const bindings: Readonly<Binding<ValueType>>[] = [];
|
598 | const filter = filterByKey(pattern);
|
599 |
|
600 | for (const b of this.registry.values()) {
|
601 | if (filter(b)) bindings.push(b);
|
602 | }
|
603 |
|
604 | const parentBindings = this._parent?.find(filter);
|
605 | return this._mergeWithParent(bindings, parentBindings);
|
606 | }
|
607 |
|
608 | /**
|
609 | * Find bindings using the tag filter. If the filter matches one of the
|
610 | * binding tags, the binding is included.
|
611 | *
|
612 | * @param tagFilter - A filter for tags. It can be in one of the following
|
613 | * forms:
|
614 | * - A regular expression, such as `/controller/`
|
615 | * - A wildcard pattern string with optional `*` and `?`, such as `'con*'`
|
616 | * For a wildcard:
|
617 | * - `*` matches zero or more characters except `.` and `:`
|
618 | * - `?` matches exactly one character except `.` and `:`
|
619 | * - An object containing tag name/value pairs, such as
|
620 | * `{name: 'my-controller'}`
|
621 | */
|
622 | findByTag<ValueType = BoundValue>(
|
623 | tagFilter: BindingTag | RegExp,
|
624 | ): Readonly<Binding<ValueType>>[] {
|
625 | return this.find(filterByTag(tagFilter));
|
626 | }
|
627 |
|
628 | /**
|
629 | * Find bindings by tag leveraging indexes
|
630 | * @param tag - Tag name pattern or name/value pairs
|
631 | */
|
632 | protected _findByTagIndex<ValueType = BoundValue>(
|
633 | tag: BindingTag | RegExp,
|
634 | ): Readonly<Binding<ValueType>>[] {
|
635 | const currentBindings = this.tagIndexer.findByTagIndex(tag);
|
636 | const parentBindings = this._parent?._findByTagIndex(tag);
|
637 | return this._mergeWithParent(currentBindings, parentBindings);
|
638 | }
|
639 |
|
640 | protected _mergeWithParent<ValueType>(
|
641 | childList: Readonly<Binding<ValueType>>[],
|
642 | parentList?: Readonly<Binding<ValueType>>[],
|
643 | ) {
|
644 | if (!parentList) return childList;
|
645 | const additions = parentList.filter(parentBinding => {
|
646 | // children bindings take precedence
|
647 | return !childList.some(
|
648 | childBinding => childBinding.key === parentBinding.key,
|
649 | );
|
650 | });
|
651 | return childList.concat(additions);
|
652 | }
|
653 |
|
654 | /**
|
655 | * Get the value bound to the given key, throw an error when no value is
|
656 | * bound for the given key.
|
657 | *
|
658 | * @example
|
659 | *
|
660 | * ```ts
|
661 | * // get the value bound to "application.instance"
|
662 | * const app = await ctx.get<Application>('application.instance');
|
663 | *
|
664 | * // get "rest" property from the value bound to "config"
|
665 | * const config = await ctx.get<RestComponentConfig>('config#rest');
|
666 | *
|
667 | * // get "a" property of "numbers" property from the value bound to "data"
|
668 | * ctx.bind('data').to({numbers: {a: 1, b: 2}, port: 3000});
|
669 | * const a = await ctx.get<number>('data#numbers.a');
|
670 | * ```
|
671 | *
|
672 | * @param keyWithPath - The binding key, optionally suffixed with a path to the
|
673 | * (deeply) nested property to retrieve.
|
674 | * @param session - Optional session for resolution (accepted for backward
|
675 | * compatibility)
|
676 | * @returns A promise of the bound value.
|
677 | */
|
678 | get<ValueType>(
|
679 | keyWithPath: BindingAddress<ValueType>,
|
680 | session?: ResolutionSession,
|
681 | ): Promise<ValueType>;
|
682 |
|
683 | /**
|
684 | * Get the value bound to the given key, optionally return a (deep) property
|
685 | * of the bound value.
|
686 | *
|
687 | * @example
|
688 | *
|
689 | * ```ts
|
690 | * // get "rest" property from the value bound to "config"
|
691 | * // use `undefined` when no config is provided
|
692 | * const config = await ctx.get<RestComponentConfig>('config#rest', {
|
693 | * optional: true
|
694 | * });
|
695 | * ```
|
696 | *
|
697 | * @param keyWithPath - The binding key, optionally suffixed with a path to the
|
698 | * (deeply) nested property to retrieve.
|
699 | * @param options - Options for resolution.
|
700 | * @returns A promise of the bound value, or a promise of undefined when
|
701 | * the optional binding is not found.
|
702 | */
|
703 | get<ValueType>(
|
704 | keyWithPath: BindingAddress<ValueType>,
|
705 | options: ResolutionOptions,
|
706 | ): Promise<ValueType | undefined>;
|
707 |
|
708 | // Implementation
|
709 | async get<ValueType>(
|
710 | keyWithPath: BindingAddress<ValueType>,
|
711 | optionsOrSession?: ResolutionOptionsOrSession,
|
712 | ): Promise<ValueType | undefined> {
|
713 | this.debug('Resolving binding: %s', keyWithPath);
|
714 | return this.getValueOrPromise<ValueType | undefined>(
|
715 | keyWithPath,
|
716 | optionsOrSession,
|
717 | );
|
718 | }
|
719 |
|
720 | /**
|
721 | * Get the synchronous value bound to the given key, optionally
|
722 | * return a (deep) property of the bound value.
|
723 | *
|
724 | * This method throws an error if the bound value requires async computation
|
725 | * (returns a promise). You should never rely on sync bindings in production
|
726 | * code.
|
727 | *
|
728 | * @example
|
729 | *
|
730 | * ```ts
|
731 | * // get the value bound to "application.instance"
|
732 | * const app = ctx.getSync<Application>('application.instance');
|
733 | *
|
734 | * // get "rest" property from the value bound to "config"
|
735 | * const config = await ctx.getSync<RestComponentConfig>('config#rest');
|
736 | * ```
|
737 | *
|
738 | * @param keyWithPath - The binding key, optionally suffixed with a path to the
|
739 | * (deeply) nested property to retrieve.
|
740 | * @param session - Session for resolution (accepted for backward compatibility)
|
741 | * @returns A promise of the bound value.
|
742 | */
|
743 | getSync<ValueType>(
|
744 | keyWithPath: BindingAddress<ValueType>,
|
745 | session?: ResolutionSession,
|
746 | ): ValueType;
|
747 |
|
748 | /**
|
749 | * Get the synchronous value bound to the given key, optionally
|
750 | * return a (deep) property of the bound value.
|
751 | *
|
752 | * This method throws an error if the bound value requires async computation
|
753 | * (returns a promise). You should never rely on sync bindings in production
|
754 | * code.
|
755 | *
|
756 | * @example
|
757 | *
|
758 | * ```ts
|
759 | * // get "rest" property from the value bound to "config"
|
760 | * // use "undefined" when no config is provided
|
761 | * const config = await ctx.getSync<RestComponentConfig>('config#rest', {
|
762 | * optional: true
|
763 | * });
|
764 | * ```
|
765 | *
|
766 | * @param keyWithPath - The binding key, optionally suffixed with a path to the
|
767 | * (deeply) nested property to retrieve.
|
768 | * @param options - Options for resolution.
|
769 | * @returns The bound value, or undefined when an optional binding is not found.
|
770 | */
|
771 | getSync<ValueType>(
|
772 | keyWithPath: BindingAddress<ValueType>,
|
773 | options?: ResolutionOptions,
|
774 | ): ValueType | undefined;
|
775 |
|
776 | // Implementation
|
777 | getSync<ValueType>(
|
778 | keyWithPath: BindingAddress<ValueType>,
|
779 | optionsOrSession?: ResolutionOptionsOrSession,
|
780 | ): ValueType | undefined {
|
781 | this.debug('Resolving binding synchronously: %s', keyWithPath);
|
782 |
|
783 | const valueOrPromise = this.getValueOrPromise<ValueType>(
|
784 | keyWithPath,
|
785 | optionsOrSession,
|
786 | );
|
787 |
|
788 | if (isPromiseLike(valueOrPromise)) {
|
789 | throw new Error(
|
790 | `Cannot get ${keyWithPath} synchronously: the value is a promise`,
|
791 | );
|
792 | }
|
793 |
|
794 | return valueOrPromise;
|
795 | }
|
796 |
|
797 | /**
|
798 | * Look up a binding by key in the context and its ancestors. If no matching
|
799 | * binding is found, an error will be thrown.
|
800 | *
|
801 | * @param key - Binding key
|
802 | */
|
803 | getBinding<ValueType = BoundValue>(
|
804 | key: BindingAddress<ValueType>,
|
805 | ): Binding<ValueType>;
|
806 |
|
807 | /**
|
808 | * Look up a binding by key in the context and its ancestors. If no matching
|
809 | * binding is found and `options.optional` is not set to true, an error will
|
810 | * be thrown.
|
811 | *
|
812 | * @param key - Binding key
|
813 | * @param options - Options to control if the binding is optional. If
|
814 | * `options.optional` is set to true, the method will return `undefined`
|
815 | * instead of throwing an error if the binding key is not found.
|
816 | */
|
817 | getBinding<ValueType>(
|
818 | key: BindingAddress<ValueType>,
|
819 | options?: {optional?: boolean},
|
820 | ): Binding<ValueType> | undefined;
|
821 |
|
822 | getBinding<ValueType>(
|
823 | key: BindingAddress<ValueType>,
|
824 | options?: {optional?: boolean},
|
825 | ): Binding<ValueType> | undefined {
|
826 | key = BindingKey.validate(key);
|
827 | const binding = this.registry.get(key);
|
828 | if (binding) {
|
829 | return binding;
|
830 | }
|
831 |
|
832 | if (this._parent) {
|
833 | return this._parent.getBinding<ValueType>(key, options);
|
834 | }
|
835 |
|
836 | if (options?.optional) return undefined;
|
837 | throw new Error(
|
838 | `The key '${key}' is not bound to any value in context ${this.name}`,
|
839 | );
|
840 | }
|
841 |
|
842 | /**
|
843 | * Find or create a binding for the given key
|
844 | * @param key - Binding address
|
845 | * @param policy - Binding creation policy
|
846 | */
|
847 | findOrCreateBinding<T>(
|
848 | key: BindingAddress<T>,
|
849 | policy?: BindingCreationPolicy,
|
850 | ) {
|
851 | let binding: Binding<T>;
|
852 | if (policy === BindingCreationPolicy.ALWAYS_CREATE) {
|
853 | binding = this.bind(key);
|
854 | } else if (policy === BindingCreationPolicy.NEVER_CREATE) {
|
855 | binding = this.getBinding(key);
|
856 | } else if (this.isBound(key)) {
|
857 | // CREATE_IF_NOT_BOUND - the key is bound
|
858 | binding = this.getBinding(key);
|
859 | } else {
|
860 | // CREATE_IF_NOT_BOUND - the key is not bound
|
861 | binding = this.bind(key);
|
862 | }
|
863 | return binding;
|
864 | }
|
865 |
|
866 | /**
|
867 | * Get the value bound to the given key.
|
868 | *
|
869 | * This is an internal version that preserves the dual sync/async result
|
870 | * of `Binding#getValue()`. Users should use `get()` or `getSync()` instead.
|
871 | *
|
872 | * @example
|
873 | *
|
874 | * ```ts
|
875 | * // get the value bound to "application.instance"
|
876 | * ctx.getValueOrPromise<Application>('application.instance');
|
877 | *
|
878 | * // get "rest" property from the value bound to "config"
|
879 | * ctx.getValueOrPromise<RestComponentConfig>('config#rest');
|
880 | *
|
881 | * // get "a" property of "numbers" property from the value bound to "data"
|
882 | * ctx.bind('data').to({numbers: {a: 1, b: 2}, port: 3000});
|
883 | * ctx.getValueOrPromise<number>('data#numbers.a');
|
884 | * ```
|
885 | *
|
886 | * @param keyWithPath - The binding key, optionally suffixed with a path to the
|
887 | * (deeply) nested property to retrieve.
|
888 | * @param optionsOrSession - Options for resolution or a session
|
889 | * @returns The bound value or a promise of the bound value, depending
|
890 | * on how the binding is configured.
|
891 | * @internal
|
892 | */
|
893 | getValueOrPromise<ValueType>(
|
894 | keyWithPath: BindingAddress<ValueType>,
|
895 | optionsOrSession?: ResolutionOptionsOrSession,
|
896 | ): ValueOrPromise<ValueType | undefined> {
|
897 | const {key, propertyPath} = BindingKey.parseKeyWithPath(keyWithPath);
|
898 |
|
899 | const options = asResolutionOptions(optionsOrSession);
|
900 |
|
901 | const binding = this.getBinding<ValueType>(key, {optional: true});
|
902 | if (binding == null) {
|
903 | if (options.optional) return undefined;
|
904 | throw new ResolutionError(
|
905 | `The key '${key}' is not bound to any value in context ${this.name}`,
|
906 | {
|
907 | context: this,
|
908 | binding: Binding.bind(key),
|
909 | options,
|
910 | },
|
911 | );
|
912 | }
|
913 |
|
914 | const boundValue = binding.getValue(this, options);
|
915 | return propertyPath == null || propertyPath === ''
|
916 | ? boundValue
|
917 | : transformValueOrPromise(boundValue, v =>
|
918 | getDeepProperty<ValueType>(v, propertyPath),
|
919 | );
|
920 | }
|
921 |
|
922 | /**
|
923 | * Create a plain JSON object for the context
|
924 | */
|
925 | toJSON(): JSONObject {
|
926 | const bindings: JSONObject = {};
|
927 | for (const [k, v] of this.registry) {
|
928 | bindings[k] = v.toJSON();
|
929 | }
|
930 | return bindings;
|
931 | }
|
932 |
|
933 | /**
|
934 | * Inspect the context and dump out a JSON object representing the context
|
935 | * hierarchy
|
936 | * @param options - Options for inspect
|
937 | */
|
938 | // TODO(rfeng): Evaluate https://nodejs.org/api/util.html#util_custom_inspection_functions_on_objects
|
939 | inspect(options: ContextInspectOptions = {}): JSONObject {
|
940 | return this._inspect(options, new ClassNameMap());
|
941 | }
|
942 |
|
943 | /**
|
944 | * Inspect the context hierarchy
|
945 | * @param options - Options for inspect
|
946 | * @param visitedClasses - A map to keep class to name so that we can have
|
947 | * different names for classes with colliding names. The situation can happen
|
948 | * when two classes with the same name are bound in different modules.
|
949 | */
|
950 | private _inspect(
|
951 | options: ContextInspectOptions,
|
952 | visitedClasses: ClassNameMap,
|
953 | ): JSONObject {
|
954 | options = {
|
955 | includeParent: true,
|
956 | includeInjections: false,
|
957 | ...options,
|
958 | };
|
959 | const bindings: JSONObject = {};
|
960 | for (const [k, v] of this.registry) {
|
961 | const ctor = v.valueConstructor ?? v.providerConstructor;
|
962 | let name: string | undefined = undefined;
|
963 | if (ctor != null) {
|
964 | name = visitedClasses.visit(ctor);
|
965 | }
|
966 | bindings[k] = v.inspect(options);
|
967 | if (name != null) {
|
968 | const binding = bindings[k] as JSONObject;
|
969 | if (v.valueConstructor) {
|
970 | binding.valueConstructor = name;
|
971 | } else if (v.providerConstructor) {
|
972 | binding.providerConstructor = name;
|
973 | }
|
974 | }
|
975 | }
|
976 | const json: JSONObject = {
|
977 | name: this.name,
|
978 | bindings,
|
979 | };
|
980 | if (!options.includeParent) return json;
|
981 | if (this._parent) {
|
982 | json.parent = this._parent._inspect(options, visitedClasses);
|
983 | }
|
984 | return json;
|
985 | }
|
986 |
|
987 | /**
|
988 | * The "bind" event is emitted when a new binding is added to the context.
|
989 | * The "unbind" event is emitted when an existing binding is removed.
|
990 | *
|
991 | * @param eventName The name of the event - always `bind` or `unbind`.
|
992 | * @param listener The listener function to call when the event is emitted.
|
993 | */
|
994 | on(eventName: 'bind' | 'unbind', listener: ContextEventListener): this;
|
995 |
|
996 | // The generic variant inherited from EventEmitter
|
997 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
998 | on(event: string | symbol, listener: (...args: any[]) => void): this;
|
999 |
|
1000 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
1001 | on(event: string | symbol, listener: (...args: any[]) => void): this {
|
1002 | return super.on(event, listener);
|
1003 | }
|
1004 |
|
1005 | /**
|
1006 | * The "bind" event is emitted when a new binding is added to the context.
|
1007 | * The "unbind" event is emitted when an existing binding is removed.
|
1008 | *
|
1009 | * @param eventName The name of the event - always `bind` or `unbind`.
|
1010 | * @param listener The listener function to call when the event is emitted.
|
1011 | */
|
1012 | once(eventName: 'bind' | 'unbind', listener: ContextEventListener): this;
|
1013 |
|
1014 | // The generic variant inherited from EventEmitter
|
1015 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
1016 | once(event: string | symbol, listener: (...args: any[]) => void): this;
|
1017 |
|
1018 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
1019 | once(event: string | symbol, listener: (...args: any[]) => void): this {
|
1020 | return super.once(event, listener);
|
1021 | }
|
1022 | }
|
1023 |
|
1024 | /**
|
1025 | * An internal utility class to handle class name conflicts
|
1026 | */
|
1027 | class ClassNameMap {
|
1028 | private readonly classes = new Map<Constructor<unknown>, string>();
|
1029 | private readonly nameIndex = new Map<string, number>();
|
1030 |
|
1031 | visit(ctor: Constructor<unknown>) {
|
1032 | let name = this.classes.get(ctor);
|
1033 | if (name == null) {
|
1034 | name = ctor.name;
|
1035 | // Now check if the name collides with another class
|
1036 | let index = this.nameIndex.get(name);
|
1037 | if (typeof index === 'number') {
|
1038 | // A conflict is found, mangle the name as `ClassName #1`
|
1039 | this.nameIndex.set(name, ++index);
|
1040 | name = `${name} #${index}`;
|
1041 | } else {
|
1042 | // The name is used for the 1st time
|
1043 | this.nameIndex.set(name, 0);
|
1044 | }
|
1045 | this.classes.set(ctor, name);
|
1046 | }
|
1047 | return name;
|
1048 | }
|
1049 | }
|
1050 |
|
1051 | /**
|
1052 | * Options for context.inspect()
|
1053 | */
|
1054 | export interface ContextInspectOptions extends BindingInspectOptions {
|
1055 | /**
|
1056 | * The flag to control if parent context should be inspected
|
1057 | */
|
1058 | includeParent?: boolean;
|
1059 | }
|
1060 |
|
1061 | /**
|
1062 | * Policy to control if a binding should be created for the context
|
1063 | */
|
1064 | export enum BindingCreationPolicy {
|
1065 | /**
|
1066 | * Always create a binding with the key for the context
|
1067 | */
|
1068 | ALWAYS_CREATE = 'Always',
|
1069 | /**
|
1070 | * Never create a binding for the context. If the key is not bound in the
|
1071 | * context, throw an error.
|
1072 | */
|
1073 | NEVER_CREATE = 'Never',
|
1074 | /**
|
1075 | * Create a binding if the key is not bound in the context. Otherwise, return
|
1076 | * the existing binding.
|
1077 | */
|
1078 | CREATE_IF_NOT_BOUND = 'IfNotBound',
|
1079 | }
|