src/support/routeMapper.js

Summary

Maintainability
A
3 hrs
Test Coverage
"use strict";


const route = require('koa-trie-router'),
  util = require('util'),
  queryString = require('query-string');

const waigo = global.waigo,
  _ = waigo._,
  logger = waigo.load('support/logger').create('RouteMapper'),
  errors = waigo.load('support/errors');


const RouteError = exports.RouteError = errors.define('RouteError');

const METHODS = ['GET', 'POST', 'DEL', 'DELETE', 'PUT', 'HEAD'];


/**
 * The route mapper.
 */
class RouteMapper {
  /**
   * Constructor.
   * @param {Object} App Koa App.
   * @constructor
   */
  constructor (App) {
    this.App = App;
  }


  /**
   * Setup routes and middleware.
   * 
   * @param {Object} middlewareConfig Middleware config.
   * @param {Object} routeConfig Route config.
   */
  * setup (middlewareConfig, routeConfig) {
    logger.info('Initialise...');

    require('koa-trie-router')(this.App.koa);

    let possibleMappings = [];

    // resolve middleware for different HTTP methods
    let commonMiddleware = {};
    _.each(METHODS, (method) => {
      logger.debug('Setting up HTTP method middleware', method);

      commonMiddleware[method] = 
        this._loadCfgMiddleware(middlewareConfig[method]);
    });

    // build mappings
    logger.debug('Setting up route mappings');

    _.each(routeConfig, (node, urlPath) => {
      possibleMappings = possibleMappings.concat(
        this._buildRoutes(commonMiddleware, urlPath, node, {
          urlPath: '',
          preMiddleware: [],
        })
      );
    });

    // now order by path (specific to general)
    // put the routes into order (specific to general)
    let orderedMappings = possibleMappings.sort(function(a, b) {
      return a.url < b.url;
    });

    this._routes = {
      all: orderedMappings,
      byName: {}
    };

    // add the handlers to routing
    logger.debug('Building reverse lookup table');

    _.each(orderedMappings, (mapping) => {
      let route = this.App.koa.route(mapping.url);

      route[mapping.method.toLowerCase()].apply(route, mapping.resolvedMiddleware);

      // save to app
      this._routes.byName[mapping.name] = mapping;
    });


    logger.debug('Finalize...');

    this.App.koa.use(this.App.koa.router);
  }


  /**
   * Load and initialise given middleware module.
   * 
   * @param  {Object|String} middlewareName Name of middleware module file or object representing combined name and options: `{ id: <middleware mame>, ...}`.
   * @param  {Object} [middlewareOptions] Middleware options.
   * 
   * @return {Function}
   */
  _loadMiddleware (middlewareName, middlewareOptions) {
    logger.debug('Load middleware', middlewareName, middlewareOptions);

    if (_.isPlainObject(middlewareName)) {
      middlewareOptions = _.omit(middlewareName, 'id');
      middlewareName = middlewareName.id;
    } else {
      // if reference is of form 'moduleName.xx.yy' then it's a controller reference
      if (0 < middlewareName.indexOf('.')) {
        return this._loadController(middlewareName);
      }

      middlewareOptions = middlewareOptions || {};
    }

    return waigo.load(`support/middleware/${middlewareName}`)(this.App, middlewareOptions);
  }



  /**
   * Load given controller method.
   * 
   * @param  {String} controller String of form `<controller path>.<method name>`.
   * 
   * @return {Function}
   */
  _loadController (controller) {
    logger.debug('Load controller', controller);

    let tokens = controller.split('.'),
      controllerPath = tokens,
      methodName = tokens.pop(),
      controllerName = controllerPath.join('.');

    let mod = waigo.load(`controllers/${controllerPath.join('/')}`);

    if (!_.isFunction(mod[methodName])) {
      throw new RouteError(`Unable to find method "${methodName}" on controller "${controllerName}"`);
    }

    return mod[methodName];
  }



  /**
   * Load middleware specified inĀ given config object.
   * 
   * @return {Array}
   */
  _loadCfgMiddleware (cfg) {
    logger.debug('Load middleware specified in config');

    cfg = cfg || {};

    return _.map(cfg._order, (m) => {
      return this._loadMiddleware(m, cfg[m]);
    });
  }




  /** 
   * Build URL to given route.
   * 
   * @param  {String} routeName   Name of route.
   * @param  {Object} [urlParams]   URL params for route.
   * @param  {Object} [queryParams] URL query params.
   * @param {Object} [options] Options.
   * @param {Boolean} [options.absolute] If `true` then return absolute URL including site base URL.
   * @return {String}             Route URL
   */
  url (routeName, urlParams, queryParams, options) {
    options = _.extend({
      absolute: false
    }, options);

    logger.debug('Generate URL for route ' + routeName);

    var route = this._routes.byName[routeName];

    if (!route) {
      throw new Error('No route named: ' + routeName);
    }

    var str = options.absolute ? this.App.config.baseURL : '';

    str += route.url;

    // route params
    _.each(urlParams, function(value, key) {
      str = str.replace(`:${key}`, value);
    });

    // query params
    if (!_.isEmpty(queryParams)) {
      str += '?' + queryString.stringify(queryParams);
    }

    return str;
  }


  /**
   * Build routes from given configuration config node.
   * 
   * @param  {Object} commonMiddleware Common middleware to use for every route.
   * @param  {Object} urlPath URL path of this node (relative to parent URL path).
   * @param  {Object} node Config node.
   * @param  {Object} parentConfig parent node config. 
   * @param  {Object} parentConfig.urlPath URL path of parent node.
   * @param  {Object} parentConfig.preMiddleware Resolved pre-middleware for all routes in this node.
   * 
   * @return {Array} List of route mappings.
   */
  _buildRoutes (commonMiddleware, urlPath, node, parentConfig) {
    urlPath = parentConfig.urlPath + urlPath;

    logger.debug('Build route', urlPath);

    // make a shallow copy (so that we can delete keys from it)
    node = _.extend({}, node);

    // load parent middleware
    let resolvedPreMiddleware = parentConfig.preMiddleware.concat(
      _.map(node.pre || [], _.bind(this._loadMiddleware, this))
    );
    delete node.pre;

    let mappings = [];

    // iterate through each possible method
    _.each(METHODS , (method) => {
      if (node[method]) {
        let routeMiddleware = _.isArray(node[method]) ? node[method] : [node[method]];

        mappings.push({
          method: method,
          name: node.name || urlPath,
          url: urlPath,
          resolvedMiddleware: commonMiddleware[method].concat(
            resolvedPreMiddleware, 
            _.map(routeMiddleware, (rm) => {
              return this._loadMiddleware(rm);
            })
          )
        });
      }

      delete node[method];
    });

    // delete name
    delete node.name;

    // go through children
    _.each(node || {}, (subNode, subUrlPath) => {
      mappings = mappings.concat(
        this._buildRoutes(commonMiddleware, subUrlPath, subNode, {
          urlPath: urlPath,
          preMiddleware: resolvedPreMiddleware,
        })
      );
    });

    return mappings;
  }

}


/** 
 * Setup routes.
 * 
 * @param {Object} app Koa app.
 * @param {Object} middlewareConfig Middleware configuration.
 * @param {Object} routeConfig      Route configuration.
 * @return {RouteMapper}
 */
exports.setup = function*(app, middlewareConfig, routeConfig) {
  let mapper = new RouteMapper(app);

  yield mapper.setup(middlewareConfig, routeConfig);

  return mapper;
};