Skip to content

Commit 685195c

Browse files
committed
feat(context): typed binding keys
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`
1 parent 0eff2d2 commit 685195c

37 files changed

+705
-352
lines changed

docs/site/Context.md

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,14 @@ as an example, we can create custom sequences that:
118118
Let's see this in action:
119119

120120
```ts
121-
import {DefaultSequence, ParsedRequest, ServerResponse} from '@loopback/rest';
121+
import {DefaultSequence, RestBindings} from '@loopback/rest';
122122

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

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

162+
### Encoding value types in binding keys
163+
164+
Consider the example from the previous section:
165+
166+
```ts
167+
app.bind('hello').to('world');
168+
console.log(app.getSync<string>('hello'));
169+
```
170+
171+
The code obtaining the bound value is explicitly specifying the type of this
172+
value. Such solution is far from ideal:
173+
174+
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.
175+
2. Consumers must explicitly provide this type to the compiler when calling ctx.get in order to benefit from compile-type checks.
176+
3. It's easy to accidentally provide a wrong type when retrieving the value and get a false sense of security.
177+
178+
The third point is important because the bugs can be subtle and difficult to spot.
179+
180+
Consider the following REST binding key:
181+
182+
```ts
183+
export const HOST = 'rest.host';
184+
```
185+
186+
The binding key does not provide any indication that `undefined` is a valid
187+
value for the HOST binding. Without that knowledge, one could write
188+
the following code and get it accepted by TypeScript compiler, only to learn
189+
at runtime that HOST may be also undefined and the code needs to find the
190+
server's host name using a different way.:
191+
192+
```ts
193+
const resolve = promisify(dns.resolve);
194+
195+
const host = await ctx.get<string>(RestBindings.HOST);
196+
const records = await resolve(host);
197+
// etc.
198+
```
199+
200+
To address this problem, LoopBack provides a templated wrapper class allowing
201+
binding keys to encode the value type too. The `HOST` binding described above
202+
can be defined as follows:
203+
204+
```ts
205+
export const HOST = new BindingKey<string | undefined>('rest.host');
206+
```
207+
208+
Context methods like `.get()` and `.getSync()` understand this wrapper
209+
and use the value type from the binding key to describe the type of the value
210+
they are returning themselves. This allows binding consumers to omit
211+
the expected value type when calling `.get()` and `.getSync()`.
212+
213+
When we rewrite the failing snippet resolving HOST names to use the new API,
214+
the TypeScript compiler immediatelly tells us about the problem:
215+
216+
```ts
217+
const host = await ctx.get(RestBindings.HOST);
218+
const records = await resolve(host);
219+
// Compiler complains:
220+
// - cannot convert string | undefined to string
221+
// - cannot convert undefined to string
222+
```
223+
162224
## Dependency injection
163225

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

259+
{% include note.html content="
260+
`@inject` decorator is not able to leverage the value-type information
261+
associated with a binding key yet, therefore the TypeScript compiler will not
262+
check that the injection target (e.g. a constructor argument) was declared
263+
with a type that the bound value can be assigned to.
264+
"}
265+
197266
Please refer to [Dependency injection](Dependency-injection.md) for further
198267
details.
199268

docs/site/Creating-components.md

Lines changed: 92 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,27 @@ export class MyComponent implements Component {
6565
}
6666
```
6767

68+
We recommend to component authors to use
69+
[Typed binding keys](./Context.md#encoding-value-types-in-binding-keys)
70+
instead of string keys and to export an object (a TypeScript namespace)
71+
providing constants for all binding keys defined by the component.
72+
73+
```ts
74+
import {MyValue, MyValueProvider} from './providers/my-value-provider';
75+
76+
export namespace MyComponentKeys {
77+
export const MY_VALUE = new BindingKey<MyValue>('my-component.my-value');
78+
}
79+
80+
export class MyComponent implements Component {
81+
constructor() {
82+
this.providers = {
83+
[MyComponentKeys.MY_VALUE.key]: MyValueProvider,
84+
};
85+
}
86+
}
87+
```
88+
6889
### Accessing values from Providers
6990

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

122143
```ts
123144
import {Provider} from '@loopback/context';
124-
import {RestBindings} from '@loopback/rest';
125-
import {ServerRequest} from 'http';
145+
import {ParsedRequest, RestBindings} from '@loopback/rest';
126146
const uuid = require('uuid/v4');
127147

128148
class CorrelationIdProvider implements Provider<string> {
129149
constructor(
130-
@inject(RestBindings.Http.REQUEST) private request: ServerRequest,
150+
@inject(RestBindings.Http.REQUEST) private request: ParsedRequest,
131151
) {}
132152

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

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

150-
```ts
151-
import {Component} from '@loopback/core';
170+
```ts
171+
import {Component} from '@loopback/core';
152172

153-
class AuthenticationComponent implements Component {
154-
constructor() {
155-
this.providers = {
156-
'authentication.actions.authenticate': AuthenticateActionProvider,
157-
};
158-
}
159-
}
160-
```
173+
export namespace AuthenticationBindings {
174+
export const AUTH_ACTION = BindingKey.create<AuthenticateFn>(
175+
'authentication.actions.authenticate',
176+
);
177+
}
161178

162-
A sequence action is typically implemented as an `action()` method in the provider.
179+
class AuthenticationComponent implements Component {
180+
constructor() {
181+
this.providers = {
182+
[AuthenticationBindings.AUTH_ACTION.key]: AuthenticateActionProvider,
183+
};
184+
}
185+
}
186+
```
163187

164-
```ts
165-
class AuthenticateActionProvider implements Provider<AuthenticateFn> {
166-
// Provider interface
167-
value() {
168-
return request => this.action(request);
169-
}
188+
A sequence action is typically implemented as an `action()` method
189+
in the provider.
170190

171-
// The sequence action
172-
action(request): UserProfile | undefined {
173-
// authenticate the user
174-
}
175-
}
176-
```
191+
```ts
192+
class AuthenticateActionProvider implements Provider<AuthenticateFn> {
193+
// Provider interface
194+
value() {
195+
return request => this.action(request);
196+
}
177197
178-
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.
198+
// The sequence action
199+
action(request): UserProfile | undefined {
200+
// authenticate the user
201+
}
202+
}
203+
```
204+
205+
It may be tempting to put action implementation directly inside
206+
the anonymous arrow function returned by provider's `value()` method.
207+
We consider that as a bad practice though, because when an error occurs,
208+
the stack trace will contain only an anonymous function that makes it more
209+
difficult to link the entry with the sequence action.
179210

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

182-
```ts
183-
class AppSequence implements SequenceHandler {
184-
constructor(
185-
@inject(RestBindings.Http.CONTEXT) protected ctx: Context,
186-
@inject(RestBindings.SequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
187-
@inject(RestBindings.SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams,
188-
@inject(RestBindings.SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
189-
@inject(RestBindings.SequenceActions.SEND) public send: Send,
190-
@inject(RestBindings.SequenceActions.REJECT) public reject: Reject,
191-
// Inject the new action here:
192-
@inject('authentication.actions.authenticate') protected authenticate: AuthenticateFn
193-
) {}
194-
195-
async handle(req: ParsedRequest, res: ServerResponse) {
196-
try {
197-
const route = this.findRoute(req);
198-
199-
// Invoke the new action:
200-
const user = await this.authenticate(req);
201-
202-
const args = await parseOperationArgs(req, route);
203-
const result = await this.invoke(route, args);
204-
this.send(res, result);
205-
} catch (err) {
206-
this.reject(res, req, err);
207-
}
208-
}
209-
}
210-
```
213+
```ts
214+
class AppSequence implements SequenceHandler {
215+
constructor(
216+
@inject(RestBindings.Http.CONTEXT) protected ctx: Context,
217+
@inject(RestBindings.SequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
218+
@inject(RestBindings.SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams,
219+
@inject(RestBindings.SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
220+
@inject(RestBindings.SequenceActions.SEND) public send: Send,
221+
@inject(RestBindings.SequenceActions.REJECT) public reject: Reject,
222+
// Inject the new action here:
223+
@inject('authentication.actions.authenticate') protected authenticate: AuthenticateFn
224+
) {}
225+
226+
async handle(req: ParsedRequest, res: ServerResponse) {
227+
try {
228+
const route = this.findRoute(req);
229+
230+
// Invoke the new action:
231+
const user = await this.authenticate(req);
232+
233+
const args = await parseOperationArgs(req, route);
234+
const result = await this.invoke(route, args);
235+
this.send(res, result);
236+
} catch (err) {
237+
this.reject(res, req, err);
238+
}
239+
}
240+
}
241+
```
211242

212243
### Accessing Elements contributed by other Sequence Actions
213244

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

225256
```ts
226-
export class AuthenticationProvider implements Provider<AuthenticateFn> {
257+
export class AuthenticateActionProvider implements Provider<AuthenticateFn> {
227258
constructor(
228259
@inject.getter(BindingKeys.Authentication.STRATEGY)
229260
readonly getStrategy
@@ -233,7 +264,7 @@ export class AuthenticationProvider implements Provider<AuthenticateFn> {
233264
return request => this.action(request);
234265
}
235266
236-
async action(request): UserProfile | undefined {
267+
async action(request): Promise<UserProfile | undefined> {
237268
const strategy = await this.getStrategy();
238269
// ...
239270
}
@@ -246,7 +277,7 @@ Use `@inject.setter` decorator to obtain a setter function that can be used to
246277
contribute new Elements to the request context.
247278

248279
```ts
249-
export class AuthenticationProvider implements Provider<AuthenticateFn> {
280+
export class AuthenticateActionProvider implements Provider<AuthenticateFn> {
250281
constructor(
251282
@inject.getter(BindingKeys.Authentication.STRATEGY) readonly getStrategy,
252283
@inject.setter(BindingKeys.Authentication.CURRENT_USER)
@@ -259,7 +290,8 @@ export class AuthenticationProvider implements Provider<AuthenticateFn> {
259290
260291
async action(request): UserProfile | undefined {
261292
const strategy = await this.getStrategy();
262-
const user = this.setCurrentUser(user); // ... authenticate
293+
// (authenticate the request using the obtained strategy)
294+
const user = this.setCurrentUser(user);
263295
return user;
264296
}
265297
}

0 commit comments

Comments
 (0)