import { _isBetween, _lazyValue, _round } from '@naturalcycles/js-lib' import { _assert } from '@naturalcycles/js-lib/error' import { _deepCopy, _mapObject, Set2 } from '@naturalcycles/js-lib/object' import { _substringAfterLast } from '@naturalcycles/js-lib/string' import type { AnyObject } from '@naturalcycles/js-lib/types' import type { Options, ValidateFunction } from 'ajv/dist/2020.js' import { Ajv2020 } from 'ajv/dist/2020.js' import { validTLDs } from '../tlds.js' import type { CustomConverterFn, CustomValidatorFn, JSchema, JsonSchemaIsoDateOptions, JsonSchemaIsoMonthOptions, JsonSchemaStringEmailOptions, } from './jSchema.js' // oxlint-disable unicorn/prefer-code-point const AJV_OPTIONS: Options = { removeAdditional: true, allErrors: true, // https://ajv.js.org/options.html#usedefaults useDefaults: 'empty', // this will mutate your input! // these are important and kept same as default: // https://ajv.js.org/options.html#coercetypes coerceTypes: false, // while `false` - it won't mutate your input strictTypes: true, strictTuples: true, allowUnionTypes: true, // supports oneOf/anyOf schemas } const AJV_NON_MUTATING_OPTIONS: Options = { ...AJV_OPTIONS, removeAdditional: false, useDefaults: false, } const AJV_MUTATING_COERCING_OPTIONS: Options = { ...AJV_OPTIONS, coerceTypes: true, } /** * Return cached instance of Ajv with default (recommended) options. * * This function should be used as much as possible, * to benefit from cached Ajv instance. */ export const getAjv = _lazyValue(createAjv) /** * Returns cached instance of Ajv, which is non-mutating. * * To be used in places where we only need to know if an item is valid or not, * and are not interested in transforming the data. */ export const getNonMutatingAjv = _lazyValue(() => createAjv(AJV_NON_MUTATING_OPTIONS)) /** * Returns cached instance of Ajv, which is coercing data. * * To be used in places where we know that we are going to receive data with the wrong type, * typically: request path params and request query params. */ export const getCoercingAjv = _lazyValue(() => createAjv(AJV_MUTATING_COERCING_OPTIONS)) /** * Create Ajv with modified defaults. * * !!! Please note that this function is EXPENSIVE computationally !!! * * https://ajv.js.org/options.html */ export function createAjv(opt?: Options): Ajv2020 { const ajv = new Ajv2020({ ...AJV_OPTIONS, ...opt, }) // Adds $merge, $patch keywords // https://github.com/ajv-validator/ajv-merge-patch // Kirill: temporarily disabled, as it creates a noise of CVE warnings // require('ajv-merge-patch')(ajv) ajv.addKeyword({ keyword: 'transform', type: 'string', modifying: true, schemaType: 'object', errors: true, validate: function validate( transform: { trim?: true; toLowerCase?: true; toUpperCase?: true; truncate?: number }, data: string, _schema, ctx, ) { if (!data) return true let transformedData = data if (transform.trim) { transformedData = transformedData.trim() } if (transform.toLowerCase) { transformedData = transformedData.toLocaleLowerCase() } if (transform.toUpperCase) { transformedData = transformedData.toLocaleUpperCase() } if (typeof transform.truncate === 'number' && transform.truncate >= 0) { transformedData = transformedData.slice(0, transform.truncate) if (transform.trim) { transformedData = transformedData.trim() } } // Explicit check for `undefined` because parentDataProperty can be `0` when it comes to arrays. if (ctx?.parentData && typeof ctx.parentDataProperty !== 'undefined') { ctx.parentData[ctx.parentDataProperty] = transformedData } return true }, }) ajv.addKeyword({ keyword: 'instanceof', modifying: false, schemaType: 'string', validate(instanceOf: string, data: unknown, _schema, _ctx) { if (typeof data !== 'object') return false if (data === null) return false let proto = Object.getPrototypeOf(data) while (proto) { if (proto.constructor?.name === instanceOf) return true proto = Object.getPrototypeOf(proto) } return false }, }) ajv.addKeyword({ keyword: 'Set2', type: ['array', 'object'], modifying: true, errors: true, schemaType: 'object', compile(innerSchema, _parentSchema, _it) { const validateItem: ValidateFunction = ajv.compile(innerSchema) function validateSet(data: any, ctx: any): boolean { let set: Set2 const isIterable = data === null || typeof data[Symbol.iterator] === 'function' if (data instanceof Set2) { set = data } else if (isIterable && ctx?.parentData) { set = new Set2(data) } else if (isIterable && !ctx?.parentData) { ;(validateSet as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: 'can only transform an Iterable into a Set2 when the schema is in an object or an array schema. This is an Ajv limitation.', }, ] return false } else { ;(validateSet as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: 'must be a Set2 object (or optionally an Iterable in some cases)', }, ] return false } let idx = 0 for (const value of set.values()) { if (!validateItem(value)) { ;(validateSet as any).errors = [ { instancePath: (ctx?.instancePath ?? '') + '/' + idx, message: `invalid set item at index ${idx}`, params: { errors: validateItem.errors }, }, ] return false } idx++ } if (ctx?.parentData && ctx.parentDataProperty !== undefined) { ctx.parentData[ctx.parentDataProperty] = set } return true } return validateSet }, }) ajv.addKeyword({ keyword: 'Buffer', modifying: true, errors: true, schemaType: 'boolean', compile(_innerSchema, _parentSchema, _it) { function validateBuffer(data: any, ctx: any): boolean { let buffer: Buffer if (data === null) return false const isValid = data instanceof Buffer || data instanceof ArrayBuffer || Array.isArray(data) || typeof data === 'string' if (!isValid) return false if (data instanceof Buffer) { buffer = data } else if (isValid && ctx?.parentData) { buffer = Buffer.from(data as any) } else if (isValid && !ctx?.parentData) { ;(validateBuffer as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: 'can only transform data into a Buffer when the schema is in an object or an array schema. This is an Ajv limitation.', }, ] return false } else { ;(validateBuffer as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: 'must be a Buffer object (or optionally an Array-like object or ArrayBuffer in some cases)', }, ] return false } if (ctx?.parentData && ctx.parentDataProperty !== undefined) { ctx.parentData[ctx.parentDataProperty] = buffer } return true } return validateBuffer }, }) ajv.addKeyword({ keyword: 'email', type: 'string', modifying: true, errors: true, schemaType: 'object', validate: function validate(opt: JsonSchemaStringEmailOptions, data: string, _schema, ctx) { const { checkTLD } = opt const cleanData = data.trim() // from `ajv-formats` const EMAIL_REGEX = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i const result = cleanData.match(EMAIL_REGEX) if (!result) { ;(validate as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: `is not a valid email address`, }, ] return false } if (checkTLD) { const tld = _substringAfterLast(cleanData, '.') if (!validTLDs.has(tld)) { ;(validate as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: `has an invalid TLD`, }, ] return false } } if (ctx?.parentData && ctx.parentDataProperty !== undefined) { ctx.parentData[ctx.parentDataProperty] = cleanData } return true }, }) ajv.addKeyword({ keyword: 'IsoDate', type: 'string', modifying: false, errors: true, schemaType: 'object', validate: function validate(opt: JsonSchemaIsoDateOptions, data: string, _schema, ctx) { const hasOptions = Object.keys(opt).length > 0 const isValid = isIsoDateValid(data) if (!isValid) { ;(validate as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: `is an invalid IsoDate`, }, ] return false } if (!hasOptions) return true const { before, sameOrBefore, after, sameOrAfter } = opt const errors: any[] = [] if (before) { const isParamValid = isIsoDateValid(before) const isRuleValid = isParamValid && before > data if (!isRuleValid) { errors.push({ instancePath: ctx?.instancePath ?? '', message: `is not before ${before}`, }) } } if (sameOrBefore) { const isParamValid = isIsoDateValid(sameOrBefore) const isRuleValid = isParamValid && sameOrBefore >= data if (!isRuleValid) { errors.push({ instancePath: ctx?.instancePath ?? '', message: `is not the same or before ${sameOrBefore}`, }) } } if (after) { const isParamValid = isIsoDateValid(after) const isRuleValid = isParamValid && after < data if (!isRuleValid) { errors.push({ instancePath: ctx?.instancePath ?? '', message: `is not after ${after}`, }) } } if (sameOrAfter) { const isParamValid = isIsoDateValid(sameOrAfter) const isRuleValid = isParamValid && sameOrAfter <= data if (!isRuleValid) { errors.push({ instancePath: ctx?.instancePath ?? '', message: `is not the same or after ${sameOrAfter}`, }) } } if (errors.length === 0) return true ;(validate as any).errors = errors return false }, }) ajv.addKeyword({ keyword: 'IsoDateTime', type: 'string', modifying: false, errors: true, schemaType: 'boolean', validate: function validate(_opt: JsonSchemaIsoMonthOptions, data: string, _schema, ctx) { const isValid = isIsoDateTimeValid(data) if (isValid) return true ;(validate as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: `is an invalid IsoDateTime`, }, ] return false }, }) ajv.addKeyword({ keyword: 'IsoMonth', type: 'string', modifying: false, errors: true, schemaType: 'object', validate: function validate(_opt: true, data: string, _schema, ctx) { const isValid = isIsoMonthValid(data) if (isValid) return true ;(validate as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: `is an invalid IsoMonth`, }, ] return false }, }) ajv.addKeyword({ keyword: 'errorMessages', schemaType: 'object', }) ajv.addKeyword({ keyword: 'hasIsOfTypeCheck', schemaType: 'boolean', }) ajv.addKeyword({ keyword: 'optionalValues', type: ['string', 'number', 'boolean', 'null'], modifying: true, errors: false, schemaType: 'array', validate: function validate( optionalValues: (string | number | boolean)[], data: string | number | boolean, _schema, ctx, ) { if (!optionalValues) return true _assert( ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `optional([x, y, z])` on a property of an object, or on an element of an array due to Ajv mutation issues.', ) if (!optionalValues.includes(data)) return true ctx.parentData[ctx.parentDataProperty] = undefined return true }, }) ajv.addKeyword({ keyword: 'keySchema', type: 'object', modifying: true, errors: false, schemaType: 'object', compile(innerSchema, _parentSchema, _it) { const isValidKeyFn: ValidateFunction = ajv.compile(innerSchema) function validate(data: AnyObject, _ctx: any): boolean { if (typeof data !== 'object' || data === null) return true for (const key of Object.keys(data)) { if (!isValidKeyFn(key)) { delete data[key] } } return true } return validate }, }) // Validates that the value is undefined. Used in record/stringMap with optional value schemas // to allow undefined values in patternProperties via anyOf. ajv.addKeyword({ keyword: 'isUndefined', modifying: false, errors: false, schemaType: 'boolean', validate: (_schema: boolean, data: unknown) => data === undefined, }) // This is added because Ajv validates the `min/maxProperties` before validating the properties. // So, in case of `minProperties(1)` and `{ foo: 'bar' }` Ajv will let it pass, even // if the property validation would strip `foo` from the data. // And Ajv would return `{}` as a successful validation. // Since the keyword validation runs after JSON Schema validation, // here we can make sure the number of properties are right ex-post property validation. // It's named with the `2` suffix, because `minProperties` is reserved. // And `maxProperties` does not suffer from this error due to the nature of how maximum works. ajv.addKeyword({ keyword: 'minProperties2', type: 'object', modifying: false, errors: true, schemaType: 'number', validate: function validate(minProperties: number, data: AnyObject, _schema, ctx) { if (typeof data !== 'object') return true const numberOfProperties = Object.entries(data).filter(([, v]) => v !== undefined).length const isValid = numberOfProperties >= minProperties if (!isValid) { ;(validate as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: `must NOT have fewer than ${minProperties} properties`, }, ] } return isValid }, }) ajv.addKeyword({ keyword: 'exclusiveProperties', type: 'object', modifying: false, errors: true, schemaType: 'array', validate: function validate(exclusiveProperties: string[][], data: AnyObject, _schema, ctx) { if (typeof data !== 'object') return true for (const props of exclusiveProperties) { let numberOfDefinedProperties = 0 for (const prop of props) { if (data[prop] !== undefined) numberOfDefinedProperties++ if (numberOfDefinedProperties > 1) { ;(validate as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: `must have only one of the "${props.join(', ')}" properties`, }, ] return false } } } return true }, }) ajv.addKeyword({ keyword: 'anyOfBy', type: 'object', modifying: true, errors: true, schemaType: 'object', compile(config, _parentSchema, _it) { const { propertyName, schemaDictionary } = config const isValidFnByKey: Record = _mapObject( schemaDictionary as Record>, (key, value) => { return [key, ajv.compile(value)] }, ) function validate(data: AnyObject, ctx: any): boolean { if (typeof data !== 'object' || data === null) return true const determinant = data[propertyName] const isValidFn = isValidFnByKey[determinant] if (!isValidFn) { ;(validate as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: `could not find a suitable schema to validate against based on "${propertyName}"`, }, ] return false } const result = isValidFn(data) if (!result) { ;(validate as any).errors = isValidFn.errors } return result } return validate }, }) ajv.addKeyword({ keyword: 'anyOfThese', modifying: true, errors: true, schemaType: 'array', compile(schemas: JSchema[], _parentSchema, _it) { const validators = schemas.map(schema => ajv.compile(schema)) function validate(data: AnyObject, ctx: any): boolean { let correctValidator: ValidateFunction | undefined let result = false let clonedData: any // Try each validator until we find one that works! for (const validator of validators) { clonedData = isPrimitive(data) ? _deepCopy(data) : data result = validator(clonedData) if (result) { correctValidator = validator break } } if (result && ctx?.parentData && ctx.parentDataProperty !== undefined) { // If we found a validator and the data is valid and we are validating a property inside an object, // then we can inject our result and be done with it. ctx.parentData[ctx.parentDataProperty] = clonedData } else if (result) { // If we found a validator but we are not validating a property inside an object, // then we must re-run the validation so that the mutations caused by Ajv // will be done on the input data, not only on the clone. result = correctValidator!(data) } else { // If we didn't find a fitting schema, // we add our own error. ;(validate as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: `could not find a suitable schema to validate against`, }, ] } return result } return validate }, }) ajv.addKeyword({ keyword: 'precision', type: ['number'], modifying: true, errors: false, schemaType: 'number', validate: function validate(numberOfDigits: number, data: number, _schema, ctx) { if (!numberOfDigits) return true _assert( ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `precision(n)` on a property of an object, or on an element of an array due to Ajv mutation issues.', ) ctx.parentData[ctx.parentDataProperty] = _round(data, 10 ** (-1 * numberOfDigits)) return true }, }) ajv.addKeyword({ keyword: 'customValidations', modifying: false, errors: true, schemaType: 'array', validate: function validate(customValidations: CustomValidatorFn[], data: any, _schema, ctx) { if (!customValidations?.length) return true for (const validator of customValidations) { const error = validator(data) if (error) { ;(validate as any).errors = [ { instancePath: ctx?.instancePath ?? '', message: error, }, ] return false } } return true }, }) ajv.addKeyword({ keyword: 'customConversions', modifying: true, errors: false, schemaType: 'array', validate: function validate( customConversions: CustomConverterFn[], data: any, _schema, ctx, ) { if (!customConversions?.length) return true _assert( ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `convert()` on a property of an object, or on an element of an array due to Ajv mutation issues.', ) for (const converter of customConversions) { data = converter(data) } ctx.parentData[ctx.parentDataProperty] = data return true }, }) // postValidation is handled in AjvSchema.getValidationResult, not by Ajv itself. // We register it here so Ajv's strict mode doesn't reject the keyword. ajv.addKeyword({ keyword: 'postValidation', valid: true, }) return ajv } const monthLengths = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] const DASH_CODE = '-'.charCodeAt(0) const ZERO_CODE = '0'.charCodeAt(0) const PLUS_CODE = '+'.charCodeAt(0) const COLON_CODE = ':'.charCodeAt(0) /** * This is a performance optimized correct validation * for ISO dates formatted as YYYY-MM-DD. * * - Slightly more performant than using `localDate`. * - More performant than string splitting and `Number()` conversions * - Less performant than regex, but it does not allow invalid dates. */ function isIsoDateValid(s: string): boolean { // must be exactly "YYYY-MM-DD" if (s.length !== 10) return false if (s.charCodeAt(4) !== DASH_CODE || s.charCodeAt(7) !== DASH_CODE) return false // fast parse numbers without substrings/Number() const year = (s.charCodeAt(0) - ZERO_CODE) * 1000 + (s.charCodeAt(1) - ZERO_CODE) * 100 + (s.charCodeAt(2) - ZERO_CODE) * 10 + (s.charCodeAt(3) - ZERO_CODE) const month = (s.charCodeAt(5) - ZERO_CODE) * 10 + (s.charCodeAt(6) - ZERO_CODE) const day = (s.charCodeAt(8) - ZERO_CODE) * 10 + (s.charCodeAt(9) - ZERO_CODE) if (month < 1 || month > 12 || day < 1) return false if (month !== 2) { return day <= monthLengths[month]! } const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 return day <= (isLeap ? 29 : 28) } /** * This is a performance optimized correct validation * for ISO datetimes formatted as "YYYY-MM-DDTHH:MM:SS" followed by * nothing, "Z" or "+hh:mm" or "-hh:mm". * * - Slightly more performant than using `localTime`. * - More performant than string splitting and `Number()` conversions * - Less performant than regex, but it does not allow invalid dates. */ function isIsoDateTimeValid(s: string): boolean { if (s.length < 19 || s.length > 25) return false if (s.charCodeAt(10) !== 84) return false // 'T' const datePart = s.slice(0, 10) // YYYY-MM-DD if (!isIsoDateValid(datePart)) return false const timePart = s.slice(11, 19) // HH:MM:SS if (!isIsoTimeValid(timePart)) return false const zonePart = s.slice(19) // nothing or Z or +/-hh:mm if (!isIsoTimezoneValid(zonePart)) return false return true } /** * This is a performance optimized correct validation * for ISO times formatted as "HH:MM:SS". * * - Slightly more performant than using `localTime`. * - More performant than string splitting and `Number()` conversions * - Less performant than regex, but it does not allow invalid dates. */ function isIsoTimeValid(s: string): boolean { if (s.length !== 8) return false if (s.charCodeAt(2) !== COLON_CODE || s.charCodeAt(5) !== COLON_CODE) return false const hour = (s.charCodeAt(0) - ZERO_CODE) * 10 + (s.charCodeAt(1) - ZERO_CODE) if (hour < 0 || hour > 23) return false const minute = (s.charCodeAt(3) - ZERO_CODE) * 10 + (s.charCodeAt(4) - ZERO_CODE) if (minute < 0 || minute > 59) return false const second = (s.charCodeAt(6) - ZERO_CODE) * 10 + (s.charCodeAt(7) - ZERO_CODE) if (second < 0 || second > 59) return false return true } /** * This is a performance optimized correct validation * for the timezone suffix of ISO times * formatted as "Z" or "+HH:MM" or "-HH:MM". * * It also accepts an empty string. */ function isIsoTimezoneValid(s: string): boolean { if (s === '') return true if (s === 'Z') return true if (s.length !== 6) return false if (s.charCodeAt(0) !== PLUS_CODE && s.charCodeAt(0) !== DASH_CODE) return false if (s.charCodeAt(3) !== COLON_CODE) return false const isWestern = s[0] === '-' const isEastern = s[0] === '+' const hour = (s.charCodeAt(1) - ZERO_CODE) * 10 + (s.charCodeAt(2) - ZERO_CODE) if (hour < 0) return false if (isWestern && hour > 12) return false if (isEastern && hour > 14) return false const minute = (s.charCodeAt(4) - ZERO_CODE) * 10 + (s.charCodeAt(5) - ZERO_CODE) if (minute < 0 || minute > 59) return false if (isEastern && hour === 14 && minute > 0) return false // max is +14:00 if (isWestern && hour === 12 && minute > 0) return false // min is -12:00 return true } /** * This is a performance optimized correct validation * for ISO month formatted as "YYYY-MM". */ function isIsoMonthValid(s: string): boolean { // must be exactly "YYYY-MM" if (s.length !== 7) return false if (s.charCodeAt(4) !== DASH_CODE) return false // fast parse numbers without substrings/Number() const year = (s.charCodeAt(0) - ZERO_CODE) * 1000 + (s.charCodeAt(1) - ZERO_CODE) * 100 + (s.charCodeAt(2) - ZERO_CODE) * 10 + (s.charCodeAt(3) - ZERO_CODE) const month = (s.charCodeAt(5) - ZERO_CODE) * 10 + (s.charCodeAt(6) - ZERO_CODE) return _isBetween(year, 1900, 2500, '[]') && _isBetween(month, 1, 12, '[]') } function isPrimitive(data: any): boolean { return data !== null && typeof data === 'object' }