UNPKG

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
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 ? ` @index(${vo.index})` : ''}${result.index ? ` @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); } };