NaturalCycles/nodejs-lib

View on GitHub
src/validation/ajv/ajvSchema.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import {
  JsonSchema,
  JsonSchemaAnyBuilder,
  JsonSchemaBuilder,
  _filterNullishValues,
  _isObject,
  _substringBefore,
  CommonLogger,
} from '@naturalcycles/js-lib'
import Ajv, { ValidateFunction } from 'ajv'
import { _inspect, fs2, requireFileToExist } from '../../index'
import { AjvValidationError } from './ajvValidationError'
import { getAjv } from './getAjv'

export interface AjvValidationOptions {
  objectName?: string
  objectId?: string

  /**
   * @default to cfg.logErrors, which defaults to true
   */
  logErrors?: boolean

  /**
   * Used to separate multiple validation errors.
   *
   * @default cfg.separator || '\n'
   */
  separator?: string
}

export interface AjvSchemaCfg {
  /**
   * Pass Ajv instance, otherwise Ajv will be created with
   * AjvSchema default (not the same as Ajv defaults) parameters
   */
  ajv: Ajv

  /**
   * Dependent schemas to pass to Ajv instance constructor.
   * Simpler than instantiating and passing ajv instance yourself.
   */
  schemas?: (JsonSchema | JsonSchemaBuilder | AjvSchema)[]

  objectName?: string

  /**
   * Used to separate multiple validation errors.
   *
   * @default '\n'
   */
  separator: string

  /**
   * @default true
   */
  logErrors: boolean

  /**
   * Default to `console`
   */
  logger: CommonLogger

  /**
   * Option of Ajv.
   * If set to true - will mutate your input objects!
   * Defaults to false.
   *
   * This option is a "shortcut" to skip creating and passing Ajv instance.
   */
  coerceTypes?: boolean
}

/**
 * On creation - compiles ajv validation function.
 * Provides convenient methods, error reporting, etc.
 *
 * @experimental
 */
export class AjvSchema<T = unknown> {
  private constructor(
    public schema: JsonSchema<T>,
    cfg: Partial<AjvSchemaCfg> = {},
  ) {
    this.cfg = {
      logErrors: true,
      logger: console,
      separator: '\n',
      ...cfg,
      ajv:
        cfg.ajv ||
        getAjv({
          schemas: cfg.schemas?.map(s => {
            if (s instanceof AjvSchema) return s.schema
            if (s instanceof JsonSchemaAnyBuilder) return s.build()
            return s as JsonSchema
          }),
          coerceTypes: cfg.coerceTypes || false,
          // verbose: true,
        }),
      // Auto-detecting "ObjectName" from $id of the schema (e.g "Address.schema.json")
      objectName: cfg.objectName || (schema.$id ? _substringBefore(schema.$id, '.') : undefined),
    }

    this.validateFunction = this.cfg.ajv.compile<T>(schema)
  }

  /**
   * Conveniently allows to pass either JsonSchema or JsonSchemaBuilder, or existing AjvSchema.
   * If it's already an AjvSchema - it'll just return it without any processing.
   * If it's a Builder - will call `build` before proceeding.
   * Otherwise - will construct AjvSchema instance ready to be used.
   *
   * Implementation note: JsonSchemaBuilder goes first in the union type, otherwise TypeScript fails to infer <T> type
   * correctly for some reason.
   */
  static create<T>(
    schema: JsonSchemaBuilder<T> | JsonSchema<T> | AjvSchema<T>,
    cfg: Partial<AjvSchemaCfg> = {},
  ): AjvSchema<T> {
    if (schema instanceof AjvSchema) return schema
    if (schema instanceof JsonSchemaAnyBuilder) {
      return new AjvSchema<T>(schema.build(), cfg)
    }
    return new AjvSchema<T>(schema as JsonSchema<T>, cfg)
  }

  /**
   * Create AjvSchema directly from a filePath of json schema.
   * Convenient method that just does fs.readFileSync for you.
   */
  static readJsonSync<T = unknown>(
    filePath: string,
    cfg: Partial<AjvSchemaCfg> = {},
  ): AjvSchema<T> {
    requireFileToExist(filePath)
    const schema = fs2.readJson<JsonSchema<T>>(filePath)
    return new AjvSchema<T>(schema, cfg)
  }

  readonly cfg: AjvSchemaCfg
  private readonly validateFunction: ValidateFunction<T>

  /**
   * It returns the original object just for convenience.
   * Reminder: Ajv will MUTATE your object under 2 circumstances:
   * 1. `useDefaults` option (enabled by default!), which will set missing/empty values that have `default` set in the schema.
   * 2. `coerceTypes` (false by default).
   *
   * Returned object is always the same object (`===`) that was passed, so it is returned just for convenience.
   */
  validate(obj: T, opt: AjvValidationOptions = {}): T {
    const err = this.getValidationError(obj, opt)
    if (err) throw err
    return obj
  }

  getValidationError(obj: T, opt: AjvValidationOptions = {}): AjvValidationError | undefined {
    if (this.isValid(obj)) return

    const errors = this.validateFunction.errors!

    const {
      objectId = _isObject(obj) ? (obj['id' as keyof T] as any) : undefined,
      objectName = this.cfg.objectName,
      logErrors = this.cfg.logErrors,
      separator = this.cfg.separator,
    } = opt
    const name = [objectName || 'Object', objectId].filter(Boolean).join('.')

    let message = this.cfg.ajv.errorsText(errors, {
      dataVar: name,
      separator,
    })

    const strValue = _inspect(obj, { maxLen: 1000 })
    message = [message, 'Input: ' + strValue].join(separator)

    if (logErrors) {
      this.cfg.logger.error(errors)
    }

    return new AjvValidationError(
      message,
      _filterNullishValues({
        errors,
        userFriendly: true,
        objectName,
        objectId,
      }),
    )
  }

  isValid(obj: T): boolean {
    return this.validateFunction(obj)
  }
}