Skip to content

Commit

Permalink
test(core): Add acceptance 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 18, 2017
1 parent 568c6e2 commit 96b8755
Show file tree
Hide file tree
Showing 2 changed files with 293 additions and 0 deletions.
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`?
215 changes: 215 additions & 0 deletions packages/core/test/acceptance/extension-point.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// 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';

// tslint:disable:no-any
/**
* Interface for the extension point configuration
*/
export interface ExtensionPointConfig {
extensions: {
[extensionName: string]: any;
};
extensionPoint: {
[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
*/
getExtensionBindings(): Binding[] {
return this.context.find(`${this.name}.*`);
}

/**
* Get a map of extension bindings by the keys
*/
getExtensionBindingMap(): {[name: string]: Binding} {
const extensions: {[name: string]: Binding} = {};
const bindings = this.context.find(`${this.name}.*`);
bindings.forEach(binding => {
extensions[binding.key] = binding;
});
return extensions;
}

/**
* Look up an extension binding by name
* @param name Name of the extension
*/
getExtensionBinding(name: string): Binding {
const key = `${this.name}.${name}`;
const extensions = this.getExtensionBindingMap();
return extensions[key];
}

/**
* Get an instance of an extension by name
* @param name Name of the extension
*/
async getExtension(name: string): Promise<EXT> {
const binding = this.getExtensionBinding(name);
// Create a child context to bind `config`
const extensionContext = new Context(this.context);
extensionContext.bind('config').to(this.configuration.extensions[name]);
return await binding.getValue(extensionContext);
}
}

describe('Extension point', () => {
let ctx: Context;
beforeEach('given a context', createContext);

interface AuthenticationStrategy {
authenticate(credentials: any): Promise<boolean>;
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<AuthenticationStrategy> {
constructor(
@inject('$context') context: Context,
@inject('authentication.config') config: ExtensionPointConfig,
) {
super('authentication.strategies', context, config);
}

async authenticate(strategy: string, credentials: any): Promise<boolean> {
const ext: AuthenticationStrategy = await this.getExtension(strategy);
return ext.authenticate(credentials);
}
}

it('lists all extensions', async () => {
const authManager = await ctx.get('authentication.manager');
const extBindings = authManager.getExtensionBindings();
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);
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('authentication-strategy');
ctx
.bind('authentication.strategies.ldap')
.toClass(LDAPStrategy)
.inScope(BindingScope.SINGLETON)
.tag('authentication-strategy');
ctx
.bind('authentication.strategies.oauth2')
.toClass(OAuth2Strategy)
.inScope(BindingScope.SINGLETON)
.tag('authentication-strategy');
}
});

0 comments on commit 96b8755

Please sign in to comment.