diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 0000000..30b1a58 --- /dev/null +++ b/.jscsrc @@ -0,0 +1,24 @@ +{ + "preset": "google", + "requireCurlyBraces": [ + "do", + "try", + "catch" + ], + "disallowSpacesInsideObjectBrackets": null, + "requireSpaceAfterLineComment": true, + "maximumLineLength": { + "value": 80, + "allowRegex": true + }, + "validateJSDoc": { + "checkParamNames": false, + "checkRedundantParams": false, + "requireParamTypes": true + }, + "excludeFiles": [ + "node_modules/**", + "coverage/**", + "test/sandbox/**" + ] +} diff --git a/.jshintrc b/.jshintrc index 3f23a40..6f9fcc5 100644 --- a/.jshintrc +++ b/.jshintrc @@ -1,24 +1,21 @@ { "node": true, "browser": true, -"camelcase" : true, "eqnull" : true, "indent": 2, "undef": true, "unused": true, "quotmark": "single", -"maxlen": 80, -"trailing": true, "newcap": true, "nonew": true, "sub": true, "unused": "vars", "globals": { - "describe": true, - "it": true, - "before": true, - "beforeEach": true, - "after": true, - "afterEach": true + "describe": false, + "it": false, + "before": false, + "beforeEach": false, + "after": false, + "afterEach": false } } diff --git a/CHANGES.md b/CHANGES.md index c62e362..fc9612b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,14 +1,194 @@ -## Changes in version 1.0 +2014-11-27, Version 2.4.0 +========================= - - New options: `modelsRootDir`, `dsRootDir` + * Implement shorthand notation for middleware paths (Raymond Feng) - - Load configuration from files, support dynamic (scripted) options + * Load middleware and phases from `middleware.json` (Miroslav Bajtoš) - ```sh - app.json, app.local.*, app.{env}.* - datasources.json, datasources.local.*, datasources.{env}.* - ``` + * Add jscs style check, fix violations found (Miroslav Bajtoš) - - Scripts in `models/` and `boot/` can export `function(app)`, - this function is then called by the bootstrapper. The existing code - using `var app = require('../app')` will continue to work. + * Clean up .jshintrc (Miroslav Bajtoš) + + * Use `chai` instead of `must` (Miroslav Bajtoš) + + +2014-11-10, Version 2.3.1 +========================= + + * Bump version (Raymond Feng) + + * Fix the test for built-in models on Windows (Raymond Feng) + + * Fix jsdoc (Raymond Feng) + + +2014-10-27, Version 2.3.0 +========================= + + * compiler: fix coding style violations (Miroslav Bajtoš) + + * support coffee-script models and client code (bitmage) + + +2014-10-22, Version 2.2.0 +========================= + + * compiler: support module-relative model sources (Miroslav Bajtoš) + + * Skip definitions of built-in loopback models (Miroslav Bajtoš) + + * package: update dependency versions (Miroslav Bajtoš) + + * Use loopback 2.x in unit tests. (Miroslav Bajtoš) + + +2014-10-09, Version 2.1.0 +========================= + + * Bump version (Raymond Feng) + + * Add support for async boot scripts (Raymond Feng) + + * Clean up jsdoc comments. (Miroslav Bajtoš) + + * Custom rootDir for app config (johnsoftek) + + * compiler: improve merging of Arrays and Objects (Miroslav Bajtoš) + + * config-loader: deeply merge Array and Object vals (Shelby Sanders) + + * gitignore: add Idea's *.iml files (Miroslav Bajtoš) + + * package: Add `jshint` to `devDependencies` (Miroslav Bajtoš) + + * Update contribution guidelines (Ryan Graham) + + * test: ensure sandbox dir is present (Miroslav Bajtoš) + + * test: add `global.navigator` for browser tests (Miroslav Bajtoš) + + * test: increase timeout for browserify (Miroslav Bajtoš) + + * index: fix jshint error (Miroslav Bajtoš) + + * documentation fix (Alex) + + * Fix typo (Fabien Franzen) + + * Implemented modelSources, bootDirs and bootScripts options (Fabien Franzen) + + +2014-07-22, Version 2.0.0 +========================= + + * executor: remove `Base` arg from model function (Miroslav Bajtoš) + + * package: update dependency versions (Miroslav Bajtoš) + + +2014-07-17, Version v2.0.0-beta3 +================================ + + * v2.0.0-beta3 (Miroslav Bajtoš) + + * compiler: return a clone of instructions (Miroslav Bajtoš) + + * test: export Int32Array and DataView for browser (Miroslav Bajtoš) + + * v2.0.0-beta2 (Miroslav Bajtoš) + + * Rename `models.json` to `model-config.json` (Miroslav Bajtoš) + + * Remove non-API docs. (Rand McKinney) + + * 2.0.0-beta1 (Miroslav Bajtoš) + + * test: fix jshint warnings (Miroslav Bajtoš) + + * compiler: fix references to loopback (Miroslav Bajtoš) + + * Rename `app.json` to `config.json` (Miroslav Bajtoš) + + * compiler: Sort models topologically (Miroslav Bajtoš) + + * executor: Split model boot into two phases (Miroslav Bajtoš) + + * compiler: Move model-sources cfg to models.json (Miroslav Bajtoš) + + * package: Bump up the version to 2.0.0-dev (Miroslav Bajtoš) + + * Rework model configuration (Miroslav Bajtoš) + + * Remove auto-attach. (Miroslav Bajtoš) + + * Change models.json to configure existing models (Miroslav Bajtoš) + + +2014-07-17, Version 1.1.1 +========================= + + * compiler: return a clone of instructions (Miroslav Bajtoš) + + * Remove README from API docs (Rand McKinney) + + +2014-07-17, Version 2.0.0-beta2 +=============================== + + * test: export Int32Array and DataView for browser (Miroslav Bajtoš) + + * v2.0.0-beta2 (Miroslav Bajtoš) + + * Rename `models.json` to `model-config.json` (Miroslav Bajtoš) + + * Remove non-API docs. (Rand McKinney) + + +2014-06-26, Version 2.0.0-beta1 +=============================== + + * 2.0.0-beta1 (Miroslav Bajtoš) + + * test: fix jshint warnings (Miroslav Bajtoš) + + * compiler: fix references to loopback (Miroslav Bajtoš) + + * Rename `app.json` to `config.json` (Miroslav Bajtoš) + + * compiler: Sort models topologically (Miroslav Bajtoš) + + * executor: Split model boot into two phases (Miroslav Bajtoš) + + * compiler: Move model-sources cfg to models.json (Miroslav Bajtoš) + + * package: Bump up the version to 2.0.0-dev (Miroslav Bajtoš) + + * Rework model configuration (Miroslav Bajtoš) + + * Remove auto-attach. (Miroslav Bajtoš) + + * Change models.json to configure existing models (Miroslav Bajtoš) + + +2014-06-26, Version 1.1.0 +========================= + + * docs: move hand-written content to README.md (Miroslav Bajtoš) + + * executor: remove direct reference to loopback (Miroslav Bajtoš) + + * Update link to doc (Rand McKinney) + + * package: Fix repository url (Miroslav Bajtoš) + + * Drop peer dep on loopback; add a runtime check (Miroslav Bajtoš) + + * Wrap too long lines (Miroslav Bajtoš) + + * Add disclaimer to JSDoc and small correction. (crandmck) + + +2014-06-05, Version 1.0.0 +========================= + + * First release! diff --git a/index.js b/index.js index 4894277..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 @@ -136,7 +138,7 @@ exports.compileToBrowserify = function(options, bundler) { addInstructionsToBrowserify(compile(options), bundler); }; -//-- undocumented low-level API --// +/*-- undocumented low-level API --*/ exports.ConfigLoader = ConfigLoader; exports.compile = compile; 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 11f3a3b..6cfd3ad 100644 --- a/lib/compiler.js +++ b/lib/compiler.js @@ -22,7 +22,7 @@ var Module = require('module'); module.exports = function compile(options) { options = options || {}; - if(typeof options === 'string') { + if (typeof options === 'string') { options = { appRootDir: options }; } @@ -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 } @@ -74,7 +83,7 @@ module.exports = function compile(options) { }; function assertIsValidConfig(name, config) { - if(config) { + if (config) { assert(typeof config === 'object', name + ' config must be a valid JSON object'); } @@ -143,7 +152,7 @@ function findScripts(dir) { } else { try { path.join(require.resolve(filepath)); - } catch(err) { + } catch (err) { debug('Skipping directory %s - %s', filepath, err.code || err); } } @@ -155,7 +164,7 @@ function findScripts(dir) { function tryReadDir() { try { return fs.readdirSync.apply(fs, arguments); - } catch(e) { + } catch (e) { return []; } } @@ -309,7 +318,9 @@ function loadModelDefinition(rootDir, jsonFile, allFiles) { var basename = path.basename(jsonFile, path.extname(jsonFile)); // find a matching file with a supported extension like `.js` or `.coffee` - var base, ext, validFileType; + var base; + var ext; + var validFileType; var sourceFile = allFiles .filter(function(f) { ext = path.extname(f); @@ -336,3 +347,104 @@ 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 allConfigs = phaseConfig[middleware]; + if (!Array.isArray(allConfigs)) + allConfigs = [allConfigs]; + + allConfigs.forEach(function(config) { + var resolved = resolveMiddlewarePath(rootDir, middleware); + + var middlewareConfig = cloneDeep(config); + middlewareConfig.phase = phase; + + var item = { + sourceFile: resolved.sourceFile, + config: middlewareConfig + }; + if (resolved.fragment) { + item.fragment = resolved.fragment; + } + middlewareList.push(item); + }); + }); + }); + + 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 + }; +} + +function resolveMiddlewarePath(rootDir, middleware) { + var resolved = {}; + + var segments = middleware.split('#'); + var pathName = segments[0]; + var fragment = segments[1]; + + if (fragment) { + resolved.fragment = fragment; + } + + if (pathName.indexOf('./') === 0 || pathName.indexOf('../') === 0) { + // Relative path + pathName = path.resolve(rootDir, pathName); + } + + if (!fragment) { + resolved.sourceFile = require.resolve(pathName); + return resolved; + } + + var err; + + // Try to require the module and check if . is a valid + // function + var m = require(pathName); + if (typeof m[fragment] === 'function') { + resolved.sourceFile = require.resolve(pathName); + return resolved; + } + + /* + * module/server/middleware/fragment + * module/middleware/fragment + */ + var candidates = [ + pathName + '/server/middleware/' + fragment, + pathName + '/middleware/' + fragment, + // TODO: [rfeng] Should we support the following flavors? + // pathName + '/lib/' + fragment, + // pathName + '/' + fragment + ]; + + for (var ix in candidates) { + try { + resolved.sourceFile = require.resolve(candidates[ix]); + delete resolved.fragment; + return resolved; + } + catch (e) { + // Report the error for the first candidate when no candidate matches + if (!err) err = e; + } + } + throw err; +} diff --git a/lib/config-loader.js b/lib/config-loader.js index 17834eb..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 --*/ /** @@ -103,7 +113,7 @@ function loadConfigFiles(files) { */ function mergeConfigurations(configObjects, mergeFn) { var result = configObjects.shift() || {}; - while(configObjects.length) { + while (configObjects.length) { var next = configObjects.shift(); mergeFn(result, next, next._filename); } @@ -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; @@ -163,7 +199,7 @@ function mergeArrays(target, config, keyPrefix) { } // Use for(;;) to iterate over undefined items, for(in) would skip them. - for (var ix=0; ix < target.length; ix++) { + for (var ix = 0; ix < target.length; ix++) { var fullKey = keyPrefix + '[' + ix + ']'; var err = mergeSingleItemOrProperty(target, config, ix, fullKey); if (err) return err; @@ -189,15 +225,15 @@ function hasCompatibleType(origValue, newValue) { /** * Try to read a config file with .json extension - * @param cwd Dirname of the file - * @param fileName Name of the file without extension + * @param {string} cwd Dirname of the file + * @param {string} fileName Name of the file without extension * @returns {Object|undefined} Content of the file, undefined if not found. */ function tryReadJsonConfig(cwd, fileName) { try { return require(path.join(cwd, fileName + '.json')); - } catch(e) { - if(e.code !== 'MODULE_NOT_FOUND') { + } catch (e) { + if (e.code !== 'MODULE_NOT_FOUND') { throw e; } } diff --git a/lib/executor.js b/lib/executor.js index 16ca218..c8e3865 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 @@ -45,10 +46,10 @@ function patchAppLoopback(app) { // patch the app object to make loopback-boot work with older versions too try { app.loopback = require('loopback'); - } catch(err) { + } catch (err) { if (err.code === 'MODULE_NOT_FOUND') { console.error( - 'When using loopback-boot with loopback <1.9, '+ + 'When using loopback-boot with loopback <1.9, ' + 'the loopback module must be available for `require(\'loopback\')`.'); } throw err; @@ -69,7 +70,7 @@ function assertLoopBackVersion(app) { } function setHost(app, instructions) { - //jshint camelcase:false + // jscs:disable requireCamelCaseOrUpperCaseIdentifiers var host = process.env.npm_config_host || process.env.OPENSHIFT_SLS_IP || @@ -79,14 +80,14 @@ function setHost(app, instructions) { process.env.npm_package_config_host || app.get('host'); - if(host !== undefined) { + if (host !== undefined) { assert(typeof host === 'string', 'app.host must be a string'); app.set('host', host); } } function setPort(app, instructions) { - //jshint camelcase:false + // jscs:disable requireCamelCaseOrUpperCaseIdentifiers var port = _.find([ process.env.npm_config_port, process.env.OPENSHIFT_SLS_PORT, @@ -98,7 +99,7 @@ function setPort(app, instructions) { 3000 ], _.isFinite); - if(port !== undefined) { + if (port !== undefined) { var portType = typeof port; assert(portType === 'string' || portType === 'number', 'app.port must be a string or number'); @@ -122,9 +123,9 @@ function setApiRoot(app, instructions) { function applyAppConfig(app, instructions) { var appConfig = instructions.config; - for(var configKey in appConfig) { + for (var configKey in appConfig) { var cur = app.get(configKey); - if(cur === undefined || cur === null) { + if (cur === undefined || cur === null) { app.set(configKey, appConfig[configKey]); } } @@ -198,7 +199,7 @@ function isBuiltinLoopBackModel(app, data) { } function forEachKeyedObject(obj, fn) { - if(typeof obj !== 'object') return; + if (typeof obj !== 'object') return; Object.keys(obj).forEach(function(key) { fn(key, obj[key]); @@ -240,8 +241,8 @@ function runScripts(app, list, callback) { function tryRequire(modulePath) { try { return require.apply(this, arguments); - } catch(e) { - if(e.code === 'MODULE_NOT_FOUND') { + } catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { debug('Warning: cannot require %s - module not found.', modulePath); return undefined; } @@ -250,6 +251,37 @@ function tryRequire(modulePath) { } } +function setupMiddleware(app, instructions) { + if (!instructions.middleware) { + // the browserified client does not support middleware + return; + } + + // Phases can be empty + 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%s', data.sourceFile, + data.fragment ? ('#' + data.fragment) : ''); + var factory = require(data.sourceFile); + if (data.fragment) { + factory = factory[data.fragment]; + } + assert(typeof factory === 'function', + 'Middleware factory must be a function'); + app.middlewareFromConfig(factory, data.config); + }); +} + function runBootScripts(app, instructions, callback) { runScripts(app, instructions.files.boot, callback); } diff --git a/package.json b/package.json index 7eba26f..a5b39c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loopback-boot", - "version": "2.3.1", + "version": "2.4.0", "description": "Convention-based bootstrapper for LoopBack applications", "keywords": [ "StrongLoop", @@ -15,7 +15,7 @@ "main": "index.js", "browser": "browser.js", "scripts": { - "pretest": "jshint .", + "pretest": "jscs . && jshint .", "test": "mocha" }, "license": { @@ -32,15 +32,15 @@ "underscore": "^1.6.0" }, "devDependencies": { - "browserify": "^6.1.0", - "fs-extra": "^0.12.0", "browserify": "^4.1.8", + "chai": "^1.10.0", "coffee-script": "^1.8.0", "coffeeify": "^0.7.0", + "fs-extra": "^0.12.0", + "jscs": "^1.7.3", "jshint": "^2.5.6", "loopback": "^2.5.0", "mocha": "^1.19.0", - "must": "^0.12.0", "supertest": "^0.14.0" } } diff --git a/test/browser.test.js b/test/browser.test.js index 985c26f..3acd166 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -1,7 +1,7 @@ var boot = require('../'); var fs = require('fs'); var path = require('path'); -var expect = require('must'); +var expect = require('chai').expect; var browserify = require('browserify'); var sandbox = require('./helpers/sandbox'); var vm = require('vm'); @@ -76,7 +76,7 @@ describe('browser support', function() { }); function browserifyTestApp(appDir, strategy, next) { - //set default args + // set default args if (((typeof strategy) === 'function') && !next) { next = strategy; strategy = undefined; @@ -91,7 +91,7 @@ function browserifyTestApp(appDir, strategy, next) { var bundlePath = sandbox.resolve('browser-app-bundle.js'); var out = fs.createWriteStream(bundlePath); b.bundle().pipe(out); - + out.on('error', function(err) { return next(err); }); out.on('close', function() { next(null, bundlePath); diff --git a/test/compiler.test.js b/test/compiler.test.js index ba362bd..a4f9a1d 100644 --- a/test/compiler.test.js +++ b/test/compiler.test.js @@ -1,7 +1,7 @@ var boot = require('../'); var fs = require('fs-extra'); var path = require('path'); -var expect = require('must'); +var expect = require('chai').expect; var sandbox = require('./helpers/sandbox'); var appdir = require('./helpers/appdir'); @@ -12,7 +12,10 @@ describe('compiler', function() { beforeEach(appdir.init); describe('from options', function() { - var options, instructions, appConfig; + var options; + var instructions; + var appConfig; + beforeEach(function() { options = { config: { @@ -256,7 +259,7 @@ describe('compiler', function() { appdir.writeConfigFileSync('config.local.json', { toplevel: [ { - nested: [ 'value' ] + nested: ['value'] } ] }); @@ -337,7 +340,7 @@ describe('compiler', function() { appConfigRootDir: path.resolve(appdir.PATH, 'custom') }); - expect(instructions.config).to.have.property('port'); + expect(instructions.config).to.have.property('port'); }); it('supports `dsRootDir` option', function() { @@ -379,7 +382,7 @@ describe('compiler', function() { var instructions = boot.compile(appdir.PATH); expect(instructions.files.boot).to.eql([initJs]); }); - + it('supports `bootDirs` option', function() { appdir.createConfigFilesSync(); var initJs = appdir.writeFileSync('custom-boot/init.js', @@ -390,7 +393,7 @@ describe('compiler', function() { }); expect(instructions.files.boot).to.eql([initJs]); }); - + it('supports `bootScripts` option', function() { appdir.createConfigFilesSync(); var initJs = appdir.writeFileSync('custom-boot/init.js', @@ -475,19 +478,19 @@ describe('compiler', function() { sourceFile: path.resolve(appdir.PATH, 'models', 'car.coffee') }); }); - + it('supports `modelSources` option', function() { appdir.createConfigFilesSync({}, {}, { Car: { dataSource: 'db' } }); appdir.writeConfigFileSync('custom-models/car.json', { name: 'Car' }); appdir.writeFileSync('custom-models/car.js', ''); - + var instructions = boot.compile({ appRootDir: appdir.PATH, modelSources: ['./custom-models'] }); - + expect(instructions.models).to.have.length(1); expect(instructions.models[0]).to.eql({ name: 'Car', @@ -670,6 +673,288 @@ describe('compiler', function() { expect(instructions.config).to.not.have.property('modified'); }); }); + + describe('for middleware', function() { + + function testMiddlewareRegistration(middlewareId, sourceFile) { + var json = { + initial: { + }, + custom: { + } + }; + + json.custom[middlewareId] = { + params: 'some-config-data' + }; + + appdir.writeConfigFileSync('middleware.json', json); + + var instructions = boot.compile(appdir.PATH); + + expect(instructions.middleware).to.eql({ + phases: ['initial', 'custom'], + middleware: [ + { + sourceFile: sourceFile, + config: { + phase: 'custom', + params: 'some-config-data' + } + } + ] + }); + } + + var sourceFileForUrlNotFound; + beforeEach(function() { + fs.copySync(SIMPLE_APP, appdir.PATH); + sourceFileForUrlNotFound = require.resolve( + 'loopback/server/middleware/url-not-found'); + }); + + it('emits middleware instructions', function() { + testMiddlewareRegistration('loopback/server/middleware/url-not-found', + sourceFileForUrlNotFound); + }); + + it('emits middleware instructions for fragment', function() { + testMiddlewareRegistration('loopback#url-not-found', + sourceFileForUrlNotFound); + }); + + 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('fails when a module middleware fragment 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' + } + } + ]); + }); + + it('supports shorthand notation for middleware paths', function() { + appdir.writeConfigFileSync('middleware.json', { + 'final': { + 'loopback#url-not-found': {} + } + }); + + var instructions = boot.compile(appdir.PATH); + + expect(instructions.middleware.middleware[0].sourceFile) + .to.equal(require.resolve('loopback/server/middleware/url-not-found')); + }); + + it('supports shorthand notation for relative paths', function() { + appdir.writeConfigFileSync('middleware.json', { + 'routes': { + './middleware/index#myMiddleware': { + } + } + }); + + var instructions = boot.compile(appdir.PATH); + + expect(instructions.middleware.middleware[0].sourceFile) + .to.equal(path.resolve(appdir.PATH, + './middleware/index.js')); + expect(instructions.middleware.middleware[0]).have.property( + 'fragment', + 'myMiddleware'); + }); + + it('supports shorthand notation when the fragment name matches a property', + function() { + appdir.writeConfigFileSync('middleware.json', { + 'final': { + 'loopback#errorHandler': {} + } + }); + + var instructions = boot.compile(appdir.PATH); + + expect(instructions.middleware.middleware[0]).have.property( + 'sourceFile', + require.resolve('loopback')); + expect(instructions.middleware.middleware[0]).have.property( + 'fragment', + 'errorHandler'); + }); + + // FIXME: [rfeng] The following test is disabled until + // https://github.com/strongloop/loopback-boot/issues/73 is fixed + it.skip('resolves modules relative to appRootDir', function() { + var HANDLER_FILE = 'node_modules/handler/index.js'; + appdir.writeFileSync( + HANDLER_FILE, + 'module.exports = function(req, res, next) { next(); }'); + + appdir.writeConfigFileSync('middleware.json', { + 'initial': { + 'handler': {} + } + }); + + var instructions = boot.compile(appdir.PATH); + + expect(instructions.middleware.middleware[0]).have.property( + 'sourceFile', + appdir.resolve(HANDLER_FILE)); + }); + }); }); function getNameProperty(obj) { diff --git a/test/executor.test.js b/test/executor.test.js index c4f248c..c6fd891 100644 --- a/test/executor.test.js +++ b/test/executor.test.js @@ -2,10 +2,11 @@ var boot = require('../'); var path = require('path'); var loopback = require('loopback'); var assert = require('assert'); -var expect = require('must'); +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({ @@ -171,14 +179,13 @@ describe('executor', function() { }; builtinModel.definition.redefined = true; - boot.execute(app, someInstructions({ models: [ builtinModel ] })); + boot.execute(app, someInstructions({ models: [builtinModel] })); expect(app.models.User.settings.redefined, 'redefined').to.not.equal(true); }); 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() { @@ -266,7 +264,7 @@ describe('executor', function() { }); it('should prioritize sources', function() { - /*jshint camelcase:false */ + // jscs:disable requireCamelCaseOrUpperCaseIdentifiers process.env.npm_config_host = randomHost(); process.env.OPENSHIFT_SLS_IP = randomHost(); process.env.OPENSHIFT_NODEJS_IP = randomHost(); @@ -323,11 +321,100 @@ describe('executor', function() { 'module.exports = function(app) { app.fnCalled = true; };'); delete app.fnCalled; - boot.execute(app, someInstructions({ files: { boot: [ file ] } })); + 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 using shortform', function(done) { + + boot.execute(app, someInstructions({ + middleware: { + middleware: [ + { + sourceFile: require.resolve('loopback'), + fragment: 'static', + config: { + phase: 'files', + params: path.join(__dirname, './fixtures/simple-app/client/') + } + } + ] + } + })); + + supertest(app) + .get('/') + .end(function(err, res) { + if (err) return done(err); + expect(res.text).to.eql('\n\n\n' + + ' \n simple-app\n' + + '\n\n

simple-app

\n\n'); + 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) { // has methods @@ -340,7 +427,7 @@ function assertValidDataSource(dataSource) { assert.isFunc(dataSource, 'operations'); } -assert.isFunc = function (obj, name) { +assert.isFunc = function(obj, name) { assert(obj, 'cannot assert function ' + name + ' on object that does not exist'); assert(typeof obj[name] === 'function', name + ' is not a function'); @@ -351,6 +438,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/boot/barSync.js b/test/fixtures/simple-app/boot/barSync.js index 64c0b0b..4a38f80 100644 --- a/test/fixtures/simple-app/boot/barSync.js +++ b/test/fixtures/simple-app/boot/barSync.js @@ -2,4 +2,3 @@ process.bootFlags.push('barSyncLoaded'); module.exports = function(app) { process.bootFlags.push('barSyncExecuted'); }; - diff --git a/test/fixtures/simple-app/boot/foo.js b/test/fixtures/simple-app/boot/foo.js index 6641f03..e2fd7a5 100644 --- a/test/fixtures/simple-app/boot/foo.js +++ b/test/fixtures/simple-app/boot/foo.js @@ -1 +1 @@ -process.bootFlags.push('fooLoaded'); \ No newline at end of file +process.bootFlags.push('fooLoaded'); diff --git a/test/fixtures/simple-app/client/index.html b/test/fixtures/simple-app/client/index.html new file mode 100644 index 0000000..df535e6 --- /dev/null +++ b/test/fixtures/simple-app/client/index.html @@ -0,0 +1,10 @@ + + + + + simple-app + + +

simple-app

+ + \ No newline at end of file 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/fixtures/simple-app/middleware/index.js b/test/fixtures/simple-app/middleware/index.js new file mode 100644 index 0000000..d2b20d9 --- /dev/null +++ b/test/fixtures/simple-app/middleware/index.js @@ -0,0 +1,8 @@ +exports.myMiddleware = function(name) { + return function(req, res, next) { + req._names = req._names || []; + req._names.push(name); + res.setHeader('names', req._names.join(',')); + next(); + }; +}; diff --git a/test/fixtures/simple-app/node_modules/my-module/index.js b/test/fixtures/simple-app/node_modules/my-module/index.js new file mode 100644 index 0000000..5b64f75 --- /dev/null +++ b/test/fixtures/simple-app/node_modules/my-module/index.js @@ -0,0 +1,7 @@ +/** + * Exporting a middleware as a property of the main module + */ +exports.myMiddleware = function(req, res, next) { + res.setHeader('X-MY-MIDDLEWARE', 'myMiddleware'); + next(); +}; diff --git a/test/fixtures/simple-app/node_modules/my-module/package.json b/test/fixtures/simple-app/node_modules/my-module/package.json new file mode 100644 index 0000000..07f8627 --- /dev/null +++ b/test/fixtures/simple-app/node_modules/my-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "my-module", + "version": "1.0.0", + "description": "my-module", + "main": "index.js", + "license": "MIT" +} diff --git a/test/helpers/appdir.js b/test/helpers/appdir.js index 1a31be5..a52a606 100644 --- a/test/helpers/appdir.js +++ b/test/helpers/appdir.js @@ -42,8 +42,12 @@ appdir.writeConfigFileSync = function(name, json) { }; appdir.writeFileSync = function(name, content) { - var filePath = path.resolve(PATH, name); + var filePath = this.resolve(name); fs.mkdirsSync(path.dirname(filePath)); fs.writeFileSync(filePath, content, 'utf-8'); return filePath; }; + +appdir.resolve = function(name) { + return path.resolve(PATH, name); +}; 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(); + }; +};