Skip to content

Commit

Permalink
Add per-request child context
Browse files Browse the repository at this point in the history
  • Loading branch information
bajtos committed Apr 27, 2017
1 parent 1553bbc commit 14b48ec
Show file tree
Hide file tree
Showing 11 changed files with 182 additions and 26 deletions.
17 changes: 10 additions & 7 deletions packages/context/src/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class Binding {
// For bindings bound via toClass, this property contains the constructor function
public valueConstructor: Constructor<BoundValue>;

constructor(private readonly _context: Context, private readonly _key: string, public isLocked: boolean = false) {}
constructor(private readonly _key: string, public isLocked: boolean = false) {}
get key() { return this._key; }
get tagName() { return this._tagName; }

Expand All @@ -32,20 +32,21 @@ export class Binding {
* to check the type of the returned value to decide how to handle it.
*
* ```
* const result = binding.getValue();
* const result = binding.getValue(ctx);
* if (isPromise(result)) {
* result.then(doSomething)
* } else {
* doSomething(result);
* }
* ```
*/
getValue(): BoundValue | Promise<BoundValue> {
getValue(ctx: Context): BoundValue | Promise<BoundValue> {
return Promise.reject(new Error(`No value was configured for binding ${this._key}.`));
}

lock() {
lock(): this {
this.isLocked = true;
return this;
}

tag(tagName: string): this {
Expand Down Expand Up @@ -88,7 +89,8 @@ export class Binding {
* ```
*/
toDynamicValue(factoryFn: () => BoundValue | Promise<BoundValue>): this {
this.getValue = factoryFn;
// TODO(bajtos) allow factoryFn with @inject arguments
this.getValue = (ctx) => factoryFn();
return this;
}

Expand All @@ -100,12 +102,13 @@ export class Binding {
* we can resolve them from the context.
*/
toClass<T>(ctor: Constructor<T>): this {
this.getValue = () => instantiateClass(ctor, this._context);
this.getValue = context => instantiateClass(ctor, context);
this.valueConstructor = ctor;
return this;
}

unlock() {
unlock(): this {
this.isLocked = false;
return this;
}
}
36 changes: 27 additions & 9 deletions packages/context/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {isPromise} from './isPromise';
export class Context {
private registry: Map<string, Binding>;

constructor() {
constructor(private _parent?: Context) {
this.registry = new Map();
}

Expand All @@ -23,7 +23,7 @@ export class Context {
throw new Error(`Cannot rebind key "${key}", associated binding is locked`);
}

const binding = new Binding(this, key);
const binding = new Binding(key);
this.registry.set(key, binding);
return binding;
}
Expand All @@ -46,7 +46,8 @@ export class Context {
bindings = Array.from(this.registry.values());
}

return bindings;
const parentBindings = this._parent && this._parent.find(pattern);
return this._mergeWithParent(bindings, parentBindings);
}

findByTag(pattern: string): Binding[] {
Expand All @@ -58,21 +59,32 @@ export class Context {
if (isMatch)
bindings.push(binding);
});
return bindings;

const parentBindings = this._parent && this._parent.findByTag(pattern);
return this._mergeWithParent(bindings, parentBindings);
}

protected _mergeWithParent(childList: Binding[], parentList?: Binding[]) {
if (!parentList) return childList;
const additions = parentList.filter(parentBinding => {
// children bindings take precedence
return !childList.some(childBinding => childBinding.key === parentBinding.key);
});
return childList.concat(additions);
}

get(key: string): Promise<BoundValue> {
try {
const binding = this.getBinding(key);
return Promise.resolve(binding.getValue());
return Promise.resolve(binding.getValue(this));
} catch (err) {
return Promise.reject(err);
}
}

getSync(key: string): BoundValue {
const binding = this.getBinding(key);
const valueOrPromise = binding.getValue();
const valueOrPromise = binding.getValue(this);

if (isPromise(valueOrPromise)) {
throw new Error(
Expand All @@ -85,8 +97,14 @@ export class Context {

getBinding(key: string): Binding {
const binding = this.registry.get(key);
if (!binding)
throw new Error(`The key ${key} was not bound to any value.`);
return binding;
if (binding) {
return binding;
}

if (this._parent) {
return this._parent.getBinding(key);
}

throw new Error(`The key ${key} was not bound to any value.`);
}
}
2 changes: 1 addition & 1 deletion packages/context/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function resolveInjectedArguments(fn: Function, ctx: Context): BoundValue
}

const binding = ctx.getBinding(bindingKey);
const valueOrPromise = binding.getValue();
const valueOrPromise = binding.getValue(ctx);
if (isPromise(valueOrPromise)) {
if (!asyncResolvers) asyncResolvers = [];
asyncResolvers.push(valueOrPromise.then((v: BoundValue) => args[ix] = v));
Expand Down
48 changes: 48 additions & 0 deletions packages/context/test/acceptance/child-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@

// Copyright IBM Corp. 2013,2017. All Rights Reserved.
// Node module: loopback
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {expect} from '@loopback/testlab';
import {Context} from '../..';

describe('Context bindings - contexts inheritance', () => {
let parentCtx: Context;
let childCtx: Context;

beforeEach('given a parent and a child context', createParentAndChildContext);

it('child inherits values bound in parent', () => {
parentCtx.bind('foo').to('bar');
expect(childCtx.getSync('foo')).to.equal('bar');
});

it('child changes are not propagated to parent', () => {
childCtx.bind('foo').to('bar');
expect(() => parentCtx.getSync('foo')).to.throw(/not bound/);
});

it('includes parent bindings when searching via find()', () => {
parentCtx.bind('foo').to('parent:foo');
parentCtx.bind('bar').to('parent:bar');
childCtx.bind('foo').to('child:foo');

const found = childCtx.find().map(b => b.getValue(childCtx));
expect(found).to.deepEqual(['child:foo', 'parent:bar']);
});

it('includes parent bindings when searching via findByTag()', () => {
parentCtx.bind('foo').to('parent:foo').tag('a-tag');
parentCtx.bind('bar').to('parent:bar').tag('a-tag');
childCtx.bind('foo').to('child:foo').tag('a-tag');

const found = childCtx.findByTag('a-tag').map(b => b.getValue(childCtx));
expect(found).to.deepEqual(['child:foo', 'parent:bar']);
});

function createParentAndChildContext() {
parentCtx = new Context();
childCtx = new Context(parentCtx);
}
});
2 changes: 1 addition & 1 deletion packages/context/test/acceptance/class-level-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('Context bindings - Injecting dependencies of classes', () => {
}
const b = ctx.bind(INFO_CONTROLLER).toClass(InfoController);

const valueOrPromise = b.getValue();
const valueOrPromise = b.getValue(ctx);
expect(valueOrPromise).to.not.be.Promise();
expect(valueOrPromise as InfoController).to.have.property('appName', 'CodeHub');
});
Expand Down
7 changes: 4 additions & 3 deletions packages/context/test/unit/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {Binding, Context} from '../..';
const key = 'foo';

describe('Binding', () => {
let ctx: Context;
let binding: Binding;
beforeEach(givenBinding);

Expand All @@ -33,12 +34,12 @@ describe('Binding', () => {
describe('to(value)', () => {
it('returns the value synchronously', () => {
binding.to('value');
expect(binding.getValue()).to.equal('value');
expect(binding.getValue(ctx)).to.equal('value');
});
});

function givenBinding() {
const ctx = new Context();
binding = new Binding(ctx, key);
ctx = new Context();
binding = new Binding(key);
}
});
5 changes: 5 additions & 0 deletions packages/loopback/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ export * from '@loopback/openapi-spec';
export {
inject,
} from '@loopback/context';

export {
ServerRequest,
ServerResponse,
} from 'http';
5 changes: 3 additions & 2 deletions packages/loopback/lib/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ export class Application extends Context {
}

const ctorFactory = (req: http.ServerRequest, res: http.ServerResponse) => {
// TODO(bajtos) Create a new nested/child per-request Context
const requestContext = this;
const requestContext = new Context(this);
requestContext.bind('http.request').to(req);
requestContext.bind('http.response').to(res);
return requestContext.get(b.key);
};
const apiSpec = getApiSpec(ctor);
Expand Down
2 changes: 1 addition & 1 deletion packages/loopback/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class Server extends Context {
const apps = this.find('applications.*');
for (const appBinding of apps) {
debug('Registering app controllers for %j', appBinding.key);
const app = await appBinding.getValue() as Application;
const app = await Promise.resolve(appBinding.getValue(this)) as Application;
app.mountControllers(router);
}

Expand Down
75 changes: 73 additions & 2 deletions packages/loopback/test/acceptance/routing/routing.acceptance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Application, Server, api, OpenApiSpec, ParameterObject, OperationObject} from '../../..';
import {
Application, Server, api,
OpenApiSpec, ParameterObject, OperationObject,
ServerRequest, ServerResponse,
} from '../../..';
import {Client} from './../../support/client';
import {expect} from 'testlab';
import {givenOpenApiSpec} from '@loopback/openapi-spec-builder';
import {inject, Constructor} from '@loopback/context';
import {inject, Constructor, Context} from '@loopback/context';

/* # Feature: Routing
* - In order to build REST APIs
Expand Down Expand Up @@ -90,6 +94,73 @@ describe('Routing', () => {
expect(result).to.have.property('body', 'TestApp');
});

it('creates a new child context for each request', async () => {
const app = givenAnApplication();
app.bind('flag').to('original');

// create a special binding returning the current context instance
app.bind('context').getValue = ctx => ctx;

const spec = givenOpenApiSpec()
.withOperationReturningString('put', '/flag', 'setFlag')
.withOperationReturningString('get', '/flag', 'getFlag')
.build();

@api(spec)
class FlagController {
constructor(@inject('context') private ctx: Context) {
}

async setFlag(): Promise<string> {
this.ctx.bind('flag').to('modified');
return 'modified';
}

async getFlag(): Promise<string> {
return this.ctx.get('flag');
}
}
givenControllerInApp(app, FlagController);

// Rebind "flag" to "modified". Since we are modifying
// the per-request child context, the change should
// be discarded after the request is done.
await whenIMakeRequestTo(app).put('/flag');
// Get the value "flag" is bound to.
// This should return the original value.
const result = await whenIMakeRequestTo(app).get('/flag');
expect(result).to.have.property('body', 'original');
});

it('binds request and response objects', async () => {
const app = givenAnApplication();

const spec = givenOpenApiSpec()
.withOperationReturningString('get', '/status', 'getStatus')
.build();

@api(spec)
class StatusController {
constructor(
@inject('http.request') private request: ServerRequest,
@inject('http.response') private response: ServerResponse,
) {
}

async getStatus(): Promise<string> {
this.response.statusCode = 202; // 202 Accepted
return this.request.method as string;
}
}
givenControllerInApp(app, StatusController);

const result = await whenIMakeRequestTo(app).get('/status');
expect(result).to.containDeep({
body: 'GET',
status: 202,
});
});

/* ===== HELPERS ===== */

function givenAnApplication() {
Expand Down
9 changes: 9 additions & 0 deletions packages/loopback/test/support/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,20 @@ export class Client {
}

public async get(path : string) : Promise<Client.Result> {
return this.request('get', path);
}

public async put(path: string): Promise<Client.Result> {
return this.request('put', path);
}

public async request(verb: string, path: string): Promise<Client.Result> {
await this._ensureAppIsListening();

const url = 'http://localhost:' + this.app.config.port + path;
const options = {
uri: url,
method: verb,
resolveWithFullResponse: true,
};

Expand Down

0 comments on commit 14b48ec

Please sign in to comment.