jaredhanson/locomotive

View on GitHub
lib/controller.js

Summary

Maintainability
F
3 days
Test Coverage
/**
 * Module dependencies.
 */
var router = require('actionrouter')
  , mime = require('express').mime
  , utils = require('./utils')
  , flatten = require('utils-flatten')
  , dispatch = require('./middleware/dispatch')
  , ControllerError = require('./errors/controllererror');


/**
 * `Controller` constructor.
 *
 * @api public
 */
function Controller() {
  this.__beforeFilters = [];
  this.__afterFilters = [];
}

/**
 * Return the value of param `name` when present or `defaultValue`.
 *
 *  - Checks route placeholders, ex: _/user/:id_
 *  - Checks body params, ex: id=12, {"id":12}
 *  - Checks query string params, ex: ?id=12
 *
 * To utilize request bodies, `req.body` should be an object. This can be done
 * by using `express.bodyParser()` middleware.
 *
 * For convenience, this function is aliased to `params`.
 * 
 * Examples:
 *
 *     this.param('id');
 *
 *     this.param('full_text', false);
 *
 * For further details, see `express.Request#param()`, as `param()` invokes it
 * internally.
 *
 * @param {String} pattern
 * @param {Mixed} [defaultValue]
 * @return {String}
 * @api public
 */
Controller.prototype.param =
Controller.prototype.params = function(name, defaultValue) {
  return this.__req.param.apply(this.__req, arguments);
};

/**
 * Render response.
 *
 * Render `template`, defaulting to the template for the current action, with
 * optional `options`.  If optional callback `fn` is given, the response will
 * not be sent, but rather supplied to the callback.
 *
 * Options:  
 *  - `format`  response format, defaults to `'html'`
 *  - `engine`  view engine, defaults to `'view engine'` setting or `'ejs'`
 *
 * Examples:
 *
 *     this.render();
 *
 *     this.render('show');
 *
 *     this.render({ format: 'xml' });
 *
 *     this.render('email', function(err, body) {
 *       // send email with `body`
 *     });
 *
 * @param {String} template
 * @param {Object} options
 * @param {Function} fn
 * @api public
 */
Controller.prototype.render = function(template, options, fn) {
  if (typeof options === 'function') {
    fn = options;
    options = undefined;
  } else if (typeof template === 'function') {
    fn = template;
    options = undefined;
    template = undefined;
  }
  if (!options && typeof template === 'object') {
    options = template;
    template = undefined;
  }
  if (typeof options == 'string') {
    options = { format: options };
  }
  options = options || {};

  var self = this;
  var app = this.__req.app;  // Express app
  var tmpl = template || this.__action
    , fmt = utils.extensionizeType(options.format || 'html');
  
  tmpl = (tmpl.indexOf('/') === -1) ? this.__id + '/' + tmpl : tmpl;
  tmpl = this.__app.views.resolve(tmpl);
  
  // Filter function to capture the controller's local properties, which will
  // be made available to the view.  Any private property (defined as a property
  // whose name begins with an underscore), or property existing prior to
  // invoking the action will be filtered out.
  function localProperties(prop) {
    if (prop[0] == '_') { return false; }
    if (self.__ownProperties.indexOf(prop) != -1) { return false; }
    return true;
  }
  
  Object.keys(this).filter(localProperties).forEach(function(key) {
    var value = self[key];

    // Make sure functions are always run in the current controllers` context.
    // TODO: Implement test case for this.
    if (value instanceof Function) {
      value = value.bind(self);
    }

    self.__res.locals[key] = value;
  });
  
  var fopts = this.__app._formats[fmt] || {}
    , comps = [ tmpl, fmt, (app && app.set('view engine')) || 'ejs' ]
    , ext;
  if (options.engine) {
    comps = [ tmpl, fmt, options.engine ];
  } else if (options.extension) {
    ext = options.extension;
    if ('.' == ext[0]) { ext = ext.slice(1); }
    comps = [ tmpl, ext ];
  } else if (fopts.engine) {
    comps = [ tmpl, fmt, fopts.engine ];
  } else if (fopts.extension) {
    ext = fopts.extension;
    if ('.' == ext[0]) { ext = ext.slice(1); }
    comps = [ tmpl, ext ];
  }
  
  if (!this.__res.getHeader('Content-Type') && !fn) {
    var type = utils.normalizeType(options.mime || options.format || 'html').value
      , charset = options.charset || mime.charsets.lookup(type);
    if (charset) { type += '; charset=' + charset; }
    
    this.__res.setHeader('Content-Type', type);
  }
  
  // delete option keys consumed by Locomotive
  delete options.format;
  delete options.mime;
  delete options.charset;
  delete options.engine;
  delete options.extension;
  
  var view = comps.join('.');
  this.__res.render(view, options, fn);
};

/**
 * Respond to the acceptable formats using an `obj` of format keys containing
 * options or or callbacks.
 *
 * This method uses `req.params.format` or `req.accepted`, an array of
 * acceptable types ordered by their quality values.  When both the `format`
 * parameter and "Accept" header are not present the _first_ format key is used,
 * otherwise the first match is used. When no match is obtained the server
 * responds with 406 "Not Acceptable".
 *
 * `obj` can contain format keys with either object or function values.  When
 * containing an object, `render()` will be invoked with the template and
 * options found in the object.
 *
 *     this.respond({
 *       'json': { template: 'jrd', engine: 'jsonb' },
 *       'xml': { template: 'xrd', engine: 'xmlb' }
 *     });
 *
 * If the template and options are defaults, which don't need to be overridden,
 * simply set the format key to `true`.
 *
 *     this.respond({
 *       'json': true,
 *       'xml': true
 *     });
 *
 * Set the format key to a function, which will be invoked if the format is
 * acceptable.  This allows fine-grained control of the response.
 *
 *     this.respond({
 *       'xml': function() { self.render('atom', { engine: 'xmlb' }); },
 *       default: function() { self.render(); }
 *     })
 *
 * By default Locomotive passes an `Error` with a `.status` of 406 to
 * `next(err)` if a match is not made. If you provide a `default` format key, it
 * will be invoked instead.
 * 
 * @param {Object} obj
 * @api public
 */
Controller.prototype.respond =
Controller.prototype.respondWith = function(obj) {
  var req = this.__req
    , res = this.__res
    , format = this.__req.params.format;
  format = format ? mime.lookup(format) : undefined;
  
  if (Array.isArray(obj) || typeof obj == 'string') {
    var arr = flatten([].slice.call(arguments))
      , o = {}, i, len;
    for (i = 0, len = arr.length; i < len; ++i) { o[arr[i]] = true; }
    obj = o;
  }
    
  var op = obj.default;
  if (op) { delete obj.default; }
  var keys = Object.keys(obj);

  // prefer format param, fallback to accept header
  var key = format ? utils.accepts(keys, format) : req.accepts(keys);
  var via = format ? undefined : utils.acceptedVia(keys, req.headers.accept);
  
  if (op && via && via.type == '*' && via.subtype == '*') {
    // use default format, if available and negotiated via */*
    key = undefined;
  } else if (via && via.type == '*' && via.subtype == '*') {
    // use first format, if no default and accepts anything
    key = keys[0];
  }
  
  if (key) {
    op = obj[key];
  }
  if (op === true) { op = {}; }
  else if (typeof op === 'string') { op = { format: op }; }
  
  var vary = this.__res.getHeader('Vary');
  if (!vary) {
    res.setHeader('Vary', 'Accept');
  } else {
    vary = vary.split(/ *, */);
    if (vary.indexOf('Accept') == -1) { vary.push('Accept'); }
    res.setHeader('Vary', vary.join(', '));
  }
  
  if (typeof op == 'object') {
    var template = op.template; delete op.template;
    op.format = op.format || key;
    if (key && key.indexOf('/') != -1) { op.mime = key; }
    this.render(template, op);
  } else if (typeof op == 'function') {
    op();
  } else {
    var err = new Error('Not Acceptable');
    err.status = 406;
    err.types = utils.normalizeTypes(keys).map(function(o) { return o.value; });
    this.error(err);
  }
};

/**
 * Redirect to `url` with optional `status`, defaulting to 302.
 *
 * Examples:
 *
 *     this.redirect('/login');
 *
 *     this.redirect('http://www.example.com/', 303);
 *
 *     this.redirect(303, 'http://www.example.com/');
 *
 * For further details, see `express.Request#redirect()`, as `redirect()` invokes
 * it internally.
 *
 * @param {String} url
 * @param {Number} status
 * @api public
 */
Controller.prototype.redirect = function(url, status) {
  if (2 == arguments.length && typeof url == 'number') {
    // express 2.x signature
    this.__res.redirect.call(this.__res, status, url);
    return;
  }
  this.__res.redirect.apply(this.__res, arguments);
};

/**
 * Invoke a different controller's action in order to process the current
 * request.
 *
 *     this.invoke('users', 'show');
 *     // => invokes show action of usersController
 *
 * Shorthand notation can also be used.
 *
 *     this.invoke('users#show');
 *     // => invokes show action of usersController
 *
 * `controller` is optional.  If not given, it will invoke a different action in
 * the current controller.
 *
 *     this.invoke('other');
 *     // => invokes other action of current controller
 *
 * Calling this function immediately halts the filter chain.  After filters will
 * not be applied.
 *
 * @param {String} controller
 * @param {Number} action
 * @api public
 */
Controller.prototype.invoke = function(controller, action) {
  if (!action) {
    var split = controller.split('#');
    if (split.length > 1) {
      // shorthand controller#action form
      controller = split[0];
      action = split[1];
    } else {
      action = controller;
      controller = this.__id;
    }
  }
  controller = router.util.controllerize(controller);
  action = router.util.functionize(action);
  
  // Prevent after filters from being applied.
  this.__devoked = true;
  // Re-dispatch the current request to a different controller action.
  dispatch(this.__app, controller, action)(this.__req, this.__res, this.__next);
};

/**
 * Finish action processing, without issuing a response.
 * 
 * Controllers can use this function to jump immediately to any after filters
 * registered for the currently executing action.  This circumstance is unusual,
 * but can be used in cases where it is desired to respond to the request from
 * within an after filter rather than the action function.
 *
 * Examples:
 *
 *     this.done();
 *
 * @api public
 */
Controller.prototype.done = function() {
  this._devoke();
};

/**
 * Internal error encountered while executing action.
 *
 * Controllers should call this function when an internal error occurs during
 * the execution of an action; for example, if a database is not available.
 *
 * This function is also aliased as `next`, and if called without an `err`
 * allows a controller to pass control back to Express.  This is useful in
 * certain scenarios, but is generally not encouraged.
 *
 * Examples:
 *
 *     this.error(new Error('something went wrong'));
 *
 * @param {Error} err
 * @api public
 */
Controller.prototype.next =
Controller.prototype.error = function(err) {
  var self = this;
  // Give controller-level after filters an opportunity to handle the error.  If
  // not handled, pass control out to Express for application-level handling.
  this._devoke(err, function(e) {
    return self.__next(e);
  });
};

/**
 * Add a before filter for `action`.
 *
 * A before filter runs before an action.  Multiple filters can be registered,
 * and they will execute sequentially, one after the other, as each calls
 * `next`.  Any filter can call `next` with an `err`, halting the filter chain
 * and bypassing the action.
 *
 * Filters are especially useful for loading records from a database, ensuring
 * authorization, and avoiding nested blocks of async code.
 *
 * Examples:
 *
 *     PostsController.before('show', function(next) {
 *       var self = this;
 *       Post.findById(this.param('id'), function(err, post) {
 *         if (err) { return next(err); }
 *         self.post = post;
 *         next();
 *       });
 *     });
 *
 * @param {String|String[]} action
 * @param {Function} fn
 * @api public
 */
Controller.prototype.before = function(action, fn) {
  var i, len;
  
  // If an array of filters is passed as an argument, decompose the array into
  // its constituent arguments.
  if (Array.isArray(fn)) {
    for (i = 0, len = fn.length; i < len; i++) {
      this.before(action, fn[i]);
    }
    return this;
  }
  
  if (Array.isArray(action)) {
    // If multiple actions specified, add the filter to each of them.
    for (i = 0, len = action.length; i < len; i++) {
      this.__beforeFilters.push({ action: action[i], fn: fn });
    }
  } else {
    this.__beforeFilters.push({ action: action, fn: fn });
  }
  return this;
};

/**
 * Add a after filter for `action`.
 *
 * An after filter runs after an action.  Multiple filters can be registered,
 * and they will execute sequentially, one after the other, as each calls
 * `next`.  Any filter can call `next` with an `err`, halting the filter chain.
 *
 * Examples:
 *
 *     PostsController.after('show', function(next) {
 *       // log response time for calculating performance metrics
 *       next();
 *     });
 *
 * @param {String|String[]} action
 * @param {Function} fn
 * @api public
 */
Controller.prototype.after = function(action, fn) {
  // check if action contains a list of actions
  if (Array.isArray(action)) {
    // If multiple actions specified, add the filter to each of them.
    for (var i = 0, len = action.length; i < len; i++) {
      this.__afterFilters.push({ action: action[i], fn: fn });
    }
  } else {
    this.__afterFilters.push({ action: action, fn: fn });
  }
  return this;
};

/**
 * Invoked when controller is initialized by Locomotive.
 *
 * This function is called immediately after Locomotive instantiates the
 * controller.  It is used to give the controller a reference to the app and to
 * declare its registered ID.
 *
 * @param {Application} app
 * @param {String} id
 * @api private
 */
Controller.prototype._init = function(app, id) {
  this.__app = app;
  this.__id = id;
};

/**
 * Invoked when controller is being prepared to invoke an action.
 *
 * This function is called immediately after the controller is initialized,
 * prior to invoking the action.  It is used to initialize properties pertaining
 * to the request-response pair.
 *
 * A new controller instance is created for each request that is being handled.
 * This allows request-specific properties to be assigned to the controller,
 * without risk of conflicts due to concurrency.  The request and response will
 * be assigned as properties named `req` (also aliased as `request`) and `res`
 * (also aliased as `response`), respectively.
 *
 * @param {ServerRequest} req
 * @param {ServerResponse} res
 * @param {Function} next
 * @api private
 */
Controller.prototype._prepare = function(req, res, next) {
  this.__req = req;
  this.__res = res;
  this.__next = next;
  
  var self = this;
  this.__defineGetter__('app', function() {
    return self.__app;
  });
  this.__defineGetter__('req', function() {
    return self.__req;
  });
  this.__defineGetter__('request', function() {
    return self.__req;
  });
  this.__defineGetter__('res', function() {
    return self.__res;
  });
  this.__defineGetter__('response', function() {
    return self.__res;
  });
  
  // Record the controller's own properties prior to invoking the action.  Any
  // properties assigned while executing the action will be made available to
  // the view.  The previously existing properties will be filtered out.
  this.__ownProperties = Object.getOwnPropertyNames(this);
};

/**
 * Invoke action.
 *
 * @param {String} action
 * @api private
 */
Controller.prototype._invoke = function(action) {
  if (typeof this[action] !== 'function') {
    return this.__next(new ControllerError(this.__id + '#' + action + ' is not a function'));
  }
  
  this.__action = action;
  
  // Assign Locomotive properties to request.  These properties are used by
  // helper functions, such as urlFor(), to return results based on the context
  // of the request and response.
  this.__req._locomotive = {};
  this.__req._locomotive.app = this.__app;
  this.__req._locomotive.controller = this.__id;
  this.__req._locomotive.action = this.__action;
  
  // Merge helpers into this controller.  The controller will have access to the
  // helpers through the `this` context, and the view will have access via
  // locals.
  // TODO: Implement test case for this.
  var helpers = this.__app._helpers
    , dynamicHelpers = this.__app._dynamicHelpers
    , key;
  for (key in helpers) {
    this[key] = helpers[key];
  }
  for (key in dynamicHelpers) {
    this[key] = dynamicHelpers[key].call(undefined, this.__req, this.__res);
  }
  
  
  // Invoke the action, applying any before filters first.
  var self = this
    , filters = this.__beforeFilters
    , filter
    , i = 0;
   
  // Apply after filters once the response is finished.
  this.__res.once('finish', function() {
    self._devoke();
  });
    
  (function iter(err, data) {
    if (err) { return self.error(err); }

    filter = filters[i++];
    if (!filter) {
      // filters done, invoke action
      try {
        // TODO: Implement promise support for before and after filters.
        var promise = self[action](data);
        if (promise && typeof promise.then == 'function') {
          promise.then(null, function(err) {
            self.error(err);
          });
        }
        return;
      } catch (ex) {
        return self.error(ex);
      }
    }

    // Skip non-matching filter
    if (filter.action != '*' && filter.action != action) { return iter(err, data); }
    
    // Invoke before filter
    try {
      var arity = filter.fn.length;
      if (arity == 3) {
        // Support for Connect middleware being used directly as a filter.
        filter.fn.call(self, self.req, self.res, iter);
      } else if (arity == 2) {
        filter.fn.call(self, data, iter);
      } else if (arity < 2) {
        filter.fn.call(self, iter);
      } else {
        iter(err, data);
      }
    } catch (ex) {
      iter(ex);
    }
  })();
};

/**
 * Devoke action.
 *
 * Devoking an action will cause any after filters to be invoked.  This happens
 * after an action renders a response or redirects.  It also happens after an
 * error is detected, either by the application calling `error` or catching an
 * exception.
 *
 * @param {Error} err
 * @param {Function} cb
 * @api private
 */
Controller.prototype._devoke = function(err, cb) {
  if (this.__devoked) { return; }
  this.__devoked = true;

  var self = this
    , action = this.__action
    , filters = this.__afterFilters
    , filter
    , i = 0;

  (function iter(err) {
    filter = filters[i++];
    if (!filter) {
      // filters done
      cb && cb(err);
      return;
    }

    if (filter.action != '*' && filter.action != action) { return iter(err); }

    // invoke after filters
    try {
      var arity = filter.fn.length;
      if (err) {
        if (arity == 4) {
          filter.fn.call(self, err, self.req, self.res, iter);
        } else {
          iter(err);
        }
      } else {
        if (arity == 2 || arity == 3) {
          // Support for Connect middleware being used directly as a filter.
          filter.fn.call(self, self.req, self.res, iter);
        } else if (arity < 2) {
          filter.fn.call(self, iter);
        } else {
          iter();
        }
      }
    } catch (ex) {
      iter(ex);
    }
  })(err);
};


/**
 * Expose `Controller`.
 */
module.exports = Controller;