Skip to content

Commit

Permalink
feat(context): typed binding keys
Browse files Browse the repository at this point in the history
Modify Context, Binding, and related methods to allow developers
to add a type information to individual binding keys. These binding
keys will allow the compiler to verify that a correct value is passed
to binding setters (`.to()`, `.toClass()`, etc.) and automatically
infer the type of the value returned by `ctx.get()` and `ctx.getSync()`.

Bring the documentation in sync with the actual implementation in code.
Rename `AuthenticationProvider` to `AuthenticateActionProvider`
  • Loading branch information
bajtos committed Apr 9, 2018
1 parent 0eff2d2 commit 685195c
Show file tree
Hide file tree
Showing 37 changed files with 705 additions and 352 deletions.
77 changes: 73 additions & 4 deletions docs/site/Context.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,14 @@ as an example, we can create custom sequences that:
Let's see this in action:

```ts
import {DefaultSequence, ParsedRequest, ServerResponse} from '@loopback/rest';
import {DefaultSequence, RestBindings} from '@loopback/rest';

class MySequence extends DefaultSequence {
async handle(request: ParsedRequest, response: ServerResponse) {
// we provide these value for convenience (taken from the Context)
// but they are still available in the sequence/request context
const req = await this.ctx.get<ParsedRequest>('rest.http.request');
const res = await this.ctx.get<ServerResponse>('rest.http.response');
const req = await this.ctx.get(RestBindings.Http.REQUEST);
const res = await this.ctx.get(RestBindings.Http.RESPONSE);
this.send(res, `hello ${req.query.name}`);
}
}
Expand All @@ -141,7 +141,7 @@ Items in the Context are indexed via a key and bound to a `ContextValue`. A
`ContextKey` is simply a string value and is used to look up whatever you store
along with the key. For example:

```js
```ts
// app level
const app = new Application();
app.bind('hello').to('world'); // ContextKey='hello', ContextValue='world'
Expand All @@ -159,6 +159,68 @@ _binding_. Sequence-level bindings work the same way.
For a list of the available functions you can use for binding, visit the
[Context API Docs](http://apidocs.loopback.io/@loopback%2fcontext).

### Encoding value types in binding keys

Consider the example from the previous section:

```ts
app.bind('hello').to('world');
console.log(app.getSync<string>('hello'));
```

The code obtaining the bound value is explicitly specifying the type of this
value. Such solution is far from ideal:

1. Consumers have to know the exact name of the type that's associated with each binding key and also where to import it from.
2. Consumers must explicitly provide this type to the compiler when calling ctx.get in order to benefit from compile-type checks.
3. It's easy to accidentally provide a wrong type when retrieving the value and get a false sense of security.

The third point is important because the bugs can be subtle and difficult to spot.

Consider the following REST binding key:

```ts
export const HOST = 'rest.host';
```

The binding key does not provide any indication that `undefined` is a valid
value for the HOST binding. Without that knowledge, one could write
the following code and get it accepted by TypeScript compiler, only to learn
at runtime that HOST may be also undefined and the code needs to find the
server's host name using a different way.:

```ts
const resolve = promisify(dns.resolve);

const host = await ctx.get<string>(RestBindings.HOST);
const records = await resolve(host);
// etc.
```

To address this problem, LoopBack provides a templated wrapper class allowing
binding keys to encode the value type too. The `HOST` binding described above
can be defined as follows:

```ts
export const HOST = new BindingKey<string | undefined>('rest.host');
```

Context methods like `.get()` and `.getSync()` understand this wrapper
and use the value type from the binding key to describe the type of the value
they are returning themselves. This allows binding consumers to omit
the expected value type when calling `.get()` and `.getSync()`.

When we rewrite the failing snippet resolving HOST names to use the new API,
the TypeScript compiler immediatelly tells us about the problem:

```ts
const host = await ctx.get(RestBindings.HOST);
const records = await resolve(host);
// Compiler complains:
// - cannot convert string | undefined to string
// - cannot convert undefined to string
```

## Dependency injection

- Many configs are adding to the Context during app instantiation/boot time by
Expand Down Expand Up @@ -194,6 +256,13 @@ constructor. Context allows LoopBack to give you the necessary information at
runtime even if you do not know the value when writing up the Controller. The
above will print `Hello John` at run time.

{% include note.html content="
`@inject` decorator is not able to leverage the value-type information
associated with a binding key yet, therefore the TypeScript compiler will not
check that the injection target (e.g. a constructor argument) was declared
with a type that the bound value can be assigned to.
"}

Please refer to [Dependency injection](Dependency-injection.md) for further
details.

Expand Down
152 changes: 92 additions & 60 deletions docs/site/Creating-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,27 @@ export class MyComponent implements Component {
}
```

We recommend to component authors to use
[Typed binding keys](./Context.md#encoding-value-types-in-binding-keys)
instead of string keys and to export an object (a TypeScript namespace)
providing constants for all binding keys defined by the component.

```ts
import {MyValue, MyValueProvider} from './providers/my-value-provider';

export namespace MyComponentKeys {
export const MY_VALUE = new BindingKey<MyValue>('my-component.my-value');
}

export class MyComponent implements Component {
constructor() {
this.providers = {
[MyComponentKeys.MY_VALUE.key]: MyValueProvider,
};
}
}
```

### Accessing values from Providers

Applications can use `@inject` decorators to access the value of an exported
Expand Down Expand Up @@ -121,13 +142,12 @@ resolve them automatically.

```ts
import {Provider} from '@loopback/context';
import {RestBindings} from '@loopback/rest';
import {ServerRequest} from 'http';
import {ParsedRequest, RestBindings} from '@loopback/rest';
const uuid = require('uuid/v4');

class CorrelationIdProvider implements Provider<string> {
constructor(
@inject(RestBindings.Http.REQUEST) private request: ServerRequest,
@inject(RestBindings.Http.REQUEST) private request: ParsedRequest,
) {}

value() {
Expand All @@ -147,67 +167,78 @@ The idiomatic solution has two parts:

1. The component should define and bind a new [Sequence action](Sequence.md#actions), for example `authentication.actions.authenticate`:

```ts
import {Component} from '@loopback/core';
```ts
import {Component} from '@loopback/core';

class AuthenticationComponent implements Component {
constructor() {
this.providers = {
'authentication.actions.authenticate': AuthenticateActionProvider,
};
}
}
```
export namespace AuthenticationBindings {
export const AUTH_ACTION = BindingKey.create<AuthenticateFn>(
'authentication.actions.authenticate',
);
}

A sequence action is typically implemented as an `action()` method in the provider.
class AuthenticationComponent implements Component {
constructor() {
this.providers = {
[AuthenticationBindings.AUTH_ACTION.key]: AuthenticateActionProvider,
};
}
}
```

```ts
class AuthenticateActionProvider implements Provider<AuthenticateFn> {
// Provider interface
value() {
return request => this.action(request);
}
A sequence action is typically implemented as an `action()` method
in the provider.

// The sequence action
action(request): UserProfile | undefined {
// authenticate the user
}
}
```
```ts
class AuthenticateActionProvider implements Provider<AuthenticateFn> {
// Provider interface
value() {
return request => this.action(request);
}
It may be tempting to put action implementation directly inside the anonymous arrow function returned by provider's `value()` method. We consider that as a bad practice though, because when an error occurs, the stack trace will contain only an anonymous function that makes it more difficult to link the entry with the sequence action.
// The sequence action
action(request): UserProfile | undefined {
// authenticate the user
}
}
```

It may be tempting to put action implementation directly inside
the anonymous arrow function returned by provider's `value()` method.
We consider that as a bad practice though, because when an error occurs,
the stack trace will contain only an anonymous function that makes it more
difficult to link the entry with the sequence action.

2. The application should use a custom `Sequence` class which calls this new sequence action in an appropriate place.

```ts
class AppSequence implements SequenceHandler {
constructor(
@inject(RestBindings.Http.CONTEXT) protected ctx: Context,
@inject(RestBindings.SequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
@inject(RestBindings.SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams,
@inject(RestBindings.SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
@inject(RestBindings.SequenceActions.SEND) public send: Send,
@inject(RestBindings.SequenceActions.REJECT) public reject: Reject,
// Inject the new action here:
@inject('authentication.actions.authenticate') protected authenticate: AuthenticateFn
) {}

async handle(req: ParsedRequest, res: ServerResponse) {
try {
const route = this.findRoute(req);

// Invoke the new action:
const user = await this.authenticate(req);

const args = await parseOperationArgs(req, route);
const result = await this.invoke(route, args);
this.send(res, result);
} catch (err) {
this.reject(res, req, err);
}
}
}
```
```ts
class AppSequence implements SequenceHandler {
constructor(
@inject(RestBindings.Http.CONTEXT) protected ctx: Context,
@inject(RestBindings.SequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
@inject(RestBindings.SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams,
@inject(RestBindings.SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
@inject(RestBindings.SequenceActions.SEND) public send: Send,
@inject(RestBindings.SequenceActions.REJECT) public reject: Reject,
// Inject the new action here:
@inject('authentication.actions.authenticate') protected authenticate: AuthenticateFn
) {}

async handle(req: ParsedRequest, res: ServerResponse) {
try {
const route = this.findRoute(req);

// Invoke the new action:
const user = await this.authenticate(req);

const args = await parseOperationArgs(req, route);
const result = await this.invoke(route, args);
this.send(res, result);
} catch (err) {
this.reject(res, req, err);
}
}
}
```

### Accessing Elements contributed by other Sequence Actions

Expand All @@ -223,7 +254,7 @@ of the actual value. This allows you to defer resolution of your dependency only
until the sequence action contributing this value has already finished.

```ts
export class AuthenticationProvider implements Provider<AuthenticateFn> {
export class AuthenticateActionProvider implements Provider<AuthenticateFn> {
constructor(
@inject.getter(BindingKeys.Authentication.STRATEGY)
readonly getStrategy
Expand All @@ -233,7 +264,7 @@ export class AuthenticationProvider implements Provider<AuthenticateFn> {
return request => this.action(request);
}
async action(request): UserProfile | undefined {
async action(request): Promise<UserProfile | undefined> {
const strategy = await this.getStrategy();
// ...
}
Expand All @@ -246,7 +277,7 @@ Use `@inject.setter` decorator to obtain a setter function that can be used to
contribute new Elements to the request context.

```ts
export class AuthenticationProvider implements Provider<AuthenticateFn> {
export class AuthenticateActionProvider implements Provider<AuthenticateFn> {
constructor(
@inject.getter(BindingKeys.Authentication.STRATEGY) readonly getStrategy,
@inject.setter(BindingKeys.Authentication.CURRENT_USER)
Expand All @@ -259,7 +290,8 @@ export class AuthenticationProvider implements Provider<AuthenticateFn> {
async action(request): UserProfile | undefined {
const strategy = await this.getStrategy();
const user = this.setCurrentUser(user); // ... authenticate
// (authenticate the request using the obtained strategy)
const user = this.setCurrentUser(user);
return user;
}
}
Expand Down
Loading

0 comments on commit 685195c

Please sign in to comment.