UNPKG

oryx

Version:

A convenient API scaffolding framework for Node.js and Express

717 lines (599 loc) 23.3 kB
// core modules var fs = require('fs'); var path = require('path'); // npm modules var app_root_path = require('app-root-path').path; var Bluebird = require('bluebird'); var bodyparser = require('body-parser'); var merge = require('lodash.merge'); var micromatch = require('micromatch'); var Waterline = require('waterline'); var winston = require('winston'); // local modules var loader = require('./loader'); var routes = require('./routes'); // exposed modules var OryxError = require('./error'); var OryxResponse = require('./response'); // our list of valid HTTP methods // to be accepted on custom routes const valid_methods = [ 'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE' ]; // a regular expression to validate // that routes use the correct syntax const route_regex = new RegExp( '(' + valid_methods.join('|') + ') \/[a-z]*', 'i' ); // a json parser for use with any routes const json_parser = bodyparser.json({ type: '*/*' }); // bootstrapping is done here in order // to ensure that the 'fs' module has // a Promise interface (function () { Bluebird.promisifyAll(fs); })(); /** * Constructor for Oryx, setting up the configuration for * the designated profile (defaulting to 'development'), * and creating a new ORM instance (currently Waterline). * * @param app the Express application * @param opts the custom options * @constructor */ function Oryx(app, opts) { // check for a valid application or agent if(!app || !app.listen) { throw new OryxError('Invalid app passed to Oryx!'); } // check for the x-powered-by setting so that // we don't set it if the user doesn't want it if(app.get('x-powered-by')) { // we add a piece of middleware in order to // override the header on request, adding Oryx app.use(function (req, res, next) { res.header('X-Powered-By', 'Oryx & Express'); next(); }); } // ensure valid options opts = Object(opts); // read in both an environment and a path to the configuration directory // they default to 'development' and './config' respectively - also load // any attempts at overriding the root of the application itself var app_root = opts['app_root'] || app_root_path; var config_path = opts['config_root'] || 'config'; var profile = opts['profile'] || process.env['NODE_ENV'] || 'default'; // define all properties on the instance // this is to ensure no overwriting Object.defineProperties(this, { // store a reference to the Express // application, for use when defining // routes and models app: { value: app }, // set a custom application root this is // used to override the app-root-path module app_root: { value: app_root }, // a place to store anything being bound // to this instance at runtime bindings: { value: { routes: routes } }, // use the config loader to load and merge the necessary // configuration files, based on the passed in profile // or using the default values config: { value: loader(app_root, config_path, profile) }, // we need a winston logger specific to this // instance, as log levels may be different // per instance of Oryx logger: { value: new winston.Logger({ transports: [ new winston.transports.Console({ level: opts['log_level'] || 'debug' }) ] }) }, // create a single ORM for this instance, // used for defining any needed models orm: { value: new Waterline() }, // store the profile string, just in case // we might need to use it again at any point profile: { value: profile } }); } /** * A shorthand method for autowiring a Oryx instance * using the default values for both models and routes. * * This method will return a (Bluebird) Promise instance, * but also accepts a Node.js style callback. * * @param opts any custom options * @param cb a possible callback * @returns {Promise<R>|void} */ Oryx.prototype.autowire = function autowire(opts, cb) { // store a reference var _this = this; // ensure we parse function args correctly, in // case there are no options defined if (!cb && typeof opts === 'function') { cb = opts; opts = {}; } // force an object from the options opts = Object(opts); // start the Promise chain return _this // load the models with the // given provided options .models(opts.models) // load the routes with the // given provided options .then(function (models) { // we need to resolve the models back to the Promise chain // in order to forward both them and the API back to the user return [Bluebird.resolve(models), _this.routes(opts.routes)]; }) // we have to then spread the result in order // to get both the models and apis back together .spread(function (models, apis) { return [models, apis]; }) // call any potential Node.js style callback .nodeify(cb, { spread: true }); }; /** * Binds all models to the Oryx instance, using either * custom options or a set of defaults. Allows for basic * automated loading (if the app is laid out correctly). * Returns a (Bluebird) Promise instance, but also accepts * a Node.js style callback. * * This method should be called before bindRoutes due to * the models being bootstrapped onto the instance. * * @param opts any custom options * @param cb a possible callback * @returns {Promise<R>|void} */ Oryx.prototype.models = function models(opts, cb) { // ensure we parse function args correctly, in // case there are no options defined if (!cb && typeof opts === 'function') { cb = opts; opts = {}; } // force an object from the options opts = Object(opts); // pluck out variables from the options var pathArray = Boolean(opts.path instanceof Array); var validPath = pathArray || typeof opts.path === 'string'; // model directories var dirs; // if we don't have a valid path, we have to // default to using the models directory - this // behaviour is synonymous with 'auto' if (!validPath) { // default the directories to simply // the 'models' directory dirs = ['models']; } else { // alternatively we simply use the paths // provided by the user, and ensure they're // parsed into an array for common usage dirs = pathArray ? opts.path : [opts.path]; } // store a reference to this var _this = this; // begin Promise chain return Bluebird // resolve with the directories mapped to be relative // ensuring we're looking at the correct place .resolve( dirs.map(function (p) { return path.resolve(_this.app_root, p); }) ) // for every directory we need to check that // we can read it, and that it is indeed // a directory (as opposed to a file) .each(function (dir) { // wrap the directory in a Promise return Bluebird.resolve(dir) // then we have to ensure that the // directory is readable, before we move // onto reading it in using fs .then(function (dir) { // read the directory return fs.statAsync(dir) // ensure that the directory is actually // a valid directory, otherwise we throw // an error into the Promise chain .then(function (stat) { // ensure a directory if(!stat.isDirectory()) { // throw an error message which will be caught by the Promise // and formatted into a more information exception return Bluebird.reject(new OryxError('Path resolves to a non-directory!')); } // valid directory return dir; }) // catch any errors resulting from the inner // Promise chain, so that we can pass a custom // error back, rather than an fs error .catch(function (e) { // don't care about the error throw(new OryxError('Unable to read model directory: ' + dir, { cause: e })); }); }) // try to read the directory, and pass the // list of files on to the Promise chain, // in order to allow iteration .then(fs.readdirAsync.bind(fs)) // for every file in the directory, we need // to ensure that we only read in .js files .each(function (file) { // ensure the file is a javascript file if (file.slice(-3) === '.js') { // define the resolved file path var filepath = path.resolve(dir, file); // require in the library file var lib = require(filepath); // if the library is not an exported function // it's an invalid model, so we ignore it if (typeof lib !== 'function') return; // create the model, passing in the ORM instance // to help with defining the models var model = lib(Waterline); // basic validation of the file to ensure that we're // actually reading a model file, rather than any // random js logic if (model.hasOwnProperty('super_')) { // attempt to load the model into the ORM _this.orm.loadCollection(model); } else { // log a warning, letting the user know we can't load the file // this is not an error, rather a prompt to check just in case _this.logger.warn('Unable to load model: ' + filepath); } } }); }) // once all models have been bootstrapped onto // the ORM, we need to initialize it by passing // in the config, and storing the resulting models .then(function () { // use Bluebird to convert the node style // callback to a Promise, in order to allow // adding it to our Promise chain return Bluebird.promisify(_this.orm.initialize, _this.orm) // execute this promise, passing in the // waterline configuration from the merged // config file attached to the Oryx instance (_this.config.waterline) // we receive an object containing the model // metadata, so we attach the models to the // instance and then forward them further down // the Promise chain .then(function (models) { // we only need to store collections, rather // than the waterline connections - we pass // to the Object constructor for type safety _this.bindings.models = Object(models.collections); // give models to the chain return _this.bindings.models; }); }) // now we have our models, we can log the status // of the instance and then forward the names of // the models back to the user for any validation .then(function (models) { // pluck out the names of the models, again using // the Object constructor for type safety var names = Object.keys(Object(models)); // log an appropriate message, based on the number // of models we have managed to load - either the // names of the models or a message informing the // user that no models could be found _this.logger.debug(names.length > 0 ? 'Loaded models: ' + JSON.stringify(names) : 'No models found to load'); // alias the name of each model // to the 'name' property, in order // to provide a more obvious access // to the user names.forEach(function (m) { models[m].name = m; }); // pass the names to the user return names; }) // we want to allow Promises // or node-style callbacks .nodeify(cb); }; /** * Binds all APIs to the Oryx instance, using either * custom options or a set of defaults. Allows for basic * automated loading (if the app is laid out correctly). * Returns a (Bluebird) Promise instance, but also accepts * a Node.js style callback. * * @param opts any custom options * @param cb a possible callback * @returns {Promise<R>|void} */ Oryx.prototype.routes = function routes(opts, cb) { // ensure we parse function args correctly, in // case there are no options defined if (!cb && typeof opts === 'function') { cb = opts; opts = {}; } // force an object from the options opts = Object(opts); // store a reference to this var _this = this; // pull out options from the passed in options parameter var apiArray = Boolean(opts.apis instanceof Array); // determine whether we have a valid path for the APIs // or not, due to required fields var validPath = apiArray || (typeof opts.apis === 'object' && opts.apis.hasOwnProperty('path')); // the api root needs to resolve from either the custom options or // from a default value of './routes/api' var root_path = path.resolve(_this.app_root, opts.path || 'routes/api'); // the api_root defines the root of the url the api will // live under - this can be controlled by just calling // app.use(BASE, app) but it's a more convenient method var api_root = (function (api_root) { // default to using a base path of '/api' var p = api_root || '/api'; // we need to make sure that the api has // a trailing slash, just so that everything // is normalized if(p.substr(-1) !== '/') { p += '/'; } // done return p; })(opts.root); // hold the starting promise var pm; // define the base promise, which // differs based on whether auto // is enabled or not if (validPath) { // we need to ensure that we're working with an array // so that we can resolve all the api paths being used var api_array = apiArray ? opts.apis : [opts.apis]; // because auto is disabled, we're guaranteed a valid api // path, so unlike with the model loading, we // simply resolve a Promise with the array of APIs pm = Bluebird.resolve( // we use reduce to iterate the APIs, to we can safely // drop invalid ones without needing a filter/map combination api_array.reduce(function (apis, api) { // define the location of the provided API var location = path.resolve(_this.app_root, api.path); // if there is no 'version' key, we blindly // use the 'base' key, as the base can be // undefined in order to auto-generated if (!api.hasOwnProperty('version')) { // push the new API using the 'base' key as the API base apis.push({ base: api.base, path: location }); } else { // if we have a 'version' key, we need // to ensure that it matches the version // pattern - this allows a user to replicate // behaviour taken by Oryx's loading if (/v\d+/.test(api.version)) { // push the new API using the 'version' key as the API base apis.push({ base: api.version, path: location }); } } // pass the loaded APIs // to the next iteration return apis; }, []) ); } else { // try to read the automatic directory, and // pass the list of files on to the Promise chain, // in order to allow iteration pm = fs.readdirAsync(root_path) // we need to filter out any APIs which do not live in a // versioned folder, in order to avoid reloading existing // routes - this is unchangeable at the current time .filter(function (api) { return /v\d+/.test(api); }, { concurrency: Infinity }) // we now map each api to a version and the path // the version lives under - this allows us to // map them correctly in the app layer .map(function (api) { return { base: api, path: path.resolve(_this.app_root, root_path + '/' + api) }; }, { concurrency: Infinity }) // catch any errors resulting from the Promise chain, // so that we can pass a custom error back .catch(function (e) { // don't care about the error throw(new OryxError('Unable to read API directory: ' + root_path, { cause: e })); }); } // begin Promise chain return pm // for each API, we need to load the directory and // require in the main oryx.js file, then load all // routes and models for the API .map(function (api) { // attempt to read the path of the API // to ensure that we are able to load from // it - it should be a directory return fs.statAsync(api.path) // ensure that the directory is actually // a valid directory, otherwise we throw // an error into the Promise chain .then(function (stat) { // ensure a directory if(!stat.isDirectory()) { // throw an error message which will be caught by the Promise // and formatted into a more information exception return Bluebird.reject(new OryxError('Path resolves to a non-directory!')); } // calculate the version of the API, from either a // pre-determined version, or the basename of the path api.base = api.base || path.basename(api.path); // catch-all try { // require in the API path, and attach it to listen // under the '/api_root/ver' path _this.app.use(api_root + api.base, require(api.path)); // coerce the models to an object, for safety var models = Object(_this.bindings.models); // define a custom binding we'll provide // to all user and non-user defined routes var binding = { config: _this.config, logger: _this.logger, models: models }; // once the API is loaded, we can define the paths // for all the models - we load these after the API // so the user can define overlapping routes in advance // should they wish to Object.keys(models).forEach(function (m) { // pluck out the current model var model = _this.bindings.models[m]; // join our default routes to our model // routes, so we can handle in a common way var routes = merge({}, model['custom_routes'], _this.bindings.routes, function (a, b) { return a !== undefined ? a : b; }); // clone the routes for filtering var route_keys = Object.keys(routes); // determine if we have been provided any includes // or excludes alongside the current model var includes = model['includes'] || route_keys; var excludes = model['excludes'] || []; // ensure model.routes exists model.routes = []; // define a match function for comparisons function match(route, regex) { // if the regex is just a string type if(typeof regex === 'string') { // treat it as a glob return micromatch.isMatch(route, regex, {}); } // test using regex return regex.test(route); } // begin filtering of the routes we're // going to attach for this model route_keys // we then filter all routes on the model // to only use those using a valid syntax // of 'METHOD /url' .filter(function (r) { return route_regex.test(r); }) // includes .filter(function (route) { return includes.some(match.bind(match, route)); }) // excludes .filter(function (route) { return !excludes.some(match.bind(match, route)); }) // for every route applicable for the model, we now create a route handler // by passing the model through to the corresponding route - the returned route // is then used to attach at the endpoint '/api_root/ver/model/route' .forEach(function (r) { // add the route to the model model.routes.push(r); // find the space in the string var index = r.indexOf(' '); // retrieve the pieces we care about var method = r.substr(0, index).toLowerCase(); var path = r.substr(index + 1); // attach the route _this.app[method]( // define the model path api_root + api.base + '/' + m + path, // the body parser json_parser, // bind the model instance routes[r].bind(binding, model) ); }); }); // finished! return api; } catch (e) { // should there be an error, we log out a warning that the API could not // be loaded - this should not be an error at this point, because it could // easily be a misconfiguration of a single route _this.logger.warn('Unable to load API [' + api.base + ',' + api.path + ']'); // log out the actual error under // the debug log level so that // the user can see if necessary _this.logger.debug(e); } }) // catch any errors resulting from the inner // Promise chain, so that we can pass a custom // error back, rather than an fs error .catch(function (e) { throw(new OryxError('Unable to read API directory: ' + path.resolve(api_root + api.path), { cause: e })); }); }, { concurrency: Infinity }) // for all the loaded APIs, we can // then return a list to the user // so they know what has been loaded .then(function (apis) { // filter out all of the APIs which resolve to // undefined, in case there are any - then map // to the version/bases used against each API return apis.reduce(function (bases, api) { if (!!api) { bases.push(api.base); } return bases; }, []); }) // now we have our APIs, we can log the status // of the instance forward the loaded versions // back to the user for logging .then(function (apis) { // log an appropriate message, based on the number // of APIs we have managed to load _this.logger.debug(apis.length > 0 ? 'Loaded APIs: ' + JSON.stringify(apis) : 'No APIs found to load'); // pass the APIs to the user return apis; }) // we want to allow Promises // or node-style callbacks .nodeify(cb); }; // release the Oryx! module.exports = Oryx; module.exports.OryxError = OryxError; module.exports.OryxResponse = OryxResponse;