UNPKG

@ikaru5/heimdall-contract

Version:

Validation and Representer Objects in your Frontend

412 lines (365 loc) 15.8 kB
import {getErrorMessageFor, getGenericErrorMessage, validate, validateArray, validateProperty} from "./validation-base.js"; import { validationDefinitions as baseValidationDefinitions } from "./validations.js" /** * @typedef Options * @property {object} [schema] - set schema through constructor for little inline contracts for example * @property {function} [initNested] - passed hook for internal use only; will be called after own init * @property {function} [initAll] - passed hook for internal use only; will be called after own initNested */ /** * Provides a class based value holder. * Supports nested values. * !!!ATTENTION!!! 'dType' not allowed as name for a property !!! * dType is required and indicates what datatype is used. * dType can be "String", "Number", "Boolean", "Array", "Contract" or "Generic" (Generic means it doesn't matter) */ export default class Contract { // ----------------------------------------------------------------------------------------------- // Constructor and Init // ----------------------------------------------------------------------------------------------- /** * Heimdall Contract * @param {Options} options */ constructor(options = undefined) { // validation logic bound to the contract instance this._validate = validate.bind(this) this._validateArray = validateArray.bind(this) this._validateProperty = validateProperty.bind(this) this._getGenericErrorMessage = getGenericErrorMessage.bind(this) this._getErrorMessageFor = getErrorMessageFor.bind(this) this.contractConfig = { i18next: undefined, localizationMethod: "Internal", ignoreUnderscoredFields: false, _nonValidationConfigs: [ "default", "errorMessage", "arrayOf", "innerValidate", "contract", "as", "parseAs", "renderAs" ] } this.setConfig() if (options && Object.keys(options).includes("ignoreUnderscoredFields")) this.contractConfig.ignoreUnderscoredFields = options["ignoreUnderscoredFields"] this._additionalValidations = this.addAdditionalValidations() this._setValidations() // it is possible to set the schema in the constructor options -> small inline contracts for example if (options?.schema) { this.schema = options.schema } else { this.schema = this.defineSchema() } this.errors = {} this.init() if ("function" === typeof options?.initNested) { this.initNested = options.initNested.bind(this) this.initNested() } if ("function" === typeof options?.initAll) this.initAll = options.initAll.bind(this) if ("function" === typeof this.initAll) this.initAll() this._define(this.schema, []) this.isValidState = undefined this.isAssignedEmpty = false } /** * Return schema so the constructor can set it. * @returns {object} */ defineSchema() { return ( {} ) } addAdditionalValidations(validations = { breaker: {}, normal: {} }) { return validations } /** * Hook method */ init() { } setConfig() { } // ----------------------------------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------------------------------- /** * Is contract valid? * @param context * @returns {boolean} */ isValid(context = undefined) { this.isValidState = true // if an error occurs it will set it to false during _validate execution. this.errors = {} this._validationContext = context this._validate() return this.isValidState } /** * Helper to assign a corresponding Object. * @param inputObject * @param _depth - private - used for recursion * @param _parsedDepth - private - used for recursion * @param _currentScope - private - used for recursion */ assign(inputObject, _depth = [], _parsedDepth = [], _currentScope = this.schema) { // skip empty assignment if ("" === inputObject || undefined === inputObject) { this.isAssignedEmpty = true // some validations may need it return } for (const key of Object.keys(_currentScope)) { if (this.contractConfig.ignoreUnderscoredFields && key.startsWith("_")) continue // ignore underscored fields const value = _currentScope[key] const inputValueKeys = value.parseAs || value.as || key // inputValueKeys can be a string or an array of strings -> if array, try to find a matching key in inputObject const inputValue = Array.isArray(inputValueKeys) ? this.getFirstMatchingValueAtPath(inputValueKeys.map(key => _parsedDepth.concat(key)), inputObject) : this.getValueAtPath(_parsedDepth.concat(inputValueKeys), inputObject) if (undefined === inputValue) continue if (undefined !== value.dType) { switch (value.dType) { case "Array": for (let index = 0; index < inputValue.length; index++) { if (undefined === value.arrayOf) console.error("Type of array must be defined in arrayOf: " + _depth.concat(key).join(".")) if (undefined === value.arrayOf || "string" === typeof value.arrayOf) { this.setValueAtPath(_depth.concat(key).concat(index), inputValue[index] || this._defaultEmptyValueFor(value.arrayOf)) } else if (Array.isArray(value.arrayOf)) { // Array of multiple types const isBasicDataType = !value.arrayOf.some(type => !["String", "Number", "Boolean", "Generic"].includes(type)) if (isBasicDataType) { // simply assign the values if they are basic types this.setValueAtPath(_depth.concat(key).concat(index), inputValue[index] || this._defaultEmptyValueFor(value.arrayOf)) } else { // otherwise this is must be Contracts if ("object" !== typeof inputValue[index]) console.error("Array of objects must be an array of objects! Property: " + _depth.concat(key).join(".") + " Index: " + index) const requiredContractClass = value.arrayOf.find(contractClass => this._getNameOfClass(contractClass) === inputValue[index]["arrayElementType"]) this.setValueAtPath(_depth.concat(key).concat(index), this._createNestedContractForArray(requiredContractClass, inputValue[index])) } } else { // must be a contract, but may fail if nonsense provided this.setValueAtPath(_depth.concat(key).concat(index), this._createNestedContractForArray(value.arrayOf, inputValue[index])) } } break case "Contract": let nestedContract = this.getValueAtPath(_depth.concat(key)) nestedContract.assign(inputValue) nestedContract._parseParent(this) break default: this.setValueAtPath(_depth.concat(key), inputValue) } } else { this.assign(inputObject, _depth.concat(key), _parsedDepth.concat(key), value) } } } // :'D This is embarrassing, I used StackOverflow to build setValueAtPath and getValueAtPath. // So build thanks to https://stackoverflow.com/a/43849204 /** * Helper to assign nested value. * @param depth - nesting Array * @param value - Value to assign * @param object - default: this - object to assign nested value */ setValueAtPath(depth, value, object = this) { depth.reduce((o, p, i) => o[p] = depth.length === ++i ? value : o[p] || {}, object) } /** * Helper to gather nested value * @param depth * @param object * @returns {*} */ getValueAtPath(depth, object = this) { return depth.reduce((o, p) => o[p], object) } /** * Helper to gather nested value from multiple paths. First found will be returned. * @param {Array<String>} depths * @param {Contract} object * @returns {undefined|*} */ getFirstMatchingValueAtPath(depths, object = this) { for (const inputValueKey of depths) { const value = this.getValueAtPath(inputValueKey, object) if (undefined !== value) return value } return undefined } /** * Returns clean Object with filled data for sending, according to contract schema. * Not safe if not validated before! * @param _depth * @param _currentScope * @returns {{}} */ toObject(_depth = [], _currentScope = this.schema) { const out = {} for (const key of Object.keys(_currentScope)) { if (this.contractConfig.ignoreUnderscoredFields && key.startsWith("_")) continue // ignore underscored fields const value = _currentScope[key] const renderKeys = value.renderAs || value.as || key // for parsing "as" can be an array of keys, but for rendering it must be a single key -> take the first one const renderKey = Array.isArray(renderKeys) ? renderKeys[0] : renderKeys if (undefined !== value.dType) { switch (value.dType) { case "Array": const valueAtPath = this.getValueAtPath(_depth.concat(key)) out[renderKey] = valueAtPath?.map((element) => { if (undefined === value.arrayOf || "string" === typeof value.arrayOf) { // if Array consists of basic types, we can simply return the value return element } else if (Array.isArray(value.arrayOf)) { const isBasicDataType = !value.arrayOf.some(type => !["String", "Number", "Boolean", "Generic"].includes(type)) if (isBasicDataType) { // simply return the values if they are basic types return element } else { // otherwise this is must be nested contract if (element.toObject) return element.toObject() // if elements were assigned directly no new nested contract was created, do it here! const requiredContractClass = value.arrayOf.find(contractClass => this._getNameOfClass(contractClass) === element["arrayElementType"]) return this._createNestedContractForArray(requiredContractClass, element).toObject() } } else { // otherwise this is must be nested contract if (element.toObject) return element.toObject() // if elements were assigned directly no new nested contract was created, do it here! return this._createNestedContractForArray(value.arrayOf, element).toObject() } }) break case "Contract": out[renderKey] = this.getValueAtPath(_depth.concat(key)).toObject() break default: out[renderKey] = this.getValueAtPath(_depth.concat(key)) } } else { out[renderKey] = this.toObject(_depth.concat(key), value) } } return out } // ----------------------------------------------------------------------------------------------- // Private Methods // ----------------------------------------------------------------------------------------------- /** * Returns name of a class. * @param className * @private */ _getNameOfClass(className) { return typeof className === 'function' ? className.name : className } _createNestedContractForArray(contractClassOrDefinition, input) { const nestedContract = this._defaultEmptyValueFor("Contract", contractClassOrDefinition) nestedContract._parseParent(this) nestedContract.assign(input) return nestedContract } // DEFINITION AND INIT /** * Associates recursively the values to "this" by passing it to _defineProperty. (this.user.address.street = "") * Also prepares this.errors. * @param {Object} schema - the current schema level * @param {Array<string>} depth - the current depth path of schema (["user", "address", "street"]) * @private */ _define(schema, depth) { for (const key of Object.keys(schema)) { const value = schema[key] if (this.contractConfig.ignoreUnderscoredFields && key.startsWith("_")) continue // ignore underscored fields if (undefined !== value["dType"]) { this._defineProperty(depth.concat(key), value) } else { this._define(value, depth.concat(key)) } } } /** * Associates the value to "this" and prepares this.errors. (this.user.address.street = "") * @param {Array<string>} depth * @param {Object} config - prop config from schema * @private */ _defineProperty(depth, config) { if ("Contract" === config.dType) { this.setValueAtPath(depth, this._defaultEmptyValueFor(config.dType, config.contract)) } else { const isValidDataType = ["String", "Number", "Boolean", "Generic", "Array", "Contract"].includes(config.dType) if (!isValidDataType) console.warn("Wrong dType: " + config.dType + " for: " + depth + ". Assuming Generic dType.") const targetValue = undefined === config["default"] ? this._defaultEmptyValueFor(config.dType) : config["default"] this.setValueAtPath(depth, targetValue) } } /** * Returns a default empty value for a dType. Provide Contract-Class or schema-definition for nested empty contract! * @param dType * @param contract contract Class or definition for empty contract * @returns {*[]|string|*|null|[]|string|undefined} * @private */ _defaultEmptyValueFor(dType, contract = undefined) { switch (dType) { case "String": return "" case "Number": return null case "Boolean": return undefined case "Generic": return null case "Array": return [] case "Contract": let newContract = undefined if ("function" === typeof contract) { newContract = new contract({initNested: this.initNested, initAll: this.initAll}) } else { newContract = new Contract({schema: contract, initNested: this.initNested, initAll: this.initAll}) } newContract._parseParent(this) return newContract } return this._defaultEmptyValueFor("Generic") } /** * Method will be run by nested Contracts on creation, assignment and validation. * Following tasks are implemented: * * Inherit localizationMethod * @param parent Parent contract instance * @private */ _parseParent(parent) { this.contractConfig.localizationMethod = parent.contractConfig.localizationMethod this.contractConfig.i18next = parent.contractConfig.i18next this.contractConfig.ignoreUnderscoredFields = parent.contractConfig.ignoreUnderscoredFields // merge additionalValidations const myBreaker = this._additionalValidations?.breaker const myNormal = this._additionalValidations?.normal const parentBreaker = parent._additionalValidations?.breaker const parentNormal = parent._additionalValidations?.normal this._additionalValidations = { normal: {...parentNormal, ...myNormal}, breaker: {...parentBreaker, ...myBreaker} } this._setValidations() } /** * Set validations by merging additionalValidations with base validations. * @private */ _setValidations() { // merge additionalValidations with base validations const myBreaker = this._additionalValidations?.breaker const myNormal = this._additionalValidations?.normal const baseBreaker = baseValidationDefinitions.breaker const baseNormal = baseValidationDefinitions.normal this._validations = { normal: {...baseNormal, ...myNormal}, breaker: {...baseBreaker, ...myBreaker} } } } export const ContractBase = Contract