UNPKG

@ikaru5/heimdall-contract

Version:

Validation and Representer Objects in your Frontend

254 lines (218 loc) 13.8 kB
/** * Iterate recursively over the schema and calls _validateProperty to validate the elements. * Note: all functions are exported and bound to the contract instance. The bound version is private (starts with _). Always use the bound version! * @param schema - used for recursion * @param depth - used for recursion * @private */ export const validate = function (schema = this.schema, depth = []) { for (const key of Object.keys(schema)) { if (this.contractConfig.ignoreUnderscoredFields && key.startsWith("_")) continue // ignore underscored fields const value = schema[key] // if value has a dType, it is a property and not a nested object if (undefined !== value["dType"]) { this._validateProperty(depth.concat(key), value) // arrays are also properties, but they can have outer and inner validations if ("Array" === value.dType) this._validateArray(depth, value, key) // check inner validations } else { // if value has no dType, it is a nested object and needs to be validated recursively this._validate(value, depth.concat(key)) } } } /** * Validates array elements according to their configuration. * Handles both basic data types and nested contracts in arrays. * @param {Array<string>} depth - Current depth path in the schema * @param {Object} propertyConfiguration - Configuration object for the array property * @param {string} key - Property key name * @private */ export const validateArray = function (depth, propertyConfiguration, key) { const elements = this.getValueAtPath(depth.concat(key)) if (undefined === elements || 0 === elements.length) return // if no elements present nothing to validate // if definitions is a string, it is a dType and we can use the default validation of _validateProperty for each element // or if it is an array of strings, it must be a mixed type array of basic types if ("string" === typeof propertyConfiguration.arrayOf || (Array.isArray(propertyConfiguration.arrayOf) && "string" === typeof propertyConfiguration.arrayOf[0])) { if (undefined === propertyConfiguration.innerValidate) return // if no inner validations defined, no need to do something // create a stubbed configuration for the inner validation const stubbedConfig = propertyConfiguration.innerValidate stubbedConfig["dType"] = propertyConfiguration.arrayOf // validate each element for (let index = 0; index < elements.length; index++) { const newDepth = depth.concat(key).concat(index) // on mixed type arrays, we need to determine the type of the element and try to validate it accordingly if (Array.isArray(propertyConfiguration.arrayOf)) { const element = this.getValueAtPath(newDepth) stubbedConfig["dType"] = propertyConfiguration.arrayOf[0] if (typeof element === "number" && propertyConfiguration.arrayOf.includes("Number")) stubbedConfig["dType"] = "Number" else if (typeof element === "string" && propertyConfiguration.arrayOf.includes("String")) stubbedConfig["dType"] = "String" else if (typeof element === "boolean" && propertyConfiguration.arrayOf.includes("Boolean")) stubbedConfig["dType"] = "Boolean" else if (propertyConfiguration.arrayOf.includes("Generic")) stubbedConfig["dType"] = "Generic" } this._validateProperty(newDepth, stubbedConfig) } } else { // must be a contract, but may fail if nonsense provided for (let index = 0; index < elements.length; index++) { const elementDepth = depth.concat(key).concat(index) // no innerValidations are allowed for contracts, but breakers may be defined if (undefined !== propertyConfiguration.innerValidate) { const {usedBreakers, outbreaksValidations} = checkBreakers(this, Object.keys(propertyConfiguration.innerValidate), elements[index], propertyConfiguration.innerValidate, "Contract", elementDepth) if (outbreaksValidations) continue // if a breakers matched, no need to do further validations const remainingValidations = Object.keys(propertyConfiguration.innerValidate).filter(f => !usedBreakers.includes(f)) for (const validationName of remainingValidations) console.error(`Undefined or invalid validation: ${validationName} at ${elementDepth.join(".")}`) } // if element is not a function -> it is not a contract -> try creating a nested contract // this will most likely always be the case when values are assigned manually and not by assign-method if ("function" !== typeof elements[index]._parseParent) { elements[index] = this._createNestedContractForArray( Array.isArray(propertyConfiguration.arrayOf) ? propertyConfiguration.arrayOf.find(contractClass => this._getNameOfClass(contractClass) === elements[index]["arrayElementType"]) : propertyConfiguration.arrayOf, elements[index] ) } elements[index]._parseParent(this) // set parent and its attributes like localization method if (!elements[index].isValid(this._validationContext)) { this.isValidState = false this.setValueAtPath(["errors"].concat(depth).concat(key).concat(`${index}`), elements[index].errors) } } } } /** * Validate property and set "this.isValidState" and "this.errors". * @param depth * @param propertyConfiguration * @private */ export const validateProperty = function (depth, propertyConfiguration) { // get all validation configs for field and return if none exist const validations = Object.keys(propertyConfiguration).filter(f => !this.contractConfig._nonValidationConfigs.includes(f)) // filter out non-validation configs like "default", "as", ... // if no validations defined, no need to do something, but this should not happen: // validateProperty is called for properties only. Heimdall determines the properties by checking for "dType" in the schema. // if there is no "dType" for an entry, validateProperty is not called. // Since dType is also a validation there will always be at least one validation. if (validations.length === 0) return // get the value and the dType const propValue = this.getValueAtPath(depth) const dType = propertyConfiguration.dType const errors = [] // basket for all errors // 1. STEP: check validation breakers like "allowBlank" or "validateIf" const {usedBreakers, outbreaksValidations} = checkBreakers(this, validations, propValue, propertyConfiguration, dType, depth) if (outbreaksValidations) return // if a breakers matched, no need to do further validations // remove the breakers and get only normal validations const normalValidations = validations.filter(f => !usedBreakers.includes(f)) // 2 STEP: execute normal validations const usedNormalValidations = [] if ("Contract" === dType) { const nestedContract = this.getValueAtPath(depth) nestedContract._parseParent(this) // set parent and its attributes like localization method if (!nestedContract.isValid(this._validationContext)) { this.isValidState = false this.setValueAtPath(["errors"].concat(depth), nestedContract.errors) } // contracts should not have normal validations and will be skipped // there will be an error about invalid validations in the console if there are any (see end of this function) usedNormalValidations.push("dType") } else { for (const validationName of validations) { // iterate over all validations if (undefined !== this._validations.normal[validationName]) { // check if validation is defined if (!this._validations.normal[validationName].check({value: propValue, config: propertyConfiguration[validationName], dType, depth, contract: this})) { // validate -> validation returns true if valid const errorMessage = this._getErrorMessageFor(propValue, propertyConfiguration, dType, depth, "normal", validationName) errors.push(errorMessage) this.isValidState = false } usedNormalValidations.push(validationName) } // run custom "validation" if ("validate" === validationName) { const resultOfCustomValidation = propertyConfiguration[validationName](propValue, this, dType, depth) // it must be a function. it returns true if valid if ("boolean" === typeof resultOfCustomValidation) { // if it returns a boolean and is false, it is invalid, but no custom error message is provided if (!resultOfCustomValidation) { this.isValidState = false errors.push(this._getGenericErrorMessage()) } // use generic error message } else { errors.push(resultOfCustomValidation) this.isValidState = false } usedNormalValidations.push(validationName) } } } // 3. STEP assign errors if any if (errors.length > 0) this.setValueAtPath(["errors"].concat(depth).concat("messages"), errors) // remove normal validations, and it should be empty, but if not, there are undefined validations (probably a typo in the schema) const remainingValidations = normalValidations.filter(f => !usedNormalValidations.includes(f)) for (const validationName of remainingValidations) console.error("Undefined validation: " + validationName) } /** * Helper function to check validation breakers like "allowBlank" or "validateIf". * Breakers can skip remaining validations if their conditions are met. * @param {Contract} instance - The contract instance * @param {Array<string>} validations - Array of validation names to check * @param {*} propValue - The property value being validated * @param {Object} propertyConfiguration - Configuration object for the property * @param {string} dType - Data type of the property * @param {Array<string>} depth - Current depth path in the schema * @returns {Object} Object with usedBreakers array and outbreaksValidations boolean * @private */ const checkBreakers = (instance, validations, propValue, propertyConfiguration, dType, depth) => { const usedBreakers = [] for (const breakerName of validations) { if (undefined !== instance._validations.breaker[breakerName]) { if (instance._validations.breaker[breakerName].check({value: propValue, config: propertyConfiguration[breakerName], dType, depth, contract: instance})) { return {outbreaksValidations: true} // if one of the breakers return true, the field is valid } usedBreakers.push(breakerName) } // custom breaker if ("validateIf" === breakerName) { if (!propertyConfiguration[breakerName](propValue, instance, dType, depth)) return {outbreaksValidations: true} usedBreakers.push(breakerName) } } return {usedBreakers, outbreaksValidations: false} } /** * Retrieves the appropriate error message for a failed validation. * Supports custom error messages, localization, and fallback to default validation messages. * @param {*} propertyValue - The property value that failed validation * @param {Object} propertyConfiguration - Configuration object for the property * @param {string} dType - Data type of the property * @param {Array<string>} depth - Current depth path in the schema * @param {string} validationScope - Validation scope ("normal" or "breaker") * @param {string} validationName - Name of the validation that failed * @returns {string} The error message to display * @private */ export const getErrorMessageFor = function (propertyValue, propertyConfiguration, dType, depth, validationScope, validationName) { if (undefined !== propertyConfiguration.errorMessage) { const handleStringResult = (stringResult) => { if (this.contractConfig.tryTranslateMessages && this.contractConfig.customLocalization) return this.contractConfig.customLocalization({translationKey: stringResult, translationKeys: [stringResult], fallbackValue: stringResult, context: {value: propertyValue, dType, depth, contract: this}}) return stringResult } if ("string" === typeof propertyConfiguration.errorMessage) return handleStringResult(propertyConfiguration.errorMessage) if ("function" === typeof propertyConfiguration.errorMessage) return propertyConfiguration.errorMessage(propertyValue, this, validationName, dType, depth, validationScope) if ("string" === typeof propertyConfiguration.errorMessage[validationName]) return handleStringResult(propertyConfiguration.errorMessage[validationName]) if ("function" === typeof propertyConfiguration.errorMessage[validationName]) return propertyConfiguration.errorMessage[validationName](propertyValue, this, validationName, dType, depth, validationScope) if ("string" === typeof propertyConfiguration.errorMessage.default) return handleStringResult(propertyConfiguration.errorMessage.default) if ("function" === typeof propertyConfiguration.errorMessage.default) return propertyConfiguration.errorMessage.default(propertyValue, this, validationName, dType, depth, validationScope) } return this._validations[validationScope][validationName].message({value: propertyValue, config: propertyConfiguration[validationName], dType, depth, contract: this, customLocalization: this.contractConfig.customLocalization}) } /** * Returns a generic error message when no specific error message is available. * Supports localization through custom localization function if configured. * @returns {string} The generic error message * @private */ export const getGenericErrorMessage = function () { if (this.contractConfig.customLocalization) { return this.contractConfig.customLocalization({translationKey: "errors:generic", translationKeys: ["errors:generic"], fallbackValue: "Field invalid!", context: {contract: this}}) } else { return "Field invalid!" } }