Middleware phases - initial implementation

Modify the app and router implementation, so that the middleware is
executed in order defined by phases.

Predefined phases:

    'initial', 'session', 'auth', 'parse', 'routes', 'files', 'final'

Methods defined via `app.use`, `app.route` and friends are executed
as the first thing in 'routes' phase.

API usage:

    app.middleware('initial', compression());
    app.middleware('initial:before', serveFavicon());
    app.middleware('files:after', loopback.urlNotFound());
    app.middleware('final:after', errorHandler());

Middleware flavours:

    // regular handler
    function handler(req, res, next) {
      // do stuff
      next();
    }

    // error handler
    function errorHandler(err, req, res, next) {
      // handle error and/or call next
      next(err);
    }
This commit is contained in:
Miroslav Bajtoš 2014-11-05 20:07:58 +01:00
parent 747da886c9
commit 4e1433b519
4 changed files with 226 additions and 3 deletions

View File

@ -2,7 +2,7 @@
* Module dependencies.
*/
var express = require('express');
var express = require('./server-app');
var proto = require('./application');
var fs = require('fs');
var ejs = require('ejs');

125
lib/server-app.js Normal file
View File

@ -0,0 +1,125 @@
var express = require('express');
var merge = require('util')._extend;
var PhaseList = require('loopback-phase').PhaseList;
var debug = require('debug')('loopback:app');
var proto = {};
module.exports = function loopbackExpress() {
var app = express();
app.__expressLazyRouter = app.lazyrouter;
merge(app, proto);
return app;
};
/**
* Register a middleware handler to be executed in a given phase.
* @param {string} name The phase name, e.g. "init" or "routes".
* @param {function} handler The middleware handler, one of
* `function(req, res, next)` or
* `function(err, req, res, next)`
* @returns {object} this (fluent API)
*/
proto.middleware = function(name, handler) {
this.lazyrouter();
var fullName = 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)
throw new Error('Unknown middleware phase ' + name);
var wrapper;
if (handler.length === 4) {
// handler is function(err, req, res, next)
debug('Add error handler %j to phase %j', handlerName, fullName);
wrapper = function errorHandler(ctx, next) {
if (ctx.err) {
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) {
next();
} else {
handler(ctx.req, ctx.res, storeErrorAndContinue(ctx, next));
}
};
}
phase[hook](wrapper);
return this;
};
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;
if (self._router) return;
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;
self._requestHandlingPhases = new PhaseList();
self._requestHandlingPhases.add([
'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);
});
};
};

View File

@ -38,7 +38,7 @@
"canonical-json": "0.0.4",
"debug": "~2.0.0",
"ejs": "~1.0.0",
"express": "4.x",
"express": "^4.10.2",
"inflection": "~1.4.2",
"loopback-connector-remote": "^1.0.1",
"nodemailer": "~1.3.0",
@ -78,6 +78,7 @@
"karma-script-launcher": "~0.1.0",
"loopback-boot": "^1.1.0",
"loopback-datasource-juggler": "^2.8.0",
"loopback-phase": "^1.0.1",
"loopback-testing": "~0.2.0",
"mocha": "~1.21.4",
"serve-favicon": "~2.1.3",
@ -90,6 +91,7 @@
},
"browser": {
"express": "./lib/browser-express.js",
"./lib/server-app.js": "./lib/browser-express.js",
"connect": false,
"nodemailer": false
},

View File

@ -1,5 +1,5 @@
var path = require('path');
var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app');
var http = require('http');
var loopback = require('../');
var PersistedModel = loopback.PersistedModel;
@ -7,6 +7,102 @@ var describe = require('./util/describe');
var it = require('./util/it');
describe('app', function() {
describe.onServer('.middleware(phase, handler)', function() {
var app;
var steps;
beforeEach(function setup() {
app = loopback();
steps = [];
});
it('runs middleware in phases', function(done) {
var PHASES = [
'initial', 'session', 'auth', 'parse',
'routes', 'files', 'final'
];
PHASES.forEach(function(name) {
app.middleware(name, namedHandler(name));
});
app.use(namedHandler('main'));
executeHandlers(function(err) {
if (err) return done(err);
expect(steps).to.eql([
'initial', 'session', 'auth', 'parse',
'main', 'routes', 'files', 'final'
]);
done();
});
});
it('supports "before:" and "after:" prefixes', function(done) {
app.middleware('routes:before', namedHandler('routes:before'));
app.middleware('routes:after', namedHandler('routes:after'));
app.use(namedHandler('main'));
executeHandlers(function(err) {
if (err) return done(err);
expect(steps).to.eql(['routes:before', 'main', 'routes:after']);
done();
});
});
it('injects error from previous phases into the router', function(done) {
var expectedError = new Error('expected error');
app.middleware('initial', function(req, res, next) {
steps.push('initial');
next(expectedError);
});
// legacy solution for error handling
app.use(function errorHandler(err, req, res, next) {
expect(err).to.equal(expectedError);
steps.push('error');
next();
});
executeHandlers(function(err) {
if (err) return done(err);
expect(steps).to.eql(['initial', 'error']);
done();
});
});
it('passes unhandled error to callback', function(done) {
var expectedError = new Error('expected error');
app.middleware('initial', function(req, res, next) {
next(expectedError);
});
executeHandlers(function(err) {
expect(err).to.equal(expectedError);
done();
});
});
function namedHandler(name) {
return function(req, res, next) {
steps.push(name);
next();
};
}
function executeHandlers(callback) {
var server = http.createServer(function(req, res) {
app.handle(req, res, callback);
});
request(server)
.get('/test/url')
.end(function(err) {
if (err) return callback(err);
});
}
});
describe('app.model(Model)', function() {
var app, db;