From a4402a3979ec20ac14f01d67ea87c456f271f9b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 23 May 2014 16:39:34 +0200 Subject: [PATCH 1/8] Initial implementation Move `app.boot()` and its tests from loopback. Fix jshint warnings. Clean up unit tests - remove dependency on global variables created by loopback's test/support.js --- index.js | 309 ++++++++++++++++++++++ package.json | 15 +- test/boot.test.js | 208 +++++++++++++++ test/fixtures/simple-app/app.json | 4 + test/fixtures/simple-app/boot/bad.txt | 1 + test/fixtures/simple-app/boot/foo.js | 1 + test/fixtures/simple-app/datasources.json | 5 + test/fixtures/simple-app/models.json | 5 + test/fixtures/simple-app/models/bar.js | 1 + test/global-setup.js | 11 + 10 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 index.js create mode 100644 test/boot.test.js create mode 100644 test/fixtures/simple-app/app.json create mode 100644 test/fixtures/simple-app/boot/bad.txt create mode 100644 test/fixtures/simple-app/boot/foo.js create mode 100644 test/fixtures/simple-app/datasources.json create mode 100644 test/fixtures/simple-app/models.json create mode 100644 test/fixtures/simple-app/models/bar.js create mode 100644 test/global-setup.js diff --git a/index.js b/index.js new file mode 100644 index 0000000..4cf8c20 --- /dev/null +++ b/index.js @@ -0,0 +1,309 @@ +var assert = require('assert'); +var fs = require('fs'); +var path = require('path'); +var _ = require('underscore'); + +/** + * Initialize an application from an options object or + * a set of JSON and JavaScript files. + * + * This function takes an optional argument that is either a string + * or an object. + * + * If the argument is a string, then it sets the application root directory + * based on the string value. Then it: + * + * 1. Creates DataSources from the `datasources.json` file in the application + * root directory. + * + * 2. Creates Models from the `models.json` file in the application + * root directory. + * + * If the argument is an object, then it looks for `model`, `dataSources`, + * and `appRootDir` properties of the object. + * If the object has no `appRootDir` property then it sets the current working + * directory as the application root directory. + * Then it: + * + * 1. Creates DataSources from the `options.dataSources` object. + * + * 2. Creates Models from the `options.models` object. + * + * In both cases, the function loads JavaScript files in the `/models` and + * `/boot` subdirectories of the application root directory with `require()`. + * + * **NOTE:** mixing `app.boot()` and `app.model(name, config)` in multiple + * files may result in models being **undefined** due to race conditions. + * To avoid this when using `app.boot()` make sure all models are passed + * as part of the `models` definition. + * + * Throws an error if the config object is not valid or if boot fails. + * + * + * **Model Definitions** + * + * The following is example JSON for two `Model` definitions: + * "dealership" and "location". + * + * ```js + * { + * "dealership": { + * // a reference, by name, to a dataSource definition + * "dataSource": "my-db", + * // the options passed to Model.extend(name, properties, options) + * "options": { + * "relations": { + * "cars": { + * "type": "hasMany", + * "model": "Car", + * "foreignKey": "dealerId" + * } + * } + * }, + * // the properties passed to Model.extend(name, properties, options) + * "properties": { + * "id": {"id": true}, + * "name": "String", + * "zip": "Number", + * "address": "String" + * } + * }, + * "car": { + * "dataSource": "my-db" + * "properties": { + * "id": { + * "type": "String", + * "required": true, + * "id": true + * }, + * "make": { + * "type": "String", + * "required": true + * }, + * "model": { + * "type": "String", + * "required": true + * } + * } + * } + * } + * ``` + * + * @param app LoopBack application created by `loopback()`. + * @options {String|Object} options Boot options; If String, this is + * the application root directory; if object, has below properties. + * @property {String} appRootDir Directory to use when loading JSON and + * JavaScript files (optional). + * Defaults to the current directory (`process.cwd()`). + * @property {Object} models Object containing `Model` definitions (optional). + * @property {Object} dataSources Object containing `DataSource` + * definitions (optional). + * @end + * + * @header boot(app, [options]) + */ + +module.exports = function bootLoopBackApp(app, options) { + /*jshint camelcase:false */ + options = options || {}; + + if(typeof options === 'string') { + options = { appRootDir: options }; + } + var appRootDir = options.appRootDir = options.appRootDir || process.cwd(); + var appConfig = options.app; + var modelConfig = options.models; + var dataSourceConfig = options.dataSources; + + if(!appConfig) { + appConfig = tryReadConfig(appRootDir, 'app') || {}; + } + if(!modelConfig) { + modelConfig = tryReadConfig(appRootDir, 'models') || {}; + } + if(!dataSourceConfig) { + dataSourceConfig = tryReadConfig(appRootDir, 'datasources') || {}; + } + + assertIsValidConfig('app', appConfig); + assertIsValidConfig('model', modelConfig); + assertIsValidConfig('data source', dataSourceConfig); + + appConfig.host = + process.env.npm_config_host || + process.env.OPENSHIFT_SLS_IP || + process.env.OPENSHIFT_NODEJS_IP || + process.env.HOST || + appConfig.host || + process.env.npm_package_config_host || + app.get('host'); + + appConfig.port = _.find([ + process.env.npm_config_port, + process.env.OPENSHIFT_SLS_PORT, + process.env.OPENSHIFT_NODEJS_PORT, + process.env.PORT, + appConfig.port, + process.env.npm_package_config_port, + app.get('port'), + 3000 + ], _.isFinite); + + appConfig.restApiRoot = + appConfig.restApiRoot || + app.get('restApiRoot') || + '/api'; + + if(appConfig.host !== undefined) { + assert(typeof appConfig.host === 'string', 'app.host must be a string'); + app.set('host', appConfig.host); + } + + if(appConfig.port !== undefined) { + var portType = typeof appConfig.port; + assert(portType === 'string' || portType === 'number', + 'app.port must be a string or number'); + app.set('port', appConfig.port); + } + + assert(appConfig.restApiRoot !== undefined, 'app.restBasePath is required'); + assert(typeof appConfig.restApiRoot === 'string', + 'app.restBasePath must be a string'); + assert(/^\//.test(appConfig.restApiRoot), + 'app.restBasePath must start with "/"'); + app.set('restApiRoot', appConfig.restBasePath); + + for(var configKey in appConfig) { + var cur = app.get(configKey); + if(cur === undefined || cur === null) { + app.set(configKey, appConfig[configKey]); + } + } + + // instantiate data sources + forEachKeyedObject(dataSourceConfig, function(key, obj) { + app.dataSource(key, obj); + }); + + // instantiate models + forEachKeyedObject(modelConfig, function(key, obj) { + app.model(key, obj); + }); + + // try to attach models to dataSources by type + try { + require('loopback').autoAttach(); + } catch(e) { + if(e.name === 'AssertionError') { + console.warn(e); + } else { + throw e; + } + } + + // disable token requirement for swagger, if available + var swagger = app.remotes().exports.swagger; + var requireTokenForSwagger = appConfig.swagger && + appConfig.swagger.requireToken; + if(swagger) { + swagger.requireToken = requireTokenForSwagger || false; + } + + // require directories + requireDir(path.join(appRootDir, 'models')); + requireDir(path.join(appRootDir, 'boot')); +}; + +function assertIsValidConfig(name, config) { + if(config) { + assert(typeof config === 'object', + name + ' config must be a valid JSON object'); + } +} + +function forEachKeyedObject(obj, fn) { + if(typeof obj !== 'object') return; + + Object.keys(obj).forEach(function(key) { + fn(key, obj[key]); + }); +} + +function requireDir(dir, basenames) { + assert(dir, 'cannot require directory contents without directory name'); + + var requires = {}; + + if (arguments.length === 2) { + // if basenames argument is passed, explicitly include those files + basenames.forEach(function (basename) { + var filepath = path.resolve(path.join(dir, basename)); + requires[basename] = tryRequire(filepath); + }); + } else if (arguments.length === 1) { + // if basenames arguments isn't passed, require all javascript + // files (except for those prefixed with _) and all directories + + var files = tryReadDir(dir); + + // sort files in lowercase alpha for linux + files.sort(function (a,b) { + a = a.toLowerCase(); + b = b.toLowerCase(); + + if (a < b) { + return -1; + } else if (b < a) { + return 1; + } else { + return 0; + } + }); + + files.forEach(function (filename) { + // ignore index.js and files prefixed with underscore + if ((filename === 'index.js') || (filename[0] === '_')) { return; } + + var filepath = path.resolve(path.join(dir, filename)); + var ext = path.extname(filename); + var stats = fs.statSync(filepath); + + // only require files supported by require.extensions (.txt .md etc.) + if (stats.isFile() && !(ext in require.extensions)) { return; } + + var basename = path.basename(filename, ext); + + requires[basename] = tryRequire(filepath); + }); + + } + + return requires; +} + +function tryRequire(modulePath) { + try { + return require.apply(this, arguments); + } catch(e) { + console.error('failed to require "%s"', modulePath); + throw e; + } +} + +function tryReadDir() { + try { + return fs.readdirSync.apply(fs, arguments); + } catch(e) { + return []; + } +} + +function tryReadConfig(cwd, fileName) { + try { + return require(path.join(cwd, fileName + '.json')); + } catch(e) { + if(e.code !== 'MODULE_NOT_FOUND') { + throw e; + } + } +} diff --git a/package.json b/package.json index 9b973fe..3fdcdcb 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,23 @@ }, "main": "index.js", "scripts": { - "pretest": "jshint ." + "pretest": "jshint .", + "test": "mocha" }, "license": { "name": "Dual MIT/StrongLoop", "url": "https://github.com/strongloop/loopback-boot/blob/master/LICENSE" + }, + "dependencies": { + "underscore": "^1.6.0" + }, + "devDependencies": { + "loopback": "^1.5.0", + "mocha": "^1.19.0", + "must": "^0.11.0", + "supertest": "^0.13.0" + }, + "peerDependencies": { + "loopback": "1.x" } } diff --git a/test/boot.test.js b/test/boot.test.js new file mode 100644 index 0000000..510bfb3 --- /dev/null +++ b/test/boot.test.js @@ -0,0 +1,208 @@ +var boot = require('../'); +var path = require('path'); +var loopback = require('loopback'); +var assert = require('assert'); +var expect = require('must'); + +var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); + +describe('bootLoopBackApp', function() { + describe('from options', function () { + var app; + beforeEach(function () { + app = loopback(); + boot(app, { + app: { + port: 3000, + host: '127.0.0.1', + restApiRoot: '/rest-api', + foo: {bar: 'bat'}, + baz: true + }, + models: { + 'foo-bar-bat-baz': { + options: { + plural: 'foo-bar-bat-bazzies' + }, + dataSource: 'the-db' + } + }, + dataSources: { + 'the-db': { + connector: 'memory', + defaultForType: 'db' + } + } + }); + }); + + it('should have port setting', function () { + assert.equal(app.get('port'), 3000); + }); + + it('should have host setting', function() { + assert.equal(app.get('host'), '127.0.0.1'); + }); + + it('should have restApiRoot setting', function() { + assert.equal(app.get('restApiRoot'), '/rest-api'); + }); + + it('should have other settings', function () { + expect(app.get('foo')).to.eql({ + bar: 'bat' + }); + expect(app.get('baz')).to.eql(true); + }); + + it('Instantiate models', function () { + assert(app.models); + assert(app.models.FooBarBatBaz); + assert(app.models.fooBarBatBaz); + assertValidDataSource(app.models.FooBarBatBaz.dataSource); + assert.isFunc(app.models.FooBarBatBaz, 'find'); + assert.isFunc(app.models.FooBarBatBaz, 'create'); + }); + + it('Attach models to data sources', function () { + assert.equal(app.models.FooBarBatBaz.dataSource, app.dataSources.theDb); + }); + + it('Instantiate data sources', function () { + assert(app.dataSources); + assert(app.dataSources.theDb); + assertValidDataSource(app.dataSources.theDb); + assert(app.dataSources.TheDb); + }); + + describe('boot and models directories', function() { + beforeEach(function() { + boot(app, SIMPLE_APP); + }); + + it('should run all modules in the boot directory', function () { + assert(process.loadedFooJS); + delete process.loadedFooJS; + }); + + it('should run all modules in the models directory', function () { + assert(process.loadedBarJS); + delete process.loadedBarJS; + }); + }); + + describe('PaaS and npm env variables', function() { + function bootWithDefaults() { + app = loopback(); + boot(app, { + app: { + port: undefined, + host: undefined + } + }); + } + + it('should be honored', function() { + function assertHonored(portKey, hostKey) { + process.env[hostKey] = randomPort(); + process.env[portKey] = randomHost(); + bootWithDefaults(); + assert.equal(app.get('port'), process.env[portKey]); + assert.equal(app.get('host'), process.env[hostKey]); + delete process.env[portKey]; + delete process.env[hostKey]; + } + + assertHonored('OPENSHIFT_SLS_PORT', 'OPENSHIFT_NODEJS_IP'); + assertHonored('npm_config_port', 'npm_config_host'); + assertHonored('npm_package_config_port', 'npm_package_config_host'); + assertHonored('OPENSHIFT_SLS_PORT', 'OPENSHIFT_SLS_IP'); + assertHonored('PORT', 'HOST'); + }); + + it('should be honored in order', function() { + /*jshint camelcase:false */ + process.env.npm_config_host = randomHost(); + process.env.OPENSHIFT_SLS_IP = randomHost(); + process.env.OPENSHIFT_NODEJS_IP = randomHost(); + process.env.HOST = randomHost(); + process.env.npm_package_config_host = randomHost(); + + bootWithDefaults(); + assert.equal(app.get('host'), process.env.npm_config_host); + + delete process.env.npm_config_host; + delete process.env.OPENSHIFT_SLS_IP; + delete process.env.OPENSHIFT_NODEJS_IP; + delete process.env.HOST; + delete process.env.npm_package_config_host; + + process.env.npm_config_port = randomPort(); + process.env.OPENSHIFT_SLS_PORT = randomPort(); + process.env.OPENSHIFT_NODEJS_PORT = randomPort(); + process.env.PORT = randomPort(); + process.env.npm_package_config_port = randomPort(); + + bootWithDefaults(); + assert.equal(app.get('host'), process.env.npm_config_host); + assert.equal(app.get('port'), process.env.npm_config_port); + + delete process.env.npm_config_port; + delete process.env.OPENSHIFT_SLS_PORT; + delete process.env.OPENSHIFT_NODEJS_PORT; + delete process.env.PORT; + delete process.env.npm_package_config_port; + }); + + function randomHost() { + return Math.random().toString().split('.')[1]; + } + + function randomPort() { + return Math.floor(Math.random() * 10000); + } + + it('should honor 0 for free port', function () { + boot(app, {app: {port: 0}}); + assert.equal(app.get('port'), 0); + }); + + it('should default to port 3000', function () { + boot(app, {app: {port: undefined}}); + assert.equal(app.get('port'), 3000); + }); + }); + }); + + describe('from directory', function () { + it('Load config files', function () { + var app = loopback(); + + boot(app, SIMPLE_APP); + + assert(app.models.foo); + assert(app.models.Foo); + assert(app.models.Foo.dataSource); + assert.isFunc(app.models.Foo, 'find'); + assert.isFunc(app.models.Foo, 'create'); + }); + }); +}); + + +function assertValidDataSource(dataSource) { + // has methods + assert.isFunc(dataSource, 'createModel'); + assert.isFunc(dataSource, 'discoverModelDefinitions'); + assert.isFunc(dataSource, 'discoverSchema'); + assert.isFunc(dataSource, 'enableRemote'); + assert.isFunc(dataSource, 'disableRemote'); + assert.isFunc(dataSource, 'defineOperation'); + assert.isFunc(dataSource, 'operations'); +} + +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'); +}; diff --git a/test/fixtures/simple-app/app.json b/test/fixtures/simple-app/app.json new file mode 100644 index 0000000..8358c75 --- /dev/null +++ b/test/fixtures/simple-app/app.json @@ -0,0 +1,4 @@ +{ + "port": 3000, + "host": "127.0.0.1" +} diff --git a/test/fixtures/simple-app/boot/bad.txt b/test/fixtures/simple-app/boot/bad.txt new file mode 100644 index 0000000..81fae52 --- /dev/null +++ b/test/fixtures/simple-app/boot/bad.txt @@ -0,0 +1 @@ +this is not a js file! diff --git a/test/fixtures/simple-app/boot/foo.js b/test/fixtures/simple-app/boot/foo.js new file mode 100644 index 0000000..7e74863 --- /dev/null +++ b/test/fixtures/simple-app/boot/foo.js @@ -0,0 +1 @@ +process.loadedFooJS = true; diff --git a/test/fixtures/simple-app/datasources.json b/test/fixtures/simple-app/datasources.json new file mode 100644 index 0000000..05a18b3 --- /dev/null +++ b/test/fixtures/simple-app/datasources.json @@ -0,0 +1,5 @@ +{ + "db": { + "connector": "memory" + } +} diff --git a/test/fixtures/simple-app/models.json b/test/fixtures/simple-app/models.json new file mode 100644 index 0000000..3a22f13 --- /dev/null +++ b/test/fixtures/simple-app/models.json @@ -0,0 +1,5 @@ +{ + "foo": { + "dataSource": "db" + } +} diff --git a/test/fixtures/simple-app/models/bar.js b/test/fixtures/simple-app/models/bar.js new file mode 100644 index 0000000..0eef5d9 --- /dev/null +++ b/test/fixtures/simple-app/models/bar.js @@ -0,0 +1 @@ +process.loadedBarJS = true; diff --git a/test/global-setup.js b/test/global-setup.js new file mode 100644 index 0000000..1152607 --- /dev/null +++ b/test/global-setup.js @@ -0,0 +1,11 @@ +var loopback = require('loopback'); + +// bootLoopBackApp() calls loopback.autoAttach +// which attempts to attach all models to default datasources +// one of those models is Email which requires 'email' datasource +loopback.setDefaultDataSourceForType('mail', { + connector: loopback.Mail, + transports: [ + {type: 'STUB'} + ] +}); From c1743dc2ff28cd3107fd0e032bd058dcd88d0e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 26 May 2014 17:30:02 +0200 Subject: [PATCH 2/8] Load datasources and app cfg from multiple files Modify loading of `appConfig` and `dataSourceConfig` to look for the following files: - app.json - app.local.{js|json} - app.{$env}.{js|json} - datasources.json - datasources.local.{js|json} - datasources.{$env}.{js|json} where $env is the value of `app.get('env')`, which usually defaults to `process.env.NODE_ENV`. The values in the additional files are applied to the config object, overwritting any existing values. The new values must be value types like String or Number; Object and Array are not supported. Additional datasource config files cannot define new datasources, only modify existing ones. The commit includes refactoring of the config-loading code into a standalone file. --- .gitignore | 1 + index.js | 30 +++----- lib/config-loader.js | 154 ++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- test/boot.test.js | 138 +++++++++++++++++++++++++++++++++++ test/helpers/sandbox.js | 16 +++++ 6 files changed, 319 insertions(+), 23 deletions(-) create mode 100644 lib/config-loader.js create mode 100644 test/helpers/sandbox.js diff --git a/.gitignore b/.gitignore index 9fad0c2..bff8718 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ node_modules checkstyle.xml loopback-boot-*.tgz +/test/sandbox/ diff --git a/index.js b/index.js index 4cf8c20..ddac1e5 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ var assert = require('assert'); var fs = require('fs'); var path = require('path'); var _ = require('underscore'); +var ConfigLoader = require('./lib/config-loader'); /** * Initialize an application from an options object or @@ -103,7 +104,7 @@ var _ = require('underscore'); * @header boot(app, [options]) */ -module.exports = function bootLoopBackApp(app, options) { +exports = module.exports = function bootLoopBackApp(app, options) { /*jshint camelcase:false */ options = options || {}; @@ -111,19 +112,12 @@ module.exports = function bootLoopBackApp(app, options) { options = { appRootDir: options }; } var appRootDir = options.appRootDir = options.appRootDir || process.cwd(); - var appConfig = options.app; - var modelConfig = options.models; - var dataSourceConfig = options.dataSources; + var env = app.get('env'); - if(!appConfig) { - appConfig = tryReadConfig(appRootDir, 'app') || {}; - } - if(!modelConfig) { - modelConfig = tryReadConfig(appRootDir, 'models') || {}; - } - if(!dataSourceConfig) { - dataSourceConfig = tryReadConfig(appRootDir, 'datasources') || {}; - } + var appConfig = options.app || ConfigLoader.loadAppConfig(appRootDir, env); + var modelConfig = options.models || ConfigLoader.loadModels(appRootDir, env); + var dataSourceConfig = options.dataSources || + ConfigLoader.loadDataSources(appRootDir, env); assertIsValidConfig('app', appConfig); assertIsValidConfig('model', modelConfig); @@ -298,12 +292,4 @@ function tryReadDir() { } } -function tryReadConfig(cwd, fileName) { - try { - return require(path.join(cwd, fileName + '.json')); - } catch(e) { - if(e.code !== 'MODULE_NOT_FOUND') { - throw e; - } - } -} +exports.ConfigLoader = ConfigLoader; diff --git a/lib/config-loader.js b/lib/config-loader.js new file mode 100644 index 0000000..7cafb35 --- /dev/null +++ b/lib/config-loader.js @@ -0,0 +1,154 @@ +var fs = require('fs'); +var path = require('path'); + +var ConfigLoader = exports; + +/** + * Load application config from `app.json` and friends. + * @param {String} rootDir Directory where to look for files. + * @param {String} env Environment, usually `process.env.NODE_ENV` + * @returns {Object} + */ +ConfigLoader.loadAppConfig = function(rootDir, env) { + return loadNamed(rootDir, env, 'app', mergeAppConfig); +}; + +/** + * Load data-sources config from `datasources.json` and friends. + * @param {String} rootDir Directory where to look for files. + * @param {String} env Environment, usually `process.env.NODE_ENV` + * @returns {Object} + */ +ConfigLoader.loadDataSources = function(rootDir, env) { + return loadNamed(rootDir, env, 'datasources', mergeDataSourceConfig); +}; + +/** + * Load models config from `models.json` and friends. + * @param {String} rootDir Directory where to look for files. + * @param {String} env Environment, usually `process.env.NODE_ENV` + * @returns {Object} + */ +ConfigLoader.loadModels = function(rootDir, env) { + /*jshint unused:false */ + return tryReadJsonConfig(rootDir, 'models') || {}; +}; + +/*-- Implementation --*/ + +/** + * Load named configuration. + * @param {String} rootDir Directory where to look for files. + * @param {String} env Environment, usually `process.env.NODE_ENV` + * @param {String} name + * @param {function(target:Object, config:Object, filename:String)} mergeFn + * @returns {Object} + */ +function loadNamed(rootDir, env, name, mergeFn) { + var files = findConfigFiles(rootDir, env, name); + var configs = loadConfigFiles(files); + return mergeConfigurations(configs, mergeFn); +} + +/** + * Search `appRootDir` for all files containing configuration for `name`. + * @param {String} appRootDir + * @param {String} env Environment, usually `process.env.NODE_ENV` + * @param {String} name + * @returns {Array.} Array of absolute file paths. + */ +function findConfigFiles(appRootDir, env, name) { + var master = ifExists(name + '.json'); + if (!master) return []; + + var candidates = [ + master, + ifExistsWithAnyExt(name + '.local'), + ifExistsWithAnyExt(name + '.' + env) + ]; + + return candidates.filter(function(c) { return c !== undefined; }); + + function ifExists(fileName) { + var filepath = path.resolve(appRootDir, fileName); + return fs.existsSync(filepath) ? filepath : undefined; + } + + function ifExistsWithAnyExt(fileName) { + return ifExists(fileName + '.js') || ifExists(fileName + '.json'); + } +} + +/** + * Load configuration files into an array of objects. + * Attach non-enumerable `_filename` property to each object. + * @param {Array.} files + * @returns {Array.} + */ +function loadConfigFiles(files) { + return files.map(function(f) { + var config = require(f); + Object.defineProperty(config, '_filename', { + enumerable: false, + value: f + }); + return config; + }); +} + +/** + * Merge multiple configuration objects into a single one. + * @param {Array.} configObjects + * @param {function(target:Object, config:Object, filename:String)} mergeFn + */ +function mergeConfigurations(configObjects, mergeFn) { + var result = configObjects.shift() || {}; + while(configObjects.length) { + var next = configObjects.shift(); + mergeFn(result, next, next._filename); + } + return result; +} + +function mergeDataSourceConfig(target, config, fileName) { + for (var ds in target) { + var err = applyCustomConfig(target[ds], config[ds]); + if (err) { + throw new Error('Cannot apply ' + fileName + ' to `' + ds + '`: ' + err); + } + } +} + +function mergeAppConfig(target, config, fileName) { + var err = applyCustomConfig(target, config); + if (err) { + throw new Error('Cannot apply ' + fileName + ': ' + err); + } +} + +function applyCustomConfig(target, config) { + for (var key in config) { + var value = config[key]; + if (typeof value === 'object') { + return 'override for the option `' + key + '` is not a value type.'; + } + target[key] = value; + } + return null; // no error +} + +/** + * Try to read a config file with .json extension + * @param cwd Dirname of the file + * @param 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') { + throw e; + } + } +} diff --git a/package.json b/package.json index 3fdcdcb..f7eb437 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "loopback": "^1.5.0", "mocha": "^1.19.0", "must": "^0.11.0", - "supertest": "^0.13.0" + "supertest": "^0.13.0", + "fs-extra": "^0.9.1" }, "peerDependencies": { "loopback": "1.x" diff --git a/test/boot.test.js b/test/boot.test.js index 510bfb3..524cc6b 100644 --- a/test/boot.test.js +++ b/test/boot.test.js @@ -1,12 +1,30 @@ var boot = require('../'); +var fs = require('fs-extra'); +var extend = require('util')._extend; var path = require('path'); var loopback = require('loopback'); var assert = require('assert'); var expect = require('must'); +var sandbox = require('./helpers/sandbox'); var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); +var appDir; + describe('bootLoopBackApp', function() { + beforeEach(sandbox.reset); + + beforeEach(function makeUniqueAppDir(done) { + // Node's module loader has a very aggressive caching, therefore + // we can't reuse the same path for multiple tests + // The code here is used to generate a random string + require('crypto').randomBytes(5, function(ex, buf) { + var randomStr = buf.toString('hex'); + appDir = sandbox.resolve(randomStr); + done(); + }); + }); + describe('from options', function () { var app; beforeEach(function () { @@ -186,6 +204,102 @@ describe('bootLoopBackApp', function() { assert.isFunc(app.models.Foo, 'find'); assert.isFunc(app.models.Foo, 'create'); }); + + it('merges datasource configs from multiple files', function() { + givenAppInSandbox(); + + writeAppConfigFile('datasources.local.json', { + db: { local: 'applied' } + }); + + var env = process.env.NODE_ENV || 'development'; + writeAppConfigFile('datasources.' + env + '.json', { + db: { env: 'applied' } + }); + + var app = loopback(); + boot(app, appDir); + + var db = app.datasources.db.settings; + expect(db).to.have.property('local', 'applied'); + expect(db).to.have.property('env', 'applied'); + + var expectedLoadOrder = ['local', 'env']; + var actualLoadOrder = Object.keys(db).filter(function(k) { + return expectedLoadOrder.indexOf(k) !== -1; + }); + + expect(actualLoadOrder, 'load order').to.eql(expectedLoadOrder); + }); + + it('supports .js for custom datasource config files', function() { + givenAppInSandbox(); + fs.writeFileSync( + path.resolve(appDir, 'datasources.local.js'), + 'module.exports = { db: { fromJs: true } };'); + + var app = loopback(); + boot(app, appDir); + + var db = app.datasources.db.settings; + expect(db).to.have.property('fromJs', true); + }); + + it('refuses to merge Object properties', function() { + givenAppInSandbox(); + writeAppConfigFile('datasources.local.json', { + db: { nested: { key: 'value' } } + }); + + var app = loopback(); + expect(function() { boot(app, appDir); }) + .to.throw(/`nested` is not a value type/); + }); + + it('refuses to merge Array properties', function() { + givenAppInSandbox(); + writeAppConfigFile('datasources.local.json', { + db: { nested: ['value'] } + }); + + var app = loopback(); + expect(function() { boot(app, appDir); }) + .to.throw(/`nested` is not a value type/); + }); + + it('merges app configs from multiple files', function() { + givenAppInSandbox(); + + writeAppConfigFile('app.local.json', { cfgLocal: 'applied' }); + + var env = process.env.NODE_ENV || 'development'; + writeAppConfigFile('app.' + env + '.json', { cfgEnv: 'applied' }); + + var app = loopback(); + boot(app, appDir); + + expect(app.settings).to.have.property('cfgLocal', 'applied'); + expect(app.settings).to.have.property('cfgEnv', 'applied'); + + var expectedLoadOrder = ['cfgLocal', 'cfgEnv']; + var actualLoadOrder = Object.keys(app.settings).filter(function(k) { + return expectedLoadOrder.indexOf(k) !== -1; + }); + + expect(actualLoadOrder, 'load order').to.eql(expectedLoadOrder); + }); + + it('supports .js for custom app config files', function() { + givenAppInSandbox(); + fs.writeFileSync( + path.resolve(appDir, 'app.local.js'), + 'module.exports = { fromJs: true };'); + + var app = loopback(); + boot(app, appDir); + + expect(app.settings).to.have.property('fromJs', true); + }); }); }); @@ -206,3 +320,27 @@ assert.isFunc = function (obj, name) { ' on object that does not exist'); assert(typeof obj[name] === 'function', name + ' is not a function'); }; + +function givenAppInSandbox(appConfig, dataSources, models) { + fs.mkdirsSync(appDir); + + appConfig = extend({ + }, appConfig); + writeAppConfigFile('app.json', appConfig); + + dataSources = extend({ + db: { + connector: 'memory', + defaultForType: 'db' + } + }, dataSources); + writeAppConfigFile('datasources.json', dataSources); + + models = extend({ + }, models); + writeAppConfigFile('models.json', models); +} + +function writeAppConfigFile(name, json) { + fs.writeJsonFileSync(path.resolve(appDir, name), json); +} diff --git a/test/helpers/sandbox.js b/test/helpers/sandbox.js new file mode 100644 index 0000000..cdd35bd --- /dev/null +++ b/test/helpers/sandbox.js @@ -0,0 +1,16 @@ +var fs = require('fs-extra'); +var path = require('path'); + +var sandbox = exports; +sandbox.PATH = path.join(__dirname, '..', 'sandbox'); + +sandbox.reset = function() { + fs.removeSync(sandbox.PATH); + fs.mkdirsSync(sandbox.PATH); +}; + +sandbox.resolve = function() { + var args = Array.prototype.slice.apply(arguments); + args.unshift(sandbox.PATH); + return path.resolve.apply(path.resolve, args); +}; From 2a619773d4df9d7e5fb4f14cc1a49928425c5ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 27 May 2014 14:46:20 +0200 Subject: [PATCH 3/8] Custom rootDir for models and datasources Support custom project layouts where model & datasource config files are located in a different place from the app config. Example: # API server server/app.config.json server/datasources.json # shared between server & client models.json models/ # isomorphic client client/app.config.json client/datasources.json --- .jshintignore | 1 + index.js | 15 +++++++++++--- test/boot.test.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/.jshintignore b/.jshintignore index 25fbf5a..d8f8557 100644 --- a/.jshintignore +++ b/.jshintignore @@ -1,2 +1,3 @@ node_modules/ coverage/ +test/sandbox/ diff --git a/index.js b/index.js index ddac1e5..2ec6bfd 100644 --- a/index.js +++ b/index.js @@ -99,6 +99,10 @@ var ConfigLoader = require('./lib/config-loader'); * @property {Object} models Object containing `Model` definitions (optional). * @property {Object} dataSources Object containing `DataSource` * definitions (optional). + * @property {String} modelsRootDir Directory to use when loading `models.json` + * and `models/*.js`. Defaults to `appRootDir`. + * @property {String} datasourcesRootDir Directory to use when loading + * `datasources.json`. Defaults to `appRootDir`. * @end * * @header boot(app, [options]) @@ -115,9 +119,14 @@ exports = module.exports = function bootLoopBackApp(app, options) { var env = app.get('env'); var appConfig = options.app || ConfigLoader.loadAppConfig(appRootDir, env); - var modelConfig = options.models || ConfigLoader.loadModels(appRootDir, env); + + var modelsRootDir = options.modelsRootDir || appRootDir; + var modelConfig = options.models || + ConfigLoader.loadModels(modelsRootDir, env); + + var dsRootDir = options.dsRootDir || appRootDir; var dataSourceConfig = options.dataSources || - ConfigLoader.loadDataSources(appRootDir, env); + ConfigLoader.loadDataSources(dsRootDir, env); assertIsValidConfig('app', appConfig); assertIsValidConfig('model', modelConfig); @@ -204,7 +213,7 @@ exports = module.exports = function bootLoopBackApp(app, options) { } // require directories - requireDir(path.join(appRootDir, 'models')); + requireDir(path.join(modelsRootDir, 'models')); requireDir(path.join(appRootDir, 'boot')); }; diff --git a/test/boot.test.js b/test/boot.test.js index 524cc6b..745dcef 100644 --- a/test/boot.test.js +++ b/test/boot.test.js @@ -300,6 +300,48 @@ describe('bootLoopBackApp', function() { expect(app.settings).to.have.property('fromJs', true); }); + + it('supports `dsRootDir` option', function() { + givenAppInSandbox(); + + var customDir = path.resolve(appDir, 'custom'); + fs.mkdirsSync(customDir); + fs.renameSync( + path.resolve(appDir, 'datasources.json'), + path.resolve(customDir, 'datasources.json')); + + var app = loopback(); + + // workaround for https://github.com/strongloop/loopback/pull/283 + app.datasources = app.dataSources = {}; + + boot(app, { + appRootDir: appDir, + dsRootDir: path.resolve(appDir, 'custom') + }); + + expect(app.datasources).to.have.property('db'); + }); + + it('supports `modelsRootDir` option', function() { + givenAppInSandbox(); + + writeAppConfigFile('custom/models.json', { + foo: { dataSource: 'db' } + }); + + global.testData = {}; + writeAppFile('custom/models/foo.js', 'global.testData.foo = "loaded";'); + + var app = loopback(); + boot(app, { + appRootDir: appDir, + modelsRootDir: path.resolve(appDir, 'custom') + }); + + expect(app.models).to.have.property('foo'); + expect(global.testData).to.have.property('foo', 'loaded'); + }); }); }); @@ -342,5 +384,11 @@ function givenAppInSandbox(appConfig, dataSources, models) { } function writeAppConfigFile(name, json) { - fs.writeJsonFileSync(path.resolve(appDir, name), json); + writeAppFile(name, JSON.stringify(json, null, 2)); +} + +function writeAppFile(name, content) { + var filePath = path.resolve(appDir, name); + fs.mkdirsSync(path.dirname(filePath)); + fs.writeFileSync(filePath, content, 'utf-8'); } From 9930934686282c4c036ac03af0ab1c09e6c894df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 27 May 2014 16:05:43 +0200 Subject: [PATCH 4/8] cleanup: remove unused code in requireDir() No changes in functionality. --- index.js | 67 ++++++++++++++++++++++++++------------------------------ 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/index.js b/index.js index 2ec6bfd..77e5efd 100644 --- a/index.js +++ b/index.js @@ -232,54 +232,49 @@ function forEachKeyedObject(obj, fn) { }); } -function requireDir(dir, basenames) { +function requireDir(dir) { assert(dir, 'cannot require directory contents without directory name'); var requires = {}; - if (arguments.length === 2) { - // if basenames argument is passed, explicitly include those files - basenames.forEach(function (basename) { - var filepath = path.resolve(path.join(dir, basename)); - requires[basename] = tryRequire(filepath); - }); - } else if (arguments.length === 1) { - // if basenames arguments isn't passed, require all javascript - // files (except for those prefixed with _) and all directories + // require all javascript files (except for those prefixed with _) + // and all directories - var files = tryReadDir(dir); + var files = tryReadDir(dir); - // sort files in lowercase alpha for linux - files.sort(function (a,b) { - a = a.toLowerCase(); - b = b.toLowerCase(); + // sort files in lowercase alpha for linux + files.sort(function(a, b) { + a = a.toLowerCase(); + b = b.toLowerCase(); - if (a < b) { - return -1; - } else if (b < a) { - return 1; - } else { - return 0; - } - }); + if (a < b) { + return -1; + } else if (b < a) { + return 1; + } else { + return 0; + } + }); - files.forEach(function (filename) { - // ignore index.js and files prefixed with underscore - if ((filename === 'index.js') || (filename[0] === '_')) { return; } + files.forEach(function(filename) { + // ignore index.js and files prefixed with underscore + if ((filename === 'index.js') || (filename[0] === '_')) { + return; + } - var filepath = path.resolve(path.join(dir, filename)); - var ext = path.extname(filename); - var stats = fs.statSync(filepath); + var filepath = path.resolve(path.join(dir, filename)); + var ext = path.extname(filename); + var stats = fs.statSync(filepath); - // only require files supported by require.extensions (.txt .md etc.) - if (stats.isFile() && !(ext in require.extensions)) { return; } + // only require files supported by require.extensions (.txt .md etc.) + if (stats.isFile() && !(ext in require.extensions)) { + return; + } - var basename = path.basename(filename, ext); + var basename = path.basename(filename, ext); - requires[basename] = tryRequire(filepath); - }); - - } + requires[basename] = tryRequire(filepath); + }); return requires; } From 3ba43e119732996659d0ede65ff1b76ee7a2223d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 27 May 2014 16:11:54 +0200 Subject: [PATCH 5/8] Pass `app` to fn exported by auto-required script When a script in `models/` or `boot/` exports a function which is not a loopback.Model constructor, the bootstrapper immediatelly calls this exported function wit the current `app` object. This is providing a dependency injection mechanism for boot scripts, so that they no longer need to know where to find the `app` object. Note: the dependency injection is optional. Existing code getting `app` reference via `require('../app')` will continue to work. --- index.js | 21 +++++++++++++++------ test/boot.test.js | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 77e5efd..240c574 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ var assert = require('assert'); var fs = require('fs'); var path = require('path'); var _ = require('underscore'); +var loopback = require('loopback'); var ConfigLoader = require('./lib/config-loader'); /** @@ -195,7 +196,7 @@ exports = module.exports = function bootLoopBackApp(app, options) { // try to attach models to dataSources by type try { - require('loopback').autoAttach(); + loopback.autoAttach(); } catch(e) { if(e.name === 'AssertionError') { console.warn(e); @@ -213,8 +214,8 @@ exports = module.exports = function bootLoopBackApp(app, options) { } // require directories - requireDir(path.join(modelsRootDir, 'models')); - requireDir(path.join(appRootDir, 'boot')); + requireDir(path.join(modelsRootDir, 'models'), app); + requireDir(path.join(appRootDir, 'boot'), app); }; function assertIsValidConfig(name, config) { @@ -232,7 +233,7 @@ function forEachKeyedObject(obj, fn) { }); } -function requireDir(dir) { +function requireDir(dir, app) { assert(dir, 'cannot require directory contents without directory name'); var requires = {}; @@ -271,9 +272,12 @@ function requireDir(dir) { return; } - var basename = path.basename(filename, ext); + var exports = tryRequire(filepath); + if (isFunctionNotModelCtor(exports)) + exports(app); - requires[basename] = tryRequire(filepath); + var basename = path.basename(filename, ext); + requires[basename] = exports; }); return requires; @@ -296,4 +300,9 @@ function tryReadDir() { } } +function isFunctionNotModelCtor(fn) { + return typeof fn === 'function' && + !(fn.prototype instanceof loopback.Model); +} + exports.ConfigLoader = ConfigLoader; diff --git a/test/boot.test.js b/test/boot.test.js index 745dcef..947d45b 100644 --- a/test/boot.test.js +++ b/test/boot.test.js @@ -342,6 +342,43 @@ describe('bootLoopBackApp', function() { expect(app.models).to.have.property('foo'); expect(global.testData).to.have.property('foo', 'loaded'); }); + + it('calls function exported by models/model.js', function() { + givenAppInSandbox(); + writeAppFile('models/model.js', + 'module.exports = function(app) { app.fnCalled = true; };'); + + var app = loopback(); + delete app.fnCalled; + boot(app, appDir); + expect(app.fnCalled, 'exported fn was called').to.be.true(); + }); + + it('calls function exported by boot/init.js', function() { + givenAppInSandbox(); + writeAppFile('boot/init.js', + 'module.exports = function(app) { app.fnCalled = true; };'); + + var app = loopback(); + delete app.fnCalled; + boot(app, appDir); + expect(app.fnCalled, 'exported fn was called').to.be.true(); + }); + + it('does not call Model ctor exported by models/model.json', function() { + givenAppInSandbox(); + writeAppFile('models/model.js', + 'var loopback = require("loopback");\n' + + 'module.exports = loopback.Model.extend("foo");\n' + + 'module.exports.prototype._initProperties = function() {\n' + + ' global.fnCalled = true;\n' + + '};'); + + var app = loopback(); + delete global.fnCalled; + boot(app, appDir); + expect(global.fnCalled, 'exported fn was called').to.be.undefined(); + }); }); }); From a716cdbf7b32c6d238728b65d1ae3bd97260d7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 27 May 2014 16:18:09 +0200 Subject: [PATCH 6/8] Start CHANGES.md --- CHANGES.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..c62e362 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,14 @@ +## Changes in version 1.0 + + - New options: `modelsRootDir`, `dsRootDir` + + - Load configuration from files, support dynamic (scripted) options + + ```sh + app.json, app.local.*, app.{env}.* + datasources.json, datasources.local.*, datasources.{env}.* + ``` + + - 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. From 255217f6a72cc74684ad0865c4b5f4f133db972a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 27 May 2014 16:44:21 +0200 Subject: [PATCH 7/8] Ignore models/ and boot/ subdirs without index Sub-directories of `models/` and `boot/` that cannot be required (they don't have an index.js file) are silently skipped now. This enables developers to put test files into `models/test/`. --- index.js | 5 +++++ package.json | 3 ++- test/boot.test.js | 11 +++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 240c574..6e6bdf6 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ var path = require('path'); var _ = require('underscore'); var loopback = require('loopback'); var ConfigLoader = require('./lib/config-loader'); +var debug = require('debug')('loopback-boot'); /** * Initialize an application from an options object or @@ -287,6 +288,10 @@ function tryRequire(modulePath) { try { return require.apply(this, arguments); } catch(e) { + if(e.code === 'MODULE_NOT_FOUND') { + debug('Warning: cannot require %s - module not found.', modulePath); + return undefined; + } console.error('failed to require "%s"', modulePath); throw e; } diff --git a/package.json b/package.json index f7eb437..8c614f1 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "url": "https://github.com/strongloop/loopback-boot/blob/master/LICENSE" }, "dependencies": { - "underscore": "^1.6.0" + "underscore": "^1.6.0", + "debug": "^0.8.1" }, "devDependencies": { "loopback": "^1.5.0", diff --git a/test/boot.test.js b/test/boot.test.js index 947d45b..6ebdcc8 100644 --- a/test/boot.test.js +++ b/test/boot.test.js @@ -379,6 +379,17 @@ describe('bootLoopBackApp', function() { boot(app, appDir); expect(global.fnCalled, 'exported fn was called').to.be.undefined(); }); + + it('supports models/ subdirectires that are not require()able', function() { + givenAppInSandbox(); + writeAppFile('models/test/model.test.js', + 'throw new Error("should not been called");'); + + var app = loopback(); + boot(app, appDir); + + // no assert, the test passed when we got here + }); }); }); From 1f1bc7aeab983fc53157bbc5e25c0de213296307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 28 May 2014 09:44:06 +0200 Subject: [PATCH 8/8] fixup! change debug logger name to 'loopback:boot' --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 6e6bdf6..2b309e7 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,7 @@ var path = require('path'); var _ = require('underscore'); var loopback = require('loopback'); var ConfigLoader = require('./lib/config-loader'); -var debug = require('debug')('loopback-boot'); +var debug = require('debug')('loopback:boot'); /** * Initialize an application from an options object or