snap-validate
Version:
Lightweight validation library for common patterns without heavy dependencies
507 lines (434 loc) • 14.6 kB
JavaScript
/**
* Snap Validate - Enhanced Lightweight validator library
* @version 0.3.0
*/
// Core validation class
class ValidationResult {
constructor(isValid, errors = []) {
this.isValid = isValid;
this.errors = errors;
}
addError(message) {
this.errors.push(message);
this.isValid = false;
return this;
}
}
// Base validator class
class BaseValidator {
constructor(value) {
this.value = value;
this.rules = [];
this.asyncRules = [];
this.isOptional = false;
}
required(message = 'This field is required') {
this.rules.push(() => {
// Skip validation if optional and empty
if (this.isOptional && (this.value === null || this.value === undefined || this.value === '')) {
return new ValidationResult(true);
}
if (this.value === null || this.value === undefined || this.value === '') {
return new ValidationResult(false, [message]);
}
return new ValidationResult(true);
});
return this;
}
optional() {
this.isOptional = true;
return this;
}
min(length, message = `Minimum length is ${length}`) {
this.rules.push(() => {
// Skip validation if optional and empty
if (this.isOptional && (this.value === null || this.value === undefined || this.value === '')) {
return new ValidationResult(true);
}
// Only validate if value exists and a length property
if (this.value != null && this.value !== '') {
// Check if value has length property (string, array)
if (typeof this.value === 'string' || Array.isArray(this.value)) {
if (this.value.length < length) {
return new ValidationResult(false, [message]);
}
} else if (typeof this.value === 'number') {
// For numbers, compare the value itself
if (this.value < length) {
return new ValidationResult(false, [message]);
}
} else {
return new ValidationResult(false, ['Value must be a string, array, or number']);
}
}
return new ValidationResult(true);
});
return this;
}
max(length, message = `Maximum length is ${length}`) {
this.rules.push(() => {
// Skip validation if optional and empty
if (this.isOptional && (this.value === null || this.value === undefined || this.value === '')) {
return new ValidationResult(true);
}
// Only validate if value exists and has a length property
if (this.value != null && this.value !== '') {
// Check if value has length property (string, array)
if (typeof this.value === 'string' || Array.isArray(this.value)) {
if (this.value.length > length) {
return new ValidationResult(false, [message]);
}
} else if (typeof this.value === 'number') {
// For numbers, compare the value itself
if (this.value > length) {
return new ValidationResult(false, [message]);
}
} else {
return new ValidationResult(false, ['Value must be a string, array or number']);
}
}
return new ValidationResult(true);
});
return this;
}
pattern(regex, message = 'Invalid format') {
this.rules.push(() => {
// Skip validation if optional and empty
if (this.isOptional && (this.value === null || this.value === undefined || this.value === '')) {
return new ValidationResult(true);
}
// Only test pattern if value exists and is not empty
if (this.value != null && this.value !== '') {
// Ensure value is a string before testing regex
const stringValue = String(this.value);
if (!regex.test(stringValue)) {
return new ValidationResult(false, [message]);
}
}
return new ValidationResult(true);
});
return this;
}
when(condition, validator) {
this.rules.push(() => {
// Evaluate condition
const shouldValidate = typeof condition === 'function' ? condition(this.value) : condition;
if (shouldValidate) {
// Apply the conditional validator
if (typeof validator === 'function') {
const conditionalValidator = validator(this.value);
return conditionalValidator.validate();
} else {
// If validator is already a BaseValidator instance
return validator.validate();
}
}
return new ValidationResult(true);
});
return this;
}
custom(validatorFn, message = 'Custom validation failed') {
this.rules.push(() => {
// Skip validation if optional and empty
if (this.isOptional && (this.value === null || this.value === undefined || this.value === '')) {
return new ValidationResult(true);
}
try {
const result = validatorFn(this.value);
// Handle boolean result
if (typeof result === 'boolean') {
return result ? new ValidationResult(true) : new ValidationResult(false, [message]);
}
// Handle ValidationResult object
if (result && typeof result === 'object' && 'isValid' in result) {
return result;
}
// Handle string result (error message)
if (typeof result === 'string') {
return new ValidationResult(false, [result]);
}
// Default to true if no clear result
return new ValidationResult(true);
} catch (error) {
return new ValidationResult(false, [`Custom validation error: ${error.message}`]);
}
});
return this;
}
customAsync(validatorFn, message = 'Async validation failed') {
this.asyncRules.push(async () => {
// Skip validation if optional and empty
if (this.isOptional && (this.value === null || this.value === undefined || this.value === '')) {
return new ValidationResult(true);
}
try {
const result = await validatorFn(this.value);
// Handle boolean result
if (typeof result === 'boolean') {
return result ? new ValidationResult(true) : new ValidationResult(false, [message]);
}
// Handle ValidationResult object
if (result && typeof result === 'object' && 'isValid' in result) {
return result;
}
// Handle string result (error message)
if (typeof result === 'string') {
return new ValidationResult(false, [result]);
}
// Default to true if no clear result
return new ValidationResult(true);
} catch (error) {
return new ValidationResult(false, [`Async validation error: ${error.message}`]);
}
});
return this;
}
validate() {
const result = new ValidationResult(true);
for (const rule of this.rules) {
try {
const ruleResult = rule();
if (!ruleResult.isValid) {
result.isValid = false;
result.errors.push(...ruleResult.errors);
}
} catch (error) {
// Handle any unexpected errors during validation
result.isValid = false;
result.errors.push(`Validation error: ${error.message}`);
}
}
return result;
}
async validateAsync() {
// First run synchronous validations
const syncResult = this.validate();
if (!syncResult.isValid) {
return syncResult;
}
// Then run asynchronous validations
const result = new ValidationResult(true, [...syncResult.errors]);
for (const asyncRule of this.asyncRules) {
try {
const ruleResult = await asyncRule();
if (!ruleResult.isValid) {
result.isValid = false;
result.errors.push(...ruleResult.errors);
}
} catch (error) {
// Handle any unexpected errors during async validation
result.isValid = false;
result.errors.push(`Async validation error: ${error.message}`);
}
}
return result;
}
}
// Predefined validators
const validators = {
email: (value) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return new BaseValidator(value)
.required('Email is required')
.pattern(emailRegex, 'Invalid email format');
},
phone: (value, format = 'us') => {
const phoneRegex = {
us: /^\+?1?[-.\s]?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})$/,
international: /^\+[1-9]\d{1,14}$/,
simple: /^\d{10,15}$/
};
return new BaseValidator(value)
.required('Phone number is required')
.pattern(phoneRegex[format] || phoneRegex.simple, 'Invalid phone number format');
},
creditCard: (value) => {
// Luhn algorithm check
const luhnCheck = (num) => {
let sum = 0;
let isEven = false;
// Remove spaces and ensure we have a string
const cleanNum = String(num).replace(/\s/g, '');
for (let i = cleanNum.length - 1; i >= 0; i--) {
let digit = parseInt(cleanNum[i]);
if (isEven) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
isEven = !isEven;
}
return sum % 10 === 0;
};
const validator = new BaseValidator(value)
.required('Credit card number is required');
// Add custom validation for credit card format and Luhn check
validator.rules.push(() => {
// Skip validation if optional and empty
if (validator.isOptional && (validator.value === null || validator.value === undefined || validator.value === '')) {
return new ValidationResult(true);
}
if (validator.value) {
const cleanValue = String(validator.value).replace(/\s/g, '');
// Check length (13-19 digits)
if (!/^\d{13,19}$/.test(cleanValue)) {
return new ValidationResult(false, ['Credit card must be 13-19 digits']);
}
// Check Luhn algorithm
if (!luhnCheck(cleanValue)) {
return new ValidationResult(false, ['Invalid credit card number']);
}
}
return new ValidationResult(true);
});
return validator;
},
url: (value) => {
const urlRegex = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i;
return new BaseValidator(value)
.required('URL is required')
.pattern(urlRegex, 'Invalid URL format');
},
password: (value, options = {}) => {
const {
minLength = 8,
requireUppercase = true,
requireLowercase = true,
requireNumbers = true,
requireSpecialChars = false
} = options;
const validator = new BaseValidator(value)
.required('Password is required')
.min(minLength, `Password must be at least ${minLength} characters`);
if (requireUppercase) {
validator.pattern(/[A-Z]/, 'Password must contain at least one uppercase letter');
}
if (requireLowercase) {
validator.pattern(/[a-z]/, 'Password must contain at least one lowercase letter');
}
if (requireNumbers) {
validator.pattern(/\d/, 'Password must contain at least one number');
}
if (requireSpecialChars) {
validator.pattern(/[!@#$%^&*(),.?":{}|<>]/, 'Password must contain at least one special character');
}
return validator;
},
alphanumeric: (value) => {
return new BaseValidator(value)
.required('This field is required')
.pattern(/^[a-zA-Z0-9]+$/, 'Only letters and numbers are allowed');
},
numeric: (value) => {
return new BaseValidator(value)
.required('This field is required')
.pattern(/^\d+$/, 'Only numbers are allowed');
},
zipCode: (value, country = 'us') => {
const zipRegex = {
us: /^\d{5}(-\d{4})?$/,
ca: /^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$/,
uk: /^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i
};
return new BaseValidator(value)
.required('Zip code is required')
.pattern(zipRegex[country] || zipRegex.us, 'Invalid zip code format');
}
};
// Main validation function
const validate = (schema, data) => {
// Input validation
if (!schema || typeof schema !== 'object') {
throw new Error('Schema must be a valid object');
}
if (!data || typeof data !== 'object') {
throw new Error('Data must be a valid object');
}
const results = {};
let isValid = true;
for (const [field, validator] of Object.entries(schema)) {
try {
const fieldValue = data[field];
const result = typeof validator === 'function'
? validator(fieldValue).validate()
: validator.validate();
results[field] = result;
if (!result.isValid) {
isValid = false;
}
} catch (error) {
// Handle validation setup errors
results[field] = new ValidationResult(false, [`Validation setup error: ${error.message}`]);
isValid = false;
}
}
return {
isValid,
errors: results,
getErrors: () => {
const errors = {};
for (const [field, result] of Object.entries(results)) {
if (!result.isValid) {
errors[field] = result.errors;
}
}
return errors;
}
};
};
// Async validation function
const validateAsync = async (schema, data) => {
// Input validation
if (!schema || typeof schema !== 'object') {
throw new Error('Schema must be a valid object');
}
if (!data || typeof data !== 'object') {
throw new Error('Data must be a valid object');
}
const results = {};
let isValid = true;
for (const [field, validator] of Object.entries(schema)) {
try {
const fieldValue = data[field];
let result;
if (typeof validator === 'function') {
const validatorInstance = validator(fieldValue);
result = validatorInstance.asyncRules.length > 0
? await validatorInstance.validateAsync()
: validatorInstance.validate();
} else {
result = validator.asyncRules && validator.asyncRules.length > 0
? await validator.validateAsync()
: validator.validate();
}
results[field] = result;
if (!result.isValid) {
isValid = false;
}
} catch (error) {
// Handle validation setup errors
results[field] = new ValidationResult(false, [`Validation setup error: ${error.message}`]);
isValid = false;
}
}
return {
isValid,
errors: results,
getErrors: () => {
const errors = {};
for (const [field, result] of Object.entries(results)) {
if (!result.isValid) {
errors[field] = result.errors;
}
}
return errors;
}
};
};
module.exports = {
BaseValidator,
ValidationResult,
validators,
validate,
validateAsync
};