Skip to content

Commit

Permalink
feat(context): support binding config and @inject.config
Browse files Browse the repository at this point in the history
1. Add context.configure to configure bindings
2. Add context.getConfig to look up configuration for a binding
3. Add @config.* to receive injection of configuration
  - @config
  - @config.getter
  - @config.view
4. Add docs for context/binding configuration
  • Loading branch information
raymondfeng committed May 23, 2019
1 parent 868699c commit a392852
Show file tree
Hide file tree
Showing 13 changed files with 1,184 additions and 7 deletions.
132 changes: 132 additions & 0 deletions docs/site/Context.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,3 +531,135 @@ class MyController {
}
}
```

## Configuration by convention

To allow bound items in the context to be configured, we introduce some
conventions and corresponding APIs to make it simple and consistent.

We treat configurations for bound items in the context as dependencies, which
can be resolved and injected in the same way of other forms of dependencies. For
example, the `RestServer` can be configured with `RestServerConfig`.

Let's first look at an example:

```ts
export class RestServer {
constructor(
@inject(CoreBindings.APPLICATION_INSTANCE) app: Application,
@inject(RestBindings.CONFIG, {optional: true})
config: RestServerConfig = {},
) {
// ...
}
// ...
}
```

The configuration (`RestServerConfig`) itself is a binding
(`RestBindings.CONFIG`) in the context. It's independent of the binding for
`RestServer`. The caveat is that we need to maintain a different binding key for
the configuration. Referencing a hard-coded key for the configuration also makes
it impossible to have more than one instances of the `RestServer` to be
configured with different options, such as `protocol` or `port`.

To solve these problems, we introduce an accompanying binding for an item that
expects configuration. For example:

- `servers.RestServer.server1`: RestServer
- `servers.RestServer.server1:$config`: RestServerConfig

- `servers.RestServer.server2`: RestServer
- `servers.RestServer.server2:$config`: RestServerConfig

The following APIs are available to enforce/leverage this convention:

1. `ctx.configure('servers.RestServer.server1')` => Binding for the
configuration
2. `Binding.configure('servers.RestServer.server1')` => Creates a accompanying
binding for the configuration of the target binding
3. `ctx.getConfig('servers.RestServer.server1')` => Get configuration
4. `@config` to inject corresponding configuration
5. `@config.getter` to inject a getter function for corresponding configuration
6. `@config.view` to inject a `ContextView` for corresponding configuration

The `RestServer` can now use `@config` to inject configuration for the current
binding of `RestServer`.

```ts
export class RestServer {
constructor(
@inject(CoreBindings.APPLICATION_INSTANCE) app: Application,
@config()
config: RestServerConfig = {},
) {
// ...
}
// ...
}
```

The `@config.*` decorators can take an optional `configPath` parameter to allow
the configuration value to be a deep property of the bound value. For example,
`@config('port')` injects `RestServerConfig.port` to the target.

```ts
export class MyRestServer {
constructor(
@config('host')
host: string,
@config('port')
port: number,
) {
// ...
}
// ...
}
```

Now we can use `context.configure()` to provide configuration for target
bindings.

```ts
const appCtx = new Context();
appCtx.bind('servers.RestServer.server1').toClass(RestServer);
appCtx
.configure('servers.RestServer.server1')
.to({protocol: 'https', port: 473});

appCtx.bind('servers.RestServer.server2').toClass(RestServer);
appCtx.configure('servers.RestServer.server2').to({protocol: 'http', port: 80});
```

Please note that `@config.*` is different from `@inject.*` as `@config.*`
injects configuration based on the current binding where `@config.*` is applied.
No hard-coded binding key is needed. The `@config.*` also allows the same class
such as `RestServer` to be bound to different keys with different configurations
as illustrated in the code snippet above.

All configuration accessors or injectors (such as `ctx.getConfig`, `@config`) by
default treat the configuration binding as optional, i.e. return `undefined` if
no configuration was bound. This is different from `ctx.get` and `@inject` APIs,
which require the binding to exist and throw an error when the requested binding
is not found. The behavior can be customized via `ResolutionOptions.optional`
flag.

### Allow configuration to be changed dynamically

Some configurations are designed to be changeable dynamically, for example, the
logging level for an application. To allow that, we introduce `@config.getter`
to always fetch the latest value of the configuration.

```ts
export class Logger {
@config.getter()
private getLevel: Getter<string>;

async log(level: string, message: string) {
const currentLevel = await getLevel();
if (shouldLog(level, currentLevel)) {
// ...
}
}
}
```
199 changes: 199 additions & 0 deletions packages/context/src/__tests__/acceptance/binding-config.acceptance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// 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 {config, configBindingKeyFor, Context, ContextView, Getter} from '../..';

describe('Context bindings - injecting configuration for bound artifacts', () => {
let ctx: Context;

beforeEach(givenContext);

it('binds configuration independent of binding', async () => {
// 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<RestServer>('servers.rest.server1');

expect(server1.configObj).to.eql({port: 3000});
});

it('configures an artifact with a dynamic source', async () => {
// 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<RestServer>('servers.rest.server1');
expect(server1.configObj).to.eql({port: 3000});
});

it('configures an artifact with alias', async () => {
// Configure rest server 1 to reference `rest` property of the application
// configuration
ctx
.configure('servers.rest.server1')
.toAlias(configBindingKeyFor('application', 'rest'));

// Configure the application
ctx.configure('application').to({rest: {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<RestServer>('servers.rest.server1');
expect(server1.configObj).to.eql({port: 3000});
});

it('allows configPath for injection', async () => {
class RestServerWithPort {
constructor(@config('port') public port: number) {}
}

// Bind configuration
ctx
.configure('servers.rest.server1')
.toDynamicValue(() => Promise.resolve({port: 3000}));

// Bind RestServer
ctx.bind('servers.rest.server1').toClass(RestServerWithPort);

// Resolve an instance of RestServer
// Expect server1.config to be `{port: 3000}
const server1 = await ctx.get<RestServerWithPort>('servers.rest.server1');
expect(server1.port).to.eql(3000);
});

const LOGGER_KEY = 'loggers.Logger';
it('injects a getter function to access config', async () => {
class Logger {
constructor(
@config.getter()
public configGetter: Getter<LoggerConfig | undefined>,
) {}
}

// Bind logger configuration
ctx.configure(LOGGER_KEY).to({level: 'INFO'});

// Bind Logger
ctx.bind(LOGGER_KEY).toClass(Logger);

const logger = await ctx.get<Logger>(LOGGER_KEY);
let configObj = await logger.configGetter();
expect(configObj).to.eql({level: 'INFO'});

// Update logger configuration
const configBinding = ctx.configure(LOGGER_KEY).to({level: 'DEBUG'});

configObj = await logger.configGetter();
expect(configObj).to.eql({level: 'DEBUG'});

// Now remove the logger configuration
ctx.unbind(configBinding.key);

// configGetter returns undefined as config is optional by default
configObj = await logger.configGetter();
expect(configObj).to.be.undefined();
});

it('injects a view to access config', async () => {
class Logger {
constructor(
@config.view()
public configView: ContextView<LoggerConfig>,
) {}
}

// Bind logger configuration
ctx.configure(LOGGER_KEY).to({level: 'INFO'});

// Bind Logger
ctx.bind(LOGGER_KEY).toClass(Logger);

const logger = await ctx.get<Logger>(LOGGER_KEY);
let configObj = await logger.configView.singleValue();
expect(configObj).to.eql({level: 'INFO'});

// Update logger configuration
ctx.configure(LOGGER_KEY).to({level: 'DEBUG'});

configObj = await logger.configView.singleValue();
expect(configObj).to.eql({level: 'DEBUG'});
});

it('injects a view to access config with path', async () => {
class Logger {
constructor(
@config.view('level')
public configView: ContextView<string>,
) {}
}

// Bind logger configuration
ctx.configure(LOGGER_KEY).to({level: 'INFO'});

// Bind Logger
ctx.bind(LOGGER_KEY).toClass(Logger);

const logger = await ctx.get<Logger>(LOGGER_KEY);
let level = await logger.configView.singleValue();
expect(level).to.eql('INFO');

// Update logger configuration
ctx.configure(LOGGER_KEY).to({level: 'DEBUG'});

level = await logger.configView.singleValue();
expect(level).to.eql('DEBUG');
});

it('rejects injection of config view if the target type is not ContextView', async () => {
class Logger {
constructor(
@config.view()
public configView: object,
) {}
}

// Bind logger configuration
ctx.configure(LOGGER_KEY).to({level: 'INFO'});

// Bind Logger
ctx.bind(LOGGER_KEY).toClass(Logger);

await expect(ctx.get<Logger>(LOGGER_KEY)).to.be.rejectedWith(
'The type of Logger.constructor[0] (Object) is not ContextView',
);
});

function givenContext() {
ctx = new Context();
}

interface RestServerConfig {
host?: string;
port?: number;
}

class RestServer {
constructor(@config() public configObj: RestServerConfig) {}
}

interface LoggerConfig {
level: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';
}
});
Loading

0 comments on commit a392852

Please sign in to comment.