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/.jshintignore b/.jshintignore index 25fbf5a..d8f8557 100644 --- a/.jshintignore +++ b/.jshintignore @@ -1,2 +1,3 @@ node_modules/ coverage/ +test/sandbox/ 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. diff --git a/README.md b/README.md index 699be92..55b0e4d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ LoopBack Boot is a convention-based bootstrapper for LoopBack applications. -**For full documentation, see the official StrongLoop documentation**: +**For full documentation, see the official StrongLoop documentation:** + * [Creating a LoopBack application](http://docs.strongloop.com/display/DOC/Creating+a+LoopBack+application) ## Installation @@ -11,5 +12,16 @@ LoopBack Boot is a convention-based bootstrapper for LoopBack applications. ## Usage -TBD +```js +var loopback = require('loopback'); +var boot = require('loopback-boot'); +var app = loopback(); +boot(app, __dirname); + +app.use(loopback.rest()); +app.listen(); +``` + +See [API docs](http://apidocs.strongloop.com/loopback-boot/) for +complete API reference. diff --git a/browser.js b/browser.js new file mode 100644 index 0000000..ac49bc2 --- /dev/null +++ b/browser.js @@ -0,0 +1,24 @@ +var execute = require('./lib/executor'); + +/** + * The browser version of `bootLoopBackApp`. + * + * When loopback-boot is loaded in browser, the module exports this + * function instead of `bootLoopBackApp`. + * + * The function expects the boot instructions to be included in + * the browser bundle, see `boot.compileToBrowserify`. + * + * @param {Object} app The loopback app to boot, as returned by `loopback()`. + * + * @header bootBrowserApp(app) + */ + +exports = module.exports = function bootBrowserApp(app) { + // The name of the module containing instructions + // is hard-coded in lib/bundler + var instructions = require('loopback-boot#instructions'); + execute(app, instructions); +}; + +exports.execute = execute; diff --git a/docs.json b/docs.json new file mode 100644 index 0000000..05f6402 --- /dev/null +++ b/docs.json @@ -0,0 +1,12 @@ +{ + "content": [ + { + "title": "Bootstrap API", + "depth": 2 + }, + "index.js", + "browser.js", + "docs/configuration.md", + "docs/browserify.md" + ] +} diff --git a/docs/browserify.md b/docs/browserify.md new file mode 100644 index 0000000..9873026 --- /dev/null +++ b/docs/browserify.md @@ -0,0 +1,74 @@ +## Running in a browser + +The bootstrap process is implemented in two steps that can be called +independently. + +### Build + +The first step loads all configuration files, merges values from additional +config files like `app.local.js` and produces a set of instructions +that can be used to boot the application. + +These instructions must be included in the browser bundle together +with all configuration scripts from `models/` and `boot/`. + +Don't worry, you don't have to understand these details. +Just call `boot.compileToBrowserify`, it will take care of everything for you. + +```js +/*-- build file --*/ +var browserify = require('browserify'); +var boot = require('loopback-boot'); + +var b = browserify({ + basedir: appDir, +}); + +// add the main application file +b.require('./app.js', { expose: 'loopback-app' }); + +// add boot instructions +boot.compileToBrowserify(appDir, b); + +// create the bundle +var out = fs.createWriteStream('app.bundle.js'); +b.bundle().pipe(out); +// handle out.on('error') and out.on('close') +``` + +### Run + +In the browser, the main application file should call loopback-boot +to setup the loopback application by executing the instructions +contained in the browser bundle: + +```js +/*-- app.js --*/ +var loopback = require('loopback'); +var boot = require('loopback-boot'); + +var app = module.exports = loopback(); +boot(app); +``` + +The app object created above can be accessed via `require('loopback-app')`, +where `loopback-app` is the identifier used for the main app file in +the browserify build shown above. + +Here is a simple example demonstrating the concept: + +```xml + + +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..082e959 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,50 @@ +## Configuration and conventions + +### 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 + } + } + } +} +``` diff --git a/index.js b/index.js new file mode 100644 index 0000000..fc0e2ef --- /dev/null +++ b/index.js @@ -0,0 +1,87 @@ +var ConfigLoader = require('./lib/config-loader'); +var compile = require('./lib/compiler'); +var execute = require('./lib/executor'); +var addInstructionsToBrowserify = require('./lib/bundler'); + +/** + * 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. + * + * @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). + * @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`. + * @property {String} env Environment type, defaults to `process.env.NODE_ENV` + * or `development`. Common values are `development`, `staging` and + * `production`; however the applications are free to use any names. + * @end + * + * @header bootLoopBackApp(app, [options]) + */ + +exports = module.exports = function bootLoopBackApp(app, options) { + // backwards compatibility with loopback's app.boot + options.env = options.env || app.get('env'); + + var instructions = compile(options); + execute(app, instructions); +}; + +/** + * Compile boot instructions and add them to a browserify bundler. + * @param {Object|String} options as described in `bootLoopBackApp` above. + * @param {Object} bundler A browserify bundler created by `browserify()`. + * + * @header boot.compileToBrowserify(options, bundler) + */ +exports.compileToBrowserify = function(options, bundler) { + addInstructionsToBrowserify(compile(options), bundler); +}; + +//-- undocumented low-level API --// + +exports.ConfigLoader = ConfigLoader; +exports.compile = compile; +exports.execute = execute; +exports.addInstructionsToBrowserify = addInstructionsToBrowserify; diff --git a/lib/bundler.js b/lib/bundler.js new file mode 100644 index 0000000..d26b7fe --- /dev/null +++ b/lib/bundler.js @@ -0,0 +1,59 @@ +var fs = require('fs'); +var path = require('path'); +var commondir = require('commondir'); + +/** + * Add boot instructions to a browserify bundler. + * @param {Object} instructions Boot instructions. + * @param {Object} bundler A browserify object created by `browserify()`. + */ + +module.exports = function addInstructionsToBrowserify(instructions, bundler) { + bundleScripts(instructions.files, bundler); + bundleInstructions(instructions, bundler); +}; + +function bundleScripts(files, bundler) { + for (var key in files) { + var list = files[key]; + if (!list.length) continue; + + var root = commondir(files[key].map(path.dirname)); + + for (var ix in list) { + var filepath = list[ix]; + + // Build a short unique id that does not expose too much + // information about the file system, but still preserves + // useful information about where is the file coming from. + var fileid = 'loopback-boot#' + key + '#' + path.relative(root, filepath); + + // Add the file to the bundle. + bundler.require(filepath, { expose: fileid }); + + // Rewrite the instructions entry with the new id that will be + // used to load the file via `require(fileid)`. + list[ix] = fileid; + } + } +} + +function bundleInstructions(instructions, bundler) { + var instructionsString = JSON.stringify(instructions, null, 2); + + /* The following code does not work due to a bug in browserify + * https://github.com/substack/node-browserify/issues/771 + var instructionsStream = require('resumer')() + .queue(instructionsString); + instructionsStream.path = 'boot-instructions'; + b.require(instructionsStream, { expose: 'loopback-boot#instructions' }); + */ + + // Write the instructions to a file in our node_modules folder. + // The location should not really matter as long as it is .gitignore-ed + var instructionsFile = path.resolve(__dirname, + '..', 'node_modules', 'instructions.json'); + + fs.writeFileSync(instructionsFile, instructionsString, 'utf-8'); + bundler.require(instructionsFile, { expose: 'loopback-boot#instructions' }); +} diff --git a/lib/compiler.js b/lib/compiler.js new file mode 100644 index 0000000..ac35dab --- /dev/null +++ b/lib/compiler.js @@ -0,0 +1,126 @@ +var assert = require('assert'); +var fs = require('fs'); +var path = require('path'); +var ConfigLoader = require('./config-loader'); +var debug = require('debug')('loopback:boot:compiler'); + +/** + * Gather all bootstrap-related configuration data and compile it into + * a single object containing instruction for `boot.execute`. + * + * @options {String|Object} options Boot options; If String, this is + * the application root directory; if object, has the properties + * described in `bootLoopBackApp` options above. + * @return {Object} + * + * @header boot.compile(options) + */ + +module.exports = function compile(options) { + options = options || {}; + + if(typeof options === 'string') { + options = { appRootDir: options }; + } + + var appRootDir = options.appRootDir = options.appRootDir || process.cwd(); + var env = options.env || process.env.NODE_ENV || 'development'; + + var appConfig = options.app || ConfigLoader.loadAppConfig(appRootDir, env); + assertIsValidConfig('app', appConfig); + + var modelsRootDir = options.modelsRootDir || appRootDir; + var modelsConfig = options.models || + ConfigLoader.loadModels(modelsRootDir, env); + assertIsValidConfig('model', modelsConfig); + + var dsRootDir = options.dsRootDir || appRootDir; + var dataSourcesConfig = options.dataSources || + ConfigLoader.loadDataSources(dsRootDir, env); + assertIsValidConfig('data source', dataSourcesConfig); + + // require directories + var modelsScripts = findScripts(path.join(modelsRootDir, 'models')); + var bootScripts = findScripts(path.join(appRootDir, 'boot')); + + return { + app: appConfig, + dataSources: dataSourcesConfig, + models: modelsConfig, + files: { + models: modelsScripts, + boot: bootScripts + } + }; +}; + +function assertIsValidConfig(name, config) { + if(config) { + assert(typeof config === 'object', + name + ' config must be a valid JSON object'); + } +} + +/** + * Find all javascript files (except for those prefixed with _) + * and all directories. + * @param {String} dir Full path of the directory to enumerate. + * @return {Array.} A list of absolute paths to pass to `require()`. + * @private + */ + +function findScripts(dir) { + assert(dir, 'cannot require directory contents without directory name'); + + 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; + } + }); + + var results = []; + 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()) { + if (ext in require.extensions) + results.push(filepath); + else + debug('Skipping file %s - unknown extension', filepath); + } else { + try { + path.join(require.resolve(filepath)); + } catch(err) { + debug('Skipping directory %s - %s', filepath, err.code || err); + } + } + }); + + return results; +} + +function tryReadDir() { + try { + return fs.readdirSync.apply(fs, arguments); + } catch(e) { + return []; + } +} 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/lib/executor.js b/lib/executor.js new file mode 100644 index 0000000..538970e --- /dev/null +++ b/lib/executor.js @@ -0,0 +1,167 @@ +var assert = require('assert'); +var _ = require('underscore'); +var loopback = require('loopback'); +var debug = require('debug')('loopback:boot:executor'); + +/** + * Execute bootstrap instructions gathered by `boot.compile`. + * + * @options {Object} app The loopback app to boot. + * @options {Object} instructions Boot instructions. + * + * @header boot.execute(instructions) + */ + +module.exports = function execute(app, instructions) { + setHost(app, instructions); + setPort(app, instructions); + setApiRoot(app, instructions); + applyAppConfig(app, instructions); + + setupDataSources(app, instructions); + setupModels(app, instructions); + autoAttach(); + + runBootScripts(app, instructions); + + enableAnonymousSwagger(app, instructions); +}; + +function setHost(app, instructions) { + //jshint camelcase:false + var host = + process.env.npm_config_host || + process.env.OPENSHIFT_SLS_IP || + process.env.OPENSHIFT_NODEJS_IP || + process.env.HOST || + instructions.app.host || + process.env.npm_package_config_host || + app.get('host'); + + if(host !== undefined) { + assert(typeof host === 'string', 'app.host must be a string'); + app.set('host', host); + } +} + +function setPort(app, instructions) { + //jshint camelcase:false + var port = _.find([ + process.env.npm_config_port, + process.env.OPENSHIFT_SLS_PORT, + process.env.OPENSHIFT_NODEJS_PORT, + process.env.PORT, + instructions.app.port, + process.env.npm_package_config_port, + app.get('port'), + 3000 + ], _.isFinite); + + if(port !== undefined) { + var portType = typeof port; + assert(portType === 'string' || portType === 'number', + 'app.port must be a string or number'); + app.set('port', port); + } +} + +function setApiRoot(app, instructions) { + var restApiRoot = + instructions.app.restApiRoot || + app.get('restApiRoot') || + '/api'; + + assert(restApiRoot !== undefined, 'app.restBasePath is required'); + assert(typeof restApiRoot === 'string', + 'app.restApiRoot must be a string'); + assert(/^\//.test(restApiRoot), + 'app.restApiRoot must start with "/"'); + app.set('restApiRoot', restApiRoot); +} + +function applyAppConfig(app, instructions) { + var appConfig = instructions.app; + for(var configKey in appConfig) { + var cur = app.get(configKey); + if(cur === undefined || cur === null) { + app.set(configKey, appConfig[configKey]); + } + } +} + +function setupDataSources(app, instructions) { + forEachKeyedObject(instructions.dataSources, function(key, obj) { + app.dataSource(key, obj); + }); +} + +function setupModels(app, instructions) { + forEachKeyedObject(instructions.models, function(key, obj) { + app.model(key, obj); + }); + + runScripts(app, instructions.files.models); +} + +function forEachKeyedObject(obj, fn) { + if(typeof obj !== 'object') return; + + Object.keys(obj).forEach(function(key) { + fn(key, obj[key]); + }); +} + +function runScripts(app, list) { + if (!list || !list.length) return; + list.forEach(function(filepath) { + var exports = tryRequire(filepath); + if (isFunctionNotModelCtor(exports)) + exports(app); + }); +} + +function isFunctionNotModelCtor(fn) { + return typeof fn === 'function' && + !(fn.prototype instanceof loopback.Model); +} + +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; + } +} + +// Deprecated, will be removed soon +function autoAttach() { + try { + loopback.autoAttach(); + } catch(e) { + if(e.name === 'AssertionError') { + console.warn(e); + } else { + throw e; + } + } +} + +function runBootScripts(app, instructions) { + runScripts(app, instructions.files.boot); +} + +function enableAnonymousSwagger(app, instructions) { + // disable token requirement for swagger, if available + var swagger = app.remotes().exports.swagger; + if (!swagger) return; + + var appConfig = instructions.app; + var requireTokenForSwagger = appConfig.swagger && + appConfig.swagger.requireToken; + swagger.requireToken = requireTokenForSwagger || false; +} diff --git a/package.json b/package.json index 9b973fe..f0b3cf9 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,29 @@ "url": "https://github.com/loobpack/loopback-boot" }, "main": "index.js", + "browser": "browser.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", + "debug": "^0.8.1", + "commondir": "0.0.1" + }, + "devDependencies": { + "loopback": "^1.5.0", + "mocha": "^1.19.0", + "must": "^0.11.0", + "supertest": "^0.13.0", + "fs-extra": "^0.9.1", + "browserify": "^4.1.8" + }, + "peerDependencies": { + "loopback": "1.x || 2.x" } } diff --git a/test/browser.test.js b/test/browser.test.js new file mode 100644 index 0000000..31ddf94 --- /dev/null +++ b/test/browser.test.js @@ -0,0 +1,91 @@ +var boot = require('../'); +var fs = require('fs'); +var path = require('path'); +var expect = require('must'); +var browserify = require('browserify'); +var sandbox = require('./helpers/sandbox'); +var vm = require('vm'); + +describe('browser support', function() { + it('has API for bundling and executing boot instructions', function(done) { + var appDir = path.resolve(__dirname, './fixtures/browser-app'); + + browserifyTestApp(appDir, function(err, bundlePath) { + if (err) return done(err); + + var app = executeBundledApp(bundlePath); + + // configured in fixtures/browser-app/boot/configure.js + expect(app.settings).to.have.property('custom-key', 'custom-value'); + + done(); + }); + }); +}); + +function browserifyTestApp(appDir, next) { + var b = browserify({ + basedir: appDir, + }); + b.require('./app.js', { expose: 'browser-app' }); + + boot.compileToBrowserify(appDir, b); + + var bundlePath = sandbox.resolve('browser-app-bundle.js'); + var out = fs.createWriteStream(bundlePath); + b.bundle({ debug: true }).pipe(out); + + out.on('error', function(err) { return next(err); }); + out.on('close', function() { + next(null, bundlePath); + }); +} + +function executeBundledApp(bundlePath) { + var code = fs.readFileSync(bundlePath); + var context = createBrowserLikeContext(); + vm.runInContext(code, context, bundlePath); + var app = vm.runInContext('require("browser-app")', context); + + printContextLogs(context); + + return app; +} + +function createBrowserLikeContext() { + return vm.createContext({ + // required by browserify + XMLHttpRequest: function() { throw new Error('not implemented'); }, + + // used by loopback to detect browser runtime + window: {}, + + // allow the browserified code to log messages + // call `printContextLogs(context)` to print the accumulated messages + console: { + log: function() { + this._logs.log.push(Array.prototype.slice.call(arguments)); + }, + warn: function() { + this._logs.warn.push(Array.prototype.slice.call(arguments)); + }, + error: function() { + this._logs.error.push(Array.prototype.slice.call(arguments)); + }, + _logs: { + log: [], + warn: [], + error: [] + }, + } + }); +} + +function printContextLogs(context) { + for (var k in context.console._logs) { + var items = context.console._logs[k]; + for (var ix in items) { + console[k].apply(console, items[ix]); + } + } +} diff --git a/test/compiler.test.js b/test/compiler.test.js new file mode 100644 index 0000000..b1076b0 --- /dev/null +++ b/test/compiler.test.js @@ -0,0 +1,220 @@ +var boot = require('../'); +var fs = require('fs-extra'); +var path = require('path'); +var assert = require('assert'); +var expect = require('must'); +var sandbox = require('./helpers/sandbox'); +var appdir = require('./helpers/appdir'); + +var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); + +describe('compiler', function() { + beforeEach(sandbox.reset); + beforeEach(appdir.init); + + describe('from options', function() { + var options, instructions, appConfig; + beforeEach(function() { + options = { + 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' + } + } + }; + instructions = boot.compile(options); + appConfig = instructions.app; + }); + + it('has port setting', function() { + expect(appConfig).to.have.property('port', 3000); + }); + + it('has host setting', function() { + expect(appConfig).to.have.property('host', '127.0.0.1'); + }); + + it('has restApiRoot setting', function() { + expect(appConfig).to.have.property('restApiRoot', '/rest-api'); + }); + + it('has other settings', function() { + expect(appConfig).to.have.property('baz', true); + expect(appConfig.foo, 'appConfig.foo').to.eql({ + bar: 'bat' + }); + }); + + it('has models definition', function() { + expect(instructions.models).to.eql(options.models); + }); + + it('has datasources definition', function() { + expect(instructions.dataSources).to.eql(options.dataSources); + }); + }); + + describe('from directory', function() { + it('loads config files', function() { + var instructions = boot.compile(SIMPLE_APP); + assert(instructions.models.foo); + assert(instructions.models.foo.dataSource); + }); + + it('merges datasource configs from multiple files', function() { + appdir.createConfigFilesSync(); + appdir.writeConfigFileSync('datasources.local.json', { + db: { local: 'applied' } + }); + + var env = process.env.NODE_ENV || 'development'; + appdir.writeConfigFileSync('datasources.' + env + '.json', { + db: { env: 'applied' } + }); + + var instructions = boot.compile(appdir.PATH); + + var db = instructions.dataSources.db; + 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() { + appdir.createConfigFilesSync(); + appdir.writeFileSync('datasources.local.js', + 'module.exports = { db: { fromJs: true } };'); + + var instructions = boot.compile(appdir.PATH); + + var db = instructions.dataSources.db; + expect(db).to.have.property('fromJs', true); + }); + + it('refuses to merge Object properties', function() { + appdir.createConfigFilesSync(); + appdir.writeConfigFileSync('datasources.local.json', { + db: { nested: { key: 'value' } } + }); + + expect(function() { boot.compile(appdir.PATH); }) + .to.throw(/`nested` is not a value type/); + }); + + it('refuses to merge Array properties', function() { + appdir.createConfigFilesSync(); + appdir.writeConfigFileSync('datasources.local.json', { + db: { nested: ['value'] } + }); + + expect(function() { boot.compile(appdir.PATH); }) + .to.throw(/`nested` is not a value type/); + }); + + it('merges app configs from multiple files', function() { + appdir.createConfigFilesSync(); + + appdir.writeConfigFileSync('app.local.json', { cfgLocal: 'applied' }); + + var env = process.env.NODE_ENV || 'development'; + appdir.writeConfigFileSync('app.' + env + '.json', { cfgEnv: 'applied' }); + + var instructions = boot.compile(appdir.PATH); + var appConfig = instructions.app; + + expect(appConfig).to.have.property('cfgLocal', 'applied'); + expect(appConfig).to.have.property('cfgEnv', 'applied'); + + var expectedLoadOrder = ['cfgLocal', 'cfgEnv']; + var actualLoadOrder = Object.keys(appConfig).filter(function(k) { + return expectedLoadOrder.indexOf(k) !== -1; + }); + + expect(actualLoadOrder, 'load order').to.eql(expectedLoadOrder); + }); + + it('supports .js for custom app config files', function() { + appdir.createConfigFilesSync(); + appdir.writeFileSync('app.local.js', + 'module.exports = { fromJs: true };'); + + var instructions = boot.compile(appdir.PATH); + var appConfig = instructions.app; + + expect(appConfig).to.have.property('fromJs', true); + }); + + it('supports `dsRootDir` option', function() { + appdir.createConfigFilesSync(); + + var customDir = path.resolve(appdir.PATH, 'custom'); + fs.mkdirsSync(customDir); + fs.renameSync( + path.resolve(appdir.PATH, 'datasources.json'), + path.resolve(customDir, 'datasources.json')); + + var instructions = boot.compile({ + appRootDir: appdir.PATH, + dsRootDir: path.resolve(appdir.PATH, 'custom') + }); + + expect(instructions.dataSources).to.have.property('db'); + }); + + it('supports `modelsRootDir` option', function() { + appdir.createConfigFilesSync(); + appdir.writeConfigFileSync('custom/models.json', { + foo: { dataSource: 'db' } + }); + + var fooJs = appdir.writeFileSync('custom/models/foo.js', ''); + + var instructions = boot.compile({ + appRootDir: appdir.PATH, + modelsRootDir: path.resolve(appdir.PATH, 'custom') + }); + + expect(instructions.models).to.have.property('foo'); + expect(instructions.files.models).to.eql([fooJs]); + }); + + it('includes boot/*.js scripts', function() { + appdir.createConfigFilesSync(); + var initJs = appdir.writeFileSync('boot/init.js', + 'module.exports = function(app) { app.fnCalled = true; };'); + var instructions = boot.compile(appdir.PATH); + expect(instructions.files.boot).to.eql([initJs]); + }); + + it('supports models/ subdirectires that are not require()able', function() { + appdir.createConfigFilesSync(); + appdir.writeFileSync('models/test/model.test.js', + 'throw new Error("should not been called");'); + var instructions = boot.compile(appdir.PATH); + + expect(instructions.files.models).to.eql([]); + }); + }); +}); diff --git a/test/executor.test.js b/test/executor.test.js new file mode 100644 index 0000000..1c64e95 --- /dev/null +++ b/test/executor.test.js @@ -0,0 +1,239 @@ +var boot = require('../'); +var path = require('path'); +var loopback = require('loopback'); +var assert = require('assert'); +var expect = require('must'); +var sandbox = require('./helpers/sandbox'); +var appdir = require('./helpers/appdir'); + +var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); + +var app; + + +describe('executor', function() { + beforeEach(sandbox.reset); + + beforeEach(appdir.init); + + beforeEach(function() { + app = loopback(); + }); + + var dummyInstructions = someInstructions({ + 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('instantiates models', function() { + boot.execute(app, dummyInstructions); + 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('attaches models to data sources', function() { + boot.execute(app, dummyInstructions); + assert.equal(app.models.FooBarBatBaz.dataSource, app.dataSources.theDb); + }); + + it('instantiates data sources', function() { + boot.execute(app, dummyInstructions); + assert(app.dataSources); + assert(app.dataSources.theDb); + assertValidDataSource(app.dataSources.theDb); + assert(app.dataSources.TheDb); + }); + + describe('with boot and models files', function() { + beforeEach(function() { + boot.execute(app, simpleAppInstructions()); + }); + + it('should run `boot/*` files', function() { + assert(process.loadedFooJS); + delete process.loadedFooJS; + }); + + it('should run `models/*` files', function() { + assert(process.loadedBarJS); + delete process.loadedBarJS; + }); + }); + + describe('with PaaS and npm env variables', function() { + function bootWithDefaults() { + app = loopback(); + boot.execute(app, someInstructions({ + app: { + port: undefined, + host: undefined + } + })); + } + + it('should honor host and port', function() { + function assertHonored(portKey, hostKey) { + process.env[hostKey] = randomPort(); + process.env[portKey] = randomHost(); + bootWithDefaults(); + assert.equal(app.get('port'), process.env[portKey], portKey); + assert.equal(app.get('host'), process.env[hostKey], 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 prioritize sources', 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.execute(app, someInstructions({ app: { port: 0 } })); + assert.equal(app.get('port'), 0); + }); + + it('should default to port 3000', function() { + boot.execute(app, someInstructions({ app: { port: undefined } })); + assert.equal(app.get('port'), 3000); + }); + }); + + it('calls function exported by models/model.js', function() { + var file = appdir.writeFileSync('models/model.js', + 'module.exports = function(app) { app.fnCalled = true; };'); + + delete app.fnCalled; + boot.execute(app, someInstructions({ files: { models: [ file ] } })); + expect(app.fnCalled, 'exported fn was called').to.be.true(); + }); + + it('calls function exported by boot/init.js', function() { + var file = appdir.writeFileSync('boot/init.js', + 'module.exports = function(app) { app.fnCalled = true; };'); + + delete app.fnCalled; + boot.execute(app, someInstructions({ files: { boot: [ file ] } })); + expect(app.fnCalled, 'exported fn was called').to.be.true(); + }); + + it('does not call Model ctor exported by models/model.json', function() { + var file = appdir.writeFileSync('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' + + '};'); + + delete global.fnCalled; + boot.execute(app, someInstructions({ files: { models: [ file ] } })); + expect(global.fnCalled, 'exported fn was called').to.be.undefined(); + }); +}); + + +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'); +}; + +function someInstructions(values) { + var result = { + app: values.app || {}, + models: values.models || {}, + dataSources: values.dataSources || {}, + files: { + models: [], + boot: [] + } + }; + + if (values.files) { + for (var k in values.files) + result.files[k] = values.files[k]; + } + + return result; +} + +function simpleAppInstructions() { + return boot.compile(SIMPLE_APP); +} diff --git a/test/fixtures/browser-app/app.js b/test/fixtures/browser-app/app.js new file mode 100644 index 0000000..6a9b9cb --- /dev/null +++ b/test/fixtures/browser-app/app.js @@ -0,0 +1,5 @@ +var loopback = require('loopback'); +var boot = require('../../../'); + +var app = module.exports = loopback(); +boot(app); diff --git a/test/fixtures/browser-app/boot/configure.js b/test/fixtures/browser-app/boot/configure.js new file mode 100644 index 0000000..b159ba7 --- /dev/null +++ b/test/fixtures/browser-app/boot/configure.js @@ -0,0 +1,3 @@ +module.exports = function(app) { + app.set('custom-key', 'custom-value'); +}; 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'} + ] +}); diff --git a/test/helpers/appdir.js b/test/helpers/appdir.js new file mode 100644 index 0000000..4db2e10 --- /dev/null +++ b/test/helpers/appdir.js @@ -0,0 +1,49 @@ +var path = require('path'); +var fs = require('fs-extra'); +var extend = require('util')._extend; +var sandbox = require('./sandbox'); + +var appdir = exports; + +var PATH = appdir.PATH = null; + +appdir.init = function(cb) { + // 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(err, buf) { + if (err) return cb(err); + var randomStr = buf.toString('hex'); + PATH = appdir.PATH = sandbox.resolve(randomStr); + cb(null, appdir.PATH); + }); +}; + +appdir.createConfigFilesSync = function(appConfig, dataSources, models) { + appConfig = extend({ + }, appConfig); + appdir.writeConfigFileSync ('app.json', appConfig); + + dataSources = extend({ + db: { + connector: 'memory', + defaultForType: 'db' + } + }, dataSources); + appdir.writeConfigFileSync ('datasources.json', dataSources); + + models = extend({ + }, models); + appdir.writeConfigFileSync ('models.json', models); +}; + +appdir.writeConfigFileSync = function(name, json) { + return appdir.writeFileSync(name, JSON.stringify(json, null, 2)); +}; + +appdir.writeFileSync = function(name, content) { + var filePath = path.resolve(PATH, name); + fs.mkdirsSync(path.dirname(filePath)); + fs.writeFileSync(filePath, content, 'utf-8'); + return filePath; +}; 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); +};