Skip to content

Commit

Permalink
feat(context): make it possible to set source information for interce…
Browse files Browse the repository at this point in the history
…ptions

Some interceptors want to check invocationContext.source to decide if its
logic should be applied. For example, an http access logger only cares about
invocations from the rest layer to the first controller.

This is also useful for metrics, tracing and logging.
  • Loading branch information
raymondfeng committed Dec 3, 2019
1 parent a91c989 commit 2a1ccb4
Show file tree
Hide file tree
Showing 15 changed files with 207 additions and 20 deletions.
44 changes: 44 additions & 0 deletions docs/site/Interceptors.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ bindings. It extends `Context` with additional properties as follows:
(for instance methods)
- `methodName` (`string`): Method name
- `args` (`InvocationArgs`, i.e., `any[]`): An array of arguments
- `source`: Source information about the invoker of the invocation

```ts
/**
Expand All @@ -533,6 +534,7 @@ export class InvocationContext extends Context {
public readonly target: object,
public readonly methodName: string,
public readonly args: InvocationArgs, // any[]
public readonly source?: InvocationSource,
) {
super(parent);
}
Expand All @@ -542,6 +544,48 @@ export class InvocationContext extends Context {
It's possible for an interceptor to mutate items in the `args` array to pass in
transformed input to downstream interceptors and the target method.

### Source for an invocation

The `source` property of `InvocationContext` is defined as `InvocationSource`:

```ts
/**
* An interface to represent the caller of the invocation
*/
export interface InvocationSource<T = unknown> {
/**
* Type of the invoker, such as `proxy` and `route`
*/
readonly type: string;
/**
* Metadata for the source, such as `ResolutionSession`
*/
readonly value: T;
}
```

The `source` describes the caller that invokes a method with interceptors.
Interceptors can be invoked in the following cases:

1. A route to a controller method

- The source describes the REST Route

2. A controller to a repository/service with injected proxy

- The source describes a ResolutionSession that tracks a stack of bindings
and injections

3. A controller/repository/service method invoked explicitly with
`invokeMethodWithInterceptors()` or `invokeMethod`

- The source can be set by the caller of `invokeMethodWithInterceptors()` or
`invokeMethod`

The implementation of an interceptor can check `source` to decide if its logic
should apply. For example, a global interceptor that provides caching for REST
APIs should only run if the source is from a REST Route.

### Logic around `next`

An interceptor will receive the `next` parameter, which is a function to execute
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
inject,
intercept,
Interceptor,
ResolutionSession,
ValueOrPromise,
} from '../..';

Expand Down Expand Up @@ -156,8 +157,8 @@ describe('Interception proxy', () => {
expect(msg).to.equal('Hello, JOHN');
expect(events).to.eql([
'convertName: before-greet',
'log: before-greet',
'log: after-greet',
'log: [my-controller] before-greet',
'log: [my-controller] after-greet',
'convertName: after-greet',
]);
});
Expand Down Expand Up @@ -198,18 +199,24 @@ describe('Interception proxy', () => {
expect(msg).to.equal('Hello, JOHN');
expect(events).to.eql([
'convertName: before-greet',
'log: before-greet',
'log: after-greet',
'log: [dummy-controller --> my-controller] before-greet',
'log: [dummy-controller --> my-controller] after-greet',
'convertName: after-greet',
]);
});

let events: string[];

const log: Interceptor = async (invocationCtx, next) => {
events.push('log: before-' + invocationCtx.methodName);
let source: string;
if (invocationCtx.source instanceof ResolutionSession) {
source = `[${invocationCtx.source.getBindingPath()}] `;
} else {
source = invocationCtx.source ? `[${invocationCtx.source}] ` : '';
}
events.push(`log: ${source}before-${invocationCtx.methodName}`);
const result = await next();
events.push('log: after-' + invocationCtx.methodName);
events.push(`log: ${source}after-${invocationCtx.methodName}`);
return result;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,33 @@ describe('Interceptor', () => {
}).to.throw(/skipInterceptors is not allowed/);
});

it('can set source information', async () => {
const controller = givenController();
ctx.bind('name').to('Jane');
const source = {
type: 'path',
value: 'rest',
toString: () => 'path:rest',
};
const msg = await invokeMethodWithInterceptors(
ctx,
controller,
'greetWithDI',
// No name is passed in here as it will be provided by the injection
[],
{
source,
skipParameterInjection: false,
},
);
// `Jane` is bound to `name` in the current context
expect(msg).to.equal('Hello, Jane');
expect(events).to.eql([
'log: [path:rest] before-greetWithDI',
'log: [path:rest] after-greetWithDI',
]);
});

function givenController() {
class MyController {
// Apply `log` to an async instance method with parameter injection
Expand Down Expand Up @@ -682,9 +709,10 @@ describe('Interceptor', () => {
};

const log: Interceptor = async (invocationCtx, next) => {
events.push('log: before-' + invocationCtx.methodName);
const source = invocationCtx.source ? `[${invocationCtx.source}] ` : '';
events.push(`log: ${source}before-${invocationCtx.methodName}`);
const result = await next();
events.push('log: after-' + invocationCtx.methodName);
events.push(`log: ${source}after-${invocationCtx.methodName}`);
return result;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ describe('ResolutionSession', () => {
'a --> @MyController.constructor[0] --> b',
);

expect(session.toString()).to.eql(
'a --> @MyController.constructor[0] --> b',
);

expect(session.popBinding()).to.be.exactly(bindingB);
expect(session.popInjection()).to.be.exactly(injection);
expect(session.popBinding()).to.be.exactly(bindingA);
Expand Down
8 changes: 7 additions & 1 deletion packages/context/src/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,11 @@ export class Binding<T = BoundValue> {
this._setValueGetter((ctx, options) => {
const instOrPromise = instantiateClass(ctor, ctx, options.session);
if (!options.asProxyWithInterceptors) return instOrPromise;
return createInterceptionProxyFromInstance(instOrPromise, ctx);
return createInterceptionProxyFromInstance(
instOrPromise,
ctx,
options.session,
);
});
this._valueConstructor = ctor;
return this;
Expand Down Expand Up @@ -640,13 +644,15 @@ export class Binding<T = BoundValue> {
function createInterceptionProxyFromInstance<T>(
instOrPromise: ValueOrPromise<T>,
context: Context,
session?: ResolutionSession,
) {
return transformValueOrPromise(instOrPromise, inst => {
if (typeof inst !== 'object') return inst;
return (createProxyWithInterceptors(
// Cast inst from `T` to `object`
(inst as unknown) as object,
context,
session,
) as unknown) as T;
});
}
28 changes: 25 additions & 3 deletions packages/context/src/interception-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

import {Context} from './context';
import {invokeMethodWithInterceptors} from './interceptor';
import {InvocationArgs} from './invocation';
import {InvocationArgs, InvocationSource} from './invocation';
import {ResolutionSession} from './resolution-session';
import {ValueOrPromise} from './value-promise';

/**
Expand Down Expand Up @@ -57,13 +58,29 @@ export type AsInterceptedFunction<T> = T extends (
*/
export type AsyncProxy<T> = {[P in keyof T]: AsInterceptedFunction<T[P]>};

/**
* Invocation source for injected proxies. It wraps a snapshot of the
* `ResolutionSession` that tracks the binding/injection stack.
*/
export class ProxySource implements InvocationSource<ResolutionSession> {
type = 'proxy';
constructor(readonly value: ResolutionSession) {}

toString() {
return this.value.getBindingPath();
}
}

/**
* A proxy handler that applies interceptors
*
* See https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Proxy
*/
export class InterceptionHandler<T extends object> implements ProxyHandler<T> {
constructor(private context = new Context()) {}
constructor(
private context = new Context(),
private session?: ResolutionSession,
) {}

get(target: T, propertyName: PropertyKey, receiver: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -77,6 +94,7 @@ export class InterceptionHandler<T extends object> implements ProxyHandler<T> {
target,
propertyName,
args,
{source: this.session && new ProxySource(this.session)},
);
};
} else {
Expand All @@ -93,6 +111,10 @@ export class InterceptionHandler<T extends object> implements ProxyHandler<T> {
export function createProxyWithInterceptors<T extends object>(
target: T,
context?: Context,
session?: ResolutionSession,
): AsyncProxy<T> {
return new Proxy(target, new InterceptionHandler(context)) as AsyncProxy<T>;
return new Proxy(
target,
new InterceptionHandler(context, ResolutionSession.fork(session)),
) as AsyncProxy<T>;
}
1 change: 1 addition & 0 deletions packages/context/src/interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ export function invokeMethodWithInterceptors(
target,
methodName,
args,
options.source,
);

invocationCtx.assertMethodExists();
Expand Down
23 changes: 22 additions & 1 deletion packages/context/src/invocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ export type InvocationResult = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type InvocationArgs = any[];

/**
* An interface to represent the caller of the invocation
*/
export interface InvocationSource<T = unknown> {
/**
* Type of the invoker, such as `proxy` and `route`
*/
readonly type: string;
/**
* Metadata for the source, such as `ResolutionSession`
*/
readonly value: T;
}

/**
* InvocationContext represents the context to invoke interceptors for a method.
* The context can be used to access metadata about the invocation as well as
Expand All @@ -47,6 +61,7 @@ export class InvocationContext extends Context {
public readonly target: object,
public readonly methodName: string,
public readonly args: InvocationArgs,
public readonly source?: InvocationSource,
) {
super(parent);
}
Expand All @@ -71,7 +86,8 @@ export class InvocationContext extends Context {
* Description of the invocation
*/
get description() {
return `InvocationContext(${this.name}): ${this.targetName}`;
const source = this.source == null ? '' : `${this.source} => `;
return `InvocationContext(${this.name}): ${source}${this.targetName}`;
}

toString() {
Expand Down Expand Up @@ -129,6 +145,11 @@ export type InvocationOptions = {
* Skip invocation of interceptors
*/
skipInterceptors?: boolean;
/**
* Information about the source object that makes the invocation. For REST,
* it's a `Route`. For injected proxies, it's a `Binding`.
*/
source?: InvocationSource;
};

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/context/src/resolution-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@ export class ResolutionSession {
getResolutionPath() {
return this.stack.map(i => ResolutionSession.describe(i)).join(' --> ');
}

toString() {
return this.getResolutionPath();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
Provider,
ValueOrPromise,
} from '@loopback/context';
import {Request, RestBindings} from '../../..';
import {Request, RestBindings, RouteSource} from '../../..';

/**
* Execution status
Expand Down Expand Up @@ -61,6 +61,12 @@ export async function cache<T>(
next: () => ValueOrPromise<T>,
) {
status.returnFromCache = false;
if (
invocationCtx.source == null ||
!(invocationCtx.source instanceof RouteSource)
) {
return next();
}
const req = await invocationCtx.get(RestBindings.Http.REQUEST, {
optional: true,
});
Expand Down
15 changes: 15 additions & 0 deletions packages/rest/src/__tests__/unit/router/controller-route.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createControllerFactoryForBinding,
createControllerFactoryForClass,
RestBindings,
RouteSource,
} from '../../..';

describe('ControllerRoute', () => {
Expand Down Expand Up @@ -87,6 +88,20 @@ describe('ControllerRoute', () => {
expect(route._controllerName).to.eql('my-controller');
});

it('implements toString', () => {
const spec = anOperationSpec().build();
const route = new MyRoute(
'get',
'/greet',
spec,
MyController,
myControllerFactory,
'greet',
);
expect(route.toString()).to.equal('MyRoute - get /greet');
expect(new RouteSource(route).toString()).to.equal('get /greet');
});

describe('updateBindings()', () => {
let appCtx: Context;
let requestCtx: Context;
Expand Down
Loading

0 comments on commit 2a1ccb4

Please sign in to comment.