Skip to content

Commit

Permalink
feat(core): Add support for extension point/extension
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Raymond Feng committed Oct 19, 2017
1 parent d52f637 commit 6ba46f5
Show file tree
Hide file tree
Showing 5 changed files with 473 additions and 0 deletions.
80 changes: 80 additions & 0 deletions packages/core/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.<extensionPoint-class-name>
*/
public extensionPoint(
// tslint:disable-next-line:no-any
extensionPointClass: Constructor<ExtensionPoint<any>>,
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<any>,
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 {
Expand Down
97 changes: 97 additions & 0 deletions packages/core/src/extension-point.ts
Original file line number Diff line number Diff line change
@@ -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<EXT extends object> {
/**
* 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<EXT> {
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);
}
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './application';
export * from './promisify';
export * from './component';
export * from './keys';
export * from './extension-point';
78 changes: 78 additions & 0 deletions packages/core/test/acceptance/extension-point.feature.md
Original file line number Diff line number Diff line change
@@ -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`?
Loading

0 comments on commit 6ba46f5

Please sign in to comment.