Home Intro Source Mebo GitHub

src/Handler.js

const assert = require('assert');
const EventEmitter = require('events');
const TypeCheck = require('js-typecheck');
const minimatch = require('minimatch');
const Session = require('./Session');
const Action = require('./Action');
const Metadata = require('./Metadata');
const Reader = require('./Reader');
const Writer = require('./Writer');
const Utils = require('./Utils');

// symbols used for private members to avoid any potential clashing
// caused by re-implementations
const _session = Symbol('session');
const _metadata = Symbol('metadata');
const _outputEventEmitter = Symbol('outputEventEmitter');
const _registeredHandlers = Symbol('registeredHandlers');
const _registeredWriters = Symbol('registeredWriters');
const _registeredReaders = Symbol('registeredReaders');
const _addedActions = Symbol('addedActions');

/**
 * A handler is used to bridge an execution method to Mebo.
 *
 * The data used to perform the execution of action through a handler
 * ({@link Handler.runAction}) is parsed using a reader {@link Reader}.
 *
 * The result of a handler is done through a {@link Writer}. Writers are designed
 * to support reporting a success output and an error output as well. The way the
 * result is serialized is determined by the writer implementation
 * ({@link Writer._successOutput}, {@link Writer._errorOutput}). All writers
 * shipped with Mebo have support for streams where in case of any readable stream
 * or buffer value they are piped to the output, otherwise the result is encoded
 * using JSON.
 *
 * Both reader and writer can be customized through options that can be either
 * defined through the action's metadata or directly through the handler. If you would
 * like to know more about the available options check out the respective
 * {@link Reader} & {@link Writer} documentation for the handler implementation
 * you are interested.
 *
 * Defining options through actions (detailed information can be found at
 * {@link Metadata}):
 * ```
 * @Mebo.register('myAction')
 * class MyAction extends Mebo.Action{
 *    constructor(){
 *      super();
 *
 *      // change 'name' for the registration name of the handler you
 *      // want to define the read options
 *      this.setMeta('handler.name.readOptions', {
 *        someReadOption: true
 *      });
 *    }
 *
 *    async _perform(data){
 *      // ...
 *    }
 *
 *    async _after(err, value){
 *
 *      // change 'name' for the registration name of the handler you
 *      // want to define the write options
 *      if (!err){
 *        // defining the write option 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.
 *        this.setMeta('handler.name.writeOptions', {
 *            someWriteOption: 10,
 *        });
 *      }
 *    }
 * }
 * ```
 *
 * Defining options directly through the handler:
 * ```
 * // read options
 * myHandler.runAction('myAction', {
 *  someReadOption: true,
 * })
 *
 * // write options
 * myHandler.output(value, {
 *  someWriteOption: 10,
 * })
 * ```
 *
 * Handlers are created by their registration name ({@link Handler.register}),
 * the creation is done by {@link Handler.create}:
 *
 * ```
 * // creating a handle based on the handler registration name
 * const handler = Mebo.Handler.create('myHandler');
 *
 * // loading the parsed information to the action
 * handler.runAction('actionName').then((result) => {
 *
 *    // success output
 *    handler.output(result);
 *
 * // error output
 * }).catch((err) => {
 *    handler.output(err);
 * });
 * ```
 *
 * **Tip:** You can set the env variable `NODE_ENV=development` to get the
 * traceback information included in the error output.
 *
 * **Tip:** In case you want to know the name of the handler from inside of
 * the action you can retrieved this information from the session
 * `session().get('handler')` ({@link Action.session}).
 */
class Handler{

  /**
   * Creates a Handler
   */
  constructor(){
    this[_metadata] = new Metadata();
  }

  /**
   * Associates a {@link Session} with the handler. The session assigned to
   * the handler is cloned during the assignment ({@link Session.clone}).
   *
   * @param {Session} session - session object
   */
  setSession(session){
    assert(session instanceof Session, 'Invalid session!');

    this[_session] = session.clone();
  }

  /**
   * Returns the session
   *
   * @return {Session}
   */
  session(){

    // creating session on demanding
    if (!this[_session]){
      this[_session] = new Session();
    }

    return this[_session];
  }

  /**
   * Returns a value under the handler's metadata.
   *
   * @param {string} path - path about where the value is localized (the levels
   * must be separated by '.'). In case of an empty string it returns the
   * entire metadata. The path can be defined using `option vars`
   * ({@link Metadata.optionVar}).
   * @param {*} [defaultValue] - default value returned in case a value was
   * not found for the path
   * @return {*}
   */
  meta(path, defaultValue=undefined){
    assert(TypeCheck.isString(path), 'path needs to be defined as string');

    return this[_metadata].value(path, defaultValue);
  }

  /**
   * Sets a value to the handler's metadata.
   *
   * Detailed information about the metadata support can be found at
   * {@link Metadata}.
   *
   * @param {string} path - path about where the value should be stored under the metadata
   * (the levels must be separated by '.'). The path can be defined using `option vars`
   * ({@link Metadata.optionVar}).
   * @param {*} value - value that is going to be stored under the collection
   * @param {boolean} [merge=true] - this option is used to decide in case of the
   * last level is already existing under the collection, if the value should be
   * either merged (default) or overridden.
   */
  setMeta(path, value, merge=true){
    assert(TypeCheck.isString(path), 'path needs to be defined as string');

    this[_metadata].setValue(path, value, merge);
  }

  /**
   * Executes an action through the handler.
   *
   * This process is done by creating an action that loads the information
   * parsed by the {@link Reader}.
   *
   * After the construction of the action it looks for reading options that can
   * be defined as part of the action's metadata ({@link Action.metadata}) and
   * when found they are passed to the reader. After the execution of the action
   * it looks again inside of the action's metadata for writing options, which
   * are later used during the output ({@link Handler.output}). To know how
   * to define action's metadata for the handler take a look at the initial
   * documentation about the {@link Handler}.
   *
   * @param {string} actionName - registered action name that should be executed
   * @param {Object} options - plain object containing the options that is passed
   * to the {@link Reader}.
   * for the handler should be fetched.
   * @return {*} result of the action
   */
  async runAction(actionName, options={}){

    const action = Action.create(actionName, this.session());

    // collecting read options from the action
    let actionHandlerName = this._actionHandlerName(action);

    if (actionHandlerName.length){
      this.setMeta(
        'readOptions',
        action.meta(`handler.${actionHandlerName}.readOptions`, {}),
      );
    }

    // executing action
    let result;
    await this._load(action, options);
    try{
      result = await action.run();
    }
    finally{

      // handler metadata can be defined later during
      // for this reason querying it again if it was not defined previously
      if (!actionHandlerName.length){
        actionHandlerName = this._actionHandlerName(action);
      }

      // collecting write options from the action
      if (actionHandlerName.length){
        this.setMeta(
          'writeOptions',
          action.meta(`handler.${actionHandlerName}.writeOptions`, {}),
        );
      }
    }

    return result;
  }

  /**
   * Results a value through the handler.
   *
   * In case the value is an exception then it's treated as {@link Writer._errorOutput}
   * otherwise the value is treated as {@link Writer._successOutput}.
   *
   * When an action is executed through the handler ({@link Handler.runAction})
   * it can define writing options that are used by the output. These
   * options are stored under the {@link Handler.metadata} where any options passed
   * directly to the output method override them.
   *
   * If `finalizeSession` is enabled (default) the {@link Handler.session} gets finalized
   * at the end of the output process.
   *
   * By default the {@link Writer._errorOutput} tries to handle the error as output.
   * However you can tell a writer to do not handle specific errors, by doing that the writer
   * will raise the errors instead of trying to handle them. This can be achieved
   * by having `output` defined as member of the error (`error.output = false`),
   * further information can be found at the error output documentation
   * ({@link Writer._errorOutput}).
   *
   * In case of any error raised during the output process the handler emits the signal
   * {@link Handler.onErrorDuringOutput}.
   *
   * @param {*} value - raw value that should be resulted by the handler
   * @param {Object} [options] - plain object containing options that should be used
   * by the output where each handler implementation contains their own set of options.
   * @param {boolean} [finalizeSession=true] - tells if it should finalize the session
   * ({@link Session.finalize})
   */
  output(value, options={}, finalizeSession=true){

    const writeOptions = Utils.deepMerge(this.meta('writeOptions', {}), options);
    const writer = this._createWriter(value, writeOptions);
    try{
      writer.serialize();
    }
    catch(err){
      this._emitOutputError(err);
    }

    // the session finalization runs in parallel, since it does secondary tasks
    // (such as clean-up, logging, etc...) there is no need to await for that
    if (finalizeSession){
      this.session().finalize().then(() => {
        // runaway promise
        return null;
      }).catch((err) => {
        this._emitOutputError(err);
      });
    }
  }

  /**
   * Creates a handler based on the registered name
   *
   * Alternatively this method can be called directly from Mebo as `Mebo.Handler.create(...)`
   *
   * Also, the handler name gets included in the session as arbitrary data, it can be
   * retrieved through 'handler'. This name follows the registration pattern where this
   * value is represented in lowercase internally:
   * ```
   * Session.get('handler');
   * ```
   * @param {string} handlerName - registered handler name
   * @param {string} [mask='*'] - optional mask that supports a glob syntax used
   * to match a custom registered handler (it allows to have
   * custom handler implementations for specific masks)
   * @param {...args} args - custom args passed to the constructor during factoring
   * @return {Handler}
   */
  static create(handlerName, mask='*', ...args){
    const HandlerClass = this.registeredHandler(handlerName, mask);

    // creates a new instance
    const handler = new HandlerClass(...args);

    // adding the handler name used to factory the handler under the metadata
    handler.setMeta('handler.name', handlerName);
    handler.setMeta('handler.mask', mask);

    // also, adding the handler name under the session arbitrary data
    handler.session().set('handler', handlerName);

    return handler;
  }

  /**
   * Register an {@link Handler} type to the available handlers
   *
   * @param {Handler} handlerClass - handler implementation that will be registered
   * @param {string} [handlerName] - string containing the registration name for the
   * handler. In case of an empty string, the registration is done by using the name
   * of the type (this information is stored in lowercase)
   * @param {string} [handlerMask='*'] - optional mask that supports a glob syntax used
   * to match a custom registered handler (it allows to have
   * custom handler implementations for specific masks)
   */
  static register(handlerClass, handlerName='', handlerMask='*'){
    assert(TypeCheck.isSubClassOf(handlerClass, Handler), 'Invalid handler type!');
    const handlerNameFinal = ((handlerName === '') ? handlerClass.name : handlerName);

    this._register(this[_registeredHandlers], handlerClass, handlerNameFinal, handlerMask);
  }

  /**
   * Register a {@link Reader} for the handler
   *
   * @param {Reader} readerClass - reader class
   * @param {string} handlerName - registered handler name
   * @param {string} [handlerMask='*'] - optional mask that supports a glob syntax used
   * to match a custom registered handler (it allows to have
   * custom handler implementations for specific masks)
   */
  static registerReader(readerClass, handlerName, handlerMask='*'){
    assert(TypeCheck.isSubClassOf(readerClass, Reader), 'Invalid reader type');

    this._register(this[_registeredReaders], readerClass, handlerName, handlerMask);
  }

  /**
   * Register a {@link Writer} for the handler
   *
   * @param {Writer} writerClass - writer class
   * @param {string} handlerName - registered handler name
   * @param {string} [handlerMask='*'] - optional mask that supports a glob syntax used
   * to match a custom registered handler (it allows to have
   * custom handler implementations for specific masks)
   */
  static registerWriter(writerClass, handlerName, handlerMask='*'){
    assert(TypeCheck.isSubClassOf(writerClass, Writer), 'Invalid writer type');

    this._register(this[_registeredWriters], writerClass, handlerName, handlerMask);
  }

  /**
   * Returns the registered handler
   *
   * (it can be also done via {@link Handler.registeredHandler}).
   *
   * @param {string} handlerName - registered handler name
   * @param {string} [handlerMask] - optional handler mask
   * @return {Handler}
   */
  static get(handlerName, handlerMask='*'){
    return this.registeredHandler(handlerName, handlerMask);
  }

  /**
   * Returns the registered handler
   *
   * (it can be also done via {@link Handler.get})
   *
   * @param {string} handlerName - name of the registered handler type
   * @param {string} [handlerMask='*'] - optional mask that supports a glob syntax used
   * to match a custom registered handler
   * @return {Handler}
   */
  static registeredHandler(handlerName, handlerMask='*'){
    const result = this._registered(this[_registeredHandlers], handlerName, handlerMask);

    if (result){
      return result;
    }

    throw new Error(`Handler ${handlerName} is not registered!`);
  }

  /**
   * Returns the reader registered for the handler
   *
   * @param {string} handlerName - name of the registered handler type
   * @param {string} [handlerMask='*'] - optional mask that supports a glob syntax used
   * to match a custom registered handler
   * @return {Reader}
   */
  static registeredReader(handlerName, handlerMask='*'){
    const result = this._registered(this[_registeredReaders], handlerName, handlerMask);

    if (result){
      return result;
    }

    throw new Error(`Reader is not registered for handler ${handlerName}!`);
  }

  /**
   * Returns the writer registered for the handler
   *
   * @param {string} handlerName - name of the registered handler type
   * @param {string} [handlerMask='*'] - optional mask that supports a glob syntax used
   * to match a custom registered handler
   * @return {Writer}
   */
  static registeredWriter(handlerName, handlerMask='*'){
    const result = this._registered(this[_registeredWriters], handlerName, handlerMask);

    if (result){
      return result;
    }

    throw new Error(`Writer is not registered for handler ${handlerName}!`);
  }

  /**
   * Returns a list containing the names of the registered handler types
   *
   * @return {Array<string>}
   */
  static registeredHandlerNames(){
    const result = new Set();

    for (const [registeredHandleName] of this[_registeredHandlers].keys()){
      result.add(registeredHandleName);
    }

    return [...result];
  }

  /**
   * Returns a list of registered handler makers for the input handler name
   *
   * @param {string} handlerName - registered handler name
   * @return {Array<string>}
   */
  static registeredHandlerMasks(handlerName){
    const result = [];

    for (const [registeredHandleName, registeredMask] of this[_registeredHandlers].keys()){
      if (registeredHandleName === handlerName){
        result.push(registeredMask);
      }
    }

    return result;
  }

  /**
   * Grants the execution of the action through the handler
   *
   * @param {string} handlerName - registered name of the handler
   * @param {string} actionName - registered action name
   * @param {...args} args - custom args passed to {@link Handler._grantingAction}
   */
  static grantAction(handlerName, actionName, ...args){
    assert(TypeCheck.isString(handlerName), 'handlerName needs to be defined as string');

    // making sure the action is registered, otherwise throws an exception
    Action.registeredAction(actionName);

    const handlerMasks = this.registeredHandlerMasks(handlerName);
    for (const handleMask of handlerMasks){
      const HandlerClass = this.registeredHandler(handlerName, handleMask);
      HandlerClass._grantingAction.call(HandlerClass, handlerName, actionName, ...args);
    }

    assert(handlerMasks.length, `Handler ${handlerName} is not registered`);
    if (!this[_addedActions].has(handlerName)){
      this[_addedActions].set(handlerName, new Set());
    }

    this[_addedActions].get(handlerName).add(actionName);
  }

  /**
   * Returns a list granted actions for the input handler
   *
   * @param {string} handlerName - registered handler name
   * @return {Array<string>}
   */
  static grantedActionNames(handlerName){
    assert(TypeCheck.isString(handlerName), 'handlerName needs to be defined as string');

    if (this[_addedActions].has(handlerName)){
      return [...this[_addedActions].get(handlerName).values()];
    }

    return [];
  }

  /**
   * Adds a listener to an exception raised during the {@link Handler.output} process.
   * It can happen either during the serialization process ({@link Writer.serialize})
   * or during the finalization of the session ({@link Session.finalize}).
   * This event passes as argument: error, handlerName and handlerMask.
   *
   * Currently this event is static to make easy to hook it in your application,
   * if none listener is registered to it then the error is thrown,
   * a stack trace is printed, and the Node.js process exits.
   *
   * ```
   * // registering a listener for the error
   * Mebo.Handler.onErrorDuringOutput((err, handlerName, handlerMask => {
   *    console.error(err.stack);
   * }));
   * ```
   *
   * @param {function} listener - listener function
   */
  static onErrorDuringOutput(listener){
    this[_outputEventEmitter].on('error', listener);
  }

  /**
   * Removes all listeners connected to the signal emitted
   * through {@link Handler.onErrorDuringOutput}.
   */
  static clearErrorDuringOutput(){
    this[_outputEventEmitter].removeAllListeners('error');
  }

  /**
   * This method can be re-implemented by derived classes to hook when an {@link Action}
   * is granted for a handler ({@link Handler.grantAction})
   *
   * @param {string} handlerName - registered handler name
   * @param {string} actionName - registered action name
   * @param {...args} args - custom args passed during {@link Handler.grantAction}
   * @protected
   */
  static _grantingAction(handlerName, actionName, ...args){
  }

  /**
   * Creates an instance of a reader for the current handler
   *
   * @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
   * @param {...additionalArgs} additionalArgs - additional args passed to the
   * constructor during factoring of the reader (should be used by derived classes)
   * @return {Reader}
   * @protected
   */
  _createReader(action, options, ...additionalArgs){
    const ReaderClass = Handler.registeredReader(
      this.meta('handler.name'),
      this.meta('handler.mask'),
    );

    const reader = new ReaderClass(action, ...additionalArgs);

    // passing options to the reader
    for (const option in options){
      reader.setOption(option, options[option]);
    }

    return reader;
  }

  /**
   * Creates an instance of a writer for the current handler
   *
   * @param {*} value - arbitrary value passed to the writer
   * @param {Object} options - plain object containing the options passed to the writer
   * @param {...additionalArgs} additionalArgs - additional args passed to the
   * constructor during factoring of the reader (should be used by derived classes)
   * @return {Writer}
   * @protected
   */
  _createWriter(value, options, ...additionalArgs){
    const WriterClass = Handler.registeredWriter(
      this.meta('handler.name'),
      this.meta('handler.mask'),
    );

    const writer = new WriterClass(value, ...additionalArgs);

    // passing options to the writer
    for (const option in options){
      writer.setOption(option, options[option]);
    }

    return writer;
  }

  /**
   * Loads the {@link Reader} information to the {@link Action} and {@link Session}. This
   * process is called during the execution ({@link Handler.runAction}).
   *
   * Changes done by this method to the action:
   * - Assigns the {@link Handler.session} to the action ({@link Action.session})
   * - Modifies the action input values based on the information collected by the reader
   *
   * Changes done by this method to the session:
   * - Modifies the {@link Session.autofill} based on the information collected by the reader
   * ({@link Reader.autofillValues})
   *
   * @param {Action} action - action that should be used
   * @param {Object} options - options passed to the reader
   * @private
   */
  async _load(action, options){

    assert(action instanceof Action, 'Invalid action');

    const readOptions = Utils.deepMerge(this.meta('readOptions', {}), options);
    const reader = this._createReader(action, readOptions);

    // collecting the reader values
    const inputValues = await reader.inputValues();
    const autofillValues = await reader.autofillValues();

    // setting inputs
    for (const inputName in inputValues){
      action.input(inputName).setValue(inputValues[inputName]);
    }

    // setting autofill
    const session = action.session();
    for (const autofillName in autofillValues){
      session.setAutofill(autofillName, autofillValues[autofillName]);
    }
  }

  /**
   * Auxiliary method used to get the registration of writers, readers and handlers.
   *
   * @param {Map} where - map used to find the registration
   * @param {string} handlerName - name of the registered handler type
   * @param {string} handlerMask - mask that supports a glob syntax used
   * to match a custom registered handler
   * @return {Handler|function}
   *
   * @private
   */
  static _registered(where, handlerName, handlerMask){
    assert(TypeCheck.isString(handlerName), 'handlerName needs to be defined as string');
    assert(TypeCheck.isString(handlerMask), 'mask needs to be defined as string');

    let result = null;

    for (const key of where.keys()){
      if (key[0] === handlerName && (key[1] === '*' || minimatch(handlerMask, key[1]))){
        result = where.get(key);
        break;
      }
    }

    return result;
  }

  /**
  * Auxiliary method that returns the handler name defined as metadata inside
  * of the action, if not defined returns an empty string
  *
  * @param {Action} action - action that should be used
  * @return {string}
  * @private
  */
  _actionHandlerName(action){
    let result = '';

    const registeredHandlerName = this.meta('handler.name');
    for (const handlerName in action.meta('handler', {})){

      // the searching for the handler name (case insensitive)
      if (handlerName === registeredHandlerName){
        result = handlerName;
        break;
      }
    }

    return result;
  }

  /**
   * Auxiliary method used for the registration of writers, readers and handlers
   *
   * @param {Map} where - map used to store the registration
   * @param {Handler|function} what - data that should be stored
   * @param {string} handlerName - name of the registered handler type
   * @param {string} handlerMask - mask that supports a glob syntax used
   * to match a custom registered handler
   *
   * @private
   */
  static _register(where, what, handlerName, handlerMask){
    assert(TypeCheck.isString(handlerName), 'Invalid handler registration name!');
    assert(TypeCheck.isString(handlerMask), 'handlerMask needs to be defined as string');
    assert(handlerName.length, 'handlerName cannot be empty');
    assert(handlerMask.length, 'handlerMask cannot be empty');

    // validating handler name
    assert(handlerName.length, 'handler name cannot be empty');
    assert((/^([\w_\.\-])+$/gi).test(handlerName), `Invalid handler name: ${handlerName}`); // eslint-disable-line no-useless-escape

    // since when querying registrations the new ones precede to the old ones,
    // therefore the new ones are stored on the top of the pile, for this reason creating
    // a temporary reversed map that will be used to include the new one
    const currentData = new Map();
    for (const key of Array.from(where.keys()).reverse()){

      // if there is already an existing registration for it, skipping it
      if (key[0] === handlerName && key[1] === handlerMask){
        continue;
      }

      currentData.set(key, where.get(key));
    }

    // including the new registration
    currentData.set([handlerName, handlerMask], what);

    // reversing back the final order
    where.clear();
    for (const key of Array.from(currentData.keys()).reverse()){
      where.set(key, currentData.get(key));
    }
  }

  /**
   * Emits the output error signal, it passes as argument: error, handler name
   * and handler mask.
   *
   * @param {Error} err - exception used as critical error
   */
  _emitOutputError(err){
    process.nextTick(() => {
      Handler[_outputEventEmitter].emit(
        'error',
        err,
        this.meta('handler.name'),
        this.meta('handler.mask'),
      );
    });
  }
}

Handler[_outputEventEmitter] = new EventEmitter();
Handler[_registeredHandlers] = new Map();
Handler[_registeredWriters] = new Map();
Handler[_registeredReaders] = new Map();
Handler[_addedActions] = new Map();

module.exports = Handler;