Load middleware and phases from `middleware.json`

Sample JSON:

        {
          "routes:before": {
            "morgan": {
              "params": ["dev"]
            }
          },
          "routes": {
            "loopback/server/middleware/rest": {
            }
          },
          "subapps": {
            "./adminer": {
            },
          }
        }

The JSON file can be customized using the usual conventions:
  - middleware.local.{js|json}
  - middleware.{env}.{js|json}

It is also possible to mount the same middleware in the same phase
multiple times with different configuration.

Example config:

    {
      "auth": {
        "oauth2": [
          {
            "params": "first"
          },
          {
            "params": "second"
          }
        ]
      },
    });
This commit is contained in:
Miroslav Bajtoš 2014-11-12 16:56:01 +01:00
parent 08fcc5faa7
commit 1114bc9227
9 changed files with 393 additions and 10 deletions

View File

@ -107,6 +107,8 @@ var addInstructionsToBrowserify = require('./lib/bundler');
* `production`; however the applications are free to use any names. * `production`; however the applications are free to use any names.
* @property {Array.<String>} [modelSources] List of directories where to look * @property {Array.<String>} [modelSources] List of directories where to look
* for files containing model definitions. * for files containing model definitions.
* @property {Object} [middleware] Middleware configuration to use instead
* of `{appRootDir}/middleware.json`
* @property {Array.<String>} [bootDirs] List of directories where to look * @property {Array.<String>} [bootDirs] List of directories where to look
* for boot scripts. * for boot scripts.
* @property {Array.<String>} [bootScripts] List of script files to execute * @property {Array.<String>} [bootScripts] List of script files to execute

View File

@ -1,6 +1,7 @@
var fs = require('fs'); var fs = require('fs');
var path = require('path'); var path = require('path');
var commondir = require('commondir'); var commondir = require('commondir');
var cloneDeep = require('lodash.clonedeep');
/** /**
* Add boot instructions to a browserify bundler. * Add boot instructions to a browserify bundler.
@ -60,6 +61,17 @@ function addScriptsToBundle(name, list, bundler) {
} }
function bundleInstructions(instructions, bundler) { function bundleInstructions(instructions, bundler) {
instructions = cloneDeep(instructions);
var hasMiddleware = instructions.middleware.phases.length ||
instructions.middleware.middleware.length;
if (hasMiddleware) {
console.warn(
'Discarding middleware instructions,' +
' loopback client does not support middleware.');
}
delete instructions.middleware;
var instructionsString = JSON.stringify(instructions, null, 2); var instructionsString = JSON.stringify(instructions, null, 2);
/* The following code does not work due to a bug in browserify /* The following code does not work due to a bug in browserify

View File

@ -44,6 +44,14 @@ module.exports = function compile(options) {
ConfigLoader.loadDataSources(dsRootDir, env); ConfigLoader.loadDataSources(dsRootDir, env);
assertIsValidConfig('data source', dataSourcesConfig); assertIsValidConfig('data source', dataSourcesConfig);
// not configurable yet
var middlewareRootDir = appRootDir;
var middlewareConfig = options.middleware ||
ConfigLoader.loadMiddleware(middlewareRootDir, env);
var middlewareInstructions =
buildMiddlewareInstructions(middlewareRootDir, middlewareConfig);
// require directories // require directories
var bootDirs = options.bootDirs || []; // precedence var bootDirs = options.bootDirs || []; // precedence
bootDirs = bootDirs.concat(path.join(appRootDir, 'boot')); bootDirs = bootDirs.concat(path.join(appRootDir, 'boot'));
@ -67,6 +75,7 @@ module.exports = function compile(options) {
config: appConfig, config: appConfig,
dataSources: dataSourcesConfig, dataSources: dataSourcesConfig,
models: modelInstructions, models: modelInstructions,
middleware: middlewareInstructions,
files: { files: {
boot: bootScripts boot: bootScripts
} }
@ -338,3 +347,46 @@ function loadModelDefinition(rootDir, jsonFile, allFiles) {
sourceFile: sourceFile sourceFile: sourceFile
}; };
} }
function buildMiddlewareInstructions(rootDir, config) {
var phasesNames = Object.keys(config);
var middlewareList = [];
phasesNames.forEach(function(phase) {
var phaseConfig = config[phase];
Object.keys(phaseConfig).forEach(function(middleware) {
var start = middleware.substring(0, 2);
var sourceFile = start !== './' && start !== '..' ?
middleware :
path.resolve(rootDir, middleware);
var allConfigs = phaseConfig[middleware];
if (!Array.isArray(allConfigs))
allConfigs = [allConfigs];
allConfigs.forEach(function(config) {
var middlewareConfig = cloneDeep(config);
middlewareConfig.phase = phase;
middlewareList.push({
sourceFile: require.resolve(sourceFile),
config: middlewareConfig
});
});
});
});
var flattenedPhaseNames = phasesNames
.map(function getBaseName(name) {
return name.replace(/:[^:]+$/, '');
})
.filter(function differsFromPreviousItem(value, ix, source) {
// Skip duplicate entries. That happens when
// `name:before` and `name:after` are both translated to `name`
return ix === 0 || value !== source[ix - 1];
});
return {
phases: flattenedPhaseNames,
middleware: middlewareList
};
}

View File

@ -34,6 +34,16 @@ ConfigLoader.loadModels = function(rootDir, env) {
return tryReadJsonConfig(rootDir, 'model-config') || {}; return tryReadJsonConfig(rootDir, 'model-config') || {};
}; };
/**
* Load middleware config from `middleware.json` and friends.
* @param {String} rootDir Directory where to look for files.
* @param {String} env Environment, usually `process.env.NODE_ENV`
* @returns {Object}
*/
ConfigLoader.loadMiddleware = function(rootDir, env) {
return loadNamed(rootDir, env, 'middleware', mergeMiddlewareConfig);
};
/*-- Implementation --*/ /*-- Implementation --*/
/** /**
@ -126,6 +136,32 @@ function mergeAppConfig(target, config, fileName) {
} }
} }
function mergeMiddlewareConfig(target, config, fileName) {
var err;
for (var phase in config) {
if (phase in target) {
err = mergePhaseConfig(target[phase], config[phase], phase);
} else {
err = 'The phase "' + phase + '" is not defined in the main config.';
}
if (err)
throw new Error('Cannot apply ' + fileName + ': ' + err);
}
}
function mergePhaseConfig(target, config, phase) {
var err;
for (var middleware in config) {
if (middleware in target) {
err = mergeObjects(target[middleware], config[middleware]);
} else {
err = 'The middleware "' + middleware + '" in phase "' + phase + '"' +
'is not defined in the main config.';
}
if (err) return err;
}
}
function mergeObjects(target, config, keyPrefix) { function mergeObjects(target, config, keyPrefix) {
for (var key in config) { for (var key in config) {
var fullKey = keyPrefix ? keyPrefix + '.' + key : key; var fullKey = keyPrefix ? keyPrefix + '.' + key : key;

View File

@ -26,6 +26,7 @@ module.exports = function execute(app, instructions, callback) {
setupDataSources(app, instructions); setupDataSources(app, instructions);
setupModels(app, instructions); setupModels(app, instructions);
setupMiddleware(app, instructions);
// Run the boot scripts in series synchronously or asynchronously // Run the boot scripts in series synchronously or asynchronously
// Please note async supports both styles // Please note async supports both styles
@ -250,6 +251,30 @@ function tryRequire(modulePath) {
} }
} }
function setupMiddleware(app, instructions) {
if (!instructions.middleware) {
// the browserified client does not support middleware
return;
}
var phases = instructions.middleware.phases;
assert(Array.isArray(phases),
'instructions.middleware.phases must be an array');
var middleware = instructions.middleware.middleware;
assert(Array.isArray(middleware),
'instructions.middleware.middleware must be an object');
debug('Defining middleware phases %j', phases);
app.defineMiddlewarePhases(phases);
middleware.forEach(function(data) {
debug('Configuring middleware %j', data.sourceFile);
var factory = require(data.sourceFile);
app.middlewareFromConfig(factory, data.config);
});
}
function runBootScripts(app, instructions, callback) { function runBootScripts(app, instructions, callback) {
runScripts(app, instructions.files.boot, callback); runScripts(app, instructions.files.boot, callback);
} }

View File

@ -673,6 +673,186 @@ describe('compiler', function() {
expect(instructions.config).to.not.have.property('modified'); expect(instructions.config).to.not.have.property('modified');
}); });
}); });
describe('for middleware', function() {
beforeEach(function() {
appdir.createConfigFilesSync();
});
it('emits middleware instructions', function() {
appdir.writeConfigFileSync('middleware.json', {
initial: {
},
custom: {
'loopback/server/middleware/url-not-found': {
params: 'some-config-data'
}
},
});
var instructions = boot.compile(appdir.PATH);
expect(instructions.middleware).to.eql({
phases: ['initial', 'custom'],
middleware: [{
sourceFile:
require.resolve('loopback/server/middleware/url-not-found'),
config: {
phase: 'custom',
params: 'some-config-data'
}
}]
});
});
it('fails when a module middleware cannot be resolved', function() {
appdir.writeConfigFileSync('middleware.json', {
final: {
'loopback/path-does-not-exist': { }
}
});
expect(function() { boot.compile(appdir.PATH); })
.to.throw(/path-does-not-exist/);
});
it('resolves paths relatively to appRootDir', function() {
appdir.writeConfigFileSync('./middleware.json', {
routes: {
// resolves to ./middleware.json
'./middleware': { }
}
});
var instructions = boot.compile(appdir.PATH);
expect(instructions.middleware).to.eql({
phases: ['routes'],
middleware: [{
sourceFile: path.resolve(appdir.PATH, 'middleware.json'),
config: { phase: 'routes' }
}]
});
});
it('merges config.params', function() {
appdir.writeConfigFileSync('./middleware.json', {
routes: {
'./middleware': {
params: {
key: 'initial value'
}
}
}
});
appdir.writeConfigFileSync('./middleware.local.json', {
routes: {
'./middleware': {
params: {
key: 'custom value',
}
}
}
});
var instructions = boot.compile(appdir.PATH);
expect(instructions.middleware.middleware[0].config.params).to.eql({
key: 'custom value'
});
});
it('merges config.enabled', function() {
appdir.writeConfigFileSync('./middleware.json', {
routes: {
'./middleware': {
params: {
key: 'initial value'
}
}
}
});
appdir.writeConfigFileSync('./middleware.local.json', {
routes: {
'./middleware': {
enabled: false
}
}
});
var instructions = boot.compile(appdir.PATH);
expect(instructions.middleware.middleware[0].config)
.to.have.property('enabled', false);
});
it('flattens sub-phases', function() {
appdir.writeConfigFileSync('middleware.json', {
'initial:after': {
},
'custom:before': {
'loopback/server/middleware/url-not-found': {
params: 'some-config-data'
}
},
'custom:after': {
}
});
var instructions = boot.compile(appdir.PATH);
expect(instructions.middleware.phases, 'phases')
.to.eql(['initial', 'custom']);
expect(instructions.middleware.middleware, 'middleware')
.to.eql([{
sourceFile:
require.resolve('loopback/server/middleware/url-not-found'),
config: {
phase: 'custom:before',
params: 'some-config-data'
}
}]);
});
it('supports multiple instances of the same middleware', function() {
appdir.writeConfigFileSync('middleware.json', {
'final': {
'./middleware': [
{
params: 'first'
},
{
params: 'second'
}
]
},
});
var instructions = boot.compile(appdir.PATH);
expect(instructions.middleware.middleware)
.to.eql([
{
sourceFile: path.resolve(appdir.PATH, 'middleware.json'),
config: {
phase: 'final',
params: 'first'
}
},
{
sourceFile: path.resolve(appdir.PATH, 'middleware.json'),
config: {
phase: 'final',
params: 'second'
}
},
]);
});
});
}); });
function getNameProperty(obj) { function getNameProperty(obj) {

View File

@ -6,6 +6,7 @@ var expect = require('chai').expect;
var fs = require('fs-extra'); var fs = require('fs-extra');
var sandbox = require('./helpers/sandbox'); var sandbox = require('./helpers/sandbox');
var appdir = require('./helpers/appdir'); var appdir = require('./helpers/appdir');
var supertest = require('supertest');
var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app');
@ -18,6 +19,13 @@ describe('executor', function() {
beforeEach(function() { beforeEach(function() {
app = loopback(); app = loopback();
// process.bootFlags is used by simple-app/boot/*.js scripts
process.bootFlags = [];
});
afterEach(function() {
delete process.bootFlags;
}); });
var dummyInstructions = someInstructions({ var dummyInstructions = someInstructions({
@ -178,7 +186,6 @@ describe('executor', function() {
describe('with boot and models files', function() { describe('with boot and models files', function() {
beforeEach(function() { beforeEach(function() {
process.bootFlags = process.bootFlags || [];
boot.execute(app, simpleAppInstructions()); boot.execute(app, simpleAppInstructions());
}); });
@ -212,14 +219,6 @@ describe('executor', function() {
}); });
describe('with boot with callback', function() { describe('with boot with callback', function() {
beforeEach(function() {
process.bootFlags = process.bootFlags || [];
});
afterEach(function() {
delete process.bootFlags;
});
it('should run `boot/*` files asynchronously', function(done) { it('should run `boot/*` files asynchronously', function(done) {
boot.execute(app, simpleAppInstructions(), function() { boot.execute(app, simpleAppInstructions(), function() {
expect(process.bootFlags).to.eql([ expect(process.bootFlags).to.eql([
@ -233,7 +232,6 @@ describe('executor', function() {
done(); done();
}); });
}); });
}); });
describe('with PaaS and npm env variables', function() { describe('with PaaS and npm env variables', function() {
@ -326,6 +324,68 @@ describe('executor', function() {
boot.execute(app, someInstructions({ files: { boot: [file] } })); boot.execute(app, someInstructions({ files: { boot: [file] } }));
expect(app.fnCalled, 'exported fn was called').to.be.true(); expect(app.fnCalled, 'exported fn was called').to.be.true();
}); });
it('configures middleware', function(done) {
var pushNamePath = require.resolve('./helpers/push-name-middleware');
boot.execute(app, someInstructions({
middleware: {
phases: ['initial', 'custom'],
middleware: [
{
sourceFile: pushNamePath,
config: {
phase: 'initial',
params: 'initial'
}
},
{
sourceFile: pushNamePath,
config: {
phase: 'custom',
params: 'custom'
}
},
{
sourceFile: pushNamePath,
config: {
phase: 'routes',
params: 'routes'
}
},
{
sourceFile: pushNamePath,
config: {
phase: 'routes',
enabled: false,
params: 'disabled'
}
}
]
}
}));
supertest(app)
.get('/')
.end(function(err, res) {
if (err) return done(err);
var names = (res.headers.names || '').split(',');
expect(names).to.eql(['initial', 'custom', 'routes']);
done();
});
});
it('configures middleware (end-to-end)', function(done) {
boot.execute(app, simpleAppInstructions());
supertest(app)
.get('/')
.end(function(err, res) {
if (err) return done(err);
expect(res.headers.names).to.equal('custom-middleware');
done();
});
});
}); });
function assertValidDataSource(dataSource) { function assertValidDataSource(dataSource) {
@ -350,6 +410,7 @@ function someInstructions(values) {
config: values.config || {}, config: values.config || {},
models: values.models || [], models: values.models || [],
dataSources: values.dataSources || { db: { connector: 'memory' } }, dataSources: values.dataSources || { db: { connector: 'memory' } },
middleware: values.middleware || { phases: [], middleware: [] },
files: { files: {
boot: [] boot: []
} }

View File

@ -0,0 +1,7 @@
{
"initial": {
"../../helpers/push-name-middleware": {
"params": "custom-middleware"
}
}
}

View File

@ -0,0 +1,8 @@
module.exports = function(name) {
return function(req, res, next) {
req._names = req._names || [];
req._names.push(name);
res.setHeader('names', req._names.join(','));
next();
};
};