v8n
Version:
Dead simple fluent JavaScript validation library
510 lines (419 loc) • 11.5 kB
JavaScript
class Rule {
constructor(name, fn, args, modifiers) {
this.name = name;
this.fn = fn;
this.args = args;
this.modifiers = modifiers;
}
_test(value) {
let fn = this.fn;
try {
testAux(this.modifiers.slice(), fn, this)(value);
} catch (ex) {
fn = () => false;
}
try {
return testAux(this.modifiers.slice(), fn, this)(value);
} catch (ex) {
return false;
}
}
_check(value) {
try {
testAux(this.modifiers.slice(), this.fn, this)(value);
} catch (ex) {
if (testAux(this.modifiers.slice(), it => it, this)(false)) {
return;
}
}
if (!testAux(this.modifiers.slice(), this.fn, this)(value)) {
throw null;
}
}
_testAsync(value) {
return new Promise((resolve, reject) => {
testAsyncAux(
this.modifiers.slice(),
this.fn,
this,
)(value)
.then(valid => {
if (valid) {
resolve(value);
} else {
reject(null);
}
})
.catch(ex => reject(ex));
});
}
}
function pickFn(fn, variant = 'simple') {
return typeof fn === 'object' ? fn[variant] : fn;
}
function testAux(modifiers, fn, rule) {
if (modifiers.length) {
const modifier = modifiers.shift();
const nextFn = testAux(modifiers, fn, rule);
return modifier.perform(nextFn, rule);
} else {
return pickFn(fn);
}
}
function testAsyncAux(modifiers, fn, rule) {
if (modifiers.length) {
const modifier = modifiers.shift();
const nextFn = testAsyncAux(modifiers, fn, rule);
return modifier.performAsync(nextFn, rule);
} else {
return value => Promise.resolve(pickFn(fn, 'async')(value));
}
}
class Modifier {
constructor(name, perform, performAsync) {
this.name = name;
this.perform = perform;
this.performAsync = performAsync;
}
}
class ValidationError extends Error {
constructor(rule, value, cause, target, ...remaining) {
super(remaining);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ValidationError);
}
this.rule = rule;
this.value = value;
this.cause = cause;
this.target = target;
}
}
class Context {
constructor(chain = [], nextRuleModifiers = []) {
this.chain = chain;
this.nextRuleModifiers = nextRuleModifiers;
}
_applyRule(ruleFn, name) {
return (...args) => {
this.chain.push(
new Rule(name, ruleFn.apply(this, args), args, this.nextRuleModifiers),
);
this.nextRuleModifiers = [];
return this;
};
}
_applyModifier(modifier, name) {
this.nextRuleModifiers.push(
new Modifier(name, modifier.simple, modifier.async),
);
return this;
}
_clone() {
return new Context(this.chain.slice(), this.nextRuleModifiers.slice());
}
test(value) {
return this.chain.every(rule => rule._test(value));
}
testAll(value) {
const err = [];
this.chain.forEach(rule => {
try {
rule._check(value);
} catch (ex) {
err.push(new ValidationError(rule, value, ex));
}
});
return err;
}
check(value) {
this.chain.forEach(rule => {
try {
rule._check(value);
} catch (ex) {
throw new ValidationError(rule, value, ex);
}
});
}
testAsync(value) {
return new Promise((resolve, reject) => {
executeAsyncRules(value, this.chain.slice(), resolve, reject);
});
}
}
function executeAsyncRules(value, rules, resolve, reject) {
if (rules.length) {
const rule = rules.shift();
rule._testAsync(value).then(
() => {
executeAsyncRules(value, rules, resolve, reject);
},
cause => {
reject(new ValidationError(rule, value, cause));
},
);
} else {
resolve(value);
}
}
const consideredEmpty = (value, considerTrimmedEmptyString) => {
if (
considerTrimmedEmptyString &&
typeof value === 'string' &&
value.trim().length === 0
) {
return true;
}
return value === undefined || value === null;
};
var optional = (validation, considerTrimmedEmptyString = false) => ({
simple: value =>
consideredEmpty(value, considerTrimmedEmptyString) ||
validation.check(value) === undefined,
async: value =>
consideredEmpty(value, considerTrimmedEmptyString) ||
validation.testAsync(value),
});
function v8n() {
return typeof Proxy !== 'undefined'
? proxyContext(new Context())
: proxylessContext(new Context());
}
// Custom rules
let customRules = {};
v8n.extend = function(newRules) {
Object.assign(customRules, newRules);
};
v8n.clearCustomRules = function() {
customRules = {};
};
function proxyContext(context) {
return new Proxy(context, {
get(obj, prop) {
if (prop in obj) {
return obj[prop];
}
const newContext = proxyContext(context._clone());
if (prop in availableModifiers) {
return newContext._applyModifier(availableModifiers[prop], prop);
}
if (prop in customRules) {
return newContext._applyRule(customRules[prop], prop);
}
if (prop in availableRules) {
return newContext._applyRule(availableRules[prop], prop);
}
},
});
}
function proxylessContext(context) {
const addRuleSet = (ruleSet, targetContext) => {
Object.keys(ruleSet).forEach(prop => {
targetContext[prop] = (...args) => {
const newContext = proxylessContext(targetContext._clone());
const contextWithRuleApplied = newContext._applyRule(
ruleSet[prop],
prop,
)(...args);
return contextWithRuleApplied;
};
});
return targetContext;
};
const contextWithAvailableRules = addRuleSet(availableRules, context);
const contextWithAllRules = addRuleSet(
customRules,
contextWithAvailableRules,
);
Object.keys(availableModifiers).forEach(prop => {
Object.defineProperty(contextWithAllRules, prop, {
get: () => {
const newContext = proxylessContext(contextWithAllRules._clone());
return newContext._applyModifier(availableModifiers[prop], prop);
},
});
});
return contextWithAllRules;
}
const availableModifiers = {
not: {
simple: fn => value => !fn(value),
async: fn => value =>
Promise.resolve(fn(value))
.then(result => !result)
.catch(() => true),
},
some: {
simple: fn => value => {
return split(value).some(item => {
try {
return fn(item);
} catch (ex) {
return false;
}
});
},
async: fn => value => {
return Promise.all(
split(value).map(item => {
try {
return fn(item).catch(() => false);
} catch (ex) {
return false;
}
}),
).then(result => result.some(Boolean));
},
},
every: {
simple: fn => value => value !== false && split(value).every(fn),
async: fn => value =>
Promise.all(split(value).map(fn)).then(result => result.every(Boolean)),
},
strict: {
simple: (fn, rule) => value => {
if (isSchemaRule(rule) && value && typeof value === 'object') {
return (
Object.keys(rule.args[0]).length === Object.keys(value).length &&
fn(value)
);
}
return fn(value);
},
async: (fn, rule) => value =>
Promise.resolve(fn(value))
.then(result => {
if (isSchemaRule(rule) && value && typeof value === 'object') {
return (
Object.keys(rule.args[0]).length === Object.keys(value).length &&
result
);
}
return result;
})
.catch(() => false),
},
};
function isSchemaRule(rule) {
return (
rule &&
rule.name === 'schema' &&
rule.args.length > 0 &&
typeof rule.args[0] === 'object'
);
}
function split(value) {
if (typeof value === 'string') {
return value.split('');
}
return value;
}
const availableRules = {
// Value
equal: expected => value => value == expected,
exact: expected => value => value === expected,
// Types
number: (allowInfinite = true) => value =>
typeof value === 'number' && (allowInfinite || isFinite(value)),
integer: () => value => {
const isInteger = Number.isInteger || isIntegerPolyfill;
return isInteger(value);
},
numeric: () => value => !isNaN(parseFloat(value)) && isFinite(value),
string: () => testType('string'),
boolean: () => testType('boolean'),
undefined: () => testType('undefined'),
null: () => testType('null'),
array: () => testType('array'),
object: () => testType('object'),
instanceOf: instance => value => value instanceof instance,
// Pattern
pattern: expected => value => expected.test(value),
lowercase: () => value => {
return (
typeof value === 'boolean' ||
(value === value.toLowerCase() && value.trim() !== '')
);
},
uppercase: () => value =>
value === value.toUpperCase() && value.trim() !== '',
vowel: () => value => /^[aeiou]+$/i.test(value),
consonant: () => value => /^(?=[^aeiou])([a-z]+)$/i.test(value),
// Value at
first: expected => value => value[0] == expected,
last: expected => value => value[value.length - 1] == expected,
// Length
empty: () => value => value.length === 0,
length: (min, max) => value =>
value.length >= min && value.length <= (max || min),
minLength: min => value => value.length >= min,
maxLength: max => value => value.length <= max,
// Range
negative: () => value => value < 0,
positive: () => value => value >= 0,
between: (a, b) => value => value >= a && value <= b,
range: (a, b) => value => value >= a && value <= b,
lessThan: n => value => value < n,
lessThanOrEqual: n => value => value <= n,
greaterThan: n => value => value > n,
greaterThanOrEqual: n => value => value >= n,
// Divisible
even: () => value => value % 2 === 0,
odd: () => value => value % 2 !== 0,
includes: expected => value => ~value.indexOf(expected),
schema: schema => testSchema(schema),
// branching
passesAnyOf: (...validations) => value =>
validations.some(validation => validation.test(value)),
optional,
};
function testType(expected) {
return value => {
return (
(Array.isArray(value) && expected === 'array') ||
(value === null && expected === 'null') ||
typeof value === expected
);
};
}
function isIntegerPolyfill(value) {
return (
typeof value === 'number' && isFinite(value) && Math.floor(value) === value
);
}
function testSchema(schema) {
return {
simple: value => {
const causes = [];
Object.keys(schema).forEach(key => {
const nestedValidation = schema[key];
try {
nestedValidation.check((value || {})[key]);
} catch (ex) {
ex.target = key;
causes.push(ex);
}
});
if (causes.length > 0) {
throw causes;
}
return true;
},
async: value => {
const causes = [];
const nested = Object.keys(schema).map(key => {
const nestedValidation = schema[key];
return nestedValidation.testAsync((value || {})[key]).catch(ex => {
ex.target = key;
causes.push(ex);
});
});
return Promise.all(nested).then(() => {
if (causes.length > 0) {
throw causes;
}
return true;
});
},
};
}
export default v8n;