validation/validate.ts

/** @module Validation */
import { ensureArr } from '@array/ensureArr'

/**
 * @type {Object}
 */
const OPTIONS = {
  SHOULD_LOG: true,
  SHOULD_THROW: false,
  LOG_PREFIX: null,
}

// if no default or custom validator set for an arg, just assert it is valid
const defaultValidator = () => true

export type TValidateOpts = {
  logs?: boolean
  throws?: boolean
  prefix?: string
}

export type TValidateRes = {
  success: boolean
  key: string
  value: any
  validator: any
  reason: string
}

export type TValidateFun = ((
  argObj: Record<string, any>,
  validators?: Record<string, any>,
  options?: TValidateOpts
) => [success: boolean, results: TValidateRes]) & {
  setOptions: ({ logs, throws, prefix }: TValidateOpts) => void
  resetOptions: () => void
}

/**
 *  Validates each key-value entry in argObj using the validator functions in validators with matching keys.
 *  <br/>For any failures, validate will console.error the reason.
 *  @param {Object} argObj - object, where keys are the name of the argument to validate, and value is its value
 *  @param {Object} validators - object, where keys match the argument and values are predicate functions (return true/false and are passed the arg with the same key).
 *     - Use the `$default` key to define a default validator, which will validate any argument that doesn't have a custom validator defined.
 *  @param {Object} options - contains `logs`, `throws`, and `prefix` props. When a validation fails, it will throw an error if `throws` is true. Else it logs error if `logs` is true. `prefix` prepends a string to the error messages.
 *  @returns {Array} - An entry with two values [ success, results ].<br/>
 *     - success: { Boolean } that is true if all arguments passed their validators, false otherwise<br/>
 *     - results: {Object} that holds the validation results for each argument, keyed by the same keys as in argObj. For each
 *                result object, the properties are: { success, key, value, validator, reason }.
 *  @function
 *  @example
 *    const elements = {}
 *    const name = 'michael'
 *    const address = '12345 E. Street'
 *    const [ isValid, results ] = validate(
 *      { elements, name, address },
 *      { elements: isArr, $default: isStr }
 *    )
 *    console.log(isValid) // false
 *    console.log(results.elements.success) // false
 */
export const validate: TValidateFun = (
  argObj,
  validators = {},
  options = {}
) => {
  const {
    logs = OPTIONS.SHOULD_LOG,
    throws = OPTIONS.SHOULD_THROW,
    prefix = OPTIONS.LOG_PREFIX,
  } = options

  const validationCaseEntries = Object.entries(argObj)

  // validate each argument
  const validationResults = validationCaseEntries.map(([argName, argValue]) =>
    validateArgument(
      argName,
      argValue,
      validators[argName] || validators.$default || defaultValidator
    )
  )

  // reduce the argument validation results into a single object of form { success, cases }.
  // success is true if all arguments passed their validators. Cases holds each argument's validation results.
  const reduceCases = (total, next) =>
    validationReducer(total, next, { logs, throws, prefix })
  const { success, cases } = validationResults.reduce(reduceCases, {
    success: true,
    cases: {},
  })

  return [success, cases]
}

/**
 * If you need to configure validation properties globally, you can do so here. These are overridden by the validate options arguments,
 * if one is defined in validate().
 * @function
 * @param {Object} options
 * @param {Boolean} options.logs - indicates you want validate() to log errors when a case fails
 * @param {Boolean} options.throws - indicates validate() should throw an error when a case fails
 * @param {String} options.prefix - a prefix to any console error logs or to messages of errors thrown
 */
validate.setOptions = ({ logs, throws, prefix }: TValidateOpts) => {
  if (logs !== undefined) {
    OPTIONS.SHOULD_LOG = logs
  }
  if (throws !== undefined) {
    OPTIONS.SHOULD_THROW = throws
  }
  if (prefix !== undefined) {
    OPTIONS.LOG_PREFIX = prefix
  }
}

/**
 * Resets the global validation options to their defaults
 * @function
 */
validate.resetOptions = () => {
  OPTIONS.SHOULD_LOG = true
  OPTIONS.SHOULD_THROW = false
  OPTIONS.LOG_PREFIX = null
}

/**
 * Helper for `validate`. Validates a single value given a validator
 * @param {*} key
 * @param {*} value
 * @param {Function} validator
 * @returns {Object} of form { success, reason }
 * @ignore
 */
const validateArgument = (
  key: string,
  value: any,
  validator: (val: any) => boolean
) => {
  const success = validator(value)

  // if validator is a named function, use its name. If it is an inline anonymous arrow function, its name
  // matches the argument key and it has no useful/descriptive name, so just stringify it
  const shouldStringifyValidator =
    !validator.name || validator.name === key || validator.name === '$default'
  const validatorString = shouldStringifyValidator
    ? validator.toString()
    : validator.name

  const reason = success
    ? null
    : [
        `Argument "${key}" with value `,
        value,
        ` failed validator: ${validatorString}.`,
      ]

  return { success, key, value, validator, reason }
}

/**
 * Helper for `validate`. Reduces validations into a single object of form { success, cases }
 * @param {*} finalResult
 * @param {*} nextValidation
 * @ignore
 */
const validationReducer = (
  finalResult: any,
  nextValidation: TValidateRes,
  { logs, throws, prefix }: TValidateOpts
) => {
  // handle the failure
  !nextValidation.success && handleFailure(nextValidation, logs, throws, prefix)

  return {
    success: finalResult.success && nextValidation.success,
    cases: {
      ...finalResult.cases,
      [nextValidation.key]: nextValidation,
    },
  }
}

/**
 * Handles a validation failure given validation options
 * @param {Object} validation
 * @param {Boolean} shouldLog
 * @param {Boolean} shouldThrow
 * @param {String} prefix - optional prefix to any error or console log
 * @ignore
 */
const handleFailure = (
  validation: TValidateRes,
  shouldLog?: boolean,
  shouldThrow?: boolean,
  prefix?: string
) => {
  // prepend the prefix if one is defined
  const reason = prefix ? [prefix, ...validation.reason] : validation.reason

  if (shouldThrow) throw new Error(ensureArr(reason).join())

  if (shouldLog) console.error(...reason)
}