datalize
Version:
Parameter, query, form data validation and filtering for Koa and Express.
360 lines (281 loc) • 8.27 kB
JavaScript
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;