Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(context): add support for binding configuration and injection #2259

Merged
merged 3 commits into from
May 23, 2019

Conversation

raymondfeng
Copy link
Contributor

@raymondfeng raymondfeng commented Jan 16, 2019

Reactivation of #983 based on requirements illustrated by #2249.

  1. Add context.configure() to configure bindings
  2. Add context.getConfig/getConfigSync() to look up configuration for a binding
  3. Add @config, @config.getter, and @config.view to receive injection of configuration

Checklist

  • npm test passes on your machine
  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style guide
  • API Documentation in code was updated
  • Documentation in /docs/site was updated
  • Affected artifact templates in packages/cli were updated
  • Affected example projects in examples/* were updated

@raymondfeng raymondfeng force-pushed the simpler-binding-options branch 4 times, most recently from e3aa58a to a2a9c1a Compare January 17, 2019 18:02
@bajtos
Copy link
Member

bajtos commented Jan 18, 2019

@raymondfeng In #983, we had a long discussion about different aspects and user requirements for configuration system. How is this pull request different from #983? Can you also explain which comments you have addressed (and how), and which parts you decided to leave unaddressed?

@raymondfeng
Copy link
Contributor Author

I removed the magic to resolve config values following the namespace hierarchy. The implementation now only uses a naming convention to find the bound config for a given binding key.

@bajtos
Copy link
Member

bajtos commented Jan 29, 2019

I removed the magic to resolve config values following the namespace hierarchy. The implementation now only uses a naming convention to find the bound config for a given binding key

Cool, that should make the review easier.

How do you imagine the bigger picture and the end-to-end usage of this new feature?

Few stories to consider:

  • As an LB4 app developer, I'd like to have a single JSON file to define all configuration entries. How are we going to map from a JSON format to the API proposed in this pull request? Maybe you have a different approach in mind?

  • As an extension developer, I may want my extension to provide default configuration options that can be later overridden by app-level config provided by the user.

  • As an extension developer, I may want to contribute configuration for artifacts bound by a different extension. A contrived example: @loopback/rest-explorer may want to override certain configuration of @loopback/rest.

  • As an extension developer, I want to read configuration of bindings bound by a different extension. For example, @loopback/rest-explorer needs to find out the configuration controlling /openapi.json URL.

I think it's important to start from the outside (work top-to-bottom) and think about the intended end-to-end user experience first.

It may be best to start with a spike that will allow you to iterate faster, make it easier for all of us to keep the discussion at high level & focused on the intended user experience and internal design.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We really need to answer the question of how app users are going to provide the configuration before we go deeper into implementation details.

packages/context/src/resolution-session.ts Outdated Show resolved Hide resolved
@raymondfeng raymondfeng force-pushed the simpler-binding-options branch 2 times, most recently from ca95224 to e4ed5f1 Compare February 12, 2019 17:30
@raymondfeng
Copy link
Contributor Author

raymondfeng commented Feb 12, 2019

  • As an LB4 app developer, I'd like to have a single JSON file to define all configuration entries. How are we going to map from a JSON format to the API proposed in this pull request? Maybe you have a different approach in mind?
  • As an extension developer, I may want my extension to provide default configuration options that can be later overridden by app-level config provided by the user.
  • As an extension developer, I may want to contribute configuration for artifacts bound by a different extension. A contrived example: @loopback/rest-explorer may want to override certain configuration of @loopback/rest.
  • As an extension developer, I want to read configuration of bindings bound by a different extension. For example, @loopback/rest-explorer needs to find out the configuration controlling /openapi.json URL.

I agree with the use cases but they are out of the scope of this PR.

The purpose of this PR should be very simple (precondition: there are other tiers that load/populate the configuration into the context chain) based on requirements from #2249:

  1. Define the convention to configure a bound entry in the context (the configuration should be decoupled from the target, both of them can be bound independently). This allows us to leverage our IoC/DI container for configuration resolution.

  2. Define sugar APIs on Context to provide/consume configuration for a given binding (configure() and getConfig())

  3. Define a @inject.config decorator to inject corresponding configuration into a class without the need to know the binding key.

@bajtos
Copy link
Member

bajtos commented Feb 15, 2019

I agree with the use cases but they are out of the scope of this PR.

The purpose of this PR should be very simple (precondition: there are other tiers that load/populate the configuration into the context chain) based on requirements from #2249:

  1. Define the convention to configure a bound entry in the context (the configuration should be decoupled from the target, both of them can be bound independently). This allows us to leverage our IoC/DI container for configuration resolution.

  2. Define sugar APIs on Context to provide/consume configuration for a given binding (configure() and getConfig())

  3. Define a @inject.config decorator to inject corresponding configuration into a class without the need to know the binding key.

Sure, I agree it's good to work in small increments and thus keep the scope of this pull request small. My concern is that you are defining foundational building blocks for the app/extension configuration, but we don't know if they will be able to support our requirements. Once this new feature is published as part of our public API, it will become difficult to make breaking changes.

I am not asking you to create a full implementation for the use cases I have described above, a high-level description of the indented solution is good enough.

For example:

As an LB4 app developer, I'd like to have a single JSON file to define all configuration entries. How are we going to map from a JSON format to the API proposed in this pull request?

We can define a convention where the binding key is used as the key in the JSON file too. E.g.

{
  "rest.port": 3000,
  "rest.host": "localhost",
}

Is it just me who see a similarity with Java property files? Maybe we should use nesting instead of dot-separated property names? Anyhow, that's just an implementation detail we can figure out later.

As an extension developer, I may want my extension to provide default configuration options that can be later overridden by app-level config provided by the user.

Can we rely on the order in which the configuration bindings are created?

class MyApp {
  constructor() {
    app.component(RestComponent); // defines the default configuration
   
    // custom configuration - overrides the config values bound by components
  }
}

Would this become a pain point and we will need to look for different ways how to allow extensions to provide default values?

As an extension developer, I may want to contribute configuration for artifacts bound by a different extension. A contrived example: @loopback/rest-explorer may want to override certain configuration of @loopback/rest.

And here it becomes tricky. If we rely on the order in which the configuration was defined, then we need to ensure that @loopback/rest component is mounted first, @loopback/rest-explorer second.

Thoughts?

As an extension developer, I want to read configuration of bindings bound by a different extension. For example, @loopback/rest-explorer needs to find out the configuration controlling /openapi.json URL.)

I think this is a similar problem. If we rely on the order in which components are registered, then the default configuration provided by @loobpack/rest will be available at the time when @loobpack/rest-explorer is being mounted. However, this will not take into account configuration supplied by the application for @loopback/rest after @loopback/rest-explorer was mounted. I think that means we need to use a getter function instead of obtaining the configured value directly, similarly to how we are using @inject.getter for current authenticated user.

Now we have a question very relevant to your pull request: do we want to support both flavors of config injection (value vs getter)?

I am arguing that we should always inject a getter function to support configuration changes made after the config was resolved.

For example, in your greeter extension example shown in #2249, GreeterExtensionPoint is bound in SINGLETON scope. Once the binding is resolved and the singleton is created, there is no way how to update it when the configuration changes. The following code will not work:

app.configure('greeter-extension-point').to({color: false});
(await app.get('greeter-extension-point')).greet('en', 'monochrome');
app.configure('greeter-extension-point').to({color: true});
(await app.get('greeter-extension-point')).greet('en', 'rainbow');
// ^^ this does not pick up the new setting!!!

If we change @inject.config to always resolve to a getter function, we force all configuration consumers to support config updates from the start.

Let's discuss.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few comments on implementation details, I haven't reviewed the tests yet.

packages/context/src/inject.ts Outdated Show resolved Hide resolved
@@ -331,21 +395,28 @@ export function describeInjectedArguments(
return meta || [];
}

function resolveByTag(
function resolveBindings(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly here, I don't see how is this change relevant to binding configuration. Can you revert it please?

packages/context/src/inject.ts Outdated Show resolved Hide resolved
packages/context/src/context.ts Outdated Show resolved Hide resolved
);
}

private getConfigResolver() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find it ugly to have both configResolver as a property and getConfigResolver as a getter function. Have you considered initializing this.configResolver right in the constructor?

configPath,
);

const options: ResolutionOptions = Object.assign(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about the following?

const options: ResolutionOptions = {optional: true,  ...resolutionOptions};


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

1. ctx.configure('servers.RestServer.server1') => Binding for the configuration
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am concerned that in order to contribute configuration, the code needs a reference to the target Context instance. It creates a coupling that makes it difficult for extensions to contribute configuration bindings.

Can you add API for creating standalone configuration bindings (no context instance required)? Perhaps a short-cut leveraging configBindingKeyFor helper?

For example, we can add a static method Binding.configure that will create a new Binding instance with the correct configuration key:

// in a component, export the following binding:
class MyComponent {
  bindings: [
    // some other bindings

    Binding.configure('servers.RestServer.server1').to({port: 0})
  ],
});

// in mountComponent - already in place
if (component.bindings) {
  for (const binding of component.bindings) {
    app.add(binding);
  }
}

I am not sure if Binding.configure is the right name, feel free to pick a better one!

I am also fine to leave out such new helper out of scope of this pull request, as long as the documentation explains how to create standalone Binding instances for configuration (e.g. using configBindingKeyFor)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. I'll add Binding.configure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. I'll add Binding.configure

Please describe the new API in the documentation too. (E.g. add it to the list here.)

the configuration value to be a deep property of the bound value. For example,
`@config('port')` injects `RestServerConfig.port` to the target.

```ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

The example is missing code showing how RestServer is calling @config('port').

// configuration
ctx
.configure('servers.rest.server1')
.toAlias(configBindingKeyFor('application', 'rest'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please note @inject does not leverage session.currentBinding.

This is very important, please describe the difference in the documentation.

packages/context/src/binding-key.ts Show resolved Hide resolved
packages/context/src/binding-key.ts Outdated Show resolved Hide resolved
packages/context/src/context-view.ts Outdated Show resolved Hide resolved
packages/context/src/context.ts Outdated Show resolved Hide resolved
packages/context/src/context.ts Show resolved Hide resolved
@raymondfeng
Copy link
Contributor Author

Is it a good idea to introduce the sync version? I mean what are the use cases where await ctx.getConfig() cannot be used?

I was trying to make it symmetric with get/getSync for bindings.

@raymondfeng raymondfeng force-pushed the simpler-binding-options branch 3 times, most recently from 7eb1add to 48fca08 Compare May 14, 2019 19:28
Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost there!

Besides the few remaining comments below, please structure the commit history in such way that there are two standalone commits for the addition of ContextView.prototype.singleValue() and Context.prototype.getOwnerContext() APIs. I'd like to see dedicated CHANGELOG entries.


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

1. ctx.configure('servers.RestServer.server1') => Binding for the configuration
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. I'll add Binding.configure

Please describe the new API in the documentation too. (E.g. add it to the list here.)

2. ctx.getConfig('servers.RestServer.server1') => Get configuration
3. `@config` to inject corresponding configuration
4. `@config.getter` to inject a getter function for corresponding configuration
5. `@config.view` to inject a `ContextView` for corresponding configuration
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain that:

All configuration accessors (ctx.getConfig, @config*) consider the configuration binding as optional, i.e. return undefined if no configuration was bound. This is different from ctx.get and @inject APIs, which treat bindings as required and throw an error when the requested binding is not found.

I see that you have documented this binding in API docs. Can you please describe it in docs/site/Context.md too? In my experience, most people are not going to read our API docs.

packages/context/src/keys.ts Outdated Show resolved Hide resolved
packages/context/src/context-view.ts Outdated Show resolved Hide resolved
Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reposting two older comments that seem to get lost.

packages/context/src/context.ts Show resolved Hide resolved
packages/context/src/binding-config.ts Show resolved Hide resolved
@raymondfeng raymondfeng force-pushed the simpler-binding-options branch 2 times, most recently from b5019a5 to bc3a0f3 Compare May 21, 2019 14:59
@raymondfeng
Copy link
Contributor Author

Besides the few remaining comments below, please structure the commit history in such way that there are two standalone commits for the addition of ContextView.prototype.singleValue() and Context.prototype.getOwnerContext() APIs. I'd like to see dedicated CHANGELOG entries.

I'll do during squashing.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Please get @hacksparrow's approval before landing.

docs/site/Context.md Outdated Show resolved Hide resolved
packages/context/src/context.ts Show resolved Hide resolved
@raymondfeng raymondfeng force-pushed the simpler-binding-options branch 3 times, most recently from 7107ed0 to 28db47c Compare May 21, 2019 19:45
Copy link
Contributor

@b-admike b-admike left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some questions, but overall LGTM

expect(store.settings).to.be.undefined();
});

it('injects config from config binding', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's pretty similar. In this case, only one property x is used.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok was wondering if we could just reduce it to one test, but ok with both.

it('supports singleValue() if only one value exist', async () => {
server.unbind('bar');
expect(await taggedAsFoo.singleValue()).to.eql('FOO');
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 (ignore if feedback applied).

@@ -533,6 +533,21 @@ describe('Context', () => {
});
});

describe('getOwnerContext', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 (ignore if feedback applied).

@@ -45,6 +45,17 @@ describe('ContextView', () => {
expect(await taggedAsFoo.asGetter()()).to.eql(['BAR', 'FOO']);
});

it('reports error on singleValue() if multiple values exist', async () => {
return expect(taggedAsFoo.singleValue()).to.be.rejectedWith(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious why we wanted to have the singleValue() method in the first place?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's for dynamic configuration - @config.view. See acceptance tests for refs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, but we can still use values() for that too no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we can still use values(). singleValue() is just a helper.

packages/context/src/inject-config.ts Show resolved Hide resolved
packages/context/src/inject-config.ts Outdated Show resolved Hide resolved
if (isPromiseLike(valueOrPromise)) {
const prop = configPath ? ` property ${configPath}` : '';
throw new Error(
`Cannot get config${prop} for ${key} synchronously: the value is a promise`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: should we point devs to use getConfig instead?

@raymondfeng raymondfeng force-pushed the simpler-binding-options branch 2 times, most recently from 735685c to 49805dc Compare May 22, 2019 22:13
Copy link
Contributor

@b-admike b-admike left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏

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
@raymondfeng raymondfeng merged commit a392852 into master May 23, 2019
@raymondfeng raymondfeng deleted the simpler-binding-options branch May 23, 2019 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature IoC/Context @loopback/context: Dependency Injection, Inversion of Control
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants