@ikaru5/heimdall-contract
Version:
Validation and Representer Objects in your Frontend
254 lines (218 loc) • 13.8 kB
JavaScript
/**
* 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!"
}
}