UNPKG

datalize

Version:

Parameter, query, form data validation and filtering for Koa and Express.

360 lines (281 loc) 8.27 kB
const get = require('lodash/get'); const filters = require('./filters'); const RequiredSymbol = Symbol(); const NotRequiredSymbol = Symbol(); const OptionalSymbol = Symbol(); class Field { constructor(name, label, prefix = '') { this.name = name; this.label = label; this.prefix = prefix; this.isArray = false; this.isContainer = false; this.isRequired = false; this.isOptional = false; this.isFile = false; this.splitSeparator = null; this.stack = []; this.children = []; } get path() { return this.prefix ? this.prefix + '.' + this.name : this.name; } add(fn) { // remove duplicate required filters (caused by chaining) if (fn === filters.required) { const index = this.stack.indexOf(filters.required); index !== -1 && (this.stack.splice(index, 1)); } this.stack.push(fn); return this; } addError(stack, e) { stack.push(e.message.replace('%s', this.label || this.name)); return stack; } array() { this.isArray = true; return this; } container(children) { this.isContainer = true; this.children = children; return this; } split(separator = ',') { this.isArray = true; this.splitSeparator = separator; return this; } file() { this.isFile = true; return this; } async validateArray(value, result, ctx, options) { const errors = []; if (this.isRequired && !value.length) { return {value, errors: this.addError([], new Error('%s is required.'))}; } value = await Promise.all(value.map(async (value, i) => { const validateFn = this.isContainer ? 'validateContainer' : 'validateField'; const { value: itemValue, errors: itemErrors } = await this[validateFn](value, result, ctx, options); if (Field.hasError(itemErrors)) { errors[i] = itemErrors; } return itemValue; })); return {value, errors}; } async validateContainer(value, result, ctx, options) { const errors = {}; const newValue = {}; // create custom result object container so all value lookups are related to this container const containerResult = result.createContainer(); await Promise.all(this.children.map(async (item) => { const validation = item.validate(value, containerResult, ctx, options); // store validation promise so other fields can get the value containerResult.addField(item, validation); const { value: itemValue, errors: itemErrors } = await validation; if (itemValue !== undefined) { newValue[item.name] = itemValue; containerResult.addField(item, itemValue, itemErrors); } else { containerResult.removeField(item); } if (Field.hasError(itemErrors)) { errors[item.name] = itemErrors; } })); return {value: newValue, errors}; } async validateField(value, result, ctx, options) { const errors = []; let isRequired = this.isRequired; let notRequired = false; // return undefined if is optional and value not defined if (this.isOptional && value === undefined) { return {value, errors}; } for (const fn of this.stack) { try { const ret = await fn(value, result, ctx, this); if (ret === RequiredSymbol) { if (!notRequired) { isRequired = true; } } else if (ret === NotRequiredSymbol) { isRequired = false; notRequired = true; // return undefined if is optionalIf() and value not defined } else if (ret === OptionalSymbol && value === undefined) { return {value, errors}; } else if (ret !== undefined) { value = ret; } } catch (e) { (isRequired || !filters.isEmpty(value)) && this.addError(errors, e); if (options.breakOnRequired && fn === filters.required) { break; } } } // return empty string or null if value is undefined, otherwise the field would be // removed from object if (value === undefined) { value = this.isFile ? null : ''; } return {value, errors}; } async validate(source, result, ctx, options) { const errors = []; let value = get(this.isFile ? source.__files : source, this.path); if (this.isArray && !value) { // try to load data from name[] key value = get(this.isFile ? source.__files : source, this.path + '[]'); } if (this.splitSeparator !== null) { value = filters.isEmpty(value) ? [] : String(value).split(this.splitSeparator); } // validate source value type if (Array.isArray(value) && !this.isArray) { this.addError(errors, new Error('%s is not valid.')); return {value, errors}; } else if (this.isArray && !Array.isArray(value)) { if (options.autoConvertToArray) { value = filters.isEmpty(value) ? [] : [value]; } else { this.addError(errors, new Error('%s is not valid.')); return {value, errors}; } } if (this.isArray) { return await this.validateArray(value, result, ctx, options); } else if (this.isContainer) { return await this.validateContainer(value, result, ctx, options); } else { return await this.validateField(value, result, ctx, options); } } required() { this.isRequired = true; return this.add(filters.required); } requiredIf(name, requiredValue) { return this.add(async (value, result, ctx) => { if (typeof name === 'string') { const compareValue = await result.getValue(this.prefix ? this.prefix + '.' + name : name); let isRequired = false; if (requiredValue === undefined) { isRequired = !filters.isEmpty(compareValue); } else if (Array.isArray(requiredValue)) { isRequired = requiredValue.indexOf(compareValue) !== -1; } else { isRequired = compareValue === requiredValue; } if (isRequired) { return RequiredSymbol; } } else if (typeof name === 'function') { if (await name(value, result, ctx, this)) { return RequiredSymbol; } } return NotRequiredSymbol; }).add(filters.required); } optional() { this.isOptional = true; return this; } optionalIf(name, requiredValue) { return this.add(async (value, result, ctx) => { if (typeof name === 'string') { const compareValue = await result.getValue(this.prefix ? this.prefix + '.' + name : name); let isOptional = false; if (isOptional === undefined) { isOptional = !filters.isEmpty(compareValue); } else if (Array.isArray(requiredValue)) { isOptional = requiredValue.indexOf(compareValue) !== -1; } else { isOptional = compareValue === requiredValue; } if (isOptional) { return OptionalSymbol; } } else if (typeof name === 'function') { if (await name(value, result, ctx, this)) { return OptionalSymbol; } } }); } patch() { return this.add(async (value, result, ctx) => { if (ctx.method === 'PATCH') { return OptionalSymbol; } }); } bool(value) { this.isRequired = true; this.add(filters.bool); if (value === true || value === false) { this.equal(value); } return this; } toggle(value) { this.isRequired = true; this.add(filters.toggle); if (value === 1 || value === 0) { this.equal(value); } return this; } equal(compare) { return this.add(function(value) { if (value !== compare) { throw new Error('%s is incorrect.'); } }); } default(defaultValue) { return this.add(function(value) { if (filters.isEmpty(value)) { return defaultValue; } }); } custom(fn) { return this.add(fn); } } const extend = [ 'nullable', 'email', 'number', 'uppercase', 'lowercase', 'trim', 'truncate', 'range', 'length', 'minLength', 'maxLength', 'int', 'float', 'id', 'select', 'min', 'max', ]; extend.forEach(name => { Field.prototype[name] = function(...args) { return args.length ? this.add(filters[name].bind(null, ...args)) : this.add(filters[name]); }; }); const fileExtend = [ 'mime', 'size', ]; fileExtend.forEach(name => { Field.prototype[name] = function(...args) { if (!this.isFile) { throw new Error('This filter can be used only for file field.'); } return args.length ? this.add(filters.files[name].bind(null, ...args)) : this.add(filters.files[name]); }; }); Field.hasError = function(errors) { if (Array.isArray(errors)) { return errors.length !== 0; } else { return errors && typeof errors === 'object' && Object.keys(errors).length !== 0; } }; module.exports = Field;