import * as fs from 'fs' import { JsonSchema, JsonSchemaAnyBuilder, JsonSchemaBuilder, _filterNullishValues, _isObject, _substringBefore, CommonLogger, } from '@naturalcycles/js-lib' import Ajv, { ValidateFunction } from 'ajv' import { inspectAny, 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 { private constructor(public schema: JsonSchema, cfg: Partial = {}) { 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(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 type * correctly for some reason. */ static create( schema: JsonSchemaBuilder | JsonSchema | AjvSchema, cfg: Partial = {}, ): AjvSchema { if (schema instanceof AjvSchema) return schema if (schema instanceof JsonSchemaAnyBuilder) { return new AjvSchema(schema.build(), cfg) } return new AjvSchema(schema as JsonSchema, cfg) } /** * Create AjvSchema directly from a filePath of json schema. * Convenient method that just does fs.readFileSync for you. */ static readJsonSync( filePath: string, cfg: Partial = {}, ): AjvSchema { requireFileToExist(filePath) const schema = JSON.parse(fs.readFileSync(filePath, 'utf8')) return new AjvSchema(schema, cfg) } readonly cfg: AjvSchemaCfg private readonly validateFunction: ValidateFunction /** * 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 string) : 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 = inspectAny(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) } }