Home Intro Source Mebo GitHub

src/Handlers/Web.js

const assert = require('assert');
const TypeCheck = require('js-typecheck');
const Settings = require('../Settings');
const Metadata = require('../Metadata');
const Input = require('../Input');
const Handler = require('../Handler');

// symbols used for private instance variables to avoid any potential clashing
// caused by re-implementations
const _request = Symbol('request');
const _response = Symbol('response');
const _beforeAuthActionMiddlewares = Symbol('beforeAuthActionMiddlewares');
const _beforeActionMiddlewares = Symbol('beforeActionMiddlewares');
const _webActions = Symbol('webActions');
const _actionMethodToWebfiedIndex = Symbol('actionMethodToWebfiedIndex');

// handler name (used for registration)
const _handlerName = 'web';


/**
 * Handles the web integration through expressjs and passportjs.
 *
 * It enables the execution of actions triggered by web requests.
 * The request information is read by {@link WebRequest}, this information
 * is passed to the action during the execution of the web handler
 * ({@link Web.run}). The output of this handler ({@link Web.output}) is done
 * through the {@link WebResponse} writer.
 *
 * In order to tell which actions are visible by this handler, they are required to
 * be registered beforehand via a webfication process that describes their
 * request method, rest route and if it requires authentication.
 *
 * Through decorator support:
 * ```
 * @Mebo.grant('web', {restRoute: '/myApi/action', auth: true})
 * @Mebo.register('myAction')
 * class MyAction extends Mebo.Action{
 *   // ...
 * }
 * ```
 *
 * Through registration api:
 * ```
 * Mebo.Handler.grantAction('web', 'myRegisteredAction', {restRoute: '/myApi/action', auth: true});
 * ```
 *
 * In case of actions that require authentication (`auth: true`) Mebo checks if
 * the authentication has been executed before executing the action. Therefore,
 * a passport authentication is required to be defined beforehand which can
 * be done through {@link addBeforeAuthAction}:
 *
 * ```
 * Mebo.Handler.get('web').addBeforeAuthAction(passport);
 * ```
 *
 * Also, custom middlewares can be added before the execution of any action through
 * {@link addBeforeAction}:
 *
 * ```
 * Mebo.Handler.get('web').addBeforeAction((req, res, next) => {...});
 * ```
 *
 * After the webfication process, actions can be triggered in two ways:
 *
 * - *Rest support* ({@link Web.restful}):
 * Executes an action through a rest route, it happens when an action is webfied
 * with `restRoute` where it becomes automatically visible as part of the
 * restful support. In order to activated the restful support you need to tel
 * Mebo what is the expressjs app you want to register the rest routes:
 * ```javascript
 * const app = express();
 * // this process registers the rest route for the webfied actions
 * Mebo.Handler.get('web').restful(app);
 * ```
 * The result of webfied actions is done through the restful support is automatically
 * by using google's json style guide. The only exceptions are readable stream
 * and buffer that are piped to the response
 * ({@link Web._successOutput}, {@link Web._errorOutput}).
 *
 * - *Middleware support* ({@link Web.middleware}):
 * Executes an action through an arbitrary route. Actions can be executed as
 * expressjs middlewares.
 * It's done by using `Mebo.Handler.get('web').middleware` where you tell what is the action's
 * registration name that should be executed for the express route
 * (make sure the action has been webfied before hand). By using this feature
 * you control the response of the request, since the result of the action
 * is passed to the middleware:
 * ```javascript
 * const app = express();
 * app.get(
 *    '/foo',
 *    Mebo.Handler.get('web').middleware('myRegisteredAction', (err, result, req, res) => {
 *      // some sauce...
 *    })
 * );
 * ```
 *
 * You can access a basic help page by passing help as part of the querystring. This feature
 * generates a help page automatically for the action, for instance:
 * `http://.../?help`
 *
 *
 * **Express req and res**
 *
 * The request and the response used by this handler are available
 * under the {@link Session} as: `session.get('req')` and `session.get('res')`.
 *
 * @see http://expressjs.com
 * @see http://passportjs.org
 */
class Web extends Handler{

  /**
   * Creates a web handler
   *
   * @param {Object} req - request object
   * @param {Object} res - response object
   */
  constructor(req, res){
    super();

    this.setRequest(req);
    this.setResponse(res);
  }

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

  /**
   * Sets the request object created by the express server.
   *
   * It also includes the request as part of the session: `session.get('request')`
   *
   * @see http://expressjs.com/en/api.html#req
   * @param {Object} req - request object
   */
  setRequest(req){
    assert(TypeCheck.isObject(req) && req.method, 'Invalid request object');

    this[_request] = req;
    this.session().set('req', req);

    // adding the remote ip address to the autofill as remoteAddress
    this.session().setAutofill(
      'remoteAddress',
      req.headers['x-forwarded-for'] || req.connection.remoteAddress,
    );
  }

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

  /**
   * Sets the response object created by the express server
   *
   * It also includes the response as part of the session: `session.get('response')`
   *
   * @see http://expressjs.com/en/api.html#res
   * @param {Object} res - response object
   */
  setResponse(res){
    assert(TypeCheck.isObject(res) && TypeCheck.isObject(res.locals), 'Invalid response object');

    this[_response] = res;
    this.session().set('res', res);
  }

  /**
   * Returns a middleware designed to execute a webfied {@link Web.webfyAction}
   * based on an arbitrary express route. Differently from {@link Web.restful} this method
   * does not response the request, instead it's done through the responseCallback
   * which passes the action `error`, `result` and the default middleware express
   * arguments, for instance:
   *
   * ```javascript
   * const app = express();
   * app.get(
   *    '/foo',
   *    Mebo.Handler.get('web').middleware('myRegisteredAction', (err, result, req, res) => {
   *      // ...
   *    })
   * );
   * ```
   *
   * @param {string} actionName - registered action name
   * @param {function} [responseCallback] - optional response callback that overrides
   * the default json response. The callback carries the express:
   * function(err, result, req, res, next){...}
   * @return {function}
   */
  static middleware(actionName, responseCallback){
    return this._createMiddleware(actionName, responseCallback);
  }

  /**
   * Adds a middleware that is executed before an action.
   *
   * Use this feature when you want to execute a custom middleware before the
   * execution of an action. If you want to add a middleware for a specific
   * web handler implementation then take a look at {@link Web.beforeAction}. All middlewares
   * registered by this method are executed after {@link addBeforeAuthAction}.
   *
   * Alternatively this method can be called directly from Mebo as `Mebo.Handler.get('web').addBeforeAction(...)`
   *
   * In order to pass a values computed by a "before middleware" to the action you need to
   * add the values to the handler session, so the action can read them later. The
   * web handler is available under `res.locals.web`, for instance:
   * ```
   * const web = res.locals.web;
   * web.session().setAutofill('customValue', 'myValue');
   * ```
   *
   * Where any input assigned with the autofill property 'someCustom' is going to be
   * assigned with the 'something' value:
   *
   * ```
   * class MyAction extends Mebo.action{
   *   constructor(){
   *      super();
   *      // gets assigned with `something` value
   *      this.createInput('a: text', {autofill: 'customValue'});
   *   }
   * }
   * ```
   *
   * @param {function} middleware - expressjs middleware that should be executed
   * before the action
   *
   * @see http://expressjs.com/en/guide/using-middleware.html
   */
  static addBeforeAction(middleware){
    assert(TypeCheck.isCallable(middleware), 'middleware needs to defined as a callable');

    this[_beforeActionMiddlewares].push(middleware);
  }

  /**
   * Adds a middleware that is executed before an action that requires authentication.
   *
   * Use this feature when you want to execute a custom middleware before the
   * execution of an action that requires authentication. If you want to add a
   * middleware for a specific web handler implementation then take a look at
   * {@link Web.beforeAuthAction}. All middlewares registered by this method are
   * executed before {@link addBeforeAction}.
   *
   * Use this feature to define the passportjs authentication middleware.
   *
   * In order to pass a values computed by a "before middleware" to the action you need to
   * add the values to the handler session, so the action can read them later. The
   * web handler is available under `res.locals.web`, for instance:
   * ```
   * const web = res.locals.web;
   * web.session().setAutofill('customValue', 'value');
   * ```
   *
   * Where any input assigned with the autofill property 'customValue' is going to be
   * assigned with 'value':
   *
   * ```
   * class MyAction extends Mebo.action{
   *   constructor(){
   *      super();
   *      // gets assigned with `something`
   *      this.createInput('a: text', {autofill: 'customValue'});
   *   }
   * }
   * ```
   *
   * @param {function} middleware - expressjs middleware that should be executed
   * before an action that requires authentication
   *
   * @see http://expressjs.com/en/guide/using-middleware.html
   */
  static addBeforeAuthAction(middleware){
    assert(TypeCheck.isCallable(middleware), 'middleware needs to defined as a callable');

    this[_beforeAuthActionMiddlewares].push(middleware);
  }

  /**
   * Returns a list of middlewares which are executed before an action.
   *
   * This method can be re-implemented by subclasses to include custom middlewares
   * that are tied with a specific web handler implementation. By default it returns
   * the middlewares added through {@link Web.addBeforeAction}
   *
   * @return {Array<function>}
   */
  static beforeAction(){
    return this[_beforeActionMiddlewares].slice(0);
  }

  /**
   * Clears all middlewares assigned to run before actions ({@link beforeAction})
   */
  static clearBeforeAction(){
    this[_beforeAuthActionMiddlewares].length = 0;
  }

  /**
   * Returns a list of middlewares which are executed before an action that requires auth
   *
   * This method can be re-implemented by subclasses to include custom middlewares
   * that are tied with a specific web handler implementation. By default it returns
   * the middlewares added through {@link Web.addBeforeAuthAction}
   *
   * @return {Array<function>}
   */
  static beforeAuthAction(){
    return this[_beforeAuthActionMiddlewares].slice(0);
  }

  /**
   * Clears all middlewares assigned to run before auth actions ({@link beforeAuthAction})
   */
  static clearBeforeAuthAction(){
    this[_beforeAuthActionMiddlewares].length = 0;
  }

  /**
   * Adds the restful support to the express app.
   *
   * It works by registering the rest routes for the webfied visible actions
   * ({@link Web.webfyAction}) to the express app. The response of actions
   * executed through the rest support is done via the output method.
   *
   * ```javascript
   * const app = express();
   * Mebo.Handler.get('web').restful(app);
   * ```
   * or
   * ```javascript
   * const app = express();
   * Mebo.Handler.get('web').restful(app, '/api'); // adding a prefix for the rest routes
   * ```
   *
   * @param {Object} expressApp - expressjs application instance
   * @param {string} [prefix] - optional prefix that gets included in the
   * registration of the rest routes
   */
  static restful(expressApp, prefix=''){

    assert(TypeCheck.isCallable(expressApp.use), 'Invalid express instance!');
    assert(TypeCheck.isString(prefix), 'prefix must be defined as string');

    // registering the routes
    for (const webfiedAction of this[_webActions]){
      if (webfiedAction.restRoute !== null){

        // building the final route path
        let finalRoute = prefix;
        if (prefix.length && !webfiedAction.restRoute.startsWith('/')){
          finalRoute += '/';
        }
        finalRoute += webfiedAction.restRoute;

        // registering route
        expressApp[webfiedAction.method](
          finalRoute,
          this._createMiddleware(webfiedAction.actionName),
        );
      }
    }
  }

  /**
   * Makes an action available for requests.
   *
   * By doing that the action gets visible for the {@link restful} and {@link middleware} support.
   *
   * This method is called during `Mebo.Handler.grantAction('web', ...)`
   *
   * @param {string} handlerName - registered handler name
   * @param {string} actionName - registered action name
   * @param {Object} options - custom options
   * @param {string|Array<string>} [options.method='get'] - tells the request method about how the action should
   * be available, for instance: `get`, `post`, `put`, `delete` (...). Multiples methods
   * can be defined through an array of method names
   * @param {boolean} [options.auth=null] - boolean telling if the action requires authentication
   * when set to `null` (default) this information is driven by the setting
   * ⚠ `handler/web/requireAuthByDefault` (default: `false`).
   * @param {string} [options.restRoute] - the rest route from which the action should be executed from
   * the {@link restful} support. You can use route parameters as well that later are translated to
   * input values to further information take a look at ({@link WebRequest}).
   * @protected
   */
  static _grantingAction(handlerName, actionName, {method='get', auth=null, restRoute=null}={}){

    // registering action
    let methods = (TypeCheck.isString(method)) ? [method] : method;
    methods = methods.map((x) => x.toLowerCase());

    // finding duplicated items
    const removeIndexes = [];
    for (let i=0, len=this[_webActions].length; i < len; ++i){
      const webfiedAction = this[_webActions][i];
      const action = webfiedAction.actionName;
      const actionMethod = webfiedAction.method;

      if (methods.includes(actionMethod) && restRoute === webfiedAction.restRoute){

        // when the method and route is already being used by another action then removing
        // that from the registration, since the method and route will be registered
        // for a different action
        if (action in this[_actionMethodToWebfiedIndex] && actionMethod in this[_actionMethodToWebfiedIndex][action]){
          delete this[_actionMethodToWebfiedIndex][action][actionMethod];
        }

        removeIndexes.push(i);
      }
    }

    // removing duplicated items
    if (removeIndexes.length){
      for (let i=0, len=removeIndexes.length; i < len; ++i){
        this[_webActions].splice(removeIndexes[i]-i, 1);
      }
    }

    // storing the action under the auxiliary data struct 'action method to webfied index'
    if (!(actionName in this[_actionMethodToWebfiedIndex])){
      this[_actionMethodToWebfiedIndex][actionName] = {};
    }

    // adding the routes
    for (const addMethod of methods){
      const webfiedAction = {};
      webfiedAction.actionName = actionName;
      webfiedAction.method = addMethod;
      webfiedAction.auth = auth;
      webfiedAction.restRoute = restRoute;

      // adding the index about where the webfied action is localized
      // under the 'action method to webfied index'
      this[_actionMethodToWebfiedIndex][actionName][addMethod] = this[_webActions].length;

      // adding the webfied action information
      this[_webActions].push(webfiedAction);
    }
  }

  /**
   * Creates an instance of a reader for the current handler
   *
   * This passes the {@link Web.request} to the reader.
   *
   * @param {Action} action - action instance used by the reader to parse the values
   * @param {Object} options - plain object containing the options passed to the reader
   * @return {Reader}
   * @protected
   */
  _createReader(action, options){

    return super._createReader(
      action,
      options,
      this.request(),
    );
  }

  /**
   * Creates an instance of a writer for the current handler.
   *
   * This passes the {@link Web.response} to the reader and
   * the request.query.context as an option to the writer.
   *
   * @param {*} value - arbitrary value passed to the writer
   * @param {Object} options - plain object containing the options passed to the writer
   * @return {Writer}
   * @protected
   */
  _createWriter(value, options){
    const writer = super._createWriter(value, options, this.response());

    // adding context as part of the result
    const query = this.request().query;
    if ('context' in query && writer.option('root')){
      writer.setOption('root.context', query.context);
    }

    return writer;
  }

  /**
   * Returns a wrapped middleware which makes sure that actions requiring auth
   * use the middleware otherwise the middleware is skipped
   *
   * @param {function} middleware - auth middleware
   * @return {function}
   * @private
   */
  static _wrapAuthMiddleware(middleware){
    return (req, res, next) => {
      if (res.locals.web.requireAuth){
        middleware(req, res, next);
      }
      else{
        next();
      }
    };
  }

  /**
   * Auxiliary method that creates a middleware containing an action
   *
   * @param {string} actionName - registered action name which should be executed by the middleware
   * @param {function} [responseCallback] - optional response callback that overrides
   * the default json response. The callback carries the express:
   * function(err, result, req, res, next){...}
   * @return {function}
   * @private
   */
  static _createMiddleware(actionName, responseCallback=null){

    const checkActionMiddleware = (req, res, next) => {
      const method = req.method.toLowerCase();

      // checking if the action is webfied for the current request method
      if (!(method in this[_actionMethodToWebfiedIndex][actionName])){
        return res.sendStatus(404);
      }

      // storing the request handler inside of the res.locals, so this object
      // can be accessed later by the action
      res.locals.web = Handler.create(
        'web',
        actionName,
        // passed to the handler
        req,
        res,
      );

      const actionDataIndex = this[_actionMethodToWebfiedIndex][actionName][method];
      res.locals.web.requireAuth = this[_webActions][actionDataIndex].auth;

      next();
    };

    // creating the middleware that executes the action
    const actionMiddleware = (req, res, next) => {

      // assuring the authentication has been done
      assert(res.locals.web.requireAuth !== undefined);
      assert(!res.locals.web.requireAuth || req.user !== undefined, "Can't execute an auth action without authentication!");

      // creates the action
      const web = res.locals.web;
      const render = (!TypeCheck.isCallable(responseCallback));

      // executing the action middleware
      web.runAction(actionName).then((result) => {
        if (render){
          web.output(result);
        }
        // callback that handles the response (Mebo.Handler.get('web').middleware)
        else{
          responseCallback(null, result, req, res, next);
        }
        // runaway promise
        return null;
      }).catch((err) => {
        if (render){
          web.output(err);
        }
        // callback that handles the response (Mebo.Handler.get('web').middleware)
        else{
          responseCallback(err, null, req, res, next);
        }
      });
    };

    const WebHandlerClass = this.registeredHandler('web', actionName);

    // final middleware list
    const result = [checkActionMiddleware,
      ...WebHandlerClass.beforeAuthAction().map(this._wrapAuthMiddleware),
      ...WebHandlerClass.beforeAction(),
      actionMiddleware,
    ];

    return result;
  }
}

Web[_beforeAuthActionMiddlewares] = [];
Web[_beforeActionMiddlewares] = [];
Web[_webActions] = [];
Web[_actionMethodToWebfiedIndex] = {};

// default settings
Settings.set('handler/web/requireAuthByDefault', false); // ⚠ BE CAREFUL
Settings.set('handler/web/allowHelp', true);

// registering input properties
Input.registerProperty('filePath', 'restrictWebAccess', true);
Input.registerProperty('filePath', 'webTypeHint', 'Expects a file input when manipulated through the web');

// registering option vars
Metadata.registerOptionVar('$web', 'handler.web');
Metadata.registerOptionVar('$webUploadDirectory', '$web.readOptions.uploadDirectory');
Metadata.registerOptionVar('$webUploadPreserveName', '$web.readOptions.uploadPreserveName');
Metadata.registerOptionVar('$webDefaultUploadMaxFileSize', '$web.readOptions.uploadDefaultMaxFileSize');
Metadata.registerOptionVar('$webMaxFields', '$web.readOptions.maxFields');
Metadata.registerOptionVar('$webMaxFieldsSize', '$web.readOptions.maxFieldsSize');
Metadata.registerOptionVar('$webHeaders', '$web.writeOptions.headers');
Metadata.registerOptionVar('$webHeadersOnly', '$web.writeOptions.headersOnly');
Metadata.registerOptionVar('$webResult', '$web.writeOptions.result');
Metadata.registerOptionVar('$webRoot', '$web.writeOptions.root');
Metadata.registerOptionVar('$webStatus', '$web.writeOptions.status');
Metadata.registerOptionVar('$webResultLabel', '$web.writeOptions.resultLabel');

// registering handler
Handler.register(Web, _handlerName);

// exporting module
module.exports = Web;