mjackson/mach

View on GitHub
modules/Message.js

Summary

Maintainability
F
4 days
Test Coverage
var bodec = require('bodec');
var d = require('describe-property');
var Stream = require('bufferedstream');
var bufferStream = require('./utils/bufferStream');
var normalizeHeaderName = require('./utils/normalizeHeaderName');
var parseCookie = require('./utils/parseCookie');
var parseQuery = require('./utils/parseQuery');

/**
 * The default content to use for new messages.
 */
var DEFAULT_CONTENT = bodec.fromString('');

/**
 * The default maximum length (in bytes) to use in Message#parseContent.
 */
var DEFAULT_MAX_CONTENT_LENGTH = Math.pow(2, 20); // 1M

var HEADERS_LINE_SEPARATOR = /\r?\n/;
var HEADER_SEPARATOR = ': ';

function defaultParser(message, maxLength) {
  return message.stringifyContent(maxLength);
}

/**
 * An HTTP message.
 */
function Message(content, headers) {
  this.headers = headers;
  this.content = content;
}

Object.defineProperties(Message, {

  PARSERS: d({
    enumerable: true,
    value: {
      'application/json': function (message, maxLength) {
        return message.stringifyContent(maxLength).then(JSON.parse);
      },
      'application/x-www-form-urlencoded': function (message, maxLength) {
        return message.stringifyContent(maxLength).then(parseQuery);
      }
    }
  })

});

Object.defineProperties(Message.prototype, {

  /**
   * The headers of this message as { headerName, value }.
   */
  headers: d.gs(function () {
    return this._headers;
  }, function (value) {
    this._headers = {};

    if (typeof value === 'string') {
      value.split(HEADERS_LINE_SEPARATOR).forEach(function (line) {
        var index = line.indexOf(HEADER_SEPARATOR);

        if (index === -1) {
          this.addHeader(line, true);
        } else {
          this.addHeader(line.substring(0, index), line.substring(index + HEADER_SEPARATOR.length));
        }
      }, this);
    } else if (value != null) {
      for (var headerName in value)
        if (value.hasOwnProperty(headerName))
          this.addHeader(headerName, value[headerName]);
    }
  }),

  /**
   * Returns the value of the header with the given name.
   */
  getHeader: d(function (headerName) {
    return this.headers[normalizeHeaderName(headerName)];
  }),

  /**
   * Sets the value of the header with the given name.
   */
  setHeader: d(function (headerName, value) {
    this.headers[normalizeHeaderName(headerName)] = value;
  }),

  /**
   * Adds the value to the header with the given name.
   */
  addHeader: d(function (headerName, value) {
    headerName = normalizeHeaderName(headerName);

    var headers = this.headers;
    if (headerName in headers) {
      if (Array.isArray(headers[headerName])) {
        headers[headerName].push(value);
      } else {
        headers[headerName] = [ headers[headerName], value ];
      }
    } else {
      headers[headerName] = value;
    }
  }),

  /**
   * An object containing cookies in this message, keyed by name.
   */
  cookies: d.gs(function () {
    if (!this._cookies) {
      var header = this.headers['Cookie'];

      if (header) {
        var cookies = parseCookie(header);

        // From RFC 2109:
        // If multiple cookies satisfy the criteria above, they are ordered in
        // the Cookie header such that those with more specific Path attributes
        // precede those with less specific. Ordering with respect to other
        // attributes (e.g., Domain) is unspecified.
        for (var cookieName in cookies)
          if (Array.isArray(cookies[cookieName]))
            cookies[cookieName] = cookies[cookieName][0] || '';

        this._cookies = cookies;
      } else {
        this._cookies = {};
      }
    }

    return this._cookies;
  }),

  /**
   * Gets/sets the value of the Content-Type header.
   */
  contentType: d.gs(function () {
    return this.headers['Content-Type'];
  }, function (value) {
    this.headers['Content-Type'] = value;
  }),

  /**
   * The media type (type/subtype) portion of the Content-Type header without any
   * media type parameters. e.g. when Content-Type is "text/plain;charset=utf-8",
   * the mediaType is "text/plain".
   *
   * See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7
   */
  mediaType: d.gs(function () {
    var contentType = this.contentType, match;
    return (contentType && (match = contentType.match(/^([^;,]+)/))) ? match[1].toLowerCase() : null;
  }, function (value) {
    this.contentType = value + (this.charset ? ';charset=' + this.charset : '');
  }),

  /**
   * Returns the character set used to encode the content of this message. e.g.
   * when Content-Type is "text/plain;charset=utf-8", charset is "utf-8".
   *
   * See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.4
   */
  charset: d.gs(function () {
    var contentType = this.contentType, match;
    return (contentType && (match = contentType.match(/\bcharset=([\w-]+)/))) ? match[1] : null;
  }, function (value) {
    this.contentType = this.mediaType + (value ? ';charset=' + value : '');
  }),

  /**
   * The content of this message as a binary stream.
   */
  content: d.gs(function () {
    return this._content;
  }, function (value) {
    if (value == null)
      value = DEFAULT_CONTENT;

    if (value instanceof Stream) {
      this._content = value;
      value.pause();
    } else {
      this._content = new Stream(value);
    }

    delete this._bufferedContent;
  }),

  /**
   * True if the content of this message is buffered, false otherwise.
   */
  isBuffered: d.gs(function () {
    return this._bufferedContent != null;
  }),

  /**
   * Returns a binary representation of the content of this message up to
   * the given length. This is useful in applications that need to access the
   * entire message body at once, instead of as a stream.
   *
   * Note: 0 is a valid value for maxLength. It means "no limit".
   */
  bufferContent: d(function (maxLength) {
    if (this._bufferedContent == null)
      this._bufferedContent = bufferStream(this.content, maxLength);

    return this._bufferedContent;
  }),

  /**
   * Returns the content of this message up to the given length as a string
   * with the given encoding.
   *
   * Note: 0 is a valid value for maxLength. It means "no limit".
   */
  stringifyContent: d(function (maxLength, encoding) {
    encoding = encoding || this.charset;

    return this.bufferContent(maxLength).then(function (chunk) {
      return bodec.toString(chunk, encoding);
    });
  }),

  /**
   * Returns a promise for an object of data contained in the content body.
   *
   * The maxLength argument specifies the maximum length (in bytes) that the
   * parser will accept. If the content stream exceeds the maximum length, the
   * promise is rejected with a MaxLengthExceededError. The appropriate response
   * to send to the client in this case is 413 Request Entity Too Large, but
   * many HTTP clients including most web browsers may not understand it.
   *
   * Note: 0 is a valid value for maxLength. It means "no limit".
   */
  parseContent: d(function (maxLength) {
    if (this._parsedContent)
      return this._parsedContent;

    if (typeof maxLength !== 'number')
      maxLength = DEFAULT_MAX_CONTENT_LENGTH;

    var parser = Message.PARSERS[this.mediaType] || defaultParser;
    this._parsedContent = parser(this, maxLength);

    return this._parsedContent;
  })

});

module.exports = Message;