NaturalCycles/nodejs-lib

View on GitHub
src/validation/joi/joi.validation.util.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
/*
 * Does 2 things:
 * 1. Validates the value according to Schema passed.
 * 2. Converts the value (also according to Schema).
 *
 * "Converts" mean e.g trims all strings from leading/trailing spaces.
 */

import { _hb, _isObject, _truncateMiddle } from '@naturalcycles/js-lib'
import { AnySchema, ValidationError, ValidationOptions } from 'joi'
import { JoiValidationError, JoiValidationErrorData } from './joi.validation.error'

// todo: consider replacing with Tuple of [error, value]
export interface JoiValidationResult<T = any> {
  value: T
  error?: JoiValidationError
}

// Strip colors in production (for e.g Sentry reporting)
// const stripColors = process.env.NODE_ENV === 'production' || !!process.env.GAE_INSTANCE
// Currently colors do more bad than good, so let's strip them always for now
const stripColors = true

const defaultOptions: ValidationOptions = {
  abortEarly: false,
  convert: true,
  allowUnknown: true,
  stripUnknown: {
    objects: true,
    // true: it will SILENTLY strip invalid values from arrays. Very dangerous! Can lead to data loss!
    // false: it will THROW validation error if any of array items is invalid
    // Q: is it invalid if it has unknown properties?
    // A: no, unknown properties are just stripped (in both 'false' and 'true' states), array is still valid
    // Q: will it strip or keep unknown properties in array items?..
    // A: strip
    arrays: false, // let's be very careful with that! https://github.com/hapijs/joi/issues/658
  },
  presence: 'required',
  // errors: {
  //   stack: true,
  // }
}

/**
 * Validates with Joi.
 * Throws JoiValidationError if invalid.
 * Returns *converted* value.
 *
 * If `schema` is undefined - returns value as is.
 */
export function validate<T>(
  input: any,
  schema?: AnySchema<T>,
  objectName?: string,
  opt: ValidationOptions = {},
): T {
  const { value: returnValue, error } = getValidationResult(input, schema, objectName, opt)

  if (error) {
    throw error
  }

  return returnValue
}

/**
 * Validates with Joi.
 * Returns JoiValidationResult with converted value and error (if any).
 * Does not throw.
 *
 * If `schema` is undefined - returns value as is.
 */
export function getValidationResult<T>(
  input: any,
  schema?: AnySchema<T>,
  objectName?: string,
  options: ValidationOptions = {},
): JoiValidationResult<T> {
  if (!schema) return { value: input }

  const { value, error } = schema.validate(input, {
    ...defaultOptions,
    ...options,
  })

  const vr: JoiValidationResult<T> = {
    value,
  }

  if (error) {
    vr.error = createError(input, error, objectName)
  }

  return vr
}

/**
 * Convenience function that returns true if !error.
 */
export function isValid<T>(input: T, schema?: AnySchema<T>): boolean {
  if (!schema) return true

  const { error } = schema.validate(input, defaultOptions)
  return !error
}

export function undefinedIfInvalid<T>(input: any, schema?: AnySchema<T>): T | undefined {
  if (!schema) return input

  const { value, error } = schema.validate(input, defaultOptions)

  return error ? undefined : value
}

/**
 * Will do joi-conversion, regardless of error/validity of value.
 *
 * @returns converted value
 */
export function convert<T>(input: any, schema?: AnySchema<T>): T {
  if (!schema) return input
  const { value } = schema.validate(input, defaultOptions)
  return value
}

function createError(value: any, err: ValidationError, objectName?: string): JoiValidationError {
  const tokens: string[] = []

  const objectId = _isObject(value) ? (value['id'] as string) : undefined

  if (objectId || objectName) {
    objectName ||= value?.constructor?.name

    tokens.push('Invalid ' + [objectName, objectId].filter(Boolean).join('.'))
  }

  const annotation = err.annotate(stripColors)

  if (annotation.length > 100) {
    // For rather large annotations - we include up to 5 errors up front, before printing the whole object.

    // Up to 5 `details`
    tokens.push(
      ...err.details.slice(0, 5).map(i => {
        return i.message
        // Currently not specifying the path, to not "overwhelm" the message
        // Can be reverted if needed.
        // let msg = i.message
        // const paths = i.path.filter(Boolean).join('.')
        // if (paths) msg += ` @ .${paths}`
        // return msg
      }),
    )

    if (err.details.length > 5) tokens.push(`... ${err.details.length} errors in total`)
    tokens.push('')
  }

  tokens.push(
    _truncateMiddle(annotation, 4000, `\n... ${_hb(annotation.length)} message truncated ...\n`),
  )

  const msg = tokens.join('\n')

  const data: JoiValidationErrorData = {
    joiValidationErrorItems: err.details,
    ...(objectName && { joiValidationObjectName: objectName }),
    ...(objectId && { joiValidationObjectId: objectId }),
  }

  // Make annotation non-enumerable, to not get it automatically printed,
  // but still accessible
  Object.defineProperty(data, 'annotation', {
    writable: true,
    configurable: true,
    enumerable: false,
    value: annotation,
  })

  return new JoiValidationError(msg, data)
}