diff --git a/packages/authentication/src/authentication.ts b/packages/authentication/src/authentication.ts new file mode 100644 index 000000000000..01a281899d86 --- /dev/null +++ b/packages/authentication/src/authentication.ts @@ -0,0 +1,65 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ParsedRequest} from '@loopback/rest'; + +/** + * interface definition of a function which accepts a request + * and returns an authenticated user + */ +export interface AuthenticateFn { + (request: ParsedRequest): Promise; +} + +/** + * interface definition of a user profile + * http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + */ +export interface UserProfile { + id: string; + name?: string; + email?: string; +} + +/** + * Authentication metadata stored via Reflection API + */ +export interface AuthenticationMetadata { + /** + * Name of the authentication strategy + */ + strategy: string; + /** + * Options for authentication + */ + options?: Object; +} + +/** + * Interface for authentication providers + */ +export interface Authenticator { + /** + * Check if the given strategy is supported by the authenticator + * @param stragety Name of the authentication strategy + */ + isSupported(strategy: string): boolean; + + /** + * Authenticate a request with given options + * @param request HTTP request + * @param metadata Authentication metadata + */ + authenticate( + request: ParsedRequest, + metadata?: AuthenticationMetadata, + ): Promise; +} + +/** + * Passport monkey-patches Node.js' IncomingMessage prototype + * and adds extra methods like "login" and "isAuthenticated" + */ +export type PassportRequest = ParsedRequest & Express.Request; diff --git a/packages/authentication/src/decorators/authenticate.decorator.ts b/packages/authentication/src/decorators/authenticate.decorator.ts index 5546b6f71fad..f4b2ed0afc1b 100644 --- a/packages/authentication/src/decorators/authenticate.decorator.ts +++ b/packages/authentication/src/decorators/authenticate.decorator.ts @@ -9,14 +9,7 @@ import { MethodDecoratorFactory, } from '@loopback/context'; import {AUTHENTICATION_METADATA_KEY} from '../keys'; - -/** - * Authentication metadata stored via Reflection API - */ -export interface AuthenticationMetadata { - strategy: string; - options?: Object; -} +import {AuthenticationMetadata} from '../authentication'; /** * Mark a controller method as requiring authenticated user. diff --git a/packages/authentication/src/index.ts b/packages/authentication/src/index.ts index d1ce056046ff..ed8178c50b64 100644 --- a/packages/authentication/src/index.ts +++ b/packages/authentication/src/index.ts @@ -3,8 +3,9 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +export * from './authentication'; export * from './authentication.component'; -export * from './decorators'; +export * from './decorators/authenticate.decorator'; export * from './keys'; export * from './strategy-adapter'; diff --git a/packages/authentication/src/keys.ts b/packages/authentication/src/keys.ts index db2871de646f..97f6cd59b424 100644 --- a/packages/authentication/src/keys.ts +++ b/packages/authentication/src/keys.ts @@ -4,8 +4,11 @@ // License text available at https://opensource.org/licenses/MIT import {Strategy} from 'passport'; -import {AuthenticateFn, UserProfile} from './providers/authentication.provider'; -import {AuthenticationMetadata} from './decorators/authenticate.decorator'; +import { + AuthenticateFn, + UserProfile, + AuthenticationMetadata, +} from './authentication'; import {BindingKey, MetadataAccessor} from '@loopback/context'; /** diff --git a/packages/authentication/src/providers/auth-extension-point.ts b/packages/authentication/src/providers/auth-extension-point.ts new file mode 100644 index 000000000000..700081add927 --- /dev/null +++ b/packages/authentication/src/providers/auth-extension-point.ts @@ -0,0 +1,40 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {ExtensionPoint, Context} from '@loopback/core'; +import {ParsedRequest} from '@loopback/rest'; +import { + UserProfile, + AuthenticationMetadata, + Authenticator, +} from '../authentication'; +import {AuthenticationBindings} from '../keys'; + +export class AuthenticationExtensionPoint extends ExtensionPoint< + Authenticator +> { + static extensionPointName = 'authenticators'; + + async authenticate( + ctx: Context, + request: ParsedRequest, + ): Promise { + const meta: AuthenticationMetadata | undefined = await ctx.get( + AuthenticationBindings.METADATA, + ); + if (meta == undefined) { + return undefined; + } + const authenticators = await this.getAllExtensions(ctx); + let user: UserProfile | undefined = undefined; + for (const authenticator of authenticators) { + if (authenticator.isSupported(meta.strategy)) { + user = await authenticator.authenticate(request, meta); + if (user === undefined) continue; + } + } + return user; + } +} diff --git a/packages/authentication/src/providers/auth-metadata.provider.ts b/packages/authentication/src/providers/auth-metadata.provider.ts index 7a81ce27d11c..018a27c55489 100644 --- a/packages/authentication/src/providers/auth-metadata.provider.ts +++ b/packages/authentication/src/providers/auth-metadata.provider.ts @@ -5,10 +5,8 @@ import {CoreBindings} from '@loopback/core'; import {Constructor, Provider, inject} from '@loopback/context'; -import { - AuthenticationMetadata, - getAuthenticateMetadata, -} from '../decorators/authenticate.decorator'; +import {getAuthenticateMetadata} from '../decorators/authenticate.decorator'; +import {AuthenticationMetadata} from '../authentication'; /** * @description Provides authentication metadata of a controller method diff --git a/packages/authentication/src/providers/authentication.provider.ts b/packages/authentication/src/providers/authentication.provider.ts index 90e45530a476..993d6998c10b 100644 --- a/packages/authentication/src/providers/authentication.provider.ts +++ b/packages/authentication/src/providers/authentication.provider.ts @@ -9,30 +9,7 @@ import {Provider, Getter, Setter} from '@loopback/context'; import {Strategy} from 'passport'; import {StrategyAdapter} from '../strategy-adapter'; import {AuthenticationBindings} from '../keys'; - -/** - * Passport monkey-patches Node.js' IncomingMessage prototype - * and adds extra methods like "login" and "isAuthenticated" - */ -export type PassportRequest = ParsedRequest & Express.Request; - -/** - * interface definition of a function which accepts a request - * and returns an authenticated user - */ -export interface AuthenticateFn { - (request: ParsedRequest): Promise; -} - -/** - * interface definition of a user profile - * http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - */ -export interface UserProfile { - id: string; - name?: string; - email?: string; -} +import {AuthenticateFn, UserProfile} from '../authentication'; /** * @description Provider of a function which authenticates diff --git a/packages/authentication/src/strategy-adapter.ts b/packages/authentication/src/strategy-adapter.ts index f07e4bb79021..42b2e4d2d534 100644 --- a/packages/authentication/src/strategy-adapter.ts +++ b/packages/authentication/src/strategy-adapter.ts @@ -5,7 +5,8 @@ import {HttpErrors, ParsedRequest} from '@loopback/rest'; import {Strategy} from 'passport'; -import {UserProfile} from './providers/authentication.provider'; +import {UserProfile, Authenticator} from './authentication'; +import {AuthenticationMetadata} from '..'; const PassportRequestExtras: Express.Request = require('passport/lib/http/request'); @@ -65,7 +66,7 @@ export class ShimRequest implements Express.Request { * 3. provides state methods to the strategy instance * see: https://github.com/jaredhanson/passport */ -export class StrategyAdapter { +export class StrategyAdapter implements Authenticator { /** * @param strategy instance of a class which implements a passport-strategy; * @description http://passportjs.org/ @@ -79,7 +80,7 @@ export class StrategyAdapter { * 3. authenticate using the strategy * @param req {http.ServerRequest} The incoming request. */ - authenticate(req: ParsedRequest) { + authenticate(req: ParsedRequest, metadata?: AuthenticationMetadata) { const shimReq = new ShimRequest(req); return new Promise((resolve, reject) => { // create a prototype chain of an instance of a passport strategy @@ -101,7 +102,11 @@ export class StrategyAdapter { }; // authenticate - strategy.authenticate(shimReq); + strategy.authenticate(shimReq, metadata && metadata.options); }); } + + isSupported(strategy: string) { + return strategy.startsWith('passport:'); + } } diff --git a/packages/context/src/binding-key.ts b/packages/context/src/binding-key.ts index c271af585829..0f84aaca6785 100644 --- a/packages/context/src/binding-key.ts +++ b/packages/context/src/binding-key.ts @@ -103,4 +103,20 @@ export class BindingKey { keyWithPath.substr(index + 1), ); } + + static CONFIG_NAMESPACE = '$config'; + /** + * Build a binding key for the configuration of the given binding and env. + * The format is `$config.:` + * + * @param key The binding key that accepts the configuration + * @param env The environment such as `dev`, `test`, and `prod` + */ + static buildKeyForConfig(key: string = '', env: string = '') { + const namespace = env + ? `${BindingKey.CONFIG_NAMESPACE}.${env}` + : BindingKey.CONFIG_NAMESPACE; + const bindingKey = key ? `${namespace}${key}` : BindingKey.CONFIG_NAMESPACE; + return bindingKey; + } } diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index 365f694f6ca4..acecaa9053be 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -12,6 +12,7 @@ import { isPromiseLike, BoundValue, ValueOrPromise, + resolveValueOrPromise, } from './value-promise'; import {Provider} from './provider'; @@ -129,30 +130,16 @@ export class Binding { ): ValueOrPromise { // Initialize the cache as a weakmap keyed by context if (!this._cache) this._cache = new WeakMap(); - if (isPromiseLike(result)) { - if (this.scope === BindingScope.SINGLETON) { - // Cache the value at owning context level - result = result.then(val => { - this._cache.set(ctx.getOwnerContext(this.key)!, val); - return val; - }); - } else if (this.scope === BindingScope.CONTEXT) { - // Cache the value at the current context - result = result.then(val => { - this._cache.set(ctx, val); - return val; - }); - } - } else { + return resolveValueOrPromise(result, val => { if (this.scope === BindingScope.SINGLETON) { // Cache the value - this._cache.set(ctx.getOwnerContext(this.key)!, result); + this._cache.set(ctx.getOwnerContext(this.key)!, val); } else if (this.scope === BindingScope.CONTEXT) { // Cache the value at the current context - this._cache.set(ctx, result); + this._cache.set(ctx, val); } - } - return result; + return val; + }); } /** @@ -169,7 +156,7 @@ export class Binding { * * ``` * const result = binding.getValue(ctx); - * if (isPromise(result)) { + * if (isPromiseLike(result)) { * result.then(doSomething) * } else { * doSomething(result); @@ -210,11 +197,18 @@ export class Binding { ); } + /** + * Lock the binding so that it cannot be rebound + */ lock(): this { this.isLocked = true; return this; } + /** + * Add a tag to the binding + * @param tagName Tag name or an array of tag names + */ tag(tagName: string | string[]): this { if (typeof tagName === 'string') { this.tags.add(tagName); @@ -226,6 +220,10 @@ export class Binding { return this; } + /** + * Set the binding scope + * @param scope Binding scope + */ inScope(scope: BindingScope): this { this.scope = scope; return this; @@ -330,11 +328,7 @@ export class Binding { ctx!, session, ); - if (isPromiseLike(providerOrPromise)) { - return providerOrPromise.then(p => p.value()); - } else { - return providerOrPromise.value(); - } + return resolveValueOrPromise(providerOrPromise, p => p.value()); }; return this; } @@ -357,11 +351,17 @@ export class Binding { return this; } + /** + * Unlock the binding + */ unlock(): this { this.isLocked = false; return this; } + /** + * Convert to a plain JSON object + */ toJSON(): Object { // tslint:disable-next-line:no-any const json: {[name: string]: any} = { diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 31909a04a721..bff7688ac53a 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -5,13 +5,20 @@ import {Binding} from './binding'; import {BindingKey, BindingAddress} from './binding-key'; -import {isPromiseLike, getDeepProperty, BoundValue} from './value-promise'; +import { + isPromiseLike, + getDeepProperty, + BoundValue, + ValueOrPromise, + resolveUntil, + resolveValueOrPromise, +} from './value-promise'; import {ResolutionOptions, ResolutionSession} from './resolution-session'; import {v1 as uuidv1} from 'uuid'; import * as debugModule from 'debug'; -import {ValueOrPromise} from '.'; + const debug = debugModule('loopback:context'); /** @@ -65,6 +72,198 @@ export class Context { return binding; } + /** + * Create a corresponding binding for configuration of the target bound by + * the given key in the context. + * + * For example, `ctx.configure('controllers.MyController').to({x: 1})` will + * create binding `controllers.MyController:$config` with value `{x: 1}`. + * + * @param key The key for the binding that accepts the config + * @param env The env (such as `dev`, `test`, and `prod`) for the config + */ + configure(key: string = '', env: string = ''): Binding { + const keyForConfig = BindingKey.buildKeyForConfig(key, env); + const bindingForConfig = this.bind(keyForConfig).tag(`config:${key}`); + return bindingForConfig; + } + + /** + * Resolve config from the binding key hierarchy using namespaces + * separated by `.` + * + * For example, if the binding key is `servers.rest.server1`, we'll try the + * following entries: + * 1. servers.rest.server1:$config#host (namespace: server1) + * 2. servers.rest:$config#server1.host (namespace: rest) + * 3. servers.$config#rest.server1.host` (namespace: server) + * 4. $config#servers.rest.server1.host (namespace: '' - root) + * + * @param key Binding key with namespaces separated by `.` + * @param configPath Property path for the option. For example, `x.y` + * requests for `config.x.y`. If not set, the `config` object will be + * returned. + * @param resolutionOptions Options for the resolution. + * - localConfigOnly: if set to `true`, no parent namespaces will be checked + * - optional: if not set or set to `true`, `undefined` will be returned if + * no corresponding value is found. Otherwise, an error will be thrown. + */ + getConfigAsValueOrPromise( + key: string, + configPath?: string, + resolutionOptions?: ResolutionOptions, + ): ValueOrPromise { + const env = resolutionOptions && resolutionOptions.environment; + configPath = configPath || ''; + const configKey = BindingKey.create( + BindingKey.buildKeyForConfig(key, env), + configPath, + ); + + const localConfigOnly = + resolutionOptions && resolutionOptions.localConfigOnly; + + /** + * Set up possible keys to resolve the config value + */ + const keys = []; + while (true) { + const configKeyAndPath = BindingKey.create( + BindingKey.buildKeyForConfig(key, env), + configPath, + ); + keys.push(configKeyAndPath); + if (env) { + // The `environment` is set, let's try the non env specific binding too + keys.push( + BindingKey.create(BindingKey.buildKeyForConfig(key), configPath), + ); + } + if (!key || localConfigOnly) { + // No more keys + break; + } + // Shift last part of the key into the path as we'll try the parent + // namespace in the next iteration + const index = key.lastIndexOf('.'); + configPath = configPath + ? `${key.substring(index + 1)}.${configPath}` + : `${key.substring(index + 1)}`; + key = key.substring(0, index); + } + /* istanbul ignore if */ + if (debug.enabled) { + debug('Configuration keyWithPaths: %j', keys); + } + + const resolveConfig = (keyWithPath: string) => { + // Set `optional` to `true` to resolve config locally + const options = Object.assign( + {}, // Make sure resolutionOptions is copied + resolutionOptions, + {optional: true}, // Force optional to be true + ); + return this.getValueOrPromise(keyWithPath, options); + }; + + const evaluateConfig = (keyWithPath: string, val: T) => { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Configuration keyWithPath: %s => value: %j', keyWithPath, val); + } + // Found the corresponding config + if (val !== undefined) return true; + + if (localConfigOnly) { + return true; + } + return false; + }; + + const required = resolutionOptions && resolutionOptions.optional === false; + const valueOrPromise = resolveUntil, T>( + keys[Symbol.iterator](), + resolveConfig, + evaluateConfig, + ); + return resolveValueOrPromise( + valueOrPromise, + val => { + if (val === undefined && required) { + throw Error(`Configuration '${configKey}' cannot be resolved`); + } + return val; + }, + ); + } + + /** + * Resolve config from the binding key hierarchy using namespaces + * separated by `.` + * + * For example, if the binding key is `servers.rest.server1`, we'll try the + * following entries: + * 1. servers.rest.server1:$config#host (namespace: server1) + * 2. servers.rest:$config#server1.host (namespace: rest) + * 3. servers.$config#rest.server1.host` (namespace: server) + * 4. $config#servers.rest.server1.host (namespace: '' - root) + * + * @param key Binding key with namespaces separated by `.` + * @param configPath Property path for the option. For example, `x.y` + * requests for `config.x.y`. If not set, the `config` object will be + * returned. + * @param resolutionOptions Options for the resolution. If `localConfigOnly` is + * set to true, no parent namespaces will be looked up. + */ + async getConfig( + key: string, + configPath?: string, + resolutionOptions?: ResolutionOptions, + ): Promise { + return await this.getConfigAsValueOrPromise( + key, + configPath, + resolutionOptions, + ); + } + + /** + * Resolve config synchronously from the binding key hierarchy using + * namespaces separated by `.` + * + * For example, if the binding key is `servers.rest.server1`, we'll try the + * following entries: + * 1. servers.rest.server1:$config#host (namespace: server1) + * 2. servers.rest:$config#server1.host (namespace: rest) + * 3. servers.$config#rest.server1.host` (namespace: server) + * 4. $config#servers.rest.server1.host (namespace: '' - root) + * + * @param key Binding key with namespaces separated by `.` + * @param configPath Property path for the option. For example, `x.y` + * requests for `config.x.y`. If not set, the `config` object will be + * returned. + * @param resolutionOptions Options for the resolution. If `localConfigOnly` + * is set to `true`, no parent namespaces will be looked up. + */ + getConfigSync( + key: string, + configPath?: string, + resolutionOptions?: ResolutionOptions, + ): T | undefined { + const valueOrPromise = this.getConfigAsValueOrPromise( + key, + configPath, + resolutionOptions, + ); + if (isPromiseLike(valueOrPromise)) { + throw new Error( + `Cannot get config[${configPath || + ''}] for ${key} synchronously: the value is a promise`, + ); + } + return valueOrPromise; + } + /** * Unbind a binding from the context. No parent contexts will be checked. If * you need to unbind a binding owned by a parent context, use the code below: diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 52c7af22271e..79cfc45203c9 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -13,6 +13,8 @@ export { MapObject, resolveList, resolveMap, + resolveUntil, + resolveValueOrPromise, tryWithFinally, getDeepProperty, } from './value-promise'; @@ -21,8 +23,15 @@ export {Binding, BindingScope, BindingType} from './binding'; export {Context} from './context'; export {BindingKey, BindingAddress} from './binding-key'; -export {ResolutionSession} from './resolution-session'; -export {inject, Setter, Getter, Injection, InjectionMetadata} from './inject'; +export {ResolutionSession, ResolutionOptions} from './resolution-session'; +export { + inject, + Setter, + Getter, + Injection, + InjectionMetadata, + ENVIRONMENT_KEY, +} from './inject'; export {Provider} from './provider'; export {instantiateClass, invokeMethod} from './resolver'; diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index 4b6800400512..a63a00deaed3 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -12,6 +12,7 @@ import { MetadataAccessor, } from '@loopback/metadata'; import {BoundValue, ValueOrPromise, resolveList} from './value-promise'; +import {Binding} from './binding'; import {Context} from './context'; import {BindingKey, BindingAddress} from './binding-key'; import {ResolutionSession} from './resolution-session'; @@ -44,9 +45,14 @@ export interface InjectionMetadata { */ decorator?: string; /** - * Control if the dependency is optional, default to false + * Control if the dependency is optional. Default to `false`. */ optional?: boolean; + /** + * Control if the resolution only looks up properties from + * the local configuration of the target binding itself. Default to `false`. + */ + localConfigOnly?: boolean; /** * Other attributes */ @@ -68,6 +74,12 @@ export interface Injection { resolve?: ResolverFunction; // A custom resolve function } +/** + * A special binding key for the execution environment, typically set + * by `NODE_ENV` environment variable + */ +export const ENVIRONMENT_KEY = '$environment'; + /** * A decorator to annotate method arguments for automatic injection * by LoopBack IoC container. @@ -264,6 +276,52 @@ export namespace inject { export const context = function injectContext() { return inject('', {decorator: '@inject.context'}, ctx => ctx); }; + + /** + * Inject a property from `config` of the current binding. If no corresponding + * config value is present, `undefined` will be injected. + * + * @example + * ```ts + * class Store { + * constructor( + * @inject.config('x') public optionX: number, + * @inject.config('y') public optionY: string, + * ) { } + * } + * + * ctx.configure('store1', { x: 1, y: 'a' }); + * ctx.configure('store2', { x: 2, y: 'b' }); + * + * ctx.bind('store1').toClass(Store); + * ctx.bind('store2').toClass(Store); + * + * const store1 = ctx.getSync('store1'); + * expect(store1.optionX).to.eql(1); + * expect(store1.optionY).to.eql('a'); + + * const store2 = ctx.getSync('store2'); + * expect(store2.optionX).to.eql(2); + * expect(store2.optionY).to.eql('b'); + * ``` + * + * @param configPath Optional property path of the config. If is `''` or not + * present, the `config` object will be returned. + * @param metadata Optional metadata to help the injection: + * - localConfigOnly: only look up from the configuration local to the current + * binding. Default to false. + */ + export const config = function injectConfig( + configPath?: string, + metadata?: InjectionMetadata, + ) { + configPath = configPath || ''; + metadata = Object.assign( + {configPath, decorator: '@inject.config', optional: true}, + metadata, + ); + return inject('', metadata, resolveFromConfig); + }; } function resolveAsGetter( @@ -288,6 +346,31 @@ function resolveAsSetter(ctx: Context, injection: Injection) { }; } +function resolveFromConfig( + ctx: Context, + injection: Injection, + session?: ResolutionSession, +) { + if (!(session && session.currentBinding)) { + // No binding is available + return undefined; + } + + const meta = injection.metadata || {}; + const binding = session.currentBinding; + + const env = + ctx.getSync(ENVIRONMENT_KEY, {optional: true}) || + process.env.NODE_ENV; + + return ctx.getConfigAsValueOrPromise(binding.key, meta.configPath, { + session, + optional: meta.optional, + localConfigOnly: meta.localConfigOnly, + environment: env, + }); +} + /** * Return an array of injection objects for parameters * @param target The target class for constructor or static methods, @@ -307,14 +390,11 @@ export function describeInjectedArguments( return meta || []; } -function resolveByTag( +function resolveBindings( ctx: Context, - injection: Readonly, + bindings: Readonly[], session?: ResolutionSession, ) { - const tag: string | RegExp = injection.metadata!.tag; - const bindings = ctx.findByTag(tag); - return resolveList(bindings, b => { // We need to clone the session so that resolution of multiple bindings // can be tracked in parallel @@ -322,6 +402,16 @@ function resolveByTag( }); } +function resolveByTag( + ctx: Context, + injection: Injection, + session?: ResolutionSession, +) { + const tag: string | RegExp = injection.metadata!.tag; + const bindings = ctx.findByTag(tag); + return resolveBindings(ctx, bindings, session); +} + /** * Return a map of injection objects for properties * @param target The target class for static properties or diff --git a/packages/context/src/resolution-session.ts b/packages/context/src/resolution-session.ts index e72b0151f53a..59e31f1c34c5 100644 --- a/packages/context/src/resolution-session.ts +++ b/packages/context/src/resolution-session.ts @@ -342,4 +342,25 @@ export interface ResolutionOptions { * will return `undefined` instead of throwing an error. */ optional?: boolean; + + /** + * A boolean flag to control if the resolution only looks up properties from + * the local configuration of the target binding itself. If not set to `true`, + * all namespaces of a binding key will be checked. + * + * For example, if the binding key is `servers.rest.server1`, we'll try the + * following entries: + * 1. servers.rest.server1:$config#host (namespace: server1) + * 2. servers.rest:$config#server1.host (namespace: rest) + * 3. servers.$config#rest.server1.host` (namespace: server) + * 4. $config#servers.rest.server1.host (namespace: '' - root) + * + * The default value is `false`. + */ + localConfigOnly?: boolean; + + /** + * Environment for resolution, such as `dev`, `test`, `staging`, and `prod` + */ + environment?: string; } diff --git a/packages/context/src/resolver.ts b/packages/context/src/resolver.ts index f41eb708361c..5284efb88bc9 100644 --- a/packages/context/src/resolver.ts +++ b/packages/context/src/resolver.ts @@ -10,9 +10,9 @@ import { Constructor, ValueOrPromise, MapObject, - isPromiseLike, resolveList, resolveMap, + resolveValueOrPromise, } from './value-promise'; import { @@ -56,51 +56,20 @@ export function instantiateClass( } const argsOrPromise = resolveInjectedArguments(ctor, '', ctx, session); const propertiesOrPromise = resolveInjectedProperties(ctor, ctx, session); - let inst: ValueOrPromise; - if (isPromiseLike(argsOrPromise)) { - // Instantiate the class asynchronously - inst = argsOrPromise.then(args => { - /* istanbul ignore if */ - if (debug.enabled) { - debug('Injected arguments for %s():', ctor.name, args); - } - return new ctor(...args); - }); - } else { + const inst: ValueOrPromise = resolveValueOrPromise(argsOrPromise, args => { /* istanbul ignore if */ if (debug.enabled) { - debug('Injected arguments for %s():', ctor.name, argsOrPromise); + debug('Injected arguments for %s():', ctor.name, args); } - // Instantiate the class synchronously - inst = new ctor(...argsOrPromise); - } - if (isPromiseLike(propertiesOrPromise)) { - return propertiesOrPromise.then(props => { - /* istanbul ignore if */ - if (debug.enabled) { - debug('Injected properties for %s:', ctor.name, props); - } - if (isPromiseLike(inst)) { - // Inject the properties asynchronously - return inst.then(obj => Object.assign(obj, props)); - } else { - // Inject the properties synchronously - return Object.assign(inst, props); - } - }); - } else { - if (isPromiseLike(inst)) { - /* istanbul ignore if */ - if (debug.enabled) { - debug('Injected properties for %s:', ctor.name, propertiesOrPromise); - } - // Inject the properties asynchronously - return inst.then(obj => Object.assign(obj, propertiesOrPromise)); - } else { - // Inject the properties synchronously - return Object.assign(inst, propertiesOrPromise); + return new ctor(...args); + }); + return resolveValueOrPromise(propertiesOrPromise, props => { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Injected properties for %s:', ctor.name, props); } - } + return resolveValueOrPromise(inst, obj => Object.assign(obj, props)); + }); } /** @@ -257,23 +226,13 @@ export function invokeMethod( typeof targetWithMethods[method] === 'function', `Method ${method} not found`, ); - if (isPromiseLike(argsOrPromise)) { - // Invoke the target method asynchronously - return argsOrPromise.then(args => { - /* istanbul ignore if */ - if (debug.enabled) { - debug('Injected arguments for %s:', methodName, args); - } - return targetWithMethods[method](...args); - }); - } else { + return resolveValueOrPromise(argsOrPromise, args => { /* istanbul ignore if */ if (debug.enabled) { - debug('Injected arguments for %s:', methodName, argsOrPromise); + debug('Injected arguments for %s:', methodName, args); } - // Invoke the target method synchronously - return targetWithMethods[method](...argsOrPromise); - } + return targetWithMethods[method](...args); + }); } /** diff --git a/packages/context/src/value-promise.ts b/packages/context/src/value-promise.ts index 46d670b0146b..21fb985c7cc0 100644 --- a/packages/context/src/value-promise.ts +++ b/packages/context/src/value-promise.ts @@ -218,3 +218,42 @@ export function tryWithFinally( } return result; } + +/** + * Resolve an iterator of source values into a result until the evaluator + * returns `true` + * @param source The iterator of source values + * @param resolver The resolve function that maps the source value to a result + * @param evaluator The evaluate function that decides when to stop + */ +export function resolveUntil( + source: Iterator, + resolver: (sourceVal: T) => ValueOrPromise, + evaluator: (sourceVal: T, targetVal: V | undefined) => boolean, +): ValueOrPromise { + const next = source.next(); + if (next.done) return undefined; // End of the iterator + const sourceVal = next.value; + const valueOrPromise = resolver(sourceVal); + return resolveValueOrPromise(valueOrPromise, v => { + if (evaluator(sourceVal, v)) return v; + else return resolveUntil(source, resolver, evaluator); + }); +} + +/** + * Resolve a value or promise with a function that produces a new value or + * promise + * @param valueOrPromise The value or promise + * @param resolver A function that maps the source value to a value or promise + */ +export function resolveValueOrPromise( + valueOrPromise: ValueOrPromise, + resolver: (val: T) => ValueOrPromise, +): ValueOrPromise { + if (isPromiseLike(valueOrPromise)) { + return valueOrPromise.then(resolver); + } else { + return resolver(valueOrPromise); + } +} diff --git a/packages/context/test/acceptance/class-level-bindings.acceptance.ts b/packages/context/test/acceptance/class-level-bindings.acceptance.ts index e4f79b8239fc..0f589a818613 100644 --- a/packages/context/test/acceptance/class-level-bindings.acceptance.ts +++ b/packages/context/test/acceptance/class-level-bindings.acceptance.ts @@ -12,6 +12,7 @@ import { Provider, Injection, ResolutionSession, + instantiateClass, } from '../..'; const INFO_CONTROLLER = 'controllers.info'; @@ -317,6 +318,193 @@ describe('Context bindings - Injecting dependencies of classes', () => { await expect(ctx.get('store')).to.be.rejectedWith('Bad'); }); + it('injects a config property', () => { + class Store { + constructor( + @inject.config('x') public optionX: number, + @inject.config('y') public optionY: string, + ) {} + } + + ctx.configure('store').to({x: 1, y: 'a'}); + ctx.bind('store').toClass(Store); + const store: Store = ctx.getSync('store'); + expect(store.optionX).to.eql(1); + expect(store.optionY).to.eql('a'); + }); + + it('injects a config property with promise value', async () => { + class Store { + constructor(@inject.config('x') public optionX: number) {} + } + + ctx.configure('store').toDynamicValue(async () => { + return {x: 1}; + }); + ctx.bind('store').toClass(Store); + const store = await ctx.get('store'); + expect(store.optionX).to.eql(1); + }); + + it('injects a config property with a binding provider', async () => { + class MyConfigProvider implements Provider<{}> { + constructor(@inject('prefix') private prefix: string) {} + value() { + return { + myOption: this.prefix + 'my-option', + }; + } + } + + class Store { + constructor(@inject.config('myOption') public myOption: string) {} + } + + ctx.bind('config').toProvider(MyConfigProvider); + ctx.configure('store').toProvider(MyConfigProvider); + ctx.bind('prefix').to('hello-'); + ctx.bind('store').toClass(Store); + + const store = await ctx.get('store'); + expect(store.myOption).to.eql('hello-my-option'); + }); + + it('injects a config property with a rejected promise', async () => { + class Store { + constructor(@inject.config('x') public optionX: number) {} + } + + ctx + .configure('store') + .toDynamicValue(() => Promise.reject(Error('invalid'))); + + ctx.bind('store').toClass(Store); + + await expect(ctx.get('store')).to.be.rejectedWith('invalid'); + }); + + it('injects a config property with nested property', () => { + class Store { + constructor(@inject.config('x.y') public optionXY: string) {} + } + + ctx.configure('store').to({x: {y: 'y'}}); + ctx.bind('store').toClass(Store); + const store: Store = ctx.getSync('store'); + expect(store.optionXY).to.eql('y'); + }); + + it('injects config if the binding key is not present', () => { + class Store { + constructor(@inject.config() public config: object) {} + } + + ctx.configure('store').to({x: 1, y: 'a'}); + ctx.bind('store').toClass(Store); + const store: Store = ctx.getSync('store'); + expect(store.config).to.eql({x: 1, y: 'a'}); + }); + + it("injects config if the binding key is ''", () => { + class Store { + constructor(@inject.config('') public config: object) {} + } + + ctx.configure('store').to({x: 1, y: 'a'}); + ctx.bind('store').toClass(Store); + const store: Store = ctx.getSync('store'); + expect(store.config).to.eql({x: 1, y: 'a'}); + }); + + it('injects config if the binding key is a path', () => { + class Store { + constructor(@inject.config('x') public optionX: number) {} + } + + ctx.configure('store').to({x: 1, y: 'a'}); + ctx.bind('store').toClass(Store); + const store: Store = ctx.getSync('store'); + expect(store.optionX).to.eql(1); + }); + + it('injects undefined option if key not found', () => { + class Store { + constructor( + // tslint:disable-next-line:no-any + @inject.config('not-exist') public option: string | undefined, + ) {} + } + + ctx.configure('store').to({x: 1, y: 'a'}); + ctx.bind('store').toClass(Store); + const store: Store = ctx.getSync('store'); + expect(store.option).to.be.undefined(); + }); + + it('injects a config property based on the parent binding', async () => { + class Store { + constructor( + @inject.config('x') public optionX: number, + @inject.config('y') public optionY: string, + ) {} + } + + ctx.configure('store1').to({x: 1, y: 'a'}); + ctx.configure('store2').to({x: 2, y: 'b'}); + + ctx.bind('store1').toClass(Store); + ctx.bind('store2').toClass(Store); + + const store1 = await ctx.get('store1'); + expect(store1.optionX).to.eql(1); + expect(store1.optionY).to.eql('a'); + + const store2 = await ctx.get('store2'); + expect(store2.optionX).to.eql(2); + expect(store2.optionY).to.eql('b'); + }); + + it('injects undefined config if no binding is present', async () => { + class Store { + constructor( + // tslint:disable-next-line:no-any + @inject.config('x') public config: string | undefined, + ) {} + } + + const store = await instantiateClass(Store, ctx); + expect(store.config).to.be.undefined(); + }); + + it('injects config from config binding', () => { + class MyStore { + constructor(@inject.config('x') public optionX: number) {} + } + + ctx.configure('stores.MyStore').to({x: 1, y: 'a'}); + ctx.bind('stores.MyStore').toClass(MyStore); + + const store: MyStore = ctx.getSync('stores.MyStore'); + expect(store.optionX).to.eql(1); + }); + + it('injects config from config binding by key namespaces', () => { + class MyStore { + constructor( + @inject.config('x') public optionX: number, + @inject.config('y') public optionY: string, + ) {} + } + + ctx.configure('stores').to({MyStore: {y: 'a'}}); + ctx.configure('stores.MyStore').to({x: 1}); + ctx.bind('stores.MyStore').toClass(MyStore); + + const store: MyStore = ctx.getSync('stores.MyStore'); + expect(store.optionX).to.eql(1); + expect(store.optionY).to.eql('a'); + }); + function createContext() { ctx = new Context(); } diff --git a/packages/context/test/acceptance/config.feature.md b/packages/context/test/acceptance/config.feature.md new file mode 100644 index 000000000000..ef4130231c81 --- /dev/null +++ b/packages/context/test/acceptance/config.feature.md @@ -0,0 +1,147 @@ +# Feature: Context bindings - injecting configuration for bound artifacts + +- In order to receive configuration for a given binding +- As a developer +- I want to set up configuration for a binding key +- So that configuration can be injected by the IoC framework + +# Scenario: configure an artifact before it's bound + +- Given a `Context` +- Given a class RestServer with a constructor accepting a single argument +`config: RestServerConfig` +- Given `RestServer` ctor argument is decorated with `@inject.config()` +- When I bind a configuration object `{port: 3000}` to `$config:servers.rest.server1` +- And bind the rest server to `servers.rest.server1` +- And resolve the binding for `servers.rest.server1` +- Then I get a new instance of `RestServer` +- And the instance was created with `config` set to `{port: 3000}` + +```ts +class RestServer { + constructor(@inject.config() public config: RestServerConfig) {} + } + +const ctx = new Context(); + +// Bind configuration +ctx.configure('servers.rest.server1').to({port: 3000}); + +// Bind RestServer +ctx.bind('servers.rest.server1').toClass(RestServer); + +// Resolve an instance of RestServer +// Expect server1.config to be `{port: 3000} +const server1 = await ctx.get('servers.rest.server1'); +``` + +# Scenario: configure an artifact with a dynamic source + +- Given a `Context` +- Given a class RestServer with a constructor accepting a single argument +`config: RestServerConfig` +- Given `RestServer` ctor argument is decorated with `@inject.config()` +- When I bind a configuration factory of `{port: 3000}` to `$config:servers.rest.server1` +- And bind the rest server to `servers.rest.server1` +- And resolve the binding for `servers.rest.server1` +- Then I get a new instance of `RestServer` +- And the instance was created with `config` set to `{port: 3000}` + +```ts +class RestServer { + constructor(@inject.config() public config: RestServerConfig) {} + } + +const ctx = new Context(); + +// Bind configuration +ctx.configure('servers.rest.server1') + .toDynamicValue(() => Promise.resolve({port: 3000})); + +// Bind RestServer +ctx.bind('servers.rest.server1').toClass(RestServer); + +// Resolve an instance of RestServer +// Expect server1.config to be `{port: 3000} +const server1 = await ctx.get('servers.rest.server1'); +``` + +# Scenario: configure values at parent level(s) + +- Given a `Context` +- Given a class RestServer with a constructor accepting a single argument +`config: RestServerConfig` +- Given `RestServer` ctor argument is decorated with `@inject.config()` +- When I bind a configuration object `{server1: {port: 3000}}` to `$config:servers.rest` +- And bind the rest server to `servers.rest.server1` +- And resolve the binding for `servers.rest.server1` +- Then I get a new instance of `RestServer` +- And the instance was created with `config` set to `{port: 3000}` + +```ts +class RestServer { + constructor(@inject.config() public config: RestServerConfig) {} + } + +const ctx = new Context(); + +// Bind configuration +ctx.configure('servers.rest).to({server1: {port: 3000}}); + +// Bind RestServer +ctx.bind('servers.rest.server1').toClass(RestServer); + +// Resolve an instance of RestServer +// Expect server1.config to be `{port: 3000} +const server1 = await ctx.get('servers.rest.server1'); +``` + +# Scenario: configure values for different envs + +- Given a `Context` +- Given a class RestServer with a constructor accepting a single argument +`config: RestServerConfig` +- Given `RestServer` ctor argument is decorated with `@inject.config()` +- When I bind a configuration object `{port: 3000}` to `$config.test:servers.rest.server1` +- And I bind a configuration object `{port: 4000}` to `$config.dev:servers.rest.server1` +- And bind the rest server to `servers.rest.server1` +- And bind the env `'dev'` to `env` +- And resolve the binding for `servers.rest.server1` +- Then I get a new instance of `RestServer` +- And the instance was created with `config` set to `{port: 4000}` + + +```ts +class RestServer { + constructor(@inject.config() public config: RestServerConfig) {} + } + +const ctx = new Context(); + +ctx.bind('$environment').to('dev'); + +// Bind configuration +ctx.configure('servers.rest.server1', 'dev').to({port: 4000}); +ctx.bind('servers.rest.server1', 'test').to({port: 3000}); + +// Bind RestServer +ctx.bind('servers.rest.server1').toClass(RestServer); + +// Resolve an instance of RestServer +// Expect server1.config to be `{port: 4000} +const server1 = await ctx.get('servers.rest.server1'); +``` + +# Notes + +This document only captures the expected behavior at context binding level. It +establishes conventions to configure and resolve binding. + +Sources of configuration can be one or more files, databases, distributed +registries, or services. How such sources are discovered and loaded is out of +scope. + +See the following modules as candidates for configuration management facilities: + +- https://github.com/lorenwest/node-config +- https://github.com/mozilla/node-convict diff --git a/packages/context/test/acceptance/config.ts b/packages/context/test/acceptance/config.ts new file mode 100644 index 000000000000..9be9eb0af576 --- /dev/null +++ b/packages/context/test/acceptance/config.ts @@ -0,0 +1,111 @@ +// Copyright IBM Corp. 2017,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 {Context, inject, ENVIRONMENT_KEY} from '../..'; + +interface RestServerConfig { + host?: string; + port?: number; +} + +class RestServer { + constructor(@inject.config() public config: RestServerConfig) {} +} + +class RestServer2 { + constructor( + @inject.config('host') public host?: string, + @inject.config('port') public port?: number, + ) {} +} + +describe('Context bindings - injecting configuration for bound artifacts', () => { + it('binds configuration independent of binding', async () => { + const ctx = new Context(); + + // Bind configuration + ctx.configure('servers.rest.server1').to({port: 3000}); + + // Bind RestServer + ctx.bind('servers.rest.server1').toClass(RestServer); + + // Resolve an instance of RestServer + // Expect server1.config to be `{port: 3000} + const server1 = await ctx.get('servers.rest.server1'); + + expect(server1.config).to.eql({port: 3000}); + }); + + it('configure an artifact with a dynamic source', async () => { + const ctx = new Context(); + + // Bind configuration + ctx + .configure('servers.rest.server1') + .toDynamicValue(() => Promise.resolve({port: 3000})); + + // Bind RestServer + ctx.bind('servers.rest.server1').toClass(RestServer); + + // Resolve an instance of RestServer + // Expect server1.config to be `{port: 3000} + const server1 = await ctx.get('servers.rest.server1'); + expect(server1.config).to.eql({port: 3000}); + }); + + it('configure values at parent level(s)', async () => { + const ctx = new Context(); + + // Bind configuration + ctx.configure('servers.rest').to({server1: {port: 3000}}); + + // Bind RestServer + ctx.bind('servers.rest.server1').toClass(RestServer); + + // Resolve an instance of RestServer + // Expect server1.config to be `{port: 3000} + const server1 = await ctx.get('servers.rest.server1'); + expect(server1.config).to.eql({port: 3000}); + }); + + it('binds configuration for environments', async () => { + const ctx = new Context(); + + ctx.bind('$environment').to('test'); + // Bind configuration + ctx.configure('servers.rest.server1', 'dev').to({port: 4000}); + ctx.configure('servers.rest.server1', 'test').to({port: 3000}); + + // Bind RestServer + ctx.bind('servers.rest.server1').toClass(RestServer); + + // Resolve an instance of RestServer + // Expect server1.config to be `{port: 3000} + const server1 = await ctx.get('servers.rest.server1'); + + expect(server1.config).to.eql({port: 3000}); + }); + + it('binds configuration for environments with defaults', async () => { + const ctx = new Context(); + + ctx.bind(ENVIRONMENT_KEY).to('dev'); + // Bind configuration + ctx.configure('servers.rest.server1', 'dev').to({port: 4000}); + ctx.configure('servers.rest.server1', 'test').to({port: 3000}); + ctx.configure('servers.rest.server1').to({host: 'localhost'}); + + // Bind RestServer + ctx.bind('servers.rest.server1').toClass(RestServer2); + + // Resolve an instance of RestServer + // Expect server1.config to be `{port: 3000} + const server1 = await ctx.get('servers.rest.server1'); + + expect(server1.host).to.eql('localhost'); + expect(server1.port).to.eql(4000); + }); +}); diff --git a/packages/context/test/unit/context.unit.ts b/packages/context/test/unit/context.unit.ts index b5e84b0b8aea..77e667d41ae1 100644 --- a/packages/context/test/unit/context.unit.ts +++ b/packages/context/test/unit/context.unit.ts @@ -11,6 +11,7 @@ import { BindingType, isPromiseLike, BindingKey, + BoundValue, } from '../..'; /** @@ -296,6 +297,106 @@ describe('Context', () => { }); }); + describe('configure()', () => { + it('configures options for a binding before it is bound', () => { + const bindingForConfig = ctx.configure('foo').to({x: 1}); + expect(bindingForConfig.key).to.equal( + BindingKey.buildKeyForConfig('foo'), + ); + expect(bindingForConfig.tags.has('config:foo')).to.be.true(); + }); + + it('configures options for a binding after it is bound', () => { + ctx.bind('foo').to('bar'); + const bindingForConfig = ctx.configure('foo').to({x: 1}); + expect(bindingForConfig.key).to.equal( + BindingKey.buildKeyForConfig('foo'), + ); + expect(bindingForConfig.tags.has('config:foo')).to.be.true(); + }); + }); + + describe('getConfig()', () => { + it('gets config for a binding', async () => { + ctx.configure('foo').toDynamicValue(() => Promise.resolve({x: 1})); + expect(await ctx.getConfig('foo')).to.eql({x: 1}); + }); + + it('gets local config for a binding', async () => { + ctx + .configure('foo') + .toDynamicValue(() => Promise.resolve({a: {x: 0, y: 0}})); + ctx.configure('foo.a').toDynamicValue(() => Promise.resolve({x: 1})); + expect( + await ctx.getConfig('foo.a', 'x', {localConfigOnly: true}), + ).to.eql(1); + expect( + await ctx.getConfig('foo.a', 'y', {localConfigOnly: true}), + ).to.be.undefined(); + }); + + it('gets parent config for a binding', async () => { + ctx + .configure('foo') + .toDynamicValue(() => Promise.resolve({a: {x: 0, y: 0}})); + ctx.configure('foo.a').toDynamicValue(() => Promise.resolve({x: 1})); + expect(await ctx.getConfig('foo.a', 'x')).to.eql(1); + expect(await ctx.getConfig('foo.a', 'y')).to.eql(0); + }); + + it('defaults optional to true for config resolution', async () => { + ctx.configure('servers').to({rest: {port: 80}}); + // `servers.rest` does not exist yet + let server1port = await ctx.getConfig('servers.rest', 'port'); + // The port is resolved at `servers` level + expect(server1port).to.eql(80); + + // Now add `servers.rest` + ctx.configure('servers.rest').to({port: 3000}); + const servers: BoundValue = await ctx.getConfig('servers'); + server1port = await ctx.getConfig('servers.rest', 'port'); + // We don't add magic to merge `servers.rest` level into `servers` + expect(servers.rest.port).to.equal(80); + expect(server1port).to.eql(3000); + }); + + it('throws error if a required config cannot be resolved', async () => { + ctx.configure('servers').to({rest: {port: 80}}); + // `servers.rest` does not exist yet + let server1port = await ctx.getConfig('servers.rest', 'port', { + optional: false, + }); + // The port is resolved at `servers` level + expect(server1port).to.eql(80); + + expect( + ctx.getConfig('servers.rest', 'host', { + optional: false, + }), + ) + .to.be.rejectedWith( + `Configuration 'servers.rest#host' cannot be resolved`, + ) + .catch(e => { + // Sink the error to avoid UnhandledPromiseRejectionWarning + }); + }); + }); + + describe('getConfigSync()', () => { + it('gets config for a binding', () => { + ctx.configure('foo').to({x: 1}); + expect(ctx.getConfigSync('foo')).to.eql({x: 1}); + }); + + it('throws a helpful error when the config is async', () => { + ctx.configure('foo').toDynamicValue(() => Promise.resolve('bar')); + expect(() => ctx.getConfigSync('foo')).to.throw( + /Cannot get config\[\] for foo synchronously: the value is a promise/, + ); + }); + }); + describe('getSync', () => { it('returns the value immediately when the binding is sync', () => { ctx.bind('foo').to('bar'); @@ -403,6 +504,21 @@ describe('Context', () => { }); }); + describe('getOwnerContext', () => { + it('returns owner context', () => { + ctx.bind('foo').to('bar'); + expect(ctx.getOwnerContext('foo')).to.equal(ctx); + }); + + it('returns owner context with parent', () => { + ctx.bind('foo').to('bar'); + const childCtx = new Context(ctx, 'child'); + childCtx.bind('xyz').to('abc'); + expect(childCtx.getOwnerContext('foo')).to.equal(ctx); + expect(childCtx.getOwnerContext('xyz')).to.equal(childCtx); + }); + }); + describe('get', () => { it('returns a promise when the binding is async', async () => { ctx.bind('foo').toDynamicValue(() => Promise.resolve('bar')); diff --git a/packages/context/test/unit/value-promise.unit.ts b/packages/context/test/unit/value-promise.unit.ts index 43998bc4d81a..8a5a5930d4c1 100644 --- a/packages/context/test/unit/value-promise.unit.ts +++ b/packages/context/test/unit/value-promise.unit.ts @@ -4,7 +4,14 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; -import {resolveList, resolveMap, tryWithFinally, getDeepProperty} from '../..'; +import { + resolveList, + resolveMap, + resolveUntil, + resolveValueOrPromise, + tryWithFinally, + getDeepProperty, +} from '../..'; describe('tryWithFinally', () => { it('performs final action for a fulfilled promise', async () => { @@ -205,3 +212,78 @@ describe('resolveMap', () => { expect(result).to.eql({a: 'Xa2', b: 'Yb2'}); }); }); + +describe('resolveUntil', () => { + it('resolves an iterator of values', () => { + const source = ['a', 'b', 'c']; + const result = resolveUntil( + source[Symbol.iterator](), + v => v.toUpperCase(), + (s, v) => s === 'a', + ); + expect(result).to.eql('A'); + }); + + it('resolves an iterator of values until the end', () => { + const source = ['a', 'b', 'c']; + const result = resolveUntil( + source[Symbol.iterator](), + v => v.toUpperCase(), + (s, v) => false, + ); + expect(result).to.be.undefined(); + }); + + it('resolves an iterator of promises', async () => { + const source = ['a', 'b', 'c']; + const result = await resolveUntil( + source[Symbol.iterator](), + v => Promise.resolve(v.toUpperCase()), + (s, v) => true, + ); + expect(result).to.eql('A'); + }); + + it('handles a rejected promise from resolver', () => { + const source = ['a', 'b', 'c']; + const result = resolveUntil( + source[Symbol.iterator](), + v => Promise.reject(new Error(v)), + (s, v) => true, + ); + expect(result).be.rejectedWith('a'); + }); + + it('handles a rejected promise from evaluator', () => { + const source = ['a', 'b', 'c']; + const result = resolveUntil( + source[Symbol.iterator](), + async v => v.toUpperCase(), + (s, v) => { + throw new Error(v); + }, + ); + expect(result).be.rejectedWith('A'); + }); +}); + +describe('resolveValueOrPromise', () => { + it('resolves a value', () => { + const result = resolveValueOrPromise('a', v => v && v.toUpperCase()); + expect(result).to.eql('A'); + }); + + it('resolves a promise', async () => { + const result = await resolveValueOrPromise('a', v => + Promise.resolve(v && v.toUpperCase()), + ); + expect(result).to.eql('A'); + }); + + it('handles a rejected promise', () => { + const result = resolveValueOrPromise('a', v => + Promise.reject(new Error(v)), + ); + expect(result).be.rejectedWith('a'); + }); +}); diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index b7309126f4a1..70cb59ddfc94 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -7,6 +7,7 @@ import {Context, Binding, BindingScope, Constructor} from '@loopback/context'; import {Server} from './server'; import {Component, mountComponent} from './component'; import {CoreBindings} from './keys'; +import {ExtensionPoint} from './extension-point'; /** * Application is the container for various types of artifacts, such as @@ -194,10 +195,50 @@ export class Application extends Context { const instance = this.getSync(componentKey); mountComponent(this, instance); } + + /** + * Register an extension point + * @param extensionPointClass Extension point class + * @param extensionPointName Name of the extension point, if not present, + * default to extensionPoints. + */ + public extensionPoint( + // tslint:disable-next-line:no-any + extensionPointClass: Constructor>, + extensionPointName?: string, + ): Binding { + extensionPointName = + extensionPointName || `extensionPoints.${extensionPointClass.name}`; + return this.bind(extensionPointName) + .toClass(extensionPointClass) + .inScope(BindingScope.CONTEXT) + .tag('extensionPoint') + .tag(`name:${extensionPointName}`); + } + + /** + * Register an extension of the given extension point + * @param extensionPointName Name of the extension point + * @param extensionClass Extension class + * @param extensionName Name of the extension. If not present, default to + * the name of extension class + */ + public extension( + extensionPointName: string, + // tslint:disable-next-line:no-any + extensionClass: Constructor, + extensionName?: string, + ): Binding { + extensionName = extensionName || extensionClass.name; + return this.bind(`${extensionPointName}.${extensionName}`) + .toClass(extensionClass) + .tag(`extensionPoint:${extensionPointName}`) + .tag(`name:${extensionName}`); + } } /** - * Configuration for application + * Configuration for an application */ export interface ApplicationConfig { /** diff --git a/packages/core/src/extension-point.ts b/packages/core/src/extension-point.ts new file mode 100644 index 000000000000..44f8ceb6b07f --- /dev/null +++ b/packages/core/src/extension-point.ts @@ -0,0 +1,178 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Context, + Binding, + inject, + resolveList, + Injection, + ResolutionSession, +} from '@loopback/context'; + +// tslint:disable:no-any +/** + * Interface for the extension point configuration + */ +export interface ExtensionPointConfig { + // Configuration properties for the extension point itself + [property: string]: any; +} + +/** + * Base class for extension points + */ +export abstract class ExtensionPoint { + /** + * Configuration (typically to be injected) + */ + @inject.config() public readonly config: ExtensionPointConfig = {}; + + /** + * Name of the extension point. The subclass must set the value. + */ + static extensionPointName: string; + + /** + * The unique name of this extension point. It also serves as the binding + * key prefix for bound extensions + */ + public readonly name: string; + + constructor() { + const ctor = this.constructor as typeof ExtensionPoint; + this.name = ctor.extensionPointName; + if (!this.name) { + throw new Error(`${ctor.name}.extensionPointName must be set`); + } + } + + /** + * Find an array of bindings for extensions + */ + getAllExtensionBindings(ctx: Context): Readonly[] { + return ctx.findByTag(this.getTagForExtensions()); + } + + /** + * Get the binding tag for extensions of this extension point + */ + protected getTagForExtensions(): string { + return `extensionPoint:${this.name}`; + } + + /** + * Get a map of extension bindings by the keys + */ + getExtensionBindingMap(ctx: Context): {[name: string]: Readonly} { + const extensionBindingMap: {[name: string]: Readonly} = {}; + const bindings = this.getAllExtensionBindings(ctx); + bindings.forEach(binding => { + extensionBindingMap[binding.key] = binding; + }); + return extensionBindingMap; + } + + /** + * Look up an extension binding by name + * @param extensionName Name of the extension + */ + getExtensionBinding(ctx: Context, extensionName: string): Readonly { + const bindings = this.getAllExtensionBindings(ctx); + const binding = bindings.find(b => + b.tags.has(this.getTagForName(extensionName)), + ); + if (binding == null) + throw new Error( + `Extension ${extensionName} does not exist for extension point ${ + this.name + }`, + ); + return binding; + } + + /** + * Get the binding tag for an extension name + * @param extensionName Name of the extension + */ + protected getTagForName(extensionName: string): string { + return `name:${extensionName}`; + } + + /** + * Get configuration for this extension point + */ + getConfiguration() { + return this.config; + } + + /** + * Get configuration for an extension of this extension point + * @param extensionName Name of the extension + */ + async getExtensionConfiguration(ctx: Context, extensionName: string) { + return (await ctx.getConfig(`${this.name}.${extensionName}`)) || {}; + } + + /** + * Get an instance of an extension by name + * @param extensionName Name of the extension + */ + async getExtension(ctx: Context, extensionName: string): Promise { + const binding = this.getExtensionBinding(ctx, extensionName); + return binding.getValue(ctx); + } + + /** + * Get an array of registered extension instances + */ + async getAllExtensions(ctx: Context): Promise { + const bindings = this.getAllExtensionBindings(ctx); + return resolveList(bindings, async binding => { + return await binding.getValue(ctx); + }); + } + + /** + * Get the name tag (name:extension-name) associated with the binding + * @param binding + */ + static getExtensionName(binding: Binding) { + for (const tag of binding.tags) { + if (tag.startsWith('name:')) { + return tag.substr('name:'.length); + } + } + return undefined; + } +} + +/** + * @extensions() - decorator to inject extensions + */ +export function extensions() { + return inject( + '', + {decorator: '@extensions'}, + (ctx: Context, injection: Injection, session?: ResolutionSession) => { + const target = injection.target; + const ctor: any = + target instanceof Function ? target : target.constructor; + if (ctor.extensionPointName) { + const bindings = ctx.findByTag( + `extensionPoint:${ctor.extensionPointName}`, + ); + 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)); + }); + } + throw new Error( + '@extensions must be used within a extension point class', + ); + }, + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cf9d5f64358a..faa5b624fe03 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,3 +12,4 @@ export {Server} from './server'; export * from './application'; export * from './component'; export * from './keys'; +export * from './extension-point'; diff --git a/packages/core/test/acceptance/extension-point.feature.md b/packages/core/test/acceptance/extension-point.feature.md new file mode 100644 index 000000000000..9bd9446b9fb5 --- /dev/null +++ b/packages/core/test/acceptance/extension-point.feature.md @@ -0,0 +1,78 @@ +# Feature: Context bindings - organizing services as extension point/extensions by naming convention + +- In order to manage services that follow the extension point/extension pattern +- As a developer +- I would like to bind a extension point and its extensions to the context following certain conventions +- So that an extension point can find all of its extensions or one of them by name + +See https://wiki.eclipse.org/FAQ_What_are_extensions_and_extension_points%3F for the pattern. + +# Scenario - register an extension to a given extension point + +- Given a context +- Given a class `AuthenticationManager` as the extension point for extensible authentication strategies +- Given a class `LocalStrategy` that implements the `local` authentication strategy +- Given a class `LDAPStrategy` that implements the `ldap` authentication strategy +- Given a class `OAuth2Strategy` that implements the `oauth2` authentication strategy +- We should be able to add `LocalStrategy` by binding it to `authentication.strategies.local` +- We should be able to add `LDAPStrategy` by binding it to `authentication.strategies.ldap` +- We should be able to add `OAuth2Strategy` by binding it to `authentication.strategies.oauth2` + +# Scenario - find all of its extensions for a given extension point + +- Given a context +- Given a class `AuthenticationManager` as the extension point for extensible authentication strategies +- Given a class `LocalStrategy` that implements the `local` authentication strategy +- Given a class `LDAPStrategy` that implements the `ldap` authentication strategy +- Given a class `OAuth2Strategy` that implements the `oauth2` authentication strategy +- When LocalStrategy is bound to `authentication.strategies.local` +- When LDAPStrategy is bound to `authentication.strategies.ldap` +- When OAuth2Strategy is bound to `authentication.strategies.oauth2` +- AuthenticationManager should be able to list all extensions bound to `authentication.strategies.*` + +# Scenario - find one of its extensions by name for a given extension point + +- Given a context +- Given a class `AuthenticationManager` as the extension point for extensible authentication strategies +- Given a class `LocalStrategy` that implements the `local` authentication strategy +- Given a class `LDAPStrategy` that implements the `ldap` authentication strategy +- Given a class `OAuth2Strategy` that implements the `oauth2` authentication strategy +- When LocalStrategy is bound to `authentication.strategies.local` +- When LDAPStrategy is bound to `authentication.strategies.ldap` +- When OAuth2Strategy is bound to `authentication.strategies.oauth2` +- AuthenticationManager should be able to find/get LocalStrategy by `local` from the context +- AuthenticationManager should be able to find/get LDAPStrategy by `ldap` from the context +- AuthenticationManager should be able to find/get OAuth2Strategy by `oauth2` from the context + +# Scenario - populate configuration for extension points and extension + +- Given a context +- Given a class `AuthenticationManager` as the extension point for extensible authentication strategies +- Given a class `LocalStrategy` that implements the `local` authentication strategy +- Given a class `LDAPStrategy` that implements the `ldap` authentication strategy +- Given a class `OAuth2Strategy` that implements the `oauth2` authentication strategy +- When LocalStrategy is bound to `authentication.strategies.local` +- When LDAPStrategy is bound to `authentication.strategies.ldap` +- When OAuth2Strategy is bound to `authentication.strategies.oauth2` +- Given a configuration object +```js +{ + 'authentication.strategies': { + local: { /* config for local strategy */}, + ldap: { /* config for ldap strategy */} + oauth2: { /* config for oauth2 strategy */} + } +} +``` +- Each of the strategy class should be able to receive its configuration via dependency injection. For example, + +```ts +class LocalStrategy implements AuthenticationStrategy { + constructor(@inject('config') private config: LocalConfig) {} +} +``` + +# TBD + +- Should we go beyond naming conventions to make ExtensionPoint/Extension first class entity of the context? +- Should we introduce decorators such as `@extensionPoint` and `@extension`? diff --git a/packages/core/test/acceptance/extension-point.ts b/packages/core/test/acceptance/extension-point.ts new file mode 100644 index 000000000000..d1293c91d984 --- /dev/null +++ b/packages/core/test/acceptance/extension-point.ts @@ -0,0 +1,267 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + Context, + BindingScope, + inject, + Constructor, + invokeMethod, +} from '@loopback/context'; +import {Application, ExtensionPoint, extensions} from '../..'; + +// tslint:disable:no-any +interface AuthenticationStrategy { + authenticate(credentials: any): Promise; + config: any; +} + +class LocalStrategy implements AuthenticationStrategy { + constructor(@inject.config() public config: any) {} + + authenticate(credentials: any) { + return Promise.resolve(true); + } +} + +class LDAPStrategy implements AuthenticationStrategy { + constructor(@inject.config() public config: any) {} + + authenticate(credentials: any) { + return Promise.resolve(true); + } +} + +class OAuth2Strategy implements AuthenticationStrategy { + constructor(@inject.config() public config: any) {} + + authenticate(credentials: any) { + return Promise.resolve(true); + } +} + +const AUTH_EXTENSION_POINT = 'authentication.strategies'; + +class AuthenticationManager extends ExtensionPoint { + static extensionPointName = AUTH_EXTENSION_POINT; + + async authenticate( + ctx: Context, + strategy: string, + credentials: any, + ): Promise { + const ext: AuthenticationStrategy = await this.getExtension(ctx, strategy); + return ext.authenticate(credentials); + } + + async getStrategies( + // Use method injection to ensure we pick up all available extensions within + // the current context + @extensions() authenticators: AuthenticationStrategy[], + ) { + return authenticators; + } +} + +const configs: {[name: string]: object} = { + local: { + url: 'https://localhost:3000/users/login', + }, + ldap: { + url: 'ldap://localhost:1389', + }, + oauth2: { + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + tokenInspectUrl: 'https://localhost:3000/oauth2/inspect', + }, +}; + +// Register multiple extensions +const strategies: {[name: string]: Constructor} = { + local: LocalStrategy, + ldap: LDAPStrategy, + oauth2: OAuth2Strategy, +}; + +describe('Extension point', () => { + let ctx: Context; + beforeEach('given a context', createContext); + + it('lists all extensions', async () => { + const authManager = await ctx.get( + AUTH_EXTENSION_POINT, + ); + const extBindings = authManager.getAllExtensionBindings(ctx); + expect(extBindings.length).to.eql(3); + }); + + it('gets an extension by name', async () => { + const authManager = await ctx.get( + AUTH_EXTENSION_POINT, + ); + const binding = authManager.getExtensionBinding(ctx, 'ldap'); + expect(binding.key).to.eql(`${AUTH_EXTENSION_POINT}.ldap`); + expect(binding.valueConstructor).to.exactly(LDAPStrategy); + }); + + it('gets an extension instance by name', async () => { + const authManager = await ctx.get( + AUTH_EXTENSION_POINT, + ); + const ext = await authManager.getExtension(ctx, 'ldap'); + expect(ext.config).to.eql({ + url: 'ldap://localhost:1389', + }); + const result = await ext.authenticate({}); + expect(result).to.be.true(); + }); + + it('delegates to an extension', async () => { + const authManager = await ctx.get( + AUTH_EXTENSION_POINT, + ); + const result = await authManager.authenticate(ctx, 'local', { + username: 'my-user', + password: 'my-pass', + }); + expect(result).to.be.true(); + }); + + it('injects extensions', async () => { + const authManager = await ctx.get( + AUTH_EXTENSION_POINT, + ); + const authenticators: AuthenticationStrategy[] = await invokeMethod( + authManager, + 'getStrategies', + ctx, + ); + expect(authenticators).have.length(3); + }); + + function createContext() { + ctx = new Context(); + + // Register the extension point + ctx + .bind(AUTH_EXTENSION_POINT) + .toClass(AuthenticationManager) + .inScope(BindingScope.SINGLETON) + .tag('extensionPoint') + .tag(`name:${AUTH_EXTENSION_POINT}`); + + for (const e in strategies) { + ctx + .bind(`authentication.strategies.${e}`) + .toClass(strategies[e]) + .inScope(BindingScope.SINGLETON) + .tag(`extensionPoint:${AUTH_EXTENSION_POINT}`) + .tag(`name:${e}`); + ctx.configure(`authentication.strategies.${e}`).to(configs[e]); + } + } +}); + +describe('Application support for extension points', () => { + let app: Application; + + beforeEach(givenApplication); + + it('registers an extension point by class name', () => { + app.extensionPoint(AuthenticationManager); + const binding = app.getBinding('extensionPoints.AuthenticationManager'); + expect(binding.valueConstructor).to.be.exactly(AuthenticationManager); + expect(binding.scope === BindingScope.SINGLETON); + expect( + binding.tags.has('name:extensionPoints.AuthenticationManager'), + ).to.be.true(); + expect(binding.tags.has('extensionPoint')).to.be.true(); + }); + + it('registers an extension point by name', () => { + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + const binding = app.getBinding(AUTH_EXTENSION_POINT); + expect(binding.valueConstructor).to.be.exactly(AuthenticationManager); + expect(binding.scope === BindingScope.SINGLETON); + expect(binding.tags.has(`name:${AUTH_EXTENSION_POINT}`)).to.be.true(); + expect(binding.tags.has('extensionPoint')).to.be.true(); + }); + + it('registers an extension by class name', () => { + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + app.extension(AUTH_EXTENSION_POINT, LocalStrategy); + const binding = app.getBinding(`${AUTH_EXTENSION_POINT}.LocalStrategy`); + expect(binding.valueConstructor).to.be.exactly(LocalStrategy); + expect(binding.tags.has('name:LocalStrategy')).to.be.true(); + expect( + binding.tags.has(`extensionPoint:${AUTH_EXTENSION_POINT}`), + ).to.be.true(); + }); + + it('registers an extension by class name', () => { + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + app.extension(AUTH_EXTENSION_POINT, LocalStrategy); + const binding = app.getBinding(`${AUTH_EXTENSION_POINT}.LocalStrategy`); + expect(binding.valueConstructor).to.be.exactly(LocalStrategy); + expect(binding.tags.has('name:LocalStrategy')).to.be.true(); + expect( + binding.tags.has(`extensionPoint:${AUTH_EXTENSION_POINT}`), + ).to.be.true(); + }); + + it('registers an extension by name', () => { + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + app.extension(AUTH_EXTENSION_POINT, LocalStrategy, 'local'); + const binding = app.getBinding(`${AUTH_EXTENSION_POINT}.local`); + expect(binding.valueConstructor).to.be.exactly(LocalStrategy); + expect(binding.tags.has('name:local')).to.be.true(); + expect( + binding.tags.has(`extensionPoint:${AUTH_EXTENSION_POINT}`), + ).to.be.true(); + }); + + it('configures an extension point', async () => { + const config = {auth: true}; + app.configure(AUTH_EXTENSION_POINT).to(config); + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + const auth = await app.get(AUTH_EXTENSION_POINT); + expect(auth.config).to.eql(config); + }); + + it('binds an extension point in context scope', async () => { + const config = {auth: true}; + app.configure(AUTH_EXTENSION_POINT).to(config); + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + const auth1 = await app.get(AUTH_EXTENSION_POINT); + const auth2 = await app.get(AUTH_EXTENSION_POINT); + expect(auth1).to.be.exactly(auth2); + + const reqCtx = new Context(app); + const auth3 = await reqCtx.get(AUTH_EXTENSION_POINT); + const auth4 = await reqCtx.get(AUTH_EXTENSION_POINT); + expect(auth3).to.be.exactly(auth4); + expect(auth3).to.be.not.exactly(auth1); + }); + + it('configures extensions', async () => { + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + app.extension(AUTH_EXTENSION_POINT, LocalStrategy, 'local'); + app.configure(`${AUTH_EXTENSION_POINT}.local`).to(configs.local); + const extensionPoint: ExtensionPoint< + AuthenticationStrategy + > = await app.get(AUTH_EXTENSION_POINT); + const extension: LocalStrategy = await extensionPoint.getExtension( + app, + 'local', + ); + expect(extension.config).to.eql(configs.local); + }); + + function givenApplication() { + app = new Application(); + } +});