From 246f38c05d92d1d6fb691fa134719f9b51793ccd Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 17 Jun 2014 10:35:19 -0700 Subject: [PATCH 1/4] Add context propagation middleware - Implement the middleware `loopback.context` - Inject context into juggler and strong-remoting - Make http context optional and default to false - Optionally mount context middleware from `loopback.rest` --- example/client-server/models.js | 4 ++ example/client-server/server.js | 6 +++ lib/middleware/context.js | 95 +++++++++++++++++++++++++++++++++ package.json | 3 +- test/rest.middleware.test.js | 43 +++++++++++++++ 5 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 lib/middleware/context.js diff --git a/example/client-server/models.js b/example/client-server/models.js index 34d5c8bac..60285dd74 100644 --- a/example/client-server/models.js +++ b/example/client-server/models.js @@ -18,6 +18,10 @@ CartItem.sum = function(cartId, callback) { return prev + cur; }, 0); + var ns = loopback.getCurrentContext(); + if (ns && ns.get('http')) { + console.log('Remote call via url: %s', ns.get('http').req.url); + } callback(null, total); }); } diff --git a/example/client-server/server.js b/example/client-server/server.js index 7e466a563..6663f5416 100644 --- a/example/client-server/server.js +++ b/example/client-server/server.js @@ -5,6 +5,12 @@ var memory = loopback.createDataSource({ connector: loopback.Memory }); +server.use(loopback.context()); +server.use(function(req, res, next) { + loopback.getCurrentContext().set('http', {req: req, res: res}); + next(); +}); + server.use(loopback.rest()); server.model(CartItem); diff --git a/lib/middleware/context.js b/lib/middleware/context.js new file mode 100644 index 000000000..aef8260ae --- /dev/null +++ b/lib/middleware/context.js @@ -0,0 +1,95 @@ +var loopback = require('../loopback'); +var juggler = require('loopback-datasource-juggler'); +var remoting = require('strong-remoting'); +var cls = require('continuation-local-storage'); + +module.exports = context; + +var name = 'loopback'; + +function context(options) { + options = options || {}; + var scope = options.name || name; + var enableHttpContext = options.enableHttpContext || false; + var ns = cls.createNamespace(scope); + + // Make the namespace globally visible via the process.context property + process.context = process.context || {}; + process.context[scope] = ns; + + // Set up loopback.getCurrentContext() + loopback.getCurrentContext = function() { + return ns; + }; + + chain(juggler); + chain(remoting); + + // Return the middleware + return function(req, res, next) { + // Bind req/res event emitters to the given namespace + ns.bindEmitter(req); + ns.bindEmitter(res); + // Create namespace for the request context + ns.run(function(context) { + // Run the code in the context of the namespace + if(enableHttpContext) { + ns.set('http', {req: req, res: res}); // Set up the transport context + } + next(); + }); + }; +} + +/** + * Create a chained context + * @param {Object} child The child context + * @param {Object} parent The parent context + * @private + * @constructor + */ +function ChainedContext(child, parent) { + this.child = child; + this.parent = parent; +} + +/** + * Get the value by name from the context. If it doesn't exist in the child + * context, try the parent one + * @param {String} name Name of the context property + * @returns {*} Value of the context property + */ +ChainedContext.prototype.get = function (name) { + var val = this.child && this.child.get(name); + if (val === undefined) { + return this.parent && this.parent.get(name); + } +}; + +ChainedContext.prototype.set = function (name, val) { + if (this.child) { + return this.child.set(name, val); + } else { + return this.parent && this.parent.set(name, val); + } +}; + +ChainedContext.prototype.reset = function (name, val) { + if (this.child) { + return this.child.reset(name, val); + } else { + return this.parent && this.parent.reset(name, val); + } +}; + +function chain(child) { + if (typeof child.getCurrentContext === 'function') { + var childContext = new ChainedContext(child.getCurrentContext(), + loopback.getCurrentContext()); + child.getCurrentContext = function() { + return childContext; + }; + } else { + child.getCurrentContext = loopback.getCurrentContext; + } +} diff --git a/package.json b/package.json index f0d385b0a..712768426 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "strong-remoting": "^2.4.0", "uid2": "0.0.3", "underscore": "~1.7.0", - "underscore.string": "~2.3.3" + "underscore.string": "~2.3.3", + "continuation-local-storage": "~3.1.1" }, "peerDependencies": { "loopback-datasource-juggler": "^2.8.0" diff --git a/test/rest.middleware.test.js b/test/rest.middleware.test.js index 56b6d523c..ac0ced76f 100644 --- a/test/rest.middleware.test.js +++ b/test/rest.middleware.test.js @@ -130,6 +130,49 @@ describe('loopback.rest', function() { }); }); + it('should pass req to remote method via context', function(done) { + var User = givenUserModelWithAuth(); + User.getToken = function(cb) { + var context = loopback.getCurrentContext(); + var req = context.get('http').req; + expect(req).to.have.property('accessToken'); + + var juggler = require('loopback-datasource-juggler'); + expect(juggler.getCurrentContext().get('http').req) + .to.have.property('accessToken'); + + var remoting = require('strong-remoting'); + expect(remoting.getCurrentContext().get('http').req) + .to.have.property('accessToken'); + + cb(null, req && req.accessToken ? req.accessToken.id : null); + }; + // Set up the ACL + User.settings.acls.push({principalType: 'ROLE', + principalId: '$authenticated', permission: 'ALLOW', property: 'getToken'}); + + loopback.remoteMethod(User.getToken, { + accepts: [], + returns: [{ type: 'object', name: 'id' }] + }); + + app.use(loopback.context({enableHttpContext: true})); + app.enableAuth(); + app.use(loopback.rest()); + + givenLoggedInUser(function(err, token) { + if (err) return done(err); + request(app).get('/users/getToken') + .set('Authorization', token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.body.id).to.equal(token.id); + done(); + }); + }); + }); + function givenUserModelWithAuth() { // NOTE(bajtos) It is important to create a custom AccessToken model here, // in order to overwrite the entry created by previous tests in From 885f4e047d57b158e4298bd254106ff7a5ba8e69 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 22 Oct 2014 14:29:56 -0700 Subject: [PATCH 2/4] Enable the context middleware from loopback.rest --- example/client-server/server.js | 8 +-- lib/middleware/context.js | 49 +++++++------ lib/middleware/rest.js | 39 ++++++---- test/rest.middleware.test.js | 123 +++++++++++++++++++++++--------- 4 files changed, 145 insertions(+), 74 deletions(-) diff --git a/example/client-server/server.js b/example/client-server/server.js index 6663f5416..706ea093d 100644 --- a/example/client-server/server.js +++ b/example/client-server/server.js @@ -5,13 +5,7 @@ var memory = loopback.createDataSource({ connector: loopback.Memory }); -server.use(loopback.context()); -server.use(function(req, res, next) { - loopback.getCurrentContext().set('http', {req: req, res: res}); - next(); -}); - -server.use(loopback.rest()); +server.use(loopback.rest({context: {enableHttpContext: true}})); server.model(CartItem); CartItem.attachTo(memory); diff --git a/lib/middleware/context.js b/lib/middleware/context.js index aef8260ae..275a78d95 100644 --- a/lib/middleware/context.js +++ b/lib/middleware/context.js @@ -7,33 +7,42 @@ module.exports = context; var name = 'loopback'; -function context(options) { - options = options || {}; - var scope = options.name || name; - var enableHttpContext = options.enableHttpContext || false; - var ns = cls.createNamespace(scope); - +function createContext(scope) { // Make the namespace globally visible via the process.context property process.context = process.context || {}; - process.context[scope] = ns; - - // Set up loopback.getCurrentContext() - loopback.getCurrentContext = function() { - return ns; - }; + var ns = process.context[scope]; + if (!ns) { + ns = cls.createNamespace(scope); + process.context[scope] = ns; + // Set up loopback.getCurrentContext() + loopback.getCurrentContext = function() { + return ns; + }; - chain(juggler); - chain(remoting); + chain(juggler); + chain(remoting); + } + return ns; +} +function context(options) { + options = options || {}; + var scope = options.name || name; + var enableHttpContext = options.enableHttpContext || false; + var ns = createContext(scope); // Return the middleware - return function(req, res, next) { + return function contextHandler(req, res, next) { + if (req.loopbackContext) { + return next(); + } + req.loopbackContext = ns; // Bind req/res event emitters to the given namespace ns.bindEmitter(req); ns.bindEmitter(res); // Create namespace for the request context - ns.run(function(context) { + ns.run(function processRequestInContext(context) { // Run the code in the context of the namespace - if(enableHttpContext) { + if (enableHttpContext) { ns.set('http', {req: req, res: res}); // Set up the transport context } next(); @@ -59,14 +68,14 @@ function ChainedContext(child, parent) { * @param {String} name Name of the context property * @returns {*} Value of the context property */ -ChainedContext.prototype.get = function (name) { +ChainedContext.prototype.get = function(name) { var val = this.child && this.child.get(name); if (val === undefined) { return this.parent && this.parent.get(name); } }; -ChainedContext.prototype.set = function (name, val) { +ChainedContext.prototype.set = function(name, val) { if (this.child) { return this.child.set(name, val); } else { @@ -74,7 +83,7 @@ ChainedContext.prototype.set = function (name, val) { } }; -ChainedContext.prototype.reset = function (name, val) { +ChainedContext.prototype.reset = function(name, val) { if (this.child) { return this.child.reset(name, val); } else { diff --git a/lib/middleware/rest.js b/lib/middleware/rest.js index 0b321d26b..37bf785c5 100644 --- a/lib/middleware/rest.js +++ b/lib/middleware/rest.js @@ -3,6 +3,7 @@ */ var loopback = require('../loopback'); +var async = require('async'); /*! * Export the middleware. @@ -21,17 +22,32 @@ module.exports = rest; * @header loopback.rest() */ -function rest() { +function rest(options) { + options = options || {}; var tokenParser = null; - return function(req, res, next) { + var contextHandler = null; + if (options.context) { + var contextOptions = options.context; + if(typeof contextOptions !== 'object') { + contextOptions = {}; + } + contextHandler = loopback.context(contextOptions); + } + return function restApiHandler(req, res, next) { var app = req.app; var handler = app.handler('rest'); if (req.url === '/routes') { - res.send(handler.adapter.allRoutes()); + return res.send(handler.adapter.allRoutes()); } else if (req.url === '/models') { return res.send(app.remotes().toJSON()); - } else if (app.isAuthEnabled) { + } + + var handlers = []; + if (options.context) { + handlers.push(contextHandler); + } + if (app.isAuthEnabled) { if (!tokenParser) { // NOTE(bajtos) It would be better to search app.models for a model // of type AccessToken instead of searching all loopback models. @@ -41,17 +57,12 @@ function rest() { // https://github.com/strongloop/loopback/commit/f07446a var AccessToken = loopback.getModelByType(loopback.AccessToken); tokenParser = loopback.token({ model: AccessToken }); + handlers.push(tokenParser); } - - tokenParser(req, res, function(err) { - if (err) { - next(err); - } else { - handler(req, res, next); - } - }); - } else { - handler(req, res, next); } + handlers.push(handler); + async.eachSeries(handlers, function(handler, done) { + handler(req, res, done); + }, next); }; } diff --git a/test/rest.middleware.test.js b/test/rest.middleware.test.js index ac0ced76f..1ddaf50b0 100644 --- a/test/rest.middleware.test.js +++ b/test/rest.middleware.test.js @@ -130,46 +130,103 @@ describe('loopback.rest', function() { }); }); - it('should pass req to remote method via context', function(done) { - var User = givenUserModelWithAuth(); - User.getToken = function(cb) { - var context = loopback.getCurrentContext(); - var req = context.get('http').req; - expect(req).to.have.property('accessToken'); + describe('context propagation', function() { + var User; - var juggler = require('loopback-datasource-juggler'); - expect(juggler.getCurrentContext().get('http').req) - .to.have.property('accessToken'); + beforeEach(function() { + User = givenUserModelWithAuth(); + User.getToken = function(cb) { + var context = loopback.getCurrentContext(); + var req = context.get('http').req; + expect(req).to.have.property('accessToken'); - var remoting = require('strong-remoting'); - expect(remoting.getCurrentContext().get('http').req) - .to.have.property('accessToken'); + var juggler = require('loopback-datasource-juggler'); + expect(juggler.getCurrentContext().get('http').req) + .to.have.property('accessToken'); - cb(null, req && req.accessToken ? req.accessToken.id : null); - }; - // Set up the ACL - User.settings.acls.push({principalType: 'ROLE', - principalId: '$authenticated', permission: 'ALLOW', property: 'getToken'}); + var remoting = require('strong-remoting'); + expect(remoting.getCurrentContext().get('http').req) + .to.have.property('accessToken'); - loopback.remoteMethod(User.getToken, { - accepts: [], - returns: [{ type: 'object', name: 'id' }] + cb(null, req && req.accessToken ? req.accessToken.id : null); + }; + // Set up the ACL + User.settings.acls.push({principalType: 'ROLE', + principalId: '$authenticated', permission: 'ALLOW', + property: 'getToken'}); + + loopback.remoteMethod(User.getToken, { + accepts: [], + returns: [ + { type: 'object', name: 'id' } + ] + }); }); - app.use(loopback.context({enableHttpContext: true})); - app.enableAuth(); - app.use(loopback.rest()); + function invokeGetToken(done) { + givenLoggedInUser(function(err, token) { + if (err) return done(err); + request(app).get('/users/getToken') + .set('Authorization', token.id) + .expect(200) + .end(function(err, res) { + if (err) return done(err); + expect(res.body.id).to.equal(token.id); + done(); + }); + }); + } - givenLoggedInUser(function(err, token) { - if (err) return done(err); - request(app).get('/users/getToken') - .set('Authorization', token.id) - .expect(200) - .end(function(err, res) { - if (err) return done(err); - expect(res.body.id).to.equal(token.id); - done(); - }); + it('should enable context using loopback.context', function(done) { + app.use(loopback.context({enableHttpContext: true})); + app.enableAuth(); + app.use(loopback.rest()); + + invokeGetToken(done); + }); + + it('should enable context with loopback.rest', function(done) { + app.enableAuth(); + app.use(loopback.rest({context: {enableHttpContext: true}})); + + invokeGetToken(done); + }); + + it('should support explicit context', function(done) { + app.enableAuth(); + app.use(loopback.context()); + app.use(loopback.token( + { model: loopback.getModelByType(loopback.AccessToken) })); + app.use(function(req, res, next) { + loopback.getCurrentContext().set('accessToken', req.accessToken); + next(); + }); + app.use(loopback.rest()); + + User.getToken = function(cb) { + var context = loopback.getCurrentContext(); + var accessToken = context.get('accessToken'); + expect(context.get('accessToken')).to.have.property('id'); + + var juggler = require('loopback-datasource-juggler'); + context = juggler.getCurrentContext(); + expect(context.get('accessToken')).to.have.property('id'); + + var remoting = require('strong-remoting'); + context = remoting.getCurrentContext(); + expect(context.get('accessToken')).to.have.property('id'); + + cb(null, accessToken ? accessToken.id : null); + }; + + loopback.remoteMethod(User.getToken, { + accepts: [], + returns: [ + { type: 'object', name: 'id' } + ] + }); + + invokeGetToken(done); }); }); From 7a1a3b85924ab3d5d5be9de94a967ce78f1c445c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Nov 2014 11:24:25 +0100 Subject: [PATCH 3/4] Move `context` example to a standalone app --- example/client-server/models.js | 4 ---- example/client-server/server.js | 2 +- example/context/app.js | 29 +++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 example/context/app.js diff --git a/example/client-server/models.js b/example/client-server/models.js index 60285dd74..34d5c8bac 100644 --- a/example/client-server/models.js +++ b/example/client-server/models.js @@ -18,10 +18,6 @@ CartItem.sum = function(cartId, callback) { return prev + cur; }, 0); - var ns = loopback.getCurrentContext(); - if (ns && ns.get('http')) { - console.log('Remote call via url: %s', ns.get('http').req.url); - } callback(null, total); }); } diff --git a/example/client-server/server.js b/example/client-server/server.js index 706ea093d..7e466a563 100644 --- a/example/client-server/server.js +++ b/example/client-server/server.js @@ -5,7 +5,7 @@ var memory = loopback.createDataSource({ connector: loopback.Memory }); -server.use(loopback.rest({context: {enableHttpContext: true}})); +server.use(loopback.rest()); server.model(CartItem); CartItem.attachTo(memory); diff --git a/example/context/app.js b/example/context/app.js new file mode 100644 index 000000000..12cedc078 --- /dev/null +++ b/example/context/app.js @@ -0,0 +1,29 @@ +var loopback = require('../../'); +var app = loopback(); + +// Create a LoopBack context for all requests +app.use(loopback.context()); + +// Store a request property in the context +app.use(function saveHostToContext(req, res, next) { + var ns = loopback.getCurrentContext(); + ns.set('host', req.host); + next(); +}); + +app.use(loopback.rest()); + +var Color = loopback.createModel('color', { 'name': String }); +Color.beforeRemote('**', function (ctx, unused, next) { + // Inside LoopBack code, you can read the property from the context + var ns = loopback.getCurrentContext(); + console.log('Request to host', ns && ns.get('host')); + next(); +}); + +app.dataSource('db', { connector: 'memory' }); +app.model(Color, { dataSource: 'db' }); + +app.listen(3000, function() { + console.log('A list of colors is available at http://localhost:3000/colors'); +}); From 4fdcbd16aff4cb62687c00e1230083a9009f96ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 4 Nov 2014 11:27:49 +0100 Subject: [PATCH 4/4] rest middleware: clean up context config Modify `loopback.rest()` to read the configuration for `loopback.context` from `app.get('remoting')`, which is the approach used for all other configuration options related to the REST transport. --- lib/middleware/rest.js | 43 +++++++++++++++++------------------- test/rest.middleware.test.js | 5 +++-- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/lib/middleware/rest.js b/lib/middleware/rest.js index 37bf785c5..71c98154a 100644 --- a/lib/middleware/rest.js +++ b/lib/middleware/rest.js @@ -22,33 +22,31 @@ module.exports = rest; * @header loopback.rest() */ -function rest(options) { - options = options || {}; - var tokenParser = null; - var contextHandler = null; - if (options.context) { - var contextOptions = options.context; - if(typeof contextOptions !== 'object') { - contextOptions = {}; - } - contextHandler = loopback.context(contextOptions); - } +function rest() { return function restApiHandler(req, res, next) { var app = req.app; - var handler = app.handler('rest'); + var restHandler = app.handler('rest'); if (req.url === '/routes') { - return res.send(handler.adapter.allRoutes()); + return res.send(restHandler.adapter.allRoutes()); } else if (req.url === '/models') { return res.send(app.remotes().toJSON()); } - var handlers = []; - if (options.context) { - handlers.push(contextHandler); - } - if (app.isAuthEnabled) { - if (!tokenParser) { + var preHandlers; + + if (!preHandlers) { + preHandlers = []; + var remotingOptions = app.get('remoting') || {}; + + var contextOptions = remotingOptions.context; + if (contextOptions !== false) { + if (typeof contextOptions !== 'object') + contextOptions = {}; + preHandlers.push(loopback.context(contextOptions)); + } + + if (app.isAuthEnabled) { // NOTE(bajtos) It would be better to search app.models for a model // of type AccessToken instead of searching all loopback models. // Unfortunately that's not supported now. @@ -56,12 +54,11 @@ function rest(options) { // https://github.com/strongloop/loopback/pull/167 // https://github.com/strongloop/loopback/commit/f07446a var AccessToken = loopback.getModelByType(loopback.AccessToken); - tokenParser = loopback.token({ model: AccessToken }); - handlers.push(tokenParser); + preHandlers.push(loopback.token({ model: AccessToken })); } } - handlers.push(handler); - async.eachSeries(handlers, function(handler, done) { + + async.eachSeries(preHandlers.concat(restHandler), function(handler, done) { handler(req, res, done); }, next); }; diff --git a/test/rest.middleware.test.js b/test/rest.middleware.test.js index 1ddaf50b0..89877af72 100644 --- a/test/rest.middleware.test.js +++ b/test/rest.middleware.test.js @@ -178,7 +178,7 @@ describe('loopback.rest', function() { } it('should enable context using loopback.context', function(done) { - app.use(loopback.context({enableHttpContext: true})); + app.use(loopback.context({ enableHttpContext: true })); app.enableAuth(); app.use(loopback.rest()); @@ -187,7 +187,8 @@ describe('loopback.rest', function() { it('should enable context with loopback.rest', function(done) { app.enableAuth(); - app.use(loopback.rest({context: {enableHttpContext: true}})); + app.set('remoting', { context: { enableHttpContext: true } }); + app.use(loopback.rest()); invokeGetToken(done); });