From 1114bc9227c3d13aeffc7f58352d10c3dfb8c85e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 12 Nov 2014 16:56:01 +0100 Subject: [PATCH] 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" } ] }, }); --- index.js | 2 + lib/bundler.js | 12 ++ lib/compiler.js | 52 +++++++ lib/config-loader.js | 36 +++++ lib/executor.js | 25 ++++ test/compiler.test.js | 180 +++++++++++++++++++++++ test/executor.test.js | 81 ++++++++-- test/fixtures/simple-app/middleware.json | 7 + test/helpers/push-name-middleware.js | 8 + 9 files changed, 393 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/simple-app/middleware.json create mode 100644 test/helpers/push-name-middleware.js diff --git a/index.js b/index.js index 7e17282..3f4dae2 100644 --- a/index.js +++ b/index.js @@ -107,6 +107,8 @@ var addInstructionsToBrowserify = require('./lib/bundler'); * `production`; however the applications are free to use any names. * @property {Array.} [modelSources] List of directories where to look * for files containing model definitions. + * @property {Object} [middleware] Middleware configuration to use instead + * of `{appRootDir}/middleware.json` * @property {Array.} [bootDirs] List of directories where to look * for boot scripts. * @property {Array.} [bootScripts] List of script files to execute diff --git a/lib/bundler.js b/lib/bundler.js index 0e04525..908f772 100644 --- a/lib/bundler.js +++ b/lib/bundler.js @@ -1,6 +1,7 @@ var fs = require('fs'); var path = require('path'); var commondir = require('commondir'); +var cloneDeep = require('lodash.clonedeep'); /** * Add boot instructions to a browserify bundler. @@ -60,6 +61,17 @@ function addScriptsToBundle(name, list, 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); /* The following code does not work due to a bug in browserify diff --git a/lib/compiler.js b/lib/compiler.js index 2cef7d0..2f6427b 100644 --- a/lib/compiler.js +++ b/lib/compiler.js @@ -44,6 +44,14 @@ module.exports = function compile(options) { ConfigLoader.loadDataSources(dsRootDir, env); 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 var bootDirs = options.bootDirs || []; // precedence bootDirs = bootDirs.concat(path.join(appRootDir, 'boot')); @@ -67,6 +75,7 @@ module.exports = function compile(options) { config: appConfig, dataSources: dataSourcesConfig, models: modelInstructions, + middleware: middlewareInstructions, files: { boot: bootScripts } @@ -338,3 +347,46 @@ function loadModelDefinition(rootDir, jsonFile, allFiles) { 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 + }; +} diff --git a/lib/config-loader.js b/lib/config-loader.js index b8a6dfe..e7d7095 100644 --- a/lib/config-loader.js +++ b/lib/config-loader.js @@ -34,6 +34,16 @@ ConfigLoader.loadModels = function(rootDir, env) { 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 --*/ /** @@ -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) { for (var key in config) { var fullKey = keyPrefix ? keyPrefix + '.' + key : key; diff --git a/lib/executor.js b/lib/executor.js index 768b13d..8218639 100644 --- a/lib/executor.js +++ b/lib/executor.js @@ -26,6 +26,7 @@ module.exports = function execute(app, instructions, callback) { setupDataSources(app, instructions); setupModels(app, instructions); + setupMiddleware(app, instructions); // Run the boot scripts in series synchronously or asynchronously // 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) { runScripts(app, instructions.files.boot, callback); } diff --git a/test/compiler.test.js b/test/compiler.test.js index 5df2ed5..331c3d4 100644 --- a/test/compiler.test.js +++ b/test/compiler.test.js @@ -673,6 +673,186 @@ describe('compiler', function() { 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) { diff --git a/test/executor.test.js b/test/executor.test.js index 95ed9f4..e4ffd92 100644 --- a/test/executor.test.js +++ b/test/executor.test.js @@ -6,6 +6,7 @@ var expect = require('chai').expect; var fs = require('fs-extra'); var sandbox = require('./helpers/sandbox'); var appdir = require('./helpers/appdir'); +var supertest = require('supertest'); var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); @@ -18,6 +19,13 @@ describe('executor', function() { beforeEach(function() { app = loopback(); + + // process.bootFlags is used by simple-app/boot/*.js scripts + process.bootFlags = []; + }); + + afterEach(function() { + delete process.bootFlags; }); var dummyInstructions = someInstructions({ @@ -178,7 +186,6 @@ describe('executor', function() { describe('with boot and models files', function() { beforeEach(function() { - process.bootFlags = process.bootFlags || []; boot.execute(app, simpleAppInstructions()); }); @@ -212,14 +219,6 @@ describe('executor', 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) { boot.execute(app, simpleAppInstructions(), function() { expect(process.bootFlags).to.eql([ @@ -233,7 +232,6 @@ describe('executor', function() { done(); }); }); - }); describe('with PaaS and npm env variables', function() { @@ -326,6 +324,68 @@ describe('executor', function() { boot.execute(app, someInstructions({ files: { boot: [file] } })); 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) { @@ -350,6 +410,7 @@ function someInstructions(values) { config: values.config || {}, models: values.models || [], dataSources: values.dataSources || { db: { connector: 'memory' } }, + middleware: values.middleware || { phases: [], middleware: [] }, files: { boot: [] } diff --git a/test/fixtures/simple-app/middleware.json b/test/fixtures/simple-app/middleware.json new file mode 100644 index 0000000..3062e34 --- /dev/null +++ b/test/fixtures/simple-app/middleware.json @@ -0,0 +1,7 @@ +{ + "initial": { + "../../helpers/push-name-middleware": { + "params": "custom-middleware" + } + } +} diff --git a/test/helpers/push-name-middleware.js b/test/helpers/push-name-middleware.js new file mode 100644 index 0000000..f7e6d6c --- /dev/null +++ b/test/helpers/push-name-middleware.js @@ -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(); + }; +};