fastest-validator
Version:
The fastest JS validator library for NodeJS
669 lines (580 loc) • 18.7 kB
JavaScript
;
let AsyncFunction;
try {
AsyncFunction = (new Function("return Object.getPrototypeOf(async function(){}).constructor"))();
} catch(err) { /* async is not supported */}
const deepExtend = require("./helpers/deep-extend");
const replace = require("./helpers/replace");
function loadMessages() {
return Object.assign({} , require("./messages"));
}
function loadRules() {
return {
any: require("./rules/any"),
array: require("./rules/array"),
boolean: require("./rules/boolean"),
class: require("./rules/class"),
custom: require("./rules/custom"),
currency: require("./rules/currency"),
date: require("./rules/date"),
email: require("./rules/email"),
enum: require("./rules/enum"),
equal: require("./rules/equal"),
forbidden: require("./rules/forbidden"),
function: require("./rules/function"),
multi: require("./rules/multi"),
number: require("./rules/number"),
object: require("./rules/object"),
objectID: require("./rules/objectID"),
record: require("./rules/record"),
string: require("./rules/string"),
tuple: require("./rules/tuple"),
url: require("./rules/url"),
uuid: require("./rules/uuid"),
mac: require("./rules/mac"),
luhn: require("./rules/luhn")
};
}
/**
* Fastest Validator
*/
class Validator {
/**
* Validator class constructor
*
* @param {Object} opts
*/
constructor(opts) {
this.opts = {};
this.defaults = {};
this.messages = loadMessages();
this.rules = loadRules();
this.aliases = {};
this.cache = new Map();
this.customFunctions = {};
if (opts) {
deepExtend(this.opts, opts);
if (opts.defaults) deepExtend(this.defaults, opts.defaults);
if (opts.messages) {
for (const messageName in opts.messages) this.addMessage(messageName, opts.messages[messageName]);
}
if (opts.aliases) {
for (const aliasName in opts.aliases) this.alias(aliasName, opts.aliases[aliasName]);
}
if (opts.customRules) {
for (const ruleName in opts.customRules) this.add(ruleName, opts.customRules[ruleName]);
}
if (opts.customFunctions) {
for (const customName in opts.customFunctions) this.addCustomFunction(customName, opts.customFunctions[customName]);
}
if (opts.plugins) {
const plugins = opts.plugins;
if (!Array.isArray(plugins)) throw new Error("Plugins type must be array");
plugins.forEach(this.plugin.bind(this));
}
/* istanbul ignore next */
if (this.opts.debug) {
let formatter = function (code) { return code; };
if (typeof window === "undefined") {
formatter = require("./helpers/prettier");
}
this._formatter = formatter;
}
}
}
/**
* Validate an object by schema
*
* @param {Object} obj
* @param {Object} schema
* @returns {Array<Object>|boolean}
*/
validate(obj, schema) {
const check = this.compile(schema);
return check(obj);
}
/**
* Wrap a source code with `required` & `optional` checker codes.
* @param {Object} rule
* @param {String} innerSrc
* @param {String?} resVar
* @returns {String}
*/
wrapRequiredCheckSourceCode(rule, innerSrc, context, resVar) {
const src = [];
const {considerNullAsAValue = false} = this.opts;
let handleNoValue;
let skipUndefinedValue = rule.schema.optional === true || rule.schema.type === "forbidden";
let skipNullValue = considerNullAsAValue ?
rule.schema.nullable !== false || rule.schema.type === "forbidden" :
rule.schema.optional === true || rule.schema.nullable === true || rule.schema.type === "forbidden";
const ruleHasDefault = considerNullAsAValue ?
rule.schema.default != undefined && rule.schema.default != null :
rule.schema.default != undefined;
if (ruleHasDefault) {
// We should set default-value when value is undefined or null, not skip! (Except when null is allowed)
skipUndefinedValue = false;
if (considerNullAsAValue) {
if (rule.schema.nullable === false) skipNullValue = false;
} else {
if (rule.schema.nullable !== true) skipNullValue = false;
}
let defaultValue;
if (typeof rule.schema.default === "function") {
if (!context.customs[rule.index]) context.customs[rule.index] = {};
context.customs[rule.index].defaultFn = rule.schema.default;
defaultValue = `context.customs[${rule.index}].defaultFn.call(this, context.rules[${rule.index}].schema, field, parent, context)`;
} else {
defaultValue = JSON.stringify(rule.schema.default);
}
handleNoValue = `
value = ${defaultValue};
${resVar} = value;
`;
} else {
handleNoValue = this.makeError({ type: "required", actual: "value", messages: rule.messages });
}
src.push(`
${`if (value === undefined) { ${skipUndefinedValue ? "\n// allow undefined\n" : handleNoValue} }`}
${`else if (value === null) { ${skipNullValue ? "\n// allow null\n" : handleNoValue} }`}
${innerSrc ? `else { ${innerSrc} }` : ""}
`);
return src.join("\n");
}
/**
* check if the key is a meta key
*
* @param key
* @return {boolean}
*/
isMetaKey(key) {
return key.startsWith("$$");
}
/**
* will remove all "metas" keys (keys starting with $$)
*
* @param obj
*/
removeMetasKeys(obj) {
Object.keys(obj).forEach(key => {
if(!this.isMetaKey(key)) {
return;
}
delete obj[key];
});
}
/**
* Compile a schema
*
* @param {Object} schema
* @throws {Error} Invalid schema
* @returns {Function}
*/
compile(schema) {
if (schema === null || typeof schema !== "object") {
throw new Error("Invalid schema.");
}
const self = this;
const context = {
index: 0,
async: schema.$$async === true,
rules: [],
fn: [],
customs: {},
customFunctions : this.customFunctions,
utils: {
replace,
},
};
this.cache.clear();
delete schema.$$async;
/* istanbul ignore next */
if (context.async && !AsyncFunction) {
throw new Error("Asynchronous mode is not supported.");
}
if (schema.$$root !== true) {
if (Array.isArray(schema)) {
const rule = this.getRuleFromSchema(schema);
schema = rule.schema;
} else {
const prevSchema = Object.assign({}, schema);
schema = {
type: "object",
strict: prevSchema.$$strict,
properties: prevSchema
};
this.removeMetasKeys(prevSchema);
}
}
const sourceCode = [
"var errors = [];",
"var field;",
"var parent = null;",
`var label = ${schema.label ? "\"" + schema.label + "\"" : "null"};`
];
const rule = this.getRuleFromSchema(schema);
sourceCode.push(this.compileRule(rule, context, null, `${context.async ? "await " : ""}context.fn[%%INDEX%%](value, field, null, errors, context, label);`, "value"));
sourceCode.push("if (errors.length) {");
sourceCode.push(`
return errors.map(err => {
if (err.message) {
err.message = context.utils.replace(err.message, /\\{field\\}/g, err.label || err.field);
err.message = context.utils.replace(err.message, /\\{expected\\}/g, err.expected);
err.message = context.utils.replace(err.message, /\\{actual\\}/g, err.actual);
}
if(!err.label) delete err.label
return err;
});
`);
sourceCode.push("}");
sourceCode.push("return true;");
const src = sourceCode.join("\n");
const FnClass = context.async ? AsyncFunction : Function;
const checkFn = new FnClass("value", "context", src);
/* istanbul ignore next */
if (this.opts.debug) {
console.log(this._formatter("// Main check function\n" + checkFn.toString())); // eslint-disable-line no-console
}
this.cache.clear();
const resFn = function (data, opts) {
context.data = data;
if (opts && opts.meta)
context.meta = opts.meta;
return checkFn.call(self, data, context);
};
resFn.async = context.async;
return resFn;
}
/**
* Compile a rule to source code.
* @param {Object} rule
* @param {Object} context
* @param {String} path
* @param {String} innerSrc
* @param {String} resVar
* @returns {String}
*/
compileRule(rule, context, path, innerSrc, resVar) {
const sourceCode = [];
const item = this.cache.get(rule.schema);
if (item) {
// Handle cyclic schema
rule = item;
rule.cycle = true;
rule.cycleStack = [];
sourceCode.push(this.wrapRequiredCheckSourceCode(rule, `
var rule = context.rules[${rule.index}];
if (rule.cycleStack.indexOf(value) === -1) {
rule.cycleStack.push(value);
${innerSrc.replace(/%%INDEX%%/g, rule.index)}
rule.cycleStack.pop(value);
}
`, context, resVar));
} else {
this.cache.set(rule.schema, rule);
rule.index = context.index;
context.rules[context.index] = rule;
const customPath = path != null ? path : "$$root";
context.index++;
const res = rule.ruleFunction.call(this, rule, path, context);
res.source = res.source.replace(/%%INDEX%%/g, rule.index);
const FnClass = context.async ? AsyncFunction : Function;
const fn = new FnClass("value", "field", "parent", "errors", "context", "label", res.source);
context.fn[rule.index] = fn.bind(this);
sourceCode.push(this.wrapRequiredCheckSourceCode(rule, innerSrc.replace(/%%INDEX%%/g, rule.index), context, resVar));
sourceCode.push(this.makeCustomValidator({vName: resVar, path: customPath, schema: rule.schema, context, messages: rule.messages, ruleIndex: rule.index}));
/* istanbul ignore next */
if (this.opts.debug) {
console.log(this._formatter(`// Context.fn[${rule.index}]\n` + fn.toString())); // eslint-disable-line no-console
}
}
return sourceCode.join("\n");
}
/**
* Create a rule instance from schema definition.
* @param {Object} schema
* @returns {Object} rule
*/
getRuleFromSchema(schema) {
schema = this.resolveType(schema);
const alias = this.aliases[schema.type];
if (alias) {
delete schema.type;
schema = deepExtend(schema, alias, { skipIfExist: true });
}
const ruleFunction = this.rules[schema.type];
if (!ruleFunction)
throw new Error("Invalid '" + schema.type + "' type in validator schema.");
const rule = {
messages: Object.assign({}, this.messages, schema.messages),
schema: deepExtend(schema, this.defaults[schema.type], { skipIfExist: true }),
ruleFunction: ruleFunction,
};
return rule;
}
/**
* Parse rule from shorthand string
* @param {String} str shorthand string
* @param {Object} schema schema reference
*/
parseShortHand(str) {
const p = str.split("|").map((s) => s.trim());
let type = p[0];
let schema;
if (type.endsWith("[]")) {
schema = this.getRuleFromSchema({ type: "array", items: type.slice(0, -2) }).schema;
} else {
schema = {
type: p[0],
};
}
p.slice(1).forEach((s) => {
const idx = s.indexOf(":");
if (idx !== -1) {
const key = s.substring(0, idx).trim();
let value = s.substring(idx + 1).trim();
if (value === "true" || value === "false")
value = value === "true";
else if (!Number.isNaN(Number(value))) {
value = Number(value);
}
schema[key] = value;
} else {
// boolean value
if (s.startsWith("no-")) schema[s.slice(3)] = false;
else schema[s] = true;
}
});
return schema;
}
/**
* Generate error source code.
* @param {Object} opts
* @param {String} opts.type
* @param {String} opts.field
* @param {any} opts.expected
* @param {any} opts.actual
* @param {Object} opts.messages
*/
makeError({ type, field, expected, actual, messages }) {
const o = {
type: `"${type}"`,
message: `"${messages[type]}"`,
};
if (field) o.field = `"${field}"`;
else o.field = "field";
if (expected != null) o.expected = expected;
if (actual != null) o.actual = actual;
o.label = "label";
const s = Object.keys(o)
.map(key => `${key}: ${o[key]}`)
.join(", ");
return `errors.push({ ${s} });`;
}
/**
* Generate custom validator function source code.
* @param {Object} opts
* @param {String} opts.vName
* @param {String} opts.fnName
* @param {String} opts.ruleIndex
* @param {String} opts.path
* @param {Object} opts.schema
* @param {Object} opts.context
* @param {Object} opts.messages
*/
makeCustomValidator({ vName = "value", fnName = "custom", ruleIndex, path, schema, context, messages }) {
const ruleVName = "rule" + ruleIndex;
const fnCustomErrorsVName = "fnCustomErrors" + ruleIndex;
if (typeof schema[fnName] == "function" || (Array.isArray(schema[fnName]))) {
if (context.customs[ruleIndex]) {
context.customs[ruleIndex].messages = messages;
context.customs[ruleIndex].schema = schema;
} else {
context.customs[ruleIndex] = { messages, schema };
}
const ret = [];
if (this.opts.useNewCustomCheckerFunction) {
ret.push( `
const ${ruleVName} = context.customs[${ruleIndex}];
const ${fnCustomErrorsVName} = [];
`);
if(Array.isArray(schema[fnName])){
for (let i = 0; i < schema[fnName].length; i++) {
let custom = schema[fnName][i];
if (typeof custom === "string") {
custom = this.parseShortHand(custom);
schema[fnName][i] = custom;
}
const customIndex = ruleIndex*1000+i;
context.customs[customIndex] = { messages, schema: Object.assign({}, schema, { custom, index: i }) };
ret.push( `
const ${ruleVName}_${i} = context.customs[${customIndex}];
`);
if(custom.type){
ret.push( `
${vName} = ${context.async ? "await " : ""}context.customFunctions[${ruleVName}.schema.${fnName}[${i}].type].call(this, ${vName}, ${fnCustomErrorsVName} , ${ruleVName}_${i}.schema, "${path}", parent, context);
`);
}
if(typeof custom==="function"){
ret.push( `
${vName} = ${context.async ? "await " : ""}${ruleVName}.schema.${fnName}[${i}].call(this, ${vName}, ${fnCustomErrorsVName} , ${ruleVName}.schema, "${path}", parent, context);
`);
}
}
}else{
ret.push( `
${vName} = ${context.async ? "await " : ""}${ruleVName}.schema.${fnName}.call(this, ${vName}, ${fnCustomErrorsVName} , ${ruleVName}.schema, "${path}", parent, context);
`);
}
ret.push( `
if (Array.isArray(${fnCustomErrorsVName} )) {
${fnCustomErrorsVName} .forEach(err => errors.push(Object.assign({ message: ${ruleVName}.messages[err.type], field }, err)));
}
`);
}else{
const result = "res_" + ruleVName;
ret.push( `
const ${ruleVName} = context.customs[${ruleIndex}];
const ${result} = ${context.async ? "await " : ""}${ruleVName}.schema.${fnName}.call(this, ${vName}, ${ruleVName}.schema, "${path}", parent, context);
if (Array.isArray(${result})) {
${result}.forEach(err => errors.push(Object.assign({ message: ${ruleVName}.messages[err.type], field }, err)));
}
`);
}
return ret.join("\n");
}
return "";
}
/**
* Add a custom rule
*
* @param {String} type
* @param {Function} fn
*/
add(type, fn) {
this.rules[type] = fn;
}
/**
* Add a custom function
*
* @param {String} type
* @param {Function} fn
*/
addCustomFunction(name, fn) {
this.customFunctions[name] = fn;
}
/**
* Add a message
*
* @param {String} name
* @param {String} message
*/
addMessage(name, message) {
this.messages[name] = message;
}
/**
* create alias name for a rule
*
* @param {String} name
* @param validationRule
*/
alias(name, validationRule) {
if (this.rules[name]) throw new Error("Alias name must not be a rule name");
this.aliases[name] = validationRule;
}
/**
* Add a plugin
*
* @param {Function} fn
*/
plugin(fn) {
if (typeof fn !== "function") throw new Error("Plugin fn type must be function");
return fn(this);
}
/**
* Resolve the schema 'type' by:
* - parsing short hands into full type definitions
* - expanding arrays into 'multi' types with a rules property
* - objects which have a root $$type property into a schema which
* explicitly has a 'type' property and a 'props' property.
*
* @param schema The schema to resolve the type of
*/
resolveType(schema) {
if (typeof schema === "string") {
schema = this.parseShortHand(schema);
} else if (Array.isArray(schema)) {
if (schema.length === 0)
throw new Error("Invalid schema.");
schema = {
type: "multi",
rules: schema
};
// Check 'optional' flag
const isOptional = schema.rules
.map(s => this.getRuleFromSchema(s))
.every(rule => rule.schema.optional === true);
if (isOptional)
schema.optional = true;
// Check 'nullable' flag
const nullCheck = this.opts.considerNullAsAValue ? false : true;
const setNullable = schema.rules
.map(s => this.getRuleFromSchema(s))
.every(rule => rule.schema.nullable === nullCheck);
if (setNullable)
schema.nullable = nullCheck;
}
if (schema.$$type) {
const type = schema.$$type;
const otherShorthandProps = this.getRuleFromSchema(type).schema;
delete schema.$$type;
const props = Object.assign({}, schema);
for (const key in schema) { // clear object without changing reference
delete schema[key];
}
deepExtend(schema, otherShorthandProps, { skipIfExist: true });
schema.props = props;
}
return schema;
}
/**
* Normalize a schema, type or short hand definition by expanding it to a full form. The 'normalized'
* form is the equivalent schema with any short hands undone. This ensure that each rule; always includes
* a 'type' key, arrays always have an 'items' key, 'multi' always have a 'rules' key and objects always
* have their properties defined in a 'props' key
*
* @param {Object|String} value The value to normalize
* @returns {Object} The normalized form of the given rule or schema
*/
normalize(value) {
let result = this.resolveType(value);
if(this.aliases[result.type])
result = deepExtend(result, this.normalize(this.aliases[result.type]), { skipIfExists: true});
result = deepExtend(result, this.defaults[result.type], { skipIfExist: true });
if(result.type === "multi") {
result.rules = result.rules.map(r => this.normalize(r));
result.optional = result.rules.every(r => r.optional === true);
return result;
}
if(result.type === "array") {
result.items = this.normalize(result.items);
return result;
}
if(result.type === "object") {
if(result.props) {
Object.entries(result.props).forEach(([k,v]) => result.props[k] = this.normalize(v));
}
}
if(typeof value === "object") {
if(value.type) {
const config = this.normalize(value.type);
deepExtend(result, config, { skipIfExists: true });
}
else{
Object.entries(value).forEach(([k,v]) => result[k] = this.normalize(v));
}
}
return result;
}
}
module.exports = Validator;