qantra-pineapple
Version:
advanced json object validator specially designed to validate user json input and return readable error messages
399 lines (329 loc) • 11.1 kB
JavaScript
const { get, set, merge, isPlainObject } = require('lodash');
const debug = require('debug')('qantra:pineapple');
const defaultErrorSchema = {
marker: '$label',
value: '$value',
customError: '$customError',
onError: {
required: '$label is required',
length: '$label has invalid length',
regex: '$label has invalid format',
type: '$label invalid type',
oneOf: '$label invalid option',
inclusive: '$label dependent on ',
exclusive: '$label conflict with other',
canParse: '$label invalid parsing',
items: 'one of the $label items is invalid',
custom: '$customError',
gt: '$label must be greater than $value',
lt: '$label must be less than $value',
gte: '$label must be greater than or equal $value',
lte: '$label must be less than or equal $value',
}
};
class ValidationError extends Error {
constructor(message, path, code) {
super(message);
this.name = 'ValidationError';
this.path = path;
this.code = code;
}
}
module.exports = class Pineapple {
constructor({
models = {},
errorSchema = defaultErrorSchema,
customValidators = {},
} = {}) {
this.models = models;
this.errorSchema = errorSchema;
this.nonMethodVectors = ['label', 'path', 'onError', 'propValue', 'model', 'required'];
this.customValidators = customValidators;
this.formatted = {};
this.validationModels = {};
// Bind methods
Object.getOwnPropertyNames(Object.getPrototypeOf(this))
.filter(prop => typeof this[prop] === 'function')
.forEach(method => {
this[method] = this[method].bind(this);
});
}
// Type checking methods using more precise checks
isString(v) { return typeof v === 'string'; }
isNumber(v) { return typeof v === 'number' && !isNaN(v); }
isArray(v) { return Array.isArray(v); }
isObject(v) { return isPlainObject(v); }
isBoolean(v) { return typeof v === 'boolean'; }
isNull(v) { return v === null; }
isUndefined(v) { return v === undefined; }
isRegExp(v) { return v instanceof RegExp; }
isDate(v) { return v instanceof Date && !isNaN(v); }
isNaN(v) { return Number.isNaN(v); }
async _type(vo) {
const typeName = vo.type.toLowerCase();
const methodName = `is${typeName.charAt(0).toUpperCase()}${typeName.slice(1)}`;
if (!this[methodName]) {
throw new ValidationError(`Unknown type: ${typeName}`, vo.path, 'INVALID_TYPE');
}
return this[methodName](vo.propValue);
}
_oneOf(vo) {
if (!Array.isArray(vo.oneOf)) {
throw new ValidationError('oneOf must be an array', vo.path, 'INVALID_ONEOF');
}
return vo.oneOf.includes(vo.propValue);
}
_length(vo) {
const { length: vectorValue, propValue } = vo;
const valueLength = this.isArray(propValue) ? propValue.length : String(propValue).length;
if (this.isNumber(vectorValue)) {
return valueLength === vectorValue;
}
if (!this.isObject(vectorValue)) {
throw new ValidationError('Length must be a number or an object with min/max', vo.path, 'INVALID_LENGTH');
}
const { min, max } = vectorValue;
if (min != null && valueLength < min) return false;
if (max != null && valueLength > max) return false;
return true;
}
_regex(vo) {
try {
const regex = this.isRegExp(vo.regex) ? vo.regex : new RegExp(vo.regex);
return regex.test(vo.propValue);
} catch (err) {
throw new ValidationError(`Invalid regex: ${err.message}`, vo.path, 'INVALID_REGEX');
}
}
async _custom(vo) {
const validator = this.customValidators[vo.custom];
if (!validator) {
throw new ValidationError(`Custom validator "${vo.custom}" not found`, vo.path, 'VALIDATOR_NOT_FOUND');
}
try {
const result = await validator(vo.propValue, this.validationModels);
if (typeof result === 'boolean') {
return result;
}
this.formatted[vo.path] = result;
return true;
} catch (err) {
debug(`Custom validator "${vo.custom}" error:`, err);
return false;
}
}
async _items(vo) {
if (vo.type !== 'array' && vo.type !== 'Array') {
throw new ValidationError('Items validation only applies to arrays', vo.path, 'INVALID_ITEMS');
}
if (!Array.isArray(vo.propValue)) {
return false;
}
if (Array.isArray(vo.items)) {
return await this._validateArrayOfObjects(vo);
}
return await this._validateArrayOfPrimitives(vo);
}
async _validateArrayOfObjects(vo) {
for (let i = 0; i < vo.propValue.length; i++) {
const item = vo.propValue[i];
if (!this.isObject(item)) {
return { index: String(i), errors: [this.createErrorObj('type', vo)] };
}
const errorObject = await this.validate(item, vo.items);
if (errorObject && errorObject.length > 0) {
return { index: String(i), errors: errorObject };
}
}
return true;
}
async _validateArrayOfPrimitives(vo) {
const { items } = vo;
if (items.items || items.path) {
throw new ValidationError('Invalid items configuration for primitive array', vo.path, 'INVALID_ITEMS_CONFIG');
}
const validationVectors = this.getValidationVectors(items);
for (let i = 0; i < vo.propValue.length; i++) {
for (const vectorName of validationVectors) {
const newVO = {
propValue: vo.propValue[i],
label: vo.label,
vector: 'items',
index: String(i),
path: vo.path,
[vectorName]: items[vectorName]
};
const error = await this.exec(vectorName, newVO);
if (error) return error;
}
}
return true;
}
_canParse(vo) {
const { propValue: v, canParse } = vo;
switch (canParse) {
case 'date':
return !isNaN(Date.parse(v));
case 'int':
return Number.isInteger(Number(v));
case 'float':
return !isNaN(parseFloat(v));
default:
throw new ValidationError(`Unknown parse type: ${canParse}`, vo.path, 'INVALID_PARSE_TYPE');
}
}
_gt(vo) {
return this.isNumber(vo.propValue) && vo.propValue > vo.gt;
}
_lt(vo) {
return this.isNumber(vo.propValue) && vo.propValue < vo.lt;
}
_gte(vo) {
return this.isNumber(vo.propValue) && vo.propValue >= vo.gte;
}
_lte(vo) {
return this.isNumber(vo.propValue) && vo.propValue <= vo.lte;
}
getExactValidationModel(modelName) {
const model = this.models[modelName];
if (!model) {
throw new ValidationError(`Model "${modelName}" not found`, null, 'MODEL_NOT_FOUND');
}
return model;
}
mergeModels(baseModel, inModel) {
return merge({}, baseModel, inModel);
}
createErrorObj(validationVectorName, vo, result = {}) {
const vv = vo.vector || validationVectorName;
const label = vo.label || vo.path || 'Undefined label and path';
const value = vo[validationVectorName];
const { path } = vo;
const log = `_${validationVectorName}${vo.index ? ` ` : ''}${result.index ? ` ` : ''}`;
const message = ((vo.onError && vo.onError[vv]) || this.errorSchema.onError[vv] || '')
.replace(this.errorSchema.marker, label)
.replace(this.errorSchema.value, value)
.replace(this.errorSchema.customError, vo.customError);
return { label, path, message, log, errors: result.errors || [] };
}
async exec(validationVectorName, vo) {
const methodName = `_${validationVectorName}`;
const method = this[methodName];
if (!method) {
throw new ValidationError(`Unknown validation method: ${methodName}`, vo.path, 'INVALID_METHOD');
}
try {
const result = await method(vo);
if (!result) {
return this.createErrorObj(validationVectorName, vo);
}
if (this.isObject(result) && !result.index) {
return result;
}
if (this.isObject(result) && result.index) {
return this.createErrorObj(validationVectorName, vo, result);
}
return null;
} catch (error) {
if (error instanceof ValidationError) {
throw error;
}
throw new ValidationError(
`Validation failed: ${error.message}`,
vo.path,
'VALIDATION_ERROR'
);
}
}
getValidationVectors(vo) {
return Object.keys(vo).filter(key => typeof this[`_${key}`] === 'function');
}
async evaluate(obj, ivm, isItems) {
let vo = ivm;
if (ivm.model) {
try {
const baseValidationModel = this.getExactValidationModel(ivm.model);
vo = this.mergeModels(
isItems ? baseValidationModel.items : { ...baseValidationModel },
ivm
);
} catch (error) {
throw new ValidationError(
`Model evaluation failed: ${error.message}`,
ivm.path || ivm.key,
'MODEL_EVALUATION_ERROR'
);
}
}
vo.path = vo.path || vo.key;
vo.propValue = get(obj, vo.path);
if (vo.required && (this.isNull(vo.propValue) || this.isUndefined(vo.propValue) || vo.propValue === '')) {
return this.createErrorObj('required', vo);
}
if (this.isNull(vo.propValue) || this.isUndefined(vo.propValue)) {
return null;
}
const validationVectors = this.getValidationVectors(vo);
for (const vectorName of validationVectors) {
const error = await this.exec(vectorName, vo);
if (error) return error;
}
return null;
}
async validate(obj, validationModels) {
this.validationModels = validationModels;
const errors = [];
try {
for (const model of validationModels) {
const error = await this.evaluate(obj, model);
if (error) errors.push(error);
}
} catch (error) {
if (error instanceof ValidationError) {
throw error;
}
throw new ValidationError(
`Validation failed: ${error.message}`,
null,
'VALIDATION_ERROR'
);
}
return errors.length > 0 ? errors : false;
}
async trim(obj, validationModels) {
const trimmed = {};
for (const model of validationModels) {
let vo = model;
if (model.model && this.models[model.model]) {
vo = this.mergeModels(this.models[model.model], model);
}
if (vo.items) {
await this._trimItems(obj, vo, trimmed);
} else {
const value = this.formatted[vo.path] || get(obj, vo.path);
if (value != null && value !== undefined) {
set(trimmed, vo.path, value);
}
}
}
return trimmed;
}
async _trimItems(obj, vo, trimmed) {
const value = get(obj, vo.path);
if (Array.isArray(vo.items)) {
if (!Array.isArray(value)) return;
for (let i = 0; i < value.length; i++) {
set(
trimmed,
`${vo.path}[${i}]`,
await this.trim(value[i], vo.items)
);
}
} else if (value != null && value !== undefined) {
set(trimmed, vo.path, value);
}
}
async format(...args) {
return this.trim(...args);
}
};