molijv
Version:
Mongoose-Like JSON Schema Validator
889 lines (829 loc) • 27.6 kB
JavaScript
;
// Helper to create mongoose-like validation error
function validationError({ kind, message, path, value }) {
let err = new Error(message);
err.errors = {
[path]: {
kind,
message,
name: 'ValidatorError',
path,
value
}
};
return err
}
/*
1. Try to coerce the value to the expected type or throw a validation error
2. Validate the value against the expected type or throw a validation error
3. Check for required fields and throw a validation error if missing
4. ETC...
*/
const { isArray: isArray$1 } = Array;
// Int32 type constructor for schema typing
function Int32() {}
Int32.prototype.toString = () => 'Int32';
// Decimal128 type constructor for schema typing
function Decimal128() {}
Decimal128.prototype.toString = () => 'Decimal128';
// Double type constructor for schema typing
function Double() {}
Double.prototype.toString = () => 'Double';
// Int32 range constants
const INT32_MIN = -2147483648;
const INT32_MAX = 2147483647;
// Built-in validators for each supported type
const validators = {
// String type validation and coercion
string: {
type: String,
alias: 'str',
validator: (def, val, path) => {
const type = typeof val;
if (def.coerce === false && type !== 'string')
throw validationError({
kind: 'string',
message: def.message || `Field "${path}" must be a string`,
path,
value: val
})
let str = val;
if (def.coerce !== false) {
if (type === 'number' || type === 'boolean' || type === 'bigint') {
str = String(str);
}
}
// Validate type after coercion
if (typeof str !== 'string')
throw validationError({
kind: 'string',
message: def.message || `Field "${path}" must be a string`,
path,
value: val
})
// Apply string transformations if enabled
if (def.coerce !== false) {
if (def.trim) str = str.trim();
if (def.lowercase) str = str.toLowerCase();
if (def.uppercase) str = str.toUpperCase();
}
// Length checks
if (
def.minLength !== undefined &&
str.length < (def.minLength?.flag !== undefined ? def.minLength.flag : def.minLength)
)
throw validationError({
kind: 'minlength',
message: def.minLength?.msg || def.message || `Field "${path}" length must be >= ${def.minLength?.flag ?? def.minLength}`,
path,
value: str
})
if (
def.maxLength !== undefined &&
str.length > (def.maxLength?.flag !== undefined ? def.maxLength.flag : def.maxLength)
)
throw validationError({
kind: 'maxlength',
message: def.maxLength?.msg || def.message || `Field "${path}" length must be <= ${def.maxLength?.flag ?? def.maxLength}`,
path,
value: str
})
return str
}
},
// Boolean type validation and coercion
boolean: {
type: Boolean,
alias: 'bool',
validator: (def, val, path) => {
if (val === null || val === '') return null
if (def.coerce) {
if (typeof val === 'number') {
if (val === 1) return true
if (val === 0) return false
}
if (typeof val === 'string') {
const v = val.toLowerCase();
if (v === '1' || v === 'true' || v === 'yes') return true
if (v === '0' || v === 'false' || v === 'no') return false
}
}
if (typeof val !== 'boolean')
throw validationError({
kind: 'boolean',
message: def.message || `Field "${path}" must be a boolean`,
path,
value: val
})
return val
}
},
// Number type validation and coercion
number: {
type: Number,
alias: 'num',
validator: (def, val, path) => {
const type = typeof val;
let num = val;
if (num === null || num === '') return null
if (def.coerce !== false) {
if (type === 'string' && num !== '' || type === 'boolean' || type === 'bigint') {
num = Number(num);
if (isNaN(num)) {
throw validationError({
kind: 'int32',
message: def.message || `Field "${path}" must be a number`,
path,
value: val
})
}
}
}
if (typeof num !== 'number' || Number.isNaN(num)) {
throw validationError({
kind: 'int32',
message: def.message || `Field "${path}" must be a number`,
path,
value: val
})
}
if (!Number.isFinite(num))
throw validationError({
kind: 'number',
message: def.message || `Field "${path}" must be a valid finite number`,
path,
value: val
})
// Range checks
if (def.min?.flag !== undefined && num < def.min.flag)
throw validationError({
kind: 'min',
message: def.min?.msg || def.message || `Field "${path}" must be >= ${def.min.flag}`,
path,
value: num
})
if (def.max?.flag !== undefined && num > def.max.flag)
throw validationError({
kind: 'max',
message: def.max?.msg || def.message || `Field "${path}" must be <= ${def.max.flag}`,
path,
value: num
})
return num
}
},
// Int32 type validation and coercion
int32: {
type: Int32,
alias: 'int',
validator: (def, val, path) => {
const type = typeof val;
let num = val;
if (num === null || num === '') return null
if (def.coerce !== false) {
if (type === 'string' && num !== '' || type === 'boolean' || type === 'bigint') {
num = Number(num);
if (isNaN(num)) {
throw validationError({
kind: 'int32',
message: def.message || `Field "${path}" must be an integer`,
path,
value: val
})
}
}
}
if (typeof num !== 'number' || !Number.isInteger(num)) {
throw validationError({
kind: 'int32',
message: def.message || `Field "${path}" must be an integer`,
path,
value: val
})
}
if (num < INT32_MIN || num > INT32_MAX)
throw validationError({
kind: 'int32',
message: def.message || `Field "${path}" must be an integer between ${INT32_MIN} and ${INT32_MAX}`,
path,
value: num
})
if (def.min?.flag !== undefined && num < def.min.flag)
throw validationError({
kind: 'min',
message: def.min?.msg || def.message || `Field "${path}" must be >= ${def.min.flag}`,
path,
value: num
})
if (def.max?.flag !== undefined && num > def.max.flag)
throw validationError({
kind: 'max',
message: def.max?.msg || def.message || `Field "${path}" must be <= ${def.max.flag}`,
path,
value: num
})
return num
}
},
// Decimal128 type validation and coercion
decimal128: {
type: Decimal128,
alias: 'decimal',
validator: (def, val, path) => {
const type = typeof val;
let num = val;
if (num === null || num === '') return null
if (def.coerce !== false) {
if (type === 'string' && num !== '' || type === 'boolean' || type === 'bigint') {
num = Number(num);
if (isNaN(num)) {
throw validationError({
kind: 'decimal128',
message: def.message || `Field "${path}" must be a decimal`,
path,
value: val
})
}
}
}
if (typeof num !== 'number' || Number.isNaN(num)) {
throw validationError({
kind: 'decimal128',
message: def.message || `Field "${path}" must be a decimal`,
path,
value: val
})
}
if (!Number.isFinite(num))
throw validationError({
kind: 'decimal128',
message: def.message || `Field "${path}" must be a valid finite decimal`,
path,
value: val
})
// Range checks
if (def.min?.flag !== undefined && num < def.min.flag)
throw validationError({
kind: 'min',
message: def.min?.msg || def.message || `Field "${path}" must be >= ${def.min.flag}`,
path,
value: num
})
if (def.max?.flag !== undefined && num > def.max.flag)
throw validationError({
kind: 'max',
message: def.max?.msg || def.message || `Field "${path}" must be <= ${def.max.flag}`,
path,
value: num
})
return num
}
},
// Double type validation and coercion
double: {
type: Double,
validator: (def, val, path) => {
const type = typeof val;
let num = val;
if (num === null || num === '') return null
if (def.coerce !== false) {
if (type === 'string' && num !== '' || type === 'boolean' || type === 'bigint') {
num = Number(num);
if (isNaN(num)) {
throw validationError({
kind: 'double',
message: def.message || `Field "${path}" must be a double`,
path,
value: val
})
}
}
}
if (typeof num !== 'number' || Number.isNaN(num)) {
throw validationError({
kind: 'double',
message: def.message || `Field "${path}" must be a double`,
path,
value: val
})
}
if (!Number.isFinite(num))
throw validationError({
kind: 'double',
message: def.message || `Field "${path}" must be a valid finite double`,
path,
value: val
})
// Range checks
if (def.min?.flag !== undefined && num < def.min.flag)
throw validationError({
kind: 'min',
message: def.min?.msg || def.message || `Field "${path}" must be >= ${def.min.flag}`,
path,
value: num
})
if (def.max?.flag !== undefined && num > def.max.flag)
throw validationError({
kind: 'max',
message: def.max?.msg || def.message || `Field "${path}" must be <= ${def.max.flag}`,
path,
value: num
})
return num
}
},
// Date type validation and coercion
date: {
type: Date,
validator: (def, val, path) => {
if (val === null || val === '') return null
if (def.coerce === false && !(val instanceof Date))
throw validationError({
kind: 'date',
message: def.message || `Field "${path}" must be a valid date`,
path,
value: val
})
let dateVal = val;
// Coerce to Date if allowed
if (!(val instanceof Date) && def.coerce !== false) {
dateVal = new Date(val);
}
if (!(dateVal instanceof Date) || isNaN(dateVal.getTime()))
throw validationError({
kind: 'date',
message: def.message || `Field "${path}" must be a valid date`,
path,
value: val
})
// Range checks
if (def.min?.flag !== undefined) {
const minDate = def.min.flag instanceof Date ? def.min.flag : new Date(def.min.flag);
if (dateVal < minDate)
throw validationError({
kind: 'min',
message: def.min?.msg || def.message || `Field "${path}" must be after ${def.min.flag}`,
path,
value: dateVal
})
}
if (def.max?.flag !== undefined) {
const maxDate = def.max.flag instanceof Date ? def.max.flag : new Date(def.max.flag);
if (dateVal > maxDate)
throw validationError({
kind: 'max',
message: def.max?.msg || def.message || `Field "${path}" must be before ${def.max.flag}`,
path,
value: dateVal
})
}
return dateVal
}
},
// Object type validation
object: {
type: Object,
validator: (def, val, path) => {
if (val === null || val === '') return null
if (def.coerce === false && (typeof val !== 'object' || val === null || isArray$1(val)))
throw validationError({
kind: 'object',
message: def.message || `Field "${path}" must be an object`,
path,
value: val
})
if (typeof val !== 'object' || val === null || isArray$1(val))
throw validationError({
kind: 'object',
message: def.message || `Field "${path}" must be an object`,
path,
value: val
})
return val
}
},
// Array type validation
array: {
type: Array,
validator: (def, val, path) => {
if (val === null || val === '') return null
if (def.coerce === false && (!isArray$1(val) || val === null))
throw validationError({
kind: 'array',
message: def.message || `Field "${path}" must be an array`,
path,
value: val
})
if (!isArray$1(val) || val === null)
throw validationError({
kind: 'array',
message: def.message || `Field "${path}" must be an array`,
path,
value: val
})
return val
}
}
};
const alias = {};
for (const v in validators) {
alias[v] = validators[v];
if (validators[v].alias) {
alias[validators[v].alias] = validators[v];
}
}
const types = {};
for (const v in validators) {
types[validators[v].type.name] = validators[v].type.name;
}
types.add = (name, options = {}) => {
if (typeof name !== 'string' || !name) {
throw new Error('Type name must be a non-empty string')
}
if (types[name]) {
throw new Error(`Type "${name}" already exists`)
}
if (!options.validator || typeof options.validator !== 'function') {
throw new Error(`Type "${name}" must have a validator function`)
}
types[name] = options.type || name;
validators[name] = {
type: types[name],
validator: options.validator,
coerce: options.coerce !== false,
message: options.message || `Field must be a valid ${name}`
};
if (options.alias) {
alias[options.alias] = validators[name];
}
};
function getType (typeName) {
let key;
if (typeof typeName === 'function') {
key = typeName.name.toLowerCase();
}
else if (typeof typeName === 'string') {
key = typeName.toLowerCase();
}
const type = alias[key];
if (!type) {
throw new Error(`Unknown type: ${key}`)
}
return type
}
// Normalize schema definition to internal format, applying options
function normalizeSchema(schemaDef, options) {
// Recursively normalize schema definitions
const _normalize = (def) => {
// Handle type as function (e.g., String, Number)
// or type as string (e.g., 'string', 'number')
if (typeof def === 'function' || typeof def === 'string') {
// Use function name lowercased as key for validator
let { type, validator } = getType (def.name);
return {
type,
typeValidator: validator,
...(options.coerce === false ? { coerce: false } : {})
}
}
// Handle array schema
if (Array.isArray(def)) {
return [{ ..._normalize(def[0]) }]
}
// Handle object schema
if (typeof def === 'object' && def !== null) {
let out = {};
if (def.type && (typeof def.type === 'function' || typeof def.type === 'string')) {
// Accept type as function or string
let { type, validator } = getType (def.type);
out = { ...def, type, typeValidator: validator };
// Propagate coerce: false from global if not set on field
out.coerce = !def.coerce && options.coerce === false ? false : true;
}
else {
// Recursively normalize each field
for (const k in def) {
if (k !== 'validate') {
out[k] = _normalize(def[k]);
}
}
}
// Normalize flags for required, min, max
for (const key of ['required', 'min', 'max', 'maxLength', 'minLength']) {
if (out[key] !== undefined) {
out[key] = Array.isArray(out[key])
? { flag: out[key][0], msg: out[key][1] }
: { flag: out[key], msg: undefined };
}
}
// Normalize match to always be { value, msg }
if (out.match !== undefined) {
out.match = Array.isArray(out.match)
? { value: out.match[0], msg: out.match[1] }
: { value: out.match, msg: undefined };
}
// Normalize validate to always be { validator, message }
if (out.validate !== undefined) {
if (Array.isArray(out.validate)) {
const type = typeof out.validate[0];
if (type === 'function' || (type === 'object' && type.validator === 'function')) {
if (type === 'function') {
out.validate = { validator: out.validate[0], message: out.validate[1] };
}
else {
out.validate = { validator: out.validate[0].validator, message: out.validate[0].message || out.validate[1] };
}
}
else {
out.validate = { validator: out.validate[0], message: out.validate[1] };
}
}
else if (typeof out.validate === 'function') {
out.validate = { validator: out.validate, message: undefined };
}
else if (typeof out.validate === 'object' && typeof out.validate.validator === 'function') {
out.validate = { validator: out.validate.validator, message: out.validate.message };
}
else {
out.validate = { validator: out.validate, message: undefined };
}
}
// Normalize enum to always be { values, msg }
if (out.enum !== undefined) {
if (Array.isArray(out.enum) && Array.isArray(out.enum[0])) {
out.enum = { values: out.enum[0], msg: out.enum[1] };
}
else {
out.enum = { values: out.enum, msg: undefined };
}
}
// Propagate coerce flag to type validators
if (def.coerce === false) {
out.coerce = false;
}
// Precompile regex for match if not already a RegExp
if (out.match && !(out.match.value instanceof RegExp)) {
out.match.value = new RegExp(out.match.value);
}
// Convert min/max date strings to Date objects if needed
if (out.min && out.type === Date && typeof out.min.flag === 'string') {
out.min.flag = new Date(out.min.flag);
}
if (out.max && out.type === Date && typeof out.max.flag === 'string') {
out.max.flag = new Date(out.max.flag);
}
return out
}
// Return as is for unknown types
return def
};
return _normalize(schemaDef)
}
// MoliJV - Mongoose Like JSON Validator
// import validationError from './src/validation-error.js'
const { isArray } = Array;
const isObject = (val) => typeof val === 'object' && val !== null;
// Schema class for validation and coercion
class Schema {
constructor(schemaDef, options = {}) {
this.schemaDef = schemaDef;
// Merge user options with default
this.options = { coerce: true, ...options };
// Normalize schema for internal use
this._normalizedSchema = normalizeSchema(schemaDef, this.options);
// Precompile validator for performance
this._validatorFn = this._compileValidator(this._normalizedSchema);
}
// Validate input data against schema
validate(data) {
return this._validatorFn(this._normalizedSchema, data)
}
_compileValidator(schema) {
const options = this.options;
const validators = [];
function build(schema, path = '', validators, isSchemaArray = false) {
const schemaPath = path ? `schema.${path}` : 'schema';
const dataPath = path ? `data.${path}` : 'data';
// Handle array schema
if (isArray(schema)) {
// Assume single schema for all items
const itemSchema = schema[0];
validators.push(`
// Array validation for path: ${path}
let arr = ${dataPath.replaceAll('.', '?.')}
if (arr !== undefined) {
if (!Array.isArray(arr)) {
// Use custom validation error
throw validationError({
kind: 'array',
message: 'Field "${path}" must be an array',
path: '${path}',
value: arr
})
}
for (let i = 0; i < arr.length; i++) {
let item = arr[i]
let itemOut = {}
${(() => {
const itemValidators = [];
build(itemSchema, path, itemValidators, true);
return '{\n' + itemValidators.join('\n}\n\n{') + '\n}'
})()}
}
}
`);
return
}
// Handle nested object schema
if (isObject(schema)) {
schema.any = true;
for (const key in schema) {
if (key === 'default') continue
const fieldSchema = schema[key];
if (isObject(fieldSchema)) {
build(fieldSchema, path ? `${path}.${key}` : key, validators);
schema.any = false;
}
}
if(!schema.type || isObject(schema.type))
return
}
// Primitive or object field validation
validators.push(`
// Handle primitive field schema
const _schema = ${schemaPath}${isSchemaArray ? '[0]' : ''}
const path = '${path}${isSchemaArray ? '[\' + i + \']' : '' }'
let val = ${isSchemaArray ? 'item' : dataPath.replaceAll('.', '?.') }
// Required validation
${
schema.required ? `
if ( ${ // If partial, only validate required if val is not undefined
options.partial
? 'val !== undefined && (val === null || (typeof val === \'string\' && val.trim() === \'\'))'
// Otherwise, validate required as usual
: 'val === undefined || val === null || (typeof val === \'string\' && val.trim() === \'\')'
} )
{
// Use custom validation error
throw validationError({
kind: 'required',
message: _schema.required.msg || _schema.message || \`Field "${path}" is required\`,
path,
value: val
})
}
`
: ''
}
${ // Apply default if value is undefined
schema.default !== undefined && schema.coerce ? `
const defaultVal = _schema.default
if (val === undefined) {
out['${path}${isSchemaArray ? '[\' + i + \']' : ''}'] = typeof defaultVal === 'function' ? defaultVal() : defaultVal
}
` : ''}
if (val !== undefined) {
${ // Type validation and coercion
schema.coerce !== false ? `
const newVal = _schema.typeValidator(_schema, val, path)
if (_schema.coerce !== false && newVal !== val) val = newVal
` : `
_schema.typeValidator(_schema, val, path)
`}
${ // Enum validation
schema.enum?.values ? `
const enumMsg = _schema.enum?.msg || _schema.message
const enumSet = new Set(_schema.enum.values)
if (enumSet && !enumSet.has(val)) {
// Use custom validation error
throw validationError({
kind: 'enum',
message: enumMsg || \`Field "${path}" must be one of: \${[...enumSet].join(', ')}\`,
path,
value: val
})
}
` : ''}
${ // Pattern match validation
schema.match?.value ? `
const matchMsg = _schema.match?.msg || _schema.message
const matchVal = _schema.match?.value instanceof RegExp ? _schema.match.value : (_schema.match?.value ? new RegExp(_schema.match.value) : undefined)
if (matchVal && !matchVal.test(val)) {
// Use custom validation error
throw validationError({
kind: 'match',
message: matchMsg || \`Field "${path}" does not match required pattern\`,
path,
value: val
})
}
` : ''}
${ // Custom validator function
schema.validate?.validator ? `
const validateMsg = _schema.validate?.message || _schema.message
const customValidator = _schema.validate?.validator
if (customValidator && !customValidator(val)) {
// Use custom validation error
throw validationError({
kind: 'user',
message: validateMsg || \`Field "${path}" failed custom validation\`,
path,
value: val
})
}
` : ''}
// If val is an object, filter only fields defined in the schema
${ schema.type?.name === 'Object' || schema.type === undefined ? `
for (const k in _schema) {
if (val !== undefined && val !== null && val[k] === undefined) continue
if (val !== undefined && val !== null && _schema.any) {
out['${path}.'+k] = val[k]
delete val[k]
}
}
if (_schema.any) {
for (const k in val) {
out['${path}.'+k] = val[k]
}
}
` : `
${ schema.coerce !== false ? `
out['${path}${isSchemaArray ? '[\' + i + \']' : ''}'] = val
` : ''}
`}
}
`);
}
build(schema, '', validators);
this.stringFn = `
function validationError({ kind, message, path, value }) {
let err = new Error(message)
err.errors = {
[path]: {
kind,
message,
name: 'ValidatorError',
path,
value
}
}
return err
}
let out = {}
{
${validators.join('\n}\n\n{')}
}
return out
`;
const validator = new Function('schema', 'data', this.stringFn);
return (schema, data) => {
const out = validator(schema, data);
return options.coerce === false ? out : expandPathsObject(out)
}
}
}
// Utilitário para transformar objeto de paths em objeto real aninhado
function expandPathsObject(obj) {
let result = {};
for (let key in obj)
{
let value = obj[key];
let parts = [];
let regex = /([^[.\]]+)|\[(\d+)\]/g;
let match;
while ((match = regex.exec(key)))
{
if (match[1] !== undefined)
parts.push(match[1]);
else if (match[2] !== undefined)
parts.push(Number(match[2]));
}
let curr = result;
for (let i = 0; i < parts.length; i++)
{
let part = parts[i];
let nextPart = parts[i + 1];
if (i === parts.length - 1)
{
curr[part] = value;
}
else {
if (typeof nextPart === 'number')
{
if (!Array.isArray(curr[part]))
curr[part] = [];
}
else {
if (typeof curr[part] !== 'object' || curr[part] === null)
curr[part] = {};
}
curr = curr[part];
}
}
}
return result
}
const Types = types;
exports.Schema = Schema;
exports.Types = Types;
exports.types = types;