Skip to content

Instantly share code, notes, and snippets.

@fabianeichinger
Last active November 18, 2022 20:02
Show Gist options
  • Save fabianeichinger/f9bad3ee17d637ac3778448d0fb0f528 to your computer and use it in GitHub Desktop.
Save fabianeichinger/f9bad3ee17d637ac3778448d0fb0f528 to your computer and use it in GitHub Desktop.

Basic type coersion support for JSON Type Definition schemas in Ajv. This is currently an unimplemented feature in Ajv. Works by wrapping compiled ValidatorFunctions and handling resulting type errors. Uses lodash for JS type checking and deep get and sets on the data object.

Supports coercing from JSON types string, number, boolean, and null to JTD types string, uint8, uint16, uint32, int8, int16, int32, float32, float64, boolean, and null. The coercion rules should mirror that of Ajv. Coercing to arrays is currently not supported.

The implementation is in Typescript and should work fine with JTDDataType etc. You might get compile errors with strict typing settings though.

Usage

// import Ajv with JTD support and this helper, define your Schema

const ajv = new Ajv({ allErrors: true }) // IMPORTANT: Configure allErrors or you'll get at most one coersion.
type DataType = JTDDataType<typeof Schema>
const validate = ajv.compile<DataType>(schema)
const coerceAndValidate = makeCoercingValidator(validate, /* optional */ ['boolean', 'float64'])

License

CC0

/* 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)
}
}
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment