diff --git a/lib/remote-objects.js b/lib/remote-objects.js index ef5e0a12..a4350704 100644 --- a/lib/remote-objects.js +++ b/lib/remote-objects.js @@ -17,7 +17,9 @@ var inherits = util.inherits; var assert = require('assert'); var Dynamic = require('./dynamic'); var SharedClass = require('./shared-class'); +var SharedMethod = require('./shared-method'); var ExportsHelper = require('./exports-helper'); +var PhaseList = require('loopback-phase').PhaseList; // require the rest adapter for browserification // TODO(ritch) remove this somehow...? @@ -54,6 +56,8 @@ function RemoteObjects(options) { this.options = options || {}; this.exports = this.options.exports || {}; this._classes = {}; + + this._setupPhases(); } /*! @@ -382,6 +386,29 @@ RemoteObjects.prototype.afterError = function(methodMatch, fn) { this.on('afterError.' + methodMatch, fn); }; +RemoteObjects.prototype.registerPhaseHandler = function(phaseName, + methodNameWildcard, + handler) { + var pattern = methodNameWildcard.replace(/[.+?^${}()|[\]\\]/g, '\\$&') + // single star matches one segment only + .replace(/(^|\.)\*($|\.)/g, '$1[^.]*$2') + // double-start match one or more segments + .replace(/(^|\.)\*\*($|\.)/g, '$1.*$2'); + var matcher = new RegExp('^' + pattern + '$'); + + debug('registerPhaseHandler(%j) -> pattern %j', + methodNameWildcard, + pattern); + + this.phases.registerHandler(phaseName, function matchHandler(ctx, next) { + if (matcher.test(ctx.method.stringName)) { + handler(ctx, next); + } else { + next(); + } + }); +}; + /*! * Create a middleware style emit that supports wildcards. */ @@ -600,35 +627,57 @@ RemoteObjects.prototype._executeAuthorizationHook = function(ctx, cb) { } }; +RemoteObjects.prototype._setupPhases = function() { + var self = this; + self.phases = new PhaseList(); + var auth = self.phases.add('auth'); + var invoke = self.phases.add('invoke'); + + auth.use(function phaseAuthorization(ctx, next) { + self._executeAuthorizationHook(ctx, next); + }); + + invoke.before(function phaseBeforeInvoke(ctx, next) { + self.execHooks('before', ctx.method, ctx.getScope(), ctx, next); + }); + + invoke.use(function phaseInvoke(ctx, next) { + ctx.invoke(ctx.getScope(), ctx.method, function(err, result) { + if (!err) ctx.result = result; + next(err); + }); + }); + + invoke.after(function phaseAfterInvoke(ctx, next) { + self.execHooks('after', ctx.method, ctx.getScope(), ctx, next); + }); +}; + /** * Invoke the given shared method using the supplied context. * Execute registered before/after hooks. + * * @param {Object} ctx * @param {Object} method * @param {function(Error=)} cb */ RemoteObjects.prototype.invokeMethodInContext = function(ctx, method, cb) { var self = this; - var scope = ctx.getScope(); - self._executeAuthorizationHook(ctx, function(err) { - if (err) return triggerErrorAndCallBack(err); - - self.execHooks('before', method, scope, ctx, function(err) { - if (err) return triggerErrorAndCallBack(err); - - ctx.invoke(scope, method, function(err, result) { - if (err) return triggerErrorAndCallBack(err); + if (cb === undefined && typeof method === 'function') { + // the new API with two arguments + cb = method; + method = ctx.method; + } else { + // backwards compatibility: invokeMethodInContext(ctx, method, cb) + // TODO remove in v3.0 + assert.equal(method, ctx.method); + deprecated('invokeMethodInContext(ctx, method, cb) is deprecated.' + + 'Pass the method as ctx.method instead.'); + } - ctx.result = result; - self.execHooks('after', method, scope, ctx, function(err) { - if (err) return triggerErrorAndCallBack(err); - cb(); - }); - }); - }); - }); + self.phases.run(ctx, triggerErrorAndCallBack); function triggerErrorAndCallBack(err) { ctx.error = err; diff --git a/lib/rest-adapter.js b/lib/rest-adapter.js index aaabe9ac..3eedce1a 100644 --- a/lib/rest-adapter.js +++ b/lib/rest-adapter.js @@ -444,7 +444,7 @@ RestAdapter.prototype._invokeMethod = function(ctx, method, next) { } steps.push( - this.remotes.invokeMethodInContext.bind(this.remotes, ctx, method) + this.remotes.invokeMethodInContext.bind(this.remotes, ctx) ); if (method.rest.after) { diff --git a/package.json b/package.json index ea1c8634..87a40f5b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "inflection": "^1.7.1", "jayson": "^1.2.0", "js2xmlparser": "^0.1.9", + "loopback-phase": "^1.3.0", "mux-demux": "^3.7.9", "qs": "^2.4.2", "request": "^2.55.0", diff --git a/test/phase-handlers.test.js b/test/phase-handlers.test.js new file mode 100644 index 00000000..6db45f50 --- /dev/null +++ b/test/phase-handlers.test.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var express = require('express'); +var RemoteObjects = require('../'); +var User = require('./e2e/fixtures/user'); + +describe('phase handlers', function() { + var server, remotes, clientRemotes; + + beforeEach(function setupServer(done) { + var app = express(); + remotes = RemoteObjects.create(); + remotes.exports.User = User; + app.use(function(req, res, next) { + // always build a new handler to pick new methods added by tests + remotes.handler('rest')(req, res, next); + }); + server = app.listen(0, '127.0.0.1', done); + }); + + beforeEach(function setupClient() { + clientRemotes = RemoteObjects.create(); + clientRemotes.exports.User = User; + var url = 'http://127.0.0.1:' + server.address().port; + clientRemotes.connect(url, 'rest'); + }); + + afterEach(function teardownServer(done) { + server.close(done); + }); + + it('has built-in phases "auth" and "invoke"', function() { + expect(remotes.phases.getPhaseNames()).to.eql(['auth', 'invoke']); + }); + + it('invokes phases in the correct order', function(done) { + var phasesRun = []; + var pushNameAndNext = function(name) { + return function(ctx, next) { phasesRun.push(name); next(); }; + }; + + remotes.phases.find('auth').use(pushNameAndNext('phaseHandler-auth')); + var invokePhase = remotes.phases.find('invoke'); + invokePhase.before(pushNameAndNext('phaseHandler-invoke:before')); + invokePhase.use(pushNameAndNext('phaseHandler-invoke:use')); + invokePhase.after(pushNameAndNext('phaseHandler-invoke:after')); + + remotes.authorization = pushNameAndNext('hook-authorization'); + remotes.before('**', pushNameAndNext('hook-remotes.before')); + remotes.after('**', pushNameAndNext('hook-remotes.after')); + + User.pushName = function(cb) { phasesRun.push('invoke method'); cb(); }; + User.pushName.shared = true; + + invokeRemote('User.pushName', function(err) { + if (err) return done(err); + expect(phasesRun).to.eql([ + 'hook-authorization', + 'phaseHandler-auth', + 'hook-remotes.before', + 'phaseHandler-invoke:before', + 'invoke method', + 'phaseHandler-invoke:use', + 'hook-remotes.after', + 'phaseHandler-invoke:after', + ]); + done(); + }); + }); + + describe('registerPhaseHandler', function() { + var handlersRun; + + beforeEach(function() { + User.static = function(cb) { cb(); }; + User.static.shared = true; + + User.prototype.proto = function(cb) { cb(); }; + User.prototype.proto.shared = true; + + handlersRun = []; + function register(wildcard) { + remotes.registerPhaseHandler('invoke', wildcard, function(ctx, next) { + handlersRun.push(wildcard); + next(); + }); + } + + register('**'); + register('User.**'); + + register('User.*'); + register('User.static'); + register('User.proto'); // does not exist + + register('User.prototype.*'); + register('User.prototype.proto'); + register('User.prototype.static'); // does not exist + }); + + it('matches static methods using wildcards', function(done) { + invokeRemote('User.static', function(err) { + if (err) return done(err); + expect(handlersRun).to.eql([ + '**', + 'User.**', + 'User.*', + 'User.static', + ]); + done(); + }); + }); + + it('matches prototype methods using wildcards', function(done) { + invokeRemote('User.prototype.proto', function(err) { + if (err) return done(err); + expect(handlersRun).to.eql([ + '**', + 'User.**', + 'User.prototype.*', + 'User.prototype.proto', + ]); + done(); + }); + }); + }); + + function invokeRemote(method, callback) { + var args = []; + clientRemotes.invoke(method, args, callback); + } +});