UNPKG

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
'use strict'; 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]; } }); });