parserror.js

const CaseParser = require('./caseParser');
const ErrorCase = require('./errorCase');
const FormattedError = require('./formattedError');
const Scope = require('./scope');
const Utils = require('./utils');

/**
 * The main class of the library. It allows you to create cases, parsers and scopes.
 */
class Parserror {
  /**
   * Create a new instance of {@link Parserror}.
   *
   * @param {ParserrorOptions} [options]  The options to customize how the class behaves.
   * @returns {Parserror}
   * @static
   */
  static new(options) {
    return new Parserror(options);
  }
  /**
   * @param {Partial<ParserrorOptions>} [options={}]  The options to customize how the
   *                                                  class behaves.
   */
  constructor(options = {}) {
    /**
     * The options to customize how the class behaves.
     *
     * @type {ParserrorOptions}
     * @access protected
     * @ignore
     */
    this._options = {
      CaseParserClass: CaseParser,
      ErrorCaseClass: ErrorCase,
      FormattedErrorClass: FormattedError,
      ScopeClass: Scope,
      errorContextProperties: ['context', 'response', 'data'],
      ...options,
    };
    /**
     * The name of the global scope where the cases and parsers are added by default.
     *
     * @type {string}
     * @access protected
     * @ignore
     */
    this._globalScopeName = 'global';
    /**
     * A dictionary with the available scopes.
     *
     * @type {Object.<string, Scope>}
     * @access protected
     * @ignore
     */
    this._scopes = {};

    this.addScope(this._globalScopeName);
  }
  /**
   * Add a new error case.
   *
   * @param {ErrorCaseDefinition} definition    The case definition settings.
   * @param {?string}             [scope=null]  The name of the scope where the case
   *                                            should be added.
   *                                            If not defined, it will be added to the
   *                                            global scope.
   * @returns {Parserror} For chaining purposes.
   */
  addCase(definition, scope = null) {
    const scopeName = definition.scope || scope || this._globalScopeName;
    const useScope = this.getScope(scopeName);
    const { ErrorCaseClass, CaseParserClass, FormattedErrorClass } = this._options;

    useScope.addCase(
      new ErrorCaseClass(definition, {
        CaseParserClass,
        FormattedErrorClass,
      }),
    );

    return this;
  }
  /**
   * Adds a list of error cases.
   *
   * @param {ErrorCaseDefinition[]} definitions   The cases' definitions.
   * @param {?string}               [scope=null]  The name of the scope where the cases
   *                                              should be added. If not defined, they
   *                                              will be added to the global scope.
   * @returns {Parserror} For chaining purposes.
   */
  addCases(definitions, scope = null) {
    Utils.ensureArray(definitions).forEach((definition) => {
      this.addCase(definition, scope);
    });

    return this;
  }
  /**
   * Adds a reusable parser.
   *
   * @param {string}                          name    The name of the parser.
   * @param {Object.<string, any> | Function} parser  The parser function or map. This is
   *                                                  the second parameter for
   *                                                  {@link CaseParser#constructor}.
   * @param {?string}                         scope   The name of the scope where the
   *                                                  parser should be added. If not
   *                                                  defined, it will be added to the
   *                                                  global scope.
   * @returns {Parserror} For chaining purposes.
   */
  addParser(name, parser, scope = null) {
    const scopeName = scope || this._globalScopeName;
    const useScope = this.getScope(scopeName);
    const { CaseParserClass } = this._options;
    useScope.addParser(new CaseParserClass(name, parser));
    return this;
  }
  /**
   * Creates a new scope.
   *
   * @param {string} name
   * The name of the scope.
   * @param {ErrorCaseDefinition[]} [cases=[]]
   * A list of cases' defintions to add.
   * @param {Condition[]} [allowedOriginals=[]]
   * A list of conditions/definitions for cases that allow original messages to be
   * matched. To better understand how this work, please read the description of
   * {@link Parserror#allowOriginal}.
   * @param {boolean} [overwrite=false]
   * If there's a scope with the same name already, using this flag allows you to
   * overwrite it.
   * @returns {Parserror} For chaining purposes.
   * @throws {Error}
   * If `overwrite` is `false` and there's already a scope with the same name.
   */
  addScope(name, cases = [], allowedOriginals = [], overwrite = false) {
    if (this._scopes[name]) {
      if (overwrite) {
        this.removeScope(name);
      } else {
        throw new Error(
          `The scope '${name}' already exists. You can use 'removeScope' ` +
            "to remove it first, or set the 'overwrite' parameter to 'true'",
        );
      }
    }

    const { ScopeClass } = this._options;
    this._scopes[name] = new ScopeClass(name);

    if (cases.length) {
      this.addCases(cases, name);
    }

    if (allowedOriginals.length) {
      this.allowOriginals(allowedOriginals, name);
    }

    return this;
  }
  /**
   * Allows a specific error message to be matched. The idea is for this feature to be
   * used with fallback messages: If you want a message to be used as it is but at the
   * same time you want to use a fallback message, you would use this method; the original
   * message won't be discarded and you still have the fallback for messages that don't
   * have a match.
   *
   * @param {Condition} condition     Internally, this method will generate a new
   *                                  {@link ErrorCase}, so this parameter can be a string
   *                                  or a regular expression to match the error message,
   *                                  or an actual case definition.
   *                                  By default, the created case will have a random
   *                                  string as a name, but you can use a case definition
   *                                  to specify the name you want.
   * @param {?string}   [scope=null]  The name of the scope where the case should be
   *                                  added. If not defined, it will be added to the
   *                                  global scope.
   * @returns {Parserror} For chaining purposes.
   */
  allowOriginal(condition, scope = null) {
    let definition;
    if (typeof condition === 'string' || condition instanceof RegExp) {
      definition = {};
      definition.condition = condition;
    } else {
      definition = condition;
    }

    if (!definition.name) {
      const nameLength = 20;
      definition.name = Utils.getRandomString(nameLength);
    }

    definition.useOriginal = true;
    return this.addCase(definition, scope);
  }
  /**
   * Allows for multiple error messages to be matched. This is the "bulk alias" of
   * {@link Parserror#allowOriginal}, so please read the documentation of that method to
   * better understand in which case you would want to allow original messages.
   *
   * @param {Condition[]} conditions    The list of conditions/definitions for the cases
   *                                    that will match the messages.
   * @param {?string}     [scope=null]  The name of the scope where the cases should be
   *                                    added. If not defined, they will be added to the
   *                                    global scope.
   * @returns {Parserror} For chaining purposes.
   */
  allowOriginals(conditions, scope = null) {
    Utils.ensureArray(conditions).forEach((condition) => {
      this.allowOriginal(condition, scope);
    });

    return this;
  }
  /**
   * Gets a scope by its name.
   *
   * @param {string}  name           The name of the scope.
   * @param {boolean} [create=true]  If `true` and the scope doesn't exist, it will try to
   *                                 create it.
   * @returns {Scope}
   * @throws {Error} If `create` is `false` and the scope doesn't exist.
   */
  getScope(name, create = true) {
    let scope = this._scopes[name];
    if (!scope) {
      if (create) {
        this.addScope(name);
        scope = this._scopes[name];
      } else {
        throw new Error(`The scope '${name}' doesn't exist`);
      }
    }

    return scope;
  }
  /**
   * Parses and formats an error.
   *
   * @param {Error | string | ParserrorErrorObject} error
   * The error to parse.
   * @param {Partial<ParserrorParseOptions>} [options={}]
   * Options to customize how the parsing is done.
   * @returns {FormattedError}
   * @throws {TypeError}
   * If `error` is not an {@link Error}, a string or a {@link ParserrorErrorObject}.
   */
  parse(error, options = {}) {
    const useOptions = {
      cases: [],
      scopes: [],
      fallback: null,
      ...options,
    };

    this._validateParseOptions(useOptions);

    let context;
    let message;
    if (typeof error === 'string') {
      message = error;
      context = null;
    } else if (
      error instanceof Error ||
      (Utils.isObject(error) && typeof error.message === 'string')
    ) {
      ({ message } = error);
      context = this._searchForContext(error);
    } else {
      throw new TypeError(
        "'parse' can only handle error messages ('string'), " +
          "native errors ('Error') or literal objects ('object') with a " +
          "'message' property'",
      );
    }

    const globalScope = this.getScope(this._globalScopeName);
    let includesGlobalScope = useOptions.scopes.includes(this._globalScopeName);
    let useCases;
    if (useOptions.cases.length) {
      if (includesGlobalScope) {
        useCases = [];
      } else {
        useCases = useOptions.cases.map((name) => globalScope.getCase(name));
      }
    } else {
      if (!includesGlobalScope) {
        includesGlobalScope = true;
        useOptions.scopes.push(this._globalScopeName);
      }

      useCases = [];
    }

    const scopes = useOptions.scopes.map((scope) => this.getScope(scope));

    const scopesCases = scopes
      .map((scope) => scope.getCases())
      .reduce((newList, cases) => [...newList, ...cases], []);

    const cases = [...useCases, ...scopesCases];

    const scopesForCases = includesGlobalScope ? scopes : [...scopes, globalScope];

    let newError;
    cases.some((theCase) => {
      newError = theCase.parse(message, scopesForCases, context);
      return newError;
    });

    let result;
    if (newError) {
      result = newError;
    } else {
      const { FormattedErrorClass } = this._options;
      result = useOptions.fallback
        ? new FormattedErrorClass(useOptions.fallback, {}, { fallback: true })
        : new FormattedErrorClass(message, {}, { original: true });
    }

    return result;
  }
  /**
   * Removes a scope.
   *
   * @param {string} name  The name of the scope to remove.
   * @throws {Error} If you try to remove the global scope.
   */
  removeScope(name) {
    if (name === this._globalScopeName) {
      throw new Error("You can't delete the global scope");
    }

    delete this._scopes[name];
  }
  /**
   * Creates a wrapper: a pre configured parser to format errors with specific cases
   * and/or scopes.
   *
   * @param {string[]} cases            A list of cases' names.
   * @param {string[]} scopes           A list of scopes' names.
   * @param {?string}  [fallback=null]  A fallback message in case the error can't be
   *                                    parsed.
   *                                    If not specified, the returned error will maintain
   *                                    the original message.
   * @returns {ParserrorWrapper}
   */
  wrap(cases = [], scopes = [], fallback = null) {
    return (error, fallbackMessage = null) =>
      this.parse(error, {
        cases,
        scopes,
        fallback: fallbackMessage || fallback,
      });
  }
  /**
   * Creates a wrapper for specific scopes. A wrapper is a pre configured parser to format
   * errors with specific cases and/or scopes.
   *
   * @param {string[]} scopes           A list of scopes' names.
   * @param {?string}  [fallback=null]  A fallback message in case the error can't be
   *                                    parsed.
   *                                    If not specified, the returned error will maintain
   *                                    the original message.
   * @returns {ParserrorWrapper}
   */
  wrapForScopes(scopes, fallback = null) {
    return (error, fallbackMessage = null) =>
      this.parse(error, {
        scopes,
        fallback: fallbackMessage || fallback,
      });
  }
  /**
   * The name of the global scope.
   *
   * @type {string}
   */
  get globalScopeName() {
    return this._globalScopeName;
  }
  /**
   * Tries to find a property inside an error to be used as context information for the
   * parsers.
   *
   * @param {Error | ParserrorErrorObject} error  The error where the method will look for
   *                                              the property.
   * @returns {?Object}
   * @access protected
   * @ignore
   */
  _searchForContext(error) {
    const useProperty = this._options.errorContextProperties.find(
      (property) => typeof error[property] !== 'undefined',
    );

    return useProperty ? error[useProperty] : null;
  }
  /**
   * Validates an object to ensure it can be used as {@link ParserrorParseOptions}.
   *
   * @param {ParserrorParseOptions} options  The object to validate.
   * @throws {TypeError} If the `cases` property is not an `array`.
   * @throws {TypeError} If the `scopes` property is not an `array`.
   * @access protected
   * @ignore
   */
  _validateParseOptions(options) {
    if (!Array.isArray(options.cases)) {
      throw new TypeError("The 'cases' option can only be an 'array'");
    } else if (!Array.isArray(options.scopes)) {
      throw new TypeError("The 'scopes' option can only be an 'array'");
    }
  }
}

module.exports = Parserror;