zackehh/oryx

View on GitHub
lib/oryx.js

Summary

Maintainability
D
1 day
Test Coverage
// 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;