|
/* eslint-disable no-fallthrough */ |
|
|
|
/** |
|
* Ajv does not support type coercion (implicitly converting types, e.g. integers to booleans) for JTD schemas. |
|
* This helper wraps a given Ajv validator with a coercer. |
|
* It does a first validation run, checks for type errors, fixes them if possible, then do a second validator run. |
|
*/ |
|
|
|
import { ValidateFunction } from "ajv" |
|
import get from 'lodash/get' |
|
import set from 'lodash/set' |
|
import isString from 'lodash/isString' |
|
import isNumber from 'lodash/isNumber' |
|
import isBoolean from 'lodash/isBoolean' |
|
import isNull from 'lodash/isNull' |
|
|
|
const COERCE_TYPES = ['string', 'uint8', 'uint16', 'uint32', 'int8', 'int16', 'int32', 'float32', 'float64', 'boolean', 'null'] as const |
|
export type CoerceType = typeof COERCE_TYPES[number] |
|
|
|
function attemptFixByCoercion (data, error, coerceTypes: readonly CoerceType[]) { |
|
if (error.keyword !== 'type') return // can only fix type errors |
|
|
|
const toType = error.params?.type // the target type to which we want to coerce the value |
|
if (!coerceTypes.includes(toType)) return // filter for supported and listed types |
|
|
|
// the path in the error uses / as the serparator |
|
const [/* ignore head */, ...path] = error.instancePath.split('/') // split the path on the selector, the head is an empty string so drop that |
|
let value = get(data, path) // we can pass the path array straight to lodash for deep getting/setting |
|
|
|
// determine the origin type of the data |
|
let fromType |
|
if (isString(value)) fromType = 'string' |
|
else if (isNumber(value)) fromType = 'number' |
|
else if (isBoolean(value)) fromType = 'boolean' |
|
else if (isNull(value)) fromType = 'null' |
|
else return // unknown JSON type |
|
|
|
let coercedValue // the value coerced to the target type |
|
let bits = 8 // the base number of bits for int/uint types, multiplied by the different sizes |
|
|
|
// if any coersion fails, we return immediately, keeping the data unchanged |
|
switch (toType) { |
|
// coerce to string |
|
case 'string': |
|
if (fromType === 'number') coercedValue = "" + value |
|
else if (fromType === 'boolean') coercedValue = String(value) |
|
else if (fromType === 'null') coercedValue = "" |
|
else return |
|
break |
|
|
|
// coerce to any of the uint types |
|
case 'uint32': |
|
bits *= 2 // 8 * 2 * 2 = 32 |
|
case 'uint16': |
|
bits *= 2 // 8 * 2 = 16 |
|
case 'uint8': |
|
if (fromType === 'string') { // convert from string |
|
coercedValue = Number.parseInt(value) // parse the number (NaNs are catched in the next line, as !(NaN < anything) is true) |
|
if (!(coercedValue < (2 << bits) && coercedValue >= 0)) return // check if the number is in [0, MAX_SIZE] for the type |
|
} else if (fromType === 'boolean') coercedValue = value ? 1 : 0 |
|
else if (fromType === 'null') coercedValue = 0 |
|
else return |
|
break |
|
|
|
// coerce to any of the int types |
|
case 'int32': |
|
bits *= 2 // 8 * 2 * 2 = 32 |
|
case 'int16': |
|
bits *= 2 // 8 * 2 = 16 |
|
case 'int8': |
|
if (fromType === 'string') { // convert from string |
|
coercedValue = Number.parseInt(value) // parse the number (NaNs are catched in the next line, as !(NaN < anything) is true) |
|
if (!(coercedValue < (2 << (bits - 1)) && coercedValue >= (2 << (bits - 1)))) return // check if the number is in [MIN_SIZE, MAX_SIZE] of the type |
|
} else if (fromType === 'boolean') coercedValue = value ? 1 : 0 |
|
else if (fromType === 'null') coercedValue = 0 |
|
else return |
|
break |
|
|
|
// coerce to any of the float types |
|
case 'float32': // JS only has 64 bit floats, it practically makes no difference |
|
case 'float64': |
|
if (fromType === 'string') { |
|
coercedValue = Number(value) |
|
if (isNaN(coercedValue)) return |
|
} else if (fromType === 'boolean') coercedValue = value ? 1 : 0 |
|
else if (fromType === 'null') coercedValue = 0 |
|
else return |
|
break |
|
|
|
// coerce to a boolean |
|
case 'boolean': |
|
if (fromType === 'string') { |
|
if (value === 'true') coercedValue = true |
|
else if (value === 'false') coercedValue = false |
|
else return |
|
} else if (fromType === 'number') { |
|
if (value === 1) coercedValue = true |
|
else if (value === 0) coercedValue = false |
|
else return |
|
} else if (fromType === 'null') coercedValue = false |
|
else return |
|
break |
|
|
|
// coerce to null |
|
case 'null': |
|
if (fromType === 'string' && value === '') coercedValue = null |
|
else if (fromType === 'number' && value === 0) coercedValue = null |
|
else if (fromType === 'boolean' && !value) coercedValue = null |
|
else return |
|
} |
|
|
|
set(data, path, coercedValue) // save the coerced value to the data |
|
} |
|
|
|
/** |
|
* Wrap an Ajv validate function to do type coercing. Make sure the Ajv instance is configured with `allErrors: true`. |
|
* |
|
* @param compiledValidator A validator compiled by a JTD Ajv instance. The Ajv instance must be configured with `allErrors: true`. |
|
* @param coerceTypes Types to which may be coerced, i.e. if a boolean would be required by the schema and is in this list, a coercion is attempted. |
|
* @returns A proxy of the underlying compiled validator(data) function that will both validate and type coerce (maybe modifying the data). |
|
*/ |
|
export default function makeCoercingValidator<T> (compiledValidator: ValidateFunction<T>, coerceTypes: readonly CoerceType[] = COERCE_TYPES): ValidateFunction<T> { |
|
return new Proxy(compiledValidator, { |
|
apply (target, thisArg, argumentsList) { |
|
if (target === compiledValidator) { // this proxy only acts on calls of the validate function |
|
const data = argumentsList[0] |
|
if (Reflect.apply(target, thisArg, argumentsList)) { // first validator run |
|
return true // if it's immediately successful, we the coercion and the second run |
|
} |
|
|
|
for (const error of compiledValidator.errors) { |
|
attemptFixByCoercion(data, error, coerceTypes) // try to fix errors by coersion |
|
} |
|
|
|
return Reflect.apply(target, thisArg, argumentsList) // second and final validator run |
|
} else { // if a function in the validate function is called, just pass that through |
|
return Reflect.apply(target, thisArg, argumentsList) |
|
} |
|
} |
|
}) |
|
} |