Merge pull request #908 from strongloop/fix/express-helpers-in-phased-middleware

Rework phased middleware, fix several bugs
This commit is contained in:
Miroslav Bajtoš 2014-12-12 13:44:52 +01:00
commit 5150a58364
3 changed files with 244 additions and 112 deletions

View File

@ -1,9 +1,10 @@
var assert = require('assert');
var express = require('express');
var merge = require('util')._extend;
var PhaseList = require('loopback-phase').PhaseList;
var mergePhaseNameLists = require('loopback-phase').mergePhaseNameLists;
var debug = require('debug')('loopback:app');
var pathToRegexp = require('path-to-regexp');
var BUILTIN_MIDDLEWARE = { builtin: true };
var proto = {};
@ -104,9 +105,12 @@ proto.defineMiddlewarePhases = function(nameOrArray) {
this.lazyrouter();
if (Array.isArray(nameOrArray)) {
this._requestHandlingPhases.zipMerge(nameOrArray);
this._requestHandlingPhases =
mergePhaseNameLists(this._requestHandlingPhases, nameOrArray);
} else {
this._requestHandlingPhases.addBefore('routes', nameOrArray);
// add the new phase before 'routes'
var routesIx = this._requestHandlingPhases.indexOf('routes');
this._requestHandlingPhases.splice(routesIx - 1, 0, nameOrArray);
}
return this;
@ -131,90 +135,39 @@ proto.middleware = function(name, paths, handler) {
if (handler === undefined && typeof paths === 'function') {
handler = paths;
paths = [];
}
if (typeof paths === 'string' || paths instanceof RegExp) {
paths = [paths];
paths = undefined;
}
assert(typeof name === 'string' && name, '"name" must be a non-empty string');
assert(typeof handler === 'function', '"handler" must be a function');
assert(Array.isArray(paths), '"paths" must be an array');
var fullName = name;
var handlerName = handler.name || '(anonymous)';
if (paths === undefined) {
paths = '/';
}
var fullPhaseName = name;
var handlerName = handler.name || '<anonymous>';
var hook = 'use';
var m = name.match(/^(.+):(before|after)$/);
if (m) {
name = m[1];
hook = m[2];
}
var phase = this._requestHandlingPhases.find(name);
if (!phase)
if (this._requestHandlingPhases.indexOf(name) === -1)
throw new Error('Unknown middleware phase ' + name);
var matches = createRequestMatcher(paths);
debug('use %s %s %s', fullPhaseName, paths, handlerName);
var wrapper;
if (handler.length === 4) {
// handler is function(err, req, res, next)
debug('Add error handler %j to phase %j', handlerName, fullName);
this._skipLayerSorting = true;
this.use(paths, handler);
this._router.stack[this._router.stack.length - 1].phase = fullPhaseName;
this._skipLayerSorting = false;
wrapper = function errorHandler(ctx, next) {
if (ctx.err && matches(ctx.req)) {
var err = ctx.err;
ctx.err = undefined;
handler(err, ctx.req, ctx.res, storeErrorAndContinue(ctx, next));
} else {
next();
}
};
} else {
// handler is function(req, res, next)
debug('Add middleware %j to phase %j', handlerName , fullName);
wrapper = function regularHandler(ctx, next) {
if (ctx.err || !matches(ctx.req)) {
next();
} else {
handler(ctx.req, ctx.res, storeErrorAndContinue(ctx, next));
}
};
}
this._sortLayersByPhase();
phase[hook](wrapper);
return this;
};
function createRequestMatcher(paths) {
if (!paths.length) {
return function requestMatcher(req) { return true; };
}
var checks = paths.map(function(p) {
return pathToRegexp(p, {
sensitive: true,
strict: false,
end: false
});
});
return function requestMatcher(req) {
return checks.some(function(regex) {
return regex.test(req.url);
});
};
}
function storeErrorAndContinue(ctx, next) {
return function(err) {
if (err) ctx.err = err;
next();
};
}
// Install our custom PhaseList-based handler into the app
proto.lazyrouter = function() {
var self = this;
@ -222,45 +175,70 @@ proto.lazyrouter = function() {
self.__expressLazyRouter();
// Storing the fn in another property of the router object
// allows us to call the method with the router as `this`
// without the need to use slow `call` or `apply`.
self._router.__expressHandle = self._router.handle;
var router = self._router;
self._requestHandlingPhases = new PhaseList();
self._requestHandlingPhases.add([
// Mark all middleware added by Router ctor as builtin
// The sorting algo will keep them at beginning of the list
router.stack.forEach(function(layer) {
layer.phase = BUILTIN_MIDDLEWARE;
});
router.__expressUse = router.use;
router.use = function useAndSort() {
var retval = this.__expressUse.apply(this, arguments);
self._sortLayersByPhase();
return retval;
};
router.__expressRoute = router.route;
router.route = function routeAndSort() {
var retval = this.__expressRoute.apply(this, arguments);
self._sortLayersByPhase();
return retval;
};
self._requestHandlingPhases = [
'initial', 'session', 'auth', 'parse',
'routes', 'files', 'final'
]);
// In order to pass error into express router, we have
// to pass it to a middleware executed from within the router.
// This is achieved by adding a phase-handler that wraps the error
// into `req` object and then a router-handler that unwraps the error
// and calls `next(err)`.
// It is important to register these two handlers at the very beginning,
// before any other handlers are added.
self.middleware('routes', function wrapError(err, req, res, next) {
req.__err = err;
next();
});
self.use(function unwrapError(req, res, next) {
var err = req.__err;
req.__err = undefined;
next(err);
});
self.middleware('routes', function runRootHandlers(req, res, next) {
self._router.__expressHandle(req, res, next);
});
// Overwrite the original handle() function provided by express,
// replace it with our implementation based on PhaseList
self._router.handle = function(req, res, next) {
var ctx = { req: req, res: res };
self._requestHandlingPhases.run(ctx, function(err) {
next(err || ctx.err);
});
};
];
};
proto._sortLayersByPhase = function() {
if (this._skipLayerSorting) return;
var phaseOrder = {};
this._requestHandlingPhases.forEach(function(name, ix) {
phaseOrder[name + ':before'] = ix * 3;
phaseOrder[name] = ix * 3 + 1;
phaseOrder[name + ':after'] = ix * 3 + 2;
});
var router = this._router;
router.stack.sort(compareLayers);
function compareLayers(left, right) {
var leftPhase = left.phase;
var rightPhase = right.phase;
if (leftPhase === rightPhase) return 0;
// Builtin middleware is always first
if (leftPhase === BUILTIN_MIDDLEWARE) return -1;
if (rightPhase === BUILTIN_MIDDLEWARE) return 1;
// Layers registered via app.use and app.route
// are executed as the first items in `routes` phase
if (leftPhase === undefined) {
if (rightPhase === 'routes')
return -1;
return phaseOrder['routes'] - phaseOrder[rightPhase];
}
if (rightPhase === undefined)
return -compareLayers(right, left);
// Layers registered via `app.middleware` are compared via phase & hook
return phaseOrder[leftPhase] - phaseOrder[rightPhase];
}
};

View File

@ -42,10 +42,9 @@
"express": "^4.10.2",
"inflection": "~1.4.2",
"loopback-connector-remote": "^1.0.1",
"loopback-phase": "^1.0.1",
"loopback-phase": "^1.2.0",
"nodemailer": "~1.3.0",
"nodemailer-stub-transport": "~0.1.4",
"path-to-regexp": "^1.0.1",
"serve-favicon": "^2.1.6",
"strong-remoting": "^2.4.0",
"uid2": "0.0.3",

View File

@ -4,6 +4,7 @@ var async = require('async');
var path = require('path');
var http = require('http');
var express = require('express');
var loopback = require('../');
var PersistedModel = loopback.PersistedModel;
@ -41,6 +42,17 @@ describe('app', function() {
});
});
it('preserves order of handlers in the same phase', function(done) {
app.middleware('initial', namedHandler('first'));
app.middleware('initial', namedHandler('second'));
executeMiddlewareHandlers(app, function(err) {
if (err) return done(err);
expect(steps).to.eql(['first', 'second']);
done();
});
});
it('supports `before:` and `after:` prefixes', function(done) {
app.middleware('routes:before', namedHandler('routes:before'));
app.middleware('routes:after', namedHandler('routes:after'));
@ -151,6 +163,140 @@ describe('app', function() {
});
});
it('sets req.url to a sub-path', function(done) {
app.middleware('initial', ['/scope'], function(req, res, next) {
steps.push(req.url);
next();
});
executeMiddlewareHandlers(app, '/scope/id', function(err) {
if (err) return done(err);
expect(steps).to.eql(['/id']);
done();
});
});
it('exposes express helpers on req and res objects', function(done) {
var req;
var res;
app.middleware('initial', function(rq, rs, next) {
req = rq;
res = rs;
next();
});
executeMiddlewareHandlers(app, function(err) {
if (err) return done(err);
expect(getObjectAndPrototypeKeys(req), 'request').to.include.members([
'accepts',
'get',
'param',
'params',
'query',
'res'
]);
expect(getObjectAndPrototypeKeys(res), 'response').to.include.members([
'cookie',
'download',
'json',
'jsonp',
'redirect',
'req',
'send',
'sendFile',
'set'
]);
done();
});
});
it('sets req.baseUrl and req.originalUrl', function(done) {
var reqProps;
app.middleware('initial', function(req, res, next) {
reqProps = { baseUrl: req.baseUrl, originalUrl: req.originalUrl };
next();
});
executeMiddlewareHandlers(app, '/test/url', function(err) {
if (err) return done(err);
expect(reqProps).to.eql({ baseUrl: '', originalUrl: '/test/url' });
done();
});
});
it('preserves correct order of routes vs. middleware', function(done) {
// This test verifies that `app.route` triggers sort of layers
app.middleware('files', namedHandler('files'));
app.get('/test', namedHandler('route'));
executeMiddlewareHandlers(app, '/test', function(err) {
if (err) return done(err);
expect(steps).to.eql(['route', 'files']);
done();
});
});
it('correctly mounts express apps', function(done) {
var data;
var mountWasEmitted;
var subapp = express();
subapp.use(function(req, res, next) {
data = {
mountpath: req.app.mountpath,
parent: req.app.parent
};
next();
});
subapp.on('mount', function() { mountWasEmitted = true; });
app.middleware('routes', '/mountpath', subapp);
executeMiddlewareHandlers(app, '/mountpath/test', function(err) {
if (err) return done(err);
expect(mountWasEmitted, 'mountWasEmitted').to.be.true();
expect(data).to.eql({
mountpath: '/mountpath',
parent: app
});
done();
});
});
it('restores req & res on return from mounted express app', function(done) {
// jshint proto:true
var expected = {};
var actual = {};
var subapp = express();
subapp.use(function verifyTestAssumptions(req, res, next) {
expect(req.__proto__).to.not.equal(expected.req);
expect(res.__proto__).to.not.equal(expected.res);
next();
});
app.middleware('initial', function saveOriginalValues(req, res, next) {
expected.req = req.__proto__;
expected.res = res.__proto__;
next();
});
app.middleware('routes', subapp);
app.middleware('final', function saveActualValues(req, res, next) {
actual.req = req.__proto__;
actual.res = res.__proto__;
next();
});
executeMiddlewareHandlers(app, function(err) {
if (err) return done(err);
expect(actual.req, 'req').to.equal(expected.req);
expect(actual.res, 'res').to.equal(expected.res);
done();
});
});
function namedHandler(name) {
return function(req, res, next) {
steps.push(name);
@ -160,10 +306,19 @@ describe('app', function() {
function pathSavingHandler() {
return function(req, res, next) {
steps.push(req.url);
steps.push(req.originalUrl);
next();
};
}
function getObjectAndPrototypeKeys(obj) {
var result = [];
for (var k in obj) {
result.push(k);
}
result.sort();
return result;
}
});
describe.onServer('.middlewareFromConfig', function() {
@ -223,7 +378,7 @@ describe('app', function() {
app.middlewareFromConfig(
function factory() {
return function(req, res, next) {
steps.push(req.url);
steps.push(req.originalUrl);
next();
};
},
@ -286,7 +441,7 @@ describe('app', function() {
it('throws helpful error on ordering conflict', function() {
app.defineMiddlewarePhases(['first', 'second']);
expect(function() { app.defineMiddlewarePhases(['second', 'first']); })
.to.throw(/ordering conflict.*first.*second/);
.to.throw(/Ordering conflict.*first.*second/);
});
function verifyMiddlewarePhases(names, done) {