lyfeyaj/poplar

View on GitHub
lib/contexts/http.js

Summary

Maintainability
F
5 days
Test Coverage
/*!
 * Expose `HttpContext`.
 */
module.exports = HttpContext;

/*!
 * Module dependencies.
 */
var EventEmitter = require('events').EventEmitter;
var debug = require('debug')('poplar:http-context');
var _ = require('lodash');
var util = require('util');
var inherits = util.inherits;
var assert = require('assert');
var js2xmlparser = require('js2xmlparser');
var delegate = require('delegates');

var Helper = require('./../helper');
var Dynamic = require('./../dynamic');
var StateManager = require('../state_manager');

var DEFAULT_SUPPORTED_TYPES = [
    'application/json', 'application/javascript', 'application/xml',
    'text/javascript', 'text/xml',
    'json', 'xml',
    '*/*'
  ];

/**
 * Create a new `HttpContext` with the given `options`.
 *
 * @param {Object} options
 * @return {HttpContext}
 * @class
 */
function HttpContext(req, res, method, options) {
  this.req = this.request = req;
  this.res = this.response = res;
  this.method = method;
  this.options = options || {};
  this.args = this.buildArgs(method);
  this.methodName = method.name;
  this.supportedTypes = this.options.supportedTypes || DEFAULT_SUPPORTED_TYPES;

  // apply delegates
  this.applyDelegates();

  // initialize state
  this.state = StateManager.init();

  if (this.supportedTypes === DEFAULT_SUPPORTED_TYPES && !this.options.xml) {
    // Disable all XML-based types by default
    this.supportedTypes = this.supportedTypes.filter(function(type) {
      return !/\bxml\b/i.test(type);
    });
  }

  req.context = this;
}

/**
 * Inherit from `EventEmitter`.
 */
inherits(HttpContext, EventEmitter);

/**
 * Apply delegates
 */
HttpContext.prototype.applyDelegates = function(method) {
  var proto = this;
  /**
 * Response delegation.
 */
  delegate(proto, 'response')
    .getter('headersSent')
    .getter('locals')

    .method('append')
    .method('attachment')
    .method('cookie')
    .method('clearCookie')
    .method('download')
    .method('end')
    .method('format')
    .method('get')
    .method('json')
    .method('jsonp')
    .method('links')
    .method('location')
    .method('redirect')
    .method('render')
    .method('send')
    .method('sendFile')
    .method('sendStatus')
    .method('set')
    .method('status')
    .method('type')
    .method('vary');

  /**
   * Request delegation.
   */

  delegate(proto, 'request')
    .method('accepts')
    .method('acceptsCharsets')
    .method('acceptsEncodings')
    .method('acceptsLanguages')
    .method('get')
    .method('is')
    .method('param')

    .access('socket')
    .access('query')
    .access('params')
    .access('body')
    .access('path')
    .access('url')

    .getter('origin')
    .getter('baseUrl')
    .getter('cookies')
    .getter('fresh')
    .getter('host')
    .getter('hostname')
    .getter('header')
    .getter('headers')
    .getter('ip')
    .getter('ips')
    .getter('originalUrl')
    .getter('protocol')
    .getter('route')
    .getter('secure')
    .getter('signedCookies')
    .getter('stale')
    .getter('subdomains');

  // seperate `request.method` with `ctx.method` to avoid conflicts
  proto.httpMethod = proto.request.method;
};

/**
 * Build args object from the http context's `req` and `res`.
 */
HttpContext.prototype.buildArgs = function(method) {
  var args = {};
  var ctx = this;
  var accepts = method.accepts;

  // build arguments from req and method options
  for (var i = 0, n = accepts.length; i < n; i++) {
    var o = accepts[i];
    var httpFormat = o.http;
    var name = o.name || o.arg;
    var val;

    // Support array types, such as ['string']
    var isArrayType = Array.isArray(o.type);
    var otype = isArrayType ? o.type[0] : o.type;
    otype = (typeof otype === 'string') && otype.toLowerCase();
    var isAny = !otype || otype === 'any';

    // This is an http method keyword, which requires special parsing.
    if (httpFormat) {
      switch (typeof httpFormat) {
        case 'function':
          // the options have defined a formatter
          val = httpFormat(ctx);
          break;
        case 'object':
          switch (httpFormat.source) {
            case 'body':
              val = ctx.req.body;
              break;
            case 'form':
              // From the form (body)
              val = ctx.req.body && ctx.req.body[name];
              break;
            case 'query':
              // From the query string
              val = ctx.req.query[name];
              break;
            case 'path':
              // From the url path
              val = ctx.req.params[name];
              break;
            case 'header':
              val = ctx.req.get(name);
              break;
            case 'req':
              // Direct access to http req
              val = ctx.req;
              break;
            case 'res':
              // Direct access to http res
              val = ctx.res;
              break;
            case 'context':
              // Direct access to http context
              val = ctx;
              break;
          }
          break;
      }
    } else {
      val = ctx.getArgByName(name, o);
      // Safe to coerce the contents of this
      if (typeof val === 'object' && (!isArrayType || isAny)) {
        val = coerceAll(val);
      }
    }

    // If we expect an array type and we received a string, parse it with JSON.
    // If that fails, parse it with the arrayItemDelimiters option.
    if (val && typeof val === 'string' && isArrayType) {
      var parsed = false;
      if (val[0] === '[') {
        try {
          val = JSON.parse(val);
          parsed = true;
        } catch (e) {}
      }
      if (!parsed && ctx.options.arrayItemDelimiters) {
        // Construct delimiter regex if input was an array. Overwrite option
        // so this only needs to happen once.
        var delims = this.options.arrayItemDelimiters;
        if (Array.isArray(delims)) {
          delims = new RegExp(_.map(delims, Helper.escapeRegex).join('|'), 'g');
          this.options.arrayItemDelimiters = delims;
        }

        val = val.split(delims);
      }
    }

    // Coerce dynamic args when input is a string.
    if (isAny && typeof val === 'string') {
      val = coerceAll(val);
    }

    // If the input is not an array, but we were expecting one, create
    // an array. Create an empty array if input is empty.
    if (!Array.isArray(val) && isArrayType) {
      if (val !== undefined && val !== '') val = [val];
      else val = [];
    }

    // For boolean and number types, convert certain strings to that type.
    // The user can also define new dynamic types.
    if (Dynamic.canConvert(otype)) {
      val = dynamic(val, otype, ctx);
    }

    if (o.hasOwnProperty('default')) {
      val = val || o.default;
    }

    // set the argument value
    args[o.arg] = val;
  }

  return args;
};

/**
 * Get an arg by name using the given options.
 *
 * @param {String} name
 * @param {Object} options **optional**
 */
HttpContext.prototype.getArgByName = function(name, options) {
  var req = this.req;
  var args = req.params && req.params.args !== undefined ? req.params.args :
             req.body && req.body.args !== undefined ? req.body.args :
             req.query && req.query.args !== undefined ? req.query.args :
             undefined;

  if (args) {
    args = JSON.parse(args);
  }

  if (typeof args !== 'object' || !args) {
    args = {};
  }

  var arg = (args && args[name] !== undefined) ? args[name] :
            this.req.params[name] !== undefined ? this.req.params[name] :
            (this.req.body && this.req.body[name]) !== undefined ? this.req.body[name] :
            this.req.query[name] !== undefined ? this.req.query[name] :
            this.req.get(name);
  // search these in order by name
  // req.params
  // req.body
  // req.query
  // req.header

  return arg;
};

/*!
 * Integer test regexp.
 */
var isint = /^[0-9]+$/;

/*!
 * Float test regexp.
 */
var isfloat = /^([0-9]+)?\.[0-9]+$/;

// Use dynamic to coerce a value or array of values.
function dynamic(val, toType, ctx) {
  if (Array.isArray(val)) {
    return _.map(val, function(v) {
      return dynamic(v, toType, ctx);
    });
  }
  return (new Dynamic(val, ctx)).to(toType);
}

function coerce(str) {
  if (typeof str !== 'string') return str;
  if ('null' === str) return null;
  if ('true' === str) return true;
  if ('false' === str) return false;
  if (isfloat.test(str)) return parseFloat(str, 10);
  if (isint.test(str) && str.charAt(0) !== '0') return parseInt(str, 10);
  return str;
}

// coerce every string in the given object / array
function coerceAll(obj) {
  var type = Array.isArray(obj) ? 'array' : typeof obj;
  var i;
  var n;

  switch (type) {
    case 'string':
      return coerce(obj);
    case 'object':
      if (obj) {
        var props = Object.keys(obj);
        for (i = 0, n = props.length; i < n; i++) {
          var key = props[i];
          obj[key] = coerceAll(obj[key]);
        }
      }
      break;
    case 'array':
      for (i = 0, n = obj.length; i < n; i++) {
        coerceAll(obj[i]);
      }
      break;
  }

  return obj;
}

function buildArgs(ctx, method, fn) {
  try {
    return ctx.buildArgs(method);
  } catch (err) {
    // JSON.parse() might throw
    process.nextTick(function() {
      fn(err);
    });
    return undefined;
  }
}

function toJSON(input) {
  if (!input) {
    return input;
  }
  if (typeof input.toJSON === 'function') {
    return input.toJSON();
  } else if (Array.isArray(input)) {
    return _.map(input, toJSON);
  } else {
    return input;
  }
}

function toXML(input) {
  var xml;
  if (input && typeof input.toXML === 'function') {
    xml = input.toXML();
  } else {
    if (input) {
      // Trigger toJSON() conversions
      input = toJSON(input);
    }
    if (Array.isArray(input)) {
      input = { result: input };
    }
    xml = js2xmlparser('response', input, {
      prettyPrinting: {
        indentString: '  '
      },
      convertMap: {
        '[object Date]': function(date) {
          return date.toISOString();
        }
      }
    });
  }
  return xml;
}

/**
 * Finish the request and send the correct response.
 */
HttpContext.prototype.done = function() {
  // if response is already returned, then do nothing
  if (this._done) return;


  // send the result back as
  // the requested content type
  var data = this.result;
  var res = this.res;
  var accepts = this.req.accepts(this.supportedTypes);

  if (this.req.query._format) {
    accepts = this.req.query._format.toLowerCase();
  }
  var dataExists = typeof data !== 'undefined';

  if (dataExists) {
    switch (accepts) {
      case '*/*':
      case 'application/json':
      case 'json':
        res.json(data);
        break;
      case 'application/javascript':
      case 'text/javascript':
        res.jsonp(data);
        break;
      case 'application/xml':
      case 'text/xml':
      case 'xml':
        if (accepts === 'application/xml') {
          res.header('Content-Type', 'application/xml');
        } else {
          res.header('Content-Type', 'text/xml');
        }
        if (data === null) {
          res.header('Content-Length', '7');
          res.end('<null/>');
        } else {
          try {
            var xml = toXML(data);
            res.send(xml);
          } catch (e) {
            res.send(500, e + '\n' + data);
          }
        }
        break;
      default:
        // not acceptable
        res.send(406);
        break;
    }
  } else {
    if (!res.get('Content-Type')) {
      res.header('Content-Type', 'application/json');
    }
    if (res.statusCode === undefined || res.statusCode === 200) {
      res.statusCode = 204;
    }
    res.end();
  }
  this._done = true;
};