/** * @file validation.js * * Permission is hereby granted to any person obtaining a copy of this software * and associated documentation files (the "Software"), to use it for personal * or non-commercial purposes, with the following restrictions: * * 1. **No Copying or Redistribution**: The Software or any of its parts may not * be copied, merged, distributed, sublicensed, or sold without explicit * prior written permission from the author. * * 2. **Commercial Use**: Any use of the Software for commercial purposes requires * a valid license, obtainable only with the explicit consent of the author. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, * OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * * Ownership of this code remains solely with the original author. Unauthorized * use of this Software is strictly prohibited. * @summary Validation utility for validating and constraining configuration values. * @description Validation utility for validating and constraining configuration values. * @module ValidationUtils * @requires Logger * @exports ValidationUtils * @version 0.1.0 * @since 0.1.0 */ const Logger = require("./logger"); class ValidationUtils { constructor(IloggerEnabled, IloggerLevel) { const loggerEnabled = IloggerEnabled || true; const loggerLevel = IloggerLevel || "warn"; this.logger = new Logger(loggerEnabled, loggerLevel, 'ValidationUtils'); } constrain(value, min, max) { if (typeof value !== "number") { this.logger?.warn(`Value '${value}' is not a number. Defaulting to ${min}.`); return min; } return Math.min(Math.max(value, min), max); } validateSchema(config, schema, name) { const validatedConfig = {}; let configValue; // 1. Remove any unknown keys (keys not defined in the schema). // Log a warning and omit them from the final config. for (const key of Object.keys(config)) { if (!(key in schema)) { this.logger.warn( `[${name}] Unknown key '${key}' found in config. Removing it.` ); delete config[key]; } } // Validate each key in the schema and loop over wildcards if they are not in schema for ( const key in schema ) { if (key === "rules" || key === "description" || key === "schema") { continue; } const fieldSchema = schema[key]; const { rules = {} } = fieldSchema; // Default to the schema's default value if the key is missing if (config[key] === undefined) { if (fieldSchema.default === undefined) { // If there's a nested schema, go deeper with an empty object rather than logging "no rule" if (rules.schema) { this.logger.warn(`${name}.${key} has no default, but has a nested schema.`); validatedConfig[key] = this.validateSchema({}, rules.schema, `${name}.${key}`); } else { this.logger.info( `There is no rule for ${name}.${key} and no default value. ` + `Using full schema value but validating deeper levels first...` ); const SubObject = this.validateSchema({}, fieldSchema, `${name}.${key}`); validatedConfig[key] = SubObject; continue; } } else { this.logger.info(`There is no value provided for ${name}.${key}. Using default value.`); configValue = fieldSchema.default; } //continue; } else { // Use the provided value if it exists, otherwise use the default value configValue = config[key] !== undefined ? config[key] : fieldSchema.default; } // Attempt to parse the value to the expected type if possible switch (rules.type) { case "number": configValue = this.validateNumber(configValue, rules, fieldSchema, name, key); break; case "boolean": configValue = this.validateBoolean(configValue, name, key); break; case "string": configValue = this.validateString(configValue,rules,fieldSchema, name, key); break; case "array": configValue = this.validateArray(configValue, rules, fieldSchema, name, key); break; case "set": configValue = this.validateSet(configValue, rules, fieldSchema, name, key); break; case "object": configValue = this.validateObject(configValue, rules, fieldSchema, name, key); break; case "enum": configValue = this.validateEnum(configValue, rules, fieldSchema, name, key); break; case "curve": validatedConfig[key] = this.validateCurve(configValue,fieldSchema.default); continue; case "machineCurve": validatedConfig[key] = this.validateMachineCurve(configValue,fieldSchema.default); continue; case "integer": validatedConfig[key] = this.validateInteger(configValue, rules, fieldSchema, name, key); continue; case undefined: // If we see 'rules.schema' but no 'rules.type', treat it like an object: if (rules.schema && !rules.type) { // Log a warning and skip the extra pass for nested schema this.logger.warn( `${name}.${key} has a nested schema but no type. ` + `Treating it as type="object" to skip extra pass.` ); } else { // Otherwise, fallback to your existing "validateUndefined" logic validatedConfig[key] = this.validateUndefined(configValue, fieldSchema, name, key); } continue; default: this.logger.warn(`${name}.${key} has an unknown validation type: ${rules.type}. Skipping validation.`); validatedConfig[key] = fieldSchema.default; continue; } // Assign the validated or converted value validatedConfig[key] = configValue; } // Ignore unknown keys by not processing them at all this.logger.info(`Validation completed for ${name}.`); return validatedConfig; } removeUnwantedKeys(obj) { if (Array.isArray(obj)) { return obj.map((item) => this.removeUnwantedKeys(item)); } if (obj && typeof obj === "object") { const newObj = {}; for (const [k, v] of Object.entries(obj)) { // Skip or remove keys like 'default', 'rules', 'description', etc. if (["rules", "description"].includes(k)) { continue; } if("default" in v){ //put the default value in the object newObj[k] = v.default; continue; } newObj[k] = this.removeUnwantedKeys(v); } return newObj; } return obj; } validateMachineCurve(curve, defaultCurve) { if (!curve || typeof curve !== "object" || Object.keys(curve).length === 0) { this.logger.warn("Curve is missing or invalid. Defaulting to basic curve."); return defaultCurve; } // Validate that nq and np exist and are objects const { nq, np } = curve; if (!nq || typeof nq !== "object" || !np || typeof np !== "object") { this.logger.warn("Curve must contain valid 'nq' and 'np' objects. Defaulting to basic curve."); return defaultCurve; } // Validate that each dimension key points to a valid object with x and y arrays const validatedNq = this.validateDimensionStructure(nq, "nq"); const validatedNp = this.validateDimensionStructure(np, "np"); if (!validatedNq || !validatedNp) { return defaultCurve; } return { nq: validatedNq, np: validatedNp }; // Return the validated curve } validateCurve(curve, defaultCurve) { if (!curve || typeof curve !== "object" || Object.keys(curve).length === 0) { this.logger.warn("Curve is missing or invalid. Defaulting to basic curve."); return defaultCurve; } // Validate that each dimension key points to a valid object with x and y arrays const validatedCurve = this.validateDimensionStructure(curve, "curve"); if (!validatedCurve) { return defaultCurve; } return validatedCurve; // Return the validated curve } validateDimensionStructure(dimension, name) { const validatedDimension = {}; for (const [key, value] of Object.entries(dimension)) { // Validate that each key points to an object with x and y arrays if (typeof value !== "object") { this.logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`); return false; } // Validate that x and y are arrays else if (!Array.isArray(value.x) || !Array.isArray(value.y)) { this.logger.warn(`Dimension '${name}' key '${key}' is missing x or y arrays. Converting to arrays.`); // Try to convert to arrays first value.x = Object.values(value.x); value.y = Object.values(value.y); // If still not arrays return false if (!Array.isArray(value.x) || !Array.isArray(value.y)) { this.logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`); return false; } } // Validate that x and y arrays are the same length else if (value.x.length !== value.y.length) { this.logger.warn(`Dimension '${name}' key '${key}' has mismatched x and y lengths. Ignoring this key.`); return false; } // Validate that x values are in ascending order else if (!this.isSorted(value.x)) { this.logger.warn(`Dimension '${name}' key '${key}' has unsorted x values. Sorting...`); return false; } // Validate that x values are unique else if (!this.isUnique(value.x)) { this.logger.warn(`Dimension '${name}' key '${key}' has duplicate x values. Removing duplicates...`); return false; } // Validate that y values are numbers else if (!this.areNumbers(value.y)) { this.logger.warn(`Dimension '${name}' key '${key}' has non-numeric y values. Ignoring this key.`); return false; } validatedDimension[key] = value; } return validatedDimension; } isSorted(arr) { return arr.every((_, i) => i === 0 || arr[i] >= arr[i - 1]); } isUnique(arr) { return new Set(arr).size === arr.length; } areNumbers(arr) { return arr.every((x) => typeof x === "number"); } validateNumber(configValue, rules, fieldSchema, name, key) { if (typeof configValue !== "number") { const parsedValue = parseFloat(configValue); if (!isNaN(parsedValue)) { this.logger.warn(`${name}.${key} was parsed to a number: ${configValue} -> ${parsedValue}`); configValue = parsedValue; } } if (rules.min !== undefined && configValue < rules.min) { this.logger.warn( `${name}.${key} is below the minimum (${rules.min}). Using default value.` ); return fieldSchema.default; } if (rules.max !== undefined && configValue > rules.max) { this.logger.warn( `${name}.${key} exceeds the maximum (${rules.max}). Using default value.` ); return fieldSchema.default; } this.logger.debug(`${name}.${key} is a valid number: ${configValue}`); return configValue; } validateInteger(configValue, rules, fieldSchema, name, key) { if (typeof configValue !== "number" || !Number.isInteger(configValue)) { const parsedValue = parseInt(configValue, 10); if (!isNaN(parsedValue) && Number.isInteger(parsedValue)) { this.logger.warn(`${name}.${key} was parsed to an integer: ${configValue} -> ${parsedValue}`); configValue = parsedValue; } else { this.logger.warn(`${name}.${key} is not a valid integer. Using default value.`); return fieldSchema.default; } } if (rules.min !== undefined && configValue < rules.min) { this.logger.warn(`${name}.${key} is below the minimum integer value (${rules.min}). Using default value.`); return fieldSchema.default; } if (rules.max !== undefined && configValue > rules.max) { this.logger.warn(`${name}.${key} exceeds the maximum integer value (${rules.max}). Using default value.`); return fieldSchema.default; } this.logger.debug(`${name}.${key} is a valid integer: ${configValue}`); return configValue; } validateBoolean(configValue, name, key) { if (typeof configValue !== "boolean") { if (configValue === "true" || configValue === "false") { const parsedValue = configValue === "true"; this.logger.debug(`${name}.${key} was parsed to a boolean: ${configValue} -> ${parsedValue}`); configValue = parsedValue; } } return configValue; } validateString(configValue, rules, fieldSchema, name, key) { let newConfigValue = configValue; if (typeof configValue !== "string") { //check if the value is nullable if(rules.nullable){ if(configValue === null){ return null; } } this.logger.warn(`${name}.${key} is not a string. Trying to convert to string.`); newConfigValue = String(configValue); // Coerce to string if not already } //check if the string is a valid string after conversion if (typeof newConfigValue !== "string") { this.logger.warn(`${name}.${key} is not a valid string. Using default value.`); return fieldSchema.default; } return newConfigValue; } validateSet(configValue, rules, fieldSchema, name, key) { // 1. Ensure we have a Set. If not, use default. if (!(configValue instanceof Set)) { this.logger.info(`${name}.${key} is not a Set. Converting to one using default value.`); return new Set(fieldSchema.default); } // 2. Convert the Set to an array for easier filtering. const validatedArray = [...configValue] .filter((item) => { // 3. Filter based on `rules.itemType`. switch (rules.itemType) { case "number": return typeof item === "number"; case "string": return typeof item === "string"; case "null": // "null" might mean no type restriction (your usage may vary). return true; default: // Fallback if itemType is something else return typeof item === rules.itemType; } }) .slice(0, rules.maxLength || Infinity); // 4. Check if the filtered array meets the minimum length. if (validatedArray.length < (rules.minLength || 1)) { this.logger.warn( `${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.` ); return new Set(fieldSchema.default); } // 5. Return a new Set containing only the valid items. return new Set(validatedArray); } validateArray(configValue, rules, fieldSchema, name, key) { if (!Array.isArray(configValue)) { this.logger.info(`${name}.${key} is not an array. Using default value.`); return fieldSchema.default; } // Validate individual items in the array const validatedArray = configValue .filter((item) => { switch (rules.itemType) { case "number": return typeof item === "number"; case "string": return typeof item === "string"; case "null": // anything goes return true; default: return typeof item === rules.itemType; } }) .slice(0, rules.maxLength || Infinity); if (validatedArray.length < (rules.minLength || 1)) { this.logger.warn( `${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.` ); return fieldSchema.default; } return validatedArray; } validateObject(configValue, rules, fieldSchema, name, key) { if (typeof configValue !== "object" || Array.isArray(configValue)) { this.logger.warn(`${name}.${key} is not a valid object. Using default value.`); return fieldSchema.default; } if (rules.schema) { // Recursively validate nested objects if a schema is defined return this.validateSchema(configValue || {}, rules.schema, `${name}.${key}`); } else { // If no schema is defined, log a warning and use the default this.logger.warn(`${name}.${key} is an object with no schema. Using default value.`); return fieldSchema.default; } } validateEnum(configValue, rules, fieldSchema, name, key) { if (Array.isArray(rules.values)) { //if value is null take default if(configValue === null){ this.logger.warn(`${name}.${key} is null. Using default value.`); return fieldSchema.default; } const validValues = rules.values.map(e => e.value.toLowerCase()); //remove caps configValue = configValue.toLowerCase(); if (!validValues.includes(configValue)) { this.logger.warn( `${name}.${key} has an invalid value : ${configValue}. Allowed values: [${validValues.join(", ")}]. Using default value.` ); return fieldSchema.default; } } else { this.logger.warn( `${name}.${key} is an enum with no 'values' array. Using default value.` ); return fieldSchema.default; } return configValue; } validateUndefined(configValue, fieldSchema, name, key) { if (typeof configValue === "object" && !Array.isArray(configValue)) { this.logger.debug(`${name}.${key} has no defined rules but is an object going 1 level deeper.`); // Recursively validate the nested object return this.validateSchema( configValue || {}, fieldSchema, `${name}.${key}`); } else { this.logger.warn(`${name}.${key} has no defined rules. Using default value.`); return fieldSchema.default; } } } module.exports = ValidationUtils;