From 6ba46f5b7b5cb51865733e82fbbdc8286a134e54 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 17 Oct 2017 10:30:31 -0700 Subject: [PATCH] feat(core): Add support for extension point/extension Extension point/extension is a pattern for extensibility. This PR illustrates how we can support it using the Context apis and naming conventions. It also serves as an invitation to discuss if we should support such entities out of box. --- packages/core/src/application.ts | 80 +++++++ packages/core/src/extension-point.ts | 97 ++++++++ packages/core/src/index.ts | 1 + .../acceptance/extension-point.feature.md | 78 +++++++ .../core/test/acceptance/extension-point.ts | 217 ++++++++++++++++++ 5 files changed, 473 insertions(+) create mode 100644 packages/core/src/extension-point.ts create mode 100644 packages/core/test/acceptance/extension-point.feature.md create mode 100644 packages/core/test/acceptance/extension-point.ts diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index 153205341a25..704d789f50bf 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'; export class Application extends Context { constructor(public options?: ApplicationConfig) { @@ -186,6 +187,85 @@ 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, + ): this { + extensionPointName = + extensionPointName || `extensionPoints.${extensionPointClass.name}`; + this.bind(extensionPointName) + .toClass(extensionPointClass) + .inScope(BindingScope.SINGLETON) + .tag('extensionPoint') + .tag(`name:${extensionPointName}`); + return this; + } + + /** + * 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, + ): this { + try { + this.getBinding(extensionPointName); + } catch (e) { + throw new Error(`Extension point ${extensionPointName} does not exist`); + } + extensionName = extensionName || extensionClass.name; + this.bind(`${extensionPointName}.${extensionName}`) + .toClass(extensionClass) + .tag(`extensionPoint:${extensionPointName}`) + .tag(`name:${extensionName}`); + return this; + } + + /** + * Set configuration for an extension point + * @param extensionPointName Name of the extension point + * @param config Configuration object + */ + public extensionPointConfig( + extensionPointName: string, + config: object, + ): this { + // Use a corresponding binding for the extension point config + // Another option is to use `Binding.options()` + this.bind(`${extensionPointName}.config`).to(config); + return this; + } + + /** + * Set configuration for an extension + * @param extensionPointName Name of the extension point + * @param extensionName Name of the extension + * @param config Configuration object + */ + public extensionConfig( + extensionPointName: string, + extensionName: string, + config: object, + ): this { + // Use a corresponding binding for the extension config + // Another option is to use `Binding.options()` + this.bind(`${extensionPointName}.${extensionName}.config`).to(config); + return this; + } } 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..bedf05d97f37 --- /dev/null +++ b/packages/core/src/extension-point.ts @@ -0,0 +1,97 @@ +// 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} from '@loopback/context'; + +import {Application, ApplicationConfig} from './application'; + +// tslint:disable:no-any +/** + * Interface for the extension point configuration + */ +export interface ExtensionPointConfig { + // Configuration properties for extensions keyed by the extension name + extensions: { + [extensionName: string]: any; + }; + // Configuration properties for the extension point itself + [property: string]: any; +} + +/** + * Base class for extension points + */ +export abstract class ExtensionPoint { + /** + * Configuration (typically to be injected) + */ + protected configuration: ExtensionPointConfig; + + constructor( + /** + * The unique name of this extension point. It also serves as the binding + * key prefix for bound extensions + */ + protected name: string, + /** + * The Context (typically to be injected) + */ + protected context: Context, + /** + * Configuration (typically to be injected) + */ + configuration?: ExtensionPointConfig, + ) { + this.configuration = configuration || {extensionPoint: {}, extensions: {}}; + } + + /** + * Find an array of bindings for extensions + */ + getAllExtensionBindings(): Binding[] { + return this.context.findByTag(`extensionPoint:${this.name}`); + } + + /** + * Get a map of extension bindings by the keys + */ + getExtensionBindingMap(): {[name: string]: Binding} { + const extensions: {[name: string]: Binding} = {}; + const bindings = this.getAllExtensionBindings(); + bindings.forEach(binding => { + extensions[binding.key] = binding; + }); + return extensions; + } + + /** + * Look up an extension binding by name + * @param extensionName Name of the extension + */ + getExtensionBinding(extensionName: string): Binding { + const bindings = this.getAllExtensionBindings(); + const binding = bindings.find(b => b.tags.has(`name:${extensionName}`)); + if (binding == null) + throw new Error( + `Extension ${extensionName} does not exist for extension point ${this + .name}`, + ); + return binding; + } + + /** + * Get an instance of an extension by name + * @param extensionName Name of the extension + */ + async getExtension(extensionName: string): Promise { + const binding = this.getExtensionBinding(extensionName); + // Create a child context to bind `config` + const extensionContext = new Context(this.context); + extensionContext + .bind('config') + .to(this.configuration.extensions[extensionName]); + return await binding.getValue(extensionContext); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e09eaed67129..cb0405b0f1aa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,3 +13,4 @@ export * from './application'; export * from './promisify'; 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..10cbc596b301 --- /dev/null +++ b/packages/core/test/acceptance/extension-point.ts @@ -0,0 +1,217 @@ +// 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, Binding, BindingScope, inject} from '@loopback/context'; +import {Application, ExtensionPoint, ExtensionPointConfig} 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); + } +} + +class AuthenticationManager extends ExtensionPoint { + constructor( + @inject('$context') context: Context, + @inject('authentication.config') config: ExtensionPointConfig, + ) { + super('authentication.strategies', context, config); + } + + async authenticate(strategy: string, credentials: any): Promise { + const ext: AuthenticationStrategy = await this.getExtension(strategy); + return ext.authenticate(credentials); + } +} + +describe('Extension point', () => { + let ctx: Context; + beforeEach('given a context', createContext); + + it('lists all extensions', async () => { + const authManager = await ctx.get('authentication.manager'); + const extBindings = authManager.getAllExtensionBindings(); + expect(extBindings.length).to.eql(3); + }); + + it('gets an extension by name', async () => { + const authManager: AuthenticationManager = await ctx.get( + 'authentication.manager', + ); + const binding = authManager.getExtensionBinding('ldap'); + expect(binding.key).to.eql('authentication.strategies.ldap'); + expect(binding.valueConstructor).to.exactly(LDAPStrategy); + }); + + it('gets an extension instance by name', async () => { + const authManager: AuthenticationManager = await ctx.get( + 'authentication.manager', + ); + const ext = await authManager.getExtension('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: AuthenticationManager = await ctx.get( + 'authentication.manager', + ); + const result = await authManager.authenticate('local', { + username: 'my-user', + password: 'my-pass', + }); + expect(result).to.be.true(); + }); + + function createContext() { + ctx = new Context(); + + ctx.bind('$context').to(ctx); + // Register the extension point + ctx + .bind('authentication.manager') + .toClass(AuthenticationManager) + .inScope(BindingScope.SINGLETON) + .tag('extensionPoint') + .tag('name:authentication.manager'); + + ctx.bind('authentication.config').to({ + extensions: { + 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 + ctx + .bind('authentication.strategies.local') + .toClass(LocalStrategy) + .inScope(BindingScope.SINGLETON) + .tag('extensionPoint:authentication.strategies') + .tag('name:local'); + ctx + .bind('authentication.strategies.ldap') + .toClass(LDAPStrategy) + .inScope(BindingScope.SINGLETON) + .tag('extensionPoint:authentication.strategies') + .tag('name:ldap'); + ctx + .bind('authentication.strategies.oauth2') + .toClass(OAuth2Strategy) + .inScope(BindingScope.SINGLETON) + .tag('extensionPoint:authentication.strategies') + .tag('name:oauth2'); + } +}); + +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, 'authentication.manager'); + const binding = app.getBinding('authentication.manager'); + expect(binding.valueConstructor).to.be.exactly(AuthenticationManager); + expect(binding.scope === BindingScope.SINGLETON); + expect(binding.tags.has('name:authentication.manager')).to.be.true(); + expect(binding.tags.has('extensionPoint')).to.be.true(); + }); + + it('registers an extension by class name', () => { + app.extensionPoint(AuthenticationManager, 'authentication.manager'); + app.extension('authentication.manager', LocalStrategy); + const binding = app.getBinding('authentication.manager.LocalStrategy'); + expect(binding.valueConstructor).to.be.exactly(LocalStrategy); + expect(binding.tags.has('name:LocalStrategy')).to.be.true(); + expect( + binding.tags.has('extensionPoint:authentication.manager'), + ).to.be.true(); + }); + + it('registers an extension by class name', () => { + app.extensionPoint(AuthenticationManager, 'authentication.manager'); + app.extension('authentication.manager', LocalStrategy); + const binding = app.getBinding('authentication.manager.LocalStrategy'); + expect(binding.valueConstructor).to.be.exactly(LocalStrategy); + expect(binding.tags.has('name:LocalStrategy')).to.be.true(); + expect( + binding.tags.has('extensionPoint:authentication.manager'), + ).to.be.true(); + }); + + it('registers an extension by name', () => { + app.extensionPoint(AuthenticationManager, 'authentication.manager'); + app.extension('authentication.manager', LocalStrategy, 'local'); + const binding = app.getBinding('authentication.manager.local'); + expect(binding.valueConstructor).to.be.exactly(LocalStrategy); + expect(binding.tags.has('name:local')).to.be.true(); + expect( + binding.tags.has('extensionPoint:authentication.manager'), + ).to.be.true(); + }); + + it('reports an error if the extension point is not bound', () => { + try { + app.extension('authentication.manager', LocalStrategy); + } catch (e) { + expect(e.message).to.match( + /Extension point authentication\.manager does not exist/, + ); + } + }); + + function givenApplication() { + app = new Application(); + } +});