defuss-transval
Version:
A fast, functional, reliable and small state (e.g. form) validation library that aims for correctness and simplicity.
385 lines (379 loc) • 13.4 kB
JavaScript
;
var defussRuntime = require('defuss-runtime');
const validators = {
isAfter: defussRuntime.isAfter,
isArray: defussRuntime.isArray,
isBefore: defussRuntime.isBefore,
isBoolean: defussRuntime.isBoolean,
isDate: defussRuntime.isDate,
isDefined: defussRuntime.isDefined,
isEmail: defussRuntime.isEmail,
isEmpty: defussRuntime.isEmpty,
is: defussRuntime.is,
isEqual: defussRuntime.isEqual,
isGreaterThan: defussRuntime.isGreaterThan,
isInteger: defussRuntime.isInteger,
isLongerThan: defussRuntime.isLongerThan,
isLessThan: defussRuntime.isLessThan,
isSafeNumber: defussRuntime.isSafeNumber,
isSafeNumeric: defussRuntime.isSafeNumeric,
isObject: defussRuntime.isObject,
isOneOf: defussRuntime.isOneOf,
isPhoneNumber: defussRuntime.isPhoneNumber,
isRequired: defussRuntime.isRequired,
isShorterThan: defussRuntime.isShorterThan,
isSlug: defussRuntime.isSlug,
isString: defussRuntime.isString,
isUrl: defussRuntime.isUrl,
isUrlPath: defussRuntime.isUrlPath,
hasPattern: defussRuntime.hasPattern,
isTrue: defussRuntime.isTrue,
isFalse: defussRuntime.isFalse,
isTruthy: defussRuntime.isTruthy,
isFalsy: defussRuntime.isFalsy,
hasDateFormat: defussRuntime.hasDateFormat,
isInstanceOf: defussRuntime.isInstanceOf,
isTypeOf: defussRuntime.isTypeOf,
isNull: defussRuntime.isNull
};
const transformers = {
asArray: defussRuntime.asArray,
asBoolean: defussRuntime.asBoolean,
asDate: defussRuntime.asDate,
asInteger: defussRuntime.asInteger,
asNumber: defussRuntime.asNumber,
asString: defussRuntime.asString
};
class Call {
constructor(name, fn, args, type = "validator") {
this.name = name;
this.fn = fn;
this.args = args;
this.type = type;
}
}
const defaultFormatter = (msgs) => msgs.map((msg) => msg.message).join(", ");
class Rules {
fieldPath;
validationCalls;
lastValidationResult;
transformedData;
options;
messageFormatter;
_state = { isNegated: false, hasValidators: false };
constructor(fieldPath, options = {}) {
this.fieldPath = fieldPath;
this.validationCalls = [];
this.lastValidationResult = { isValid: true, messages: [] };
this.transformedData = void 0;
this.options = { timeout: 5e3, ...options };
this.isValid = this.isValid.bind(this);
}
get not() {
const state = this._state;
if (state.isNegated || state.hasValidators) {
throw new Error(
"Multiple negations are not allowed in a validation chain"
);
}
state.isNegated = true;
return this;
}
_handleValidationError(error, callback) {
callback && queueMicrotask(() => callback(false, error));
const message = error.message;
if (message.includes("timeout") || message.includes("Validation failed for field:"))
throw error;
return false;
}
async isValid(formData, callback) {
try {
const result = await this._triggerValidation(formData);
callback && queueMicrotask(() => callback(result.isValid, result.error));
return result.isValid;
} catch (error) {
return this._handleValidationError(error, callback);
}
}
async _triggerValidation(formData) {
const validationPromise = this._executeValidation(formData);
return !this.options.timeout || this.options.timeout <= 0 ? validationPromise : Promise.race([
validationPromise,
new Promise(
(_, reject) => setTimeout(
() => reject(
new Error(
`Validation timeout after ${this.options.timeout}ms`
)
),
this.options.timeout
)
)
]);
}
async _executeValidation(formData) {
let currentValue = this._getFieldValue(formData);
const validationErrors = [];
this.transformedData = JSON.parse(JSON.stringify(formData));
try {
const calls = this.validationCalls;
const fieldPath = this.fieldPath;
const pathString = defussRuntime.isPathAccessor(fieldPath) ? String(fieldPath) : fieldPath;
let transformedData = this.transformedData;
for (let i = 0; i < calls.length; i++) {
const call = calls[i];
const result = await call.fn(currentValue, ...call.args);
if (call.type === "transformer") {
transformedData = defussRuntime.setByPath(
transformedData,
pathString,
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
currentValue = result
);
} else {
if (result === true) {
continue;
} else if (result === false) {
validationErrors.push(`Validation failed for ${call.name}`);
} else if (typeof result === "string") {
validationErrors.push(result);
} else if (Array.isArray(result)) {
const stringErrors = result.filter(
(item) => typeof item === "string"
);
validationErrors.push(...stringErrors);
} else {
validationErrors.push(`Validation failed for ${call.name}`);
}
}
}
this.transformedData = transformedData;
return this._buildValidationResult(validationErrors);
} catch (error) {
return this._buildErrorResult(error);
}
}
_buildValidationResult(errors) {
const isValid = !errors.length;
const state = this._state;
const finalIsValid = state.isNegated ? !isValid : isValid;
const fieldPath = this.fieldPath;
const pathString = defussRuntime.isPathAccessor(fieldPath) ? String(fieldPath) : fieldPath;
let finalMessages = state.isNegated && isValid ? [
...errors.map((msg) => ({ message: msg, path: pathString })),
{
message: "Validation was expected to fail but passed",
path: pathString
}
] : errors.map((msg) => ({ message: msg, path: pathString }));
if (this.messageFormatter && finalMessages.length > 0) {
const formattedMessage = this.messageFormatter(
finalMessages,
defaultFormatter
);
finalMessages = [{ message: formattedMessage, path: pathString }];
}
return this.lastValidationResult = {
isValid: finalIsValid,
messages: finalMessages
};
}
_buildErrorResult(error) {
const state = this._state;
const fieldPath = this.fieldPath;
const pathString = defussRuntime.isPathAccessor(fieldPath) ? String(fieldPath) : fieldPath;
this.options.onValidationError?.(error, { fn: () => false, args: [] });
return this.lastValidationResult = {
isValid: state.isNegated,
messages: state.isNegated ? [] : [{ message: `Validation error: ${error.message}`, path: pathString }],
error
};
}
_getFieldValue(formData) {
const fieldPath = this.fieldPath;
const pathString = defussRuntime.isPathAccessor(fieldPath) ? String(fieldPath) : fieldPath;
const value = defussRuntime.getByPath(formData, pathString);
if (value !== void 0 || !pathString.includes(".")) return value;
const pathParts = pathString.split(".");
let current = formData;
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i];
if (current == null) {
throw new Error(
`Validation failed for field: ${pathString} - path not found at '${pathParts.slice(0, i).join(".")}'`
);
}
if (typeof current !== "object" || !(part in current)) {
throw new Error(
`Validation failed for field: ${pathString} - field '${part}' not found`
);
}
current = current[part];
}
return current;
}
useFormatter(messageFn) {
if (messageFn) this.messageFormatter = messageFn;
return this;
}
getMessages() {
return this.lastValidationResult.messages;
}
getData() {
return this.transformedData;
}
getField(path) {
if (this.transformedData === void 0) {
throw new Error(
"No transformed data available. Call isValid() first to execute validation and transformers."
);
}
const pathString = defussRuntime.isPathAccessor(path) ? String(path) : path;
return defussRuntime.getByPath(this.transformedData, pathString);
}
// biome-ignore lint/suspicious/noThenProperty: Required for custom promise-chain behavior
then(onfulfilled, onrejected) {
return Promise.resolve(this).then(onfulfilled, onrejected);
}
}
function chainFn(fn, type, ...args) {
if (type === "validator") this._state.hasValidators = true;
this.validationCalls.push(
new Call(
fn.name || type,
(value) => Promise.resolve(fn(value, ...args)),
args,
type
)
);
return this;
}
function _addMethodsToPrototype(methods, type) {
for (const [name, fn] of Object.entries(methods)) {
if (typeof fn === "function") {
Rules.prototype[name] = function(...args) {
return chainFn.call(this, fn, type, ...args);
};
}
}
}
_addMethodsToPrototype(validators, "validator");
_addMethodsToPrototype(transformers, "transformer");
function rule(fieldPath, options) {
return new Rules(fieldPath, options);
}
function transval(...args) {
const chains = Array.isArray(args[0]) ? args[0] : args.filter(
(arg) => arg && typeof arg === "object" && "fieldPath" in arg
);
return {
async isValid(formData, callback) {
try {
const results = await Promise.all(
chains.map((chain) => chain.isValid(formData))
);
const isValid = results.every(Boolean);
callback && queueMicrotask(() => callback(isValid));
return isValid;
} catch (error) {
callback && queueMicrotask(() => callback(false, error));
const message = error.message;
if (message.includes("timeout") || message.includes("Validation failed for field:"))
throw error;
return false;
}
},
getMessages: (path, customFormatterFn) => {
let messages;
if (path === void 0 || path === null) {
messages = chains.flatMap((chain) => chain.getMessages());
} else {
const pathString = defussRuntime.isPathAccessor(path) ? String(path) : path;
const targetChain = chains.find((chain) => {
const chainPathString = defussRuntime.isPathAccessor(chain.fieldPath) ? String(chain.fieldPath) : chain.fieldPath;
return chainPathString === pathString;
});
messages = targetChain ? targetChain.getMessages() : [];
}
if (customFormatterFn) {
return customFormatterFn(messages);
}
return messages;
},
getData: () => {
const transformedChains = chains.filter(
(chain) => chain.transformedData !== void 0
);
if (transformedChains.length === 0) {
return void 0;
}
let mergedData = JSON.parse(
JSON.stringify(transformedChains[0].transformedData)
);
for (let i = 1; i < transformedChains.length; i++) {
const chain = transformedChains[i];
const pathString = defussRuntime.isPathAccessor(chain.fieldPath) ? String(chain.fieldPath) : chain.fieldPath;
const transformedValue = defussRuntime.getByPath(chain.transformedData, pathString);
mergedData = defussRuntime.setByPath(mergedData, pathString, transformedValue);
}
return mergedData;
},
getField: (path) => {
const pathString = defussRuntime.isPathAccessor(path) ? String(path) : path;
const targetChain = chains.find((chain) => {
const chainPathString = defussRuntime.isPathAccessor(chain.fieldPath) ? String(chain.fieldPath) : chain.fieldPath;
return chainPathString === pathString;
});
if (targetChain && targetChain.transformedData !== void 0) {
return defussRuntime.getByPath(targetChain.transformedData, pathString);
}
for (const chain of chains) {
if (chain.transformedData !== void 0) {
try {
const value = defussRuntime.getByPath(chain.transformedData, pathString);
if (value !== void 0) {
return value;
}
} catch {
}
}
}
return void 0;
}
};
}
rule.extend = (ExtensionClass) => {
const extensionMethods = Object.getOwnPropertyNames(
ExtensionClass.prototype
).filter(
(name) => name !== "constructor" && !Object.getOwnPropertyNames(Rules.prototype).includes(name) && typeof ExtensionClass.prototype[name] === "function"
);
extensionMethods.forEach((methodName) => {
Rules.prototype[methodName] = function(...args) {
const extensionFn = ExtensionClass.prototype[methodName].bind(this)(
...args
);
const isTransformer = /^as[A-Z]/.test(methodName);
return chainFn.call(
this,
extensionFn,
isTransformer ? "transformer" : "validator"
);
};
});
const extendedValidationChain = (fieldPath, options) => new Rules(fieldPath, options);
extendedValidationChain.extend = rule.extend;
return extendedValidationChain;
};
exports.Call = Call;
exports.Rules = Rules;
exports.rule = rule;
exports.transformers = transformers;
exports.transval = transval;
exports.validators = validators;
Object.keys(defussRuntime).forEach(function (k) {
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
enumerable: true,
get: function () { return defussRuntime[k]; }
});
});