Home Intro Source Mebo GitHub

src/Writers/WebResponse.js

const assert = require('assert');
const stream = require('stream');
const TypeCheck = require('js-typecheck');
const Settings = require('../Settings');
const Handler = require('../Handler');
const Writer = require('../Writer');
const MeboErrors = require('../MeboErrors');

// symbols used for private members to avoid any potential clashing
// caused by re-implementations
const _response = Symbol('response');


/**
 * Web output writer.
 *
 * This writer is used by the output of the web handler ({@link Web}).
 *
 * In case the value is an exception then it's treated as
 * {@link WebResponse._errorOutput} otherwise the value is treated as
 * {@link WebResponse._successOutput}.
 *
 * When an action is executed through a handler it can define options for
 * readers and writers via {@link Metadata} support. For instance,
 * you can use it to provide a custom result for a specific handler:
 *
 * ```
 * class MyAction extends Mebo.Action{
 *
 *    // ...
 *
 *    async _perform(data){
 *      // ...
 *    }
 *
 *    async _after(err, value){
 *      // defining a custom result that only affects the web handler
 *      // this call could be done inside of the _perform method. However, we
 *      // are defining it inside of the _after to keep _perform as
 *      // abstract as possible. Since, _after is always called (even during
 *      // an error) after the execution of the action, it provides a way to
 *      // hook and define custom metadata related with the result.
 *      if (!err){
 *          // defining a custom output option
 *          this.setMeta('$webResult', {
 *              message: 'My custom web result!',
 *          });
 *      }
 *    }
 *
 *    // ...
 * }
 * ```
 *
 * <h2>Options Summary</h2>
 *
 * Option Name | Description | Default Value
 * --- | --- | :---:
 * headers | plain object containing the header names (in camel case convention) \
 * that should be used in the response | `{}`
 * headersOnly | if enabled ends the response without any data | 
 * status | success status code (the error status code is driven by the status \
 * defined as a member of the exception) | `200`
 * root | plain object that gets deep merged at the root of the json output\
 * of a success result, for instance:<br>`{data: {...}, <rootContentsA>: ..., \
 * <rootContentsB> : ...}` | `{}`
 * result | Overrides the value returned by {@link Writer.value} to an \
 * arbitrary value (only affects the success output) | 
 * resultLabel | custom label used by the success output when the value is \
 * serialized using json. This label is used to hold the result \
 * under data, for instance:<br>`{data: {<resultLabel>: value}}`<br><br>In case of \
 * undefined (default) then a fallback label is used based on the value type: \
 * <br>- primitive values are held under 'value' \
 * <br>- array value is held under 'items' \
 * <br>- object is assigned with '' (empty string) \
 * <br>* when an empty string is used, the value gets merged to the \
 * result.data | 
 *
 * <br>When defining options through the metadata support, it can done using
 * `option vars`. Mebo comes bundled with pre-defined option vars
 * for most of the options available for the readers & writers. The complete list
 * of the option vars can be found at {@link Metadata} documentation.
 *
 * Example of defining the `headers` option from inside of an action through
 * the metadata support:
 *
 * ```
 * // defining 'Content-Type' header
 * class MyAction extends Mebo.Action{
 *    async _perform(data){
 *
 *      // 'Content-Type' header
 *      this.setMeta('$webHeaders', {
 *        contentType: 'application/octet-stream',
 *      });
 *
 *      // ...
 *    }
 * }
 * ```
 *
 * Also, headers can be defined through 'before action middlewares'
 * ({@link Web.addBeforeAction} and {@link Web.addBeforeAuthAction})
 */
class WebResponse extends Writer{

  /**
   * Creates a web response writer
   *
   * @param {*} value - arbitrary value passed to the writer
   * @param {Object} res - express res object
   */
  constructor(value, res){
    super(value);
    this._setResponse(res);

    // default options
    this.setOption('headersOnly', false);
    this.setOption('headers', {});
    this.setOption('root', {
      apiVersion: Settings.get('apiVersion'),
    });
    this.setOption('status', 200);
  }

  /**
   * Returns the response object created by express
   *
   * @return {Object}
   * @see http://expressjs.com/en/api.html#res
   */
  response(){
    return this[_response];
  }

  /**
   * Implements the response for an error value.
   *
   * Any error can carry a HTTP status code. It is done by defining `status` to any error
   * (for instance ```err.status = 501;```).
   * This practice can be found in all errors shipped with mebo ({@link Conflict}, {@link NoContent},
   * {@link NotFound} and {@link ValidationFail}). In case none status is found in the error then `500`
   * is used automatically.
   *
   * The error response gets automatically encoded using json, following the basics
   * of google's json style guide. In case of an error status `500` the standard
   * result is ignored and a message `Internal Server Error` is used instead.
   *
   * Further information can be found at base class documentation
   * {@link Writer._errorOutput}.
   *
   * @protected
   */
  _errorOutput(){

    const status = this.value().status || 500;

    // setting the status code for the response
    this.response().status(status);

    // when help is requested
    if (this.value() instanceof MeboErrors.Help){
      this.response().send(this.value().message);
      return;
    }

    const result = {
      error: {
        code: status,
        message: super._errorOutput(),
      },
    };

    // adding the stack-trace information when running in development mode
    /* istanbul ignore next */
    if (process.env.NODE_ENV === 'development'){
      result.error.stacktrace = this.value().stack.split('\n');
    }

    // should not leak any error message for the status code 500
    if (status === 500){
      result.error.message = 'Internal Server Error';
    }

    this._genericOutput(result);
  }

  /**
   * Implements the response for a success value.
   *
   * A readable stream value is piped using 'application/octet-stream' by default
   * (if it has not been defined by the header option 'contentType'),
   * otherwise for non-readable stream value it's automatically encoded
   * using json, following the basics of google's json style guide.
   *
   * Further information can be found at base class documentation
   * {@link Writer._successOutput}.
   *
   * @see https://google.github.io/styleguide/jsoncstyleguide.xml
   * @protected
   */
  _successOutput(){

    const result = super._successOutput();

    // setting the status code for the response
    this.response().status(this.option('status'));

    // setting header
    this._setResponseHeaders();

    // readable stream
    if (result instanceof stream.Readable){
      this._successStreamOutput(result);
      return;
    }

    this._successJSONOutput(result);
  }

  /**
   * Sets the response object created by express
   *
   * @param {Object} value - res object
   * @see http://expressjs.com/en/api.html#res
   * @private
   */
  _setResponse(value){
    assert(TypeCheck.isObject(value) && TypeCheck.isObject(value.locals), 'Invalid response object');

    this[_response] = value;
  }

  /**
   * Results a stream through the success output
   *
   * @param {stream} value - output value
   * @private
   */
  _successStreamOutput(value){
    // setting a default content-type for readable stream in case
    // it has not been set previously
    const headers = this.option('headers');
    if (!(headers && headers.contentType)){
      this.response().setHeader('Content-Type', 'application/octet-stream');
    }

    value.pipe(this.response());
  }

  /**
   * Results the default success output through google's json style guide
   *
   * @param {*} result - output value
   * @private
   */
  _successJSONOutput(result){

    const output = {};

    // including the root options
    Object.assign(output, this.option('root'));

    // automatic result, it is done by figuring out the response based on
    // the value returned by the action, otherwise if output contains data
    // returns that instead
    output.data = {};

    if (result !== undefined){

      // in case the value has defined 'toJSON' calling that to get result
      // value that should be used for the output, reference:
      // https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
      let finalResult = result;
      if (TypeCheck.isCallable(result.toJSON)){
        finalResult = JSON.parse(JSON.stringify(result));
      }

      // resolving the result label
      const resultLabel = this._resultLabel(finalResult);

      if (resultLabel){
        output.data[resultLabel] = finalResult;
      }
      else{
        assert(!TypeCheck.isPrimitive(finalResult), "Can't output a primitive value without a 'resultLabel'");
        assert(TypeCheck.isPlainObject(finalResult), "Can't output a non-plain object value");
        output.data = finalResult;
      }
    }

    this._genericOutput(output);
  }

  /**
   * Generic output routine shared by both success and error outputs
   *
   * @param {*} output - arbitrary data used as output
   * @private
   */
  _genericOutput(output){

    // ending response without any data
    if (this.option('headersOnly')){
      this.response().end();
      return;
    }

    // json output
    this.response().json(output);
  }

  /**
   * Returns the label used to hold the result under data. In case of undefined
   * (default) it uses a fallback label based on the value type:
   *
   * - primitive values are held under 'value'
   * - array value is held under 'items'
   * - object is assigned with `null`
   * * when an empty string is used, the value gets merged to the result.data
   *
   * @param {*} value - value that should be used by the result entry
   * @return {string}
   * @private
   */
  _resultLabel(value){
    let resultLabel = this.option('resultLabel');
    if (resultLabel === undefined){
      if (TypeCheck.isPrimitive(value)){
        resultLabel = 'value';
      }
      else if (TypeCheck.isList(value)){
        resultLabel = 'items';
      }
      else{
        resultLabel = '';
      }
    }

    return resultLabel;
  }

  /**
   * Looks for any header member defined as part of the options and sets them
   * to the response header. It expects a camelCase name convention for the header name
   *  where it gets translated to the header name convention, for instance:
   * 'options.headers.contentType' translates to 'Content-Type'.
   *
   * @param {*} options - options passed to the output
   * @private
   */
  _setResponseHeaders(){

    const headers = this.option('headers');
    const response = this.response();
    if (headers){
      for (const headerName in headers){
        const convertedHeaderName = headerName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();

        // assigning a header value to the response
        response.setHeader(convertedHeaderName, headers[headerName]);
      }
    }
  }
}

// registering writer
Handler.registerWriter(WebResponse, 'web');

module.exports = WebResponse;