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'); +}); diff --git a/lib/middleware/context.js b/lib/middleware/context.js new file mode 100644 index 000000000..275a78d95 --- /dev/null +++ b/lib/middleware/context.js @@ -0,0 +1,104 @@ +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 createContext(scope) { + // Make the namespace globally visible via the process.context property + process.context = process.context || {}; + 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); + } + 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 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 processRequestInContext(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/lib/middleware/rest.js b/lib/middleware/rest.js index 0b321d26b..71c98154a 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. @@ -22,17 +23,30 @@ module.exports = rest; */ function rest() { - var tokenParser = null; - return function(req, res, next) { + return function restApiHandler(req, res, next) { var app = req.app; - var handler = app.handler('rest'); + var restHandler = app.handler('rest'); if (req.url === '/routes') { - res.send(handler.adapter.allRoutes()); + return res.send(restHandler.adapter.allRoutes()); } else if (req.url === '/models') { return res.send(app.remotes().toJSON()); - } else 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. @@ -40,18 +54,12 @@ function rest() { // 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 }); + preHandlers.push(loopback.token({ model: AccessToken })); } - - tokenParser(req, res, function(err) { - if (err) { - next(err); - } else { - handler(req, res, next); - } - }); - } else { - handler(req, res, next); } + + async.eachSeries(preHandlers.concat(restHandler), function(handler, done) { + handler(req, res, done); + }, next); }; } 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..89877af72 100644 --- a/test/rest.middleware.test.js +++ b/test/rest.middleware.test.js @@ -130,6 +130,107 @@ describe('loopback.rest', function() { }); }); + describe('context propagation', function() { + var User; + + 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 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' } + ] + }); + }); + + 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(); + }); + }); + } + + 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.set('remoting', { context: { enableHttpContext: true } }); + app.use(loopback.rest()); + + 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); + }); + }); + 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