453 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			453 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
		
			Executable File
		
	
	
	
	
| // @ts-self-types="./index.d.ts"
 | |
| /**
 | |
|  * @fileoverview Merge Strategy
 | |
|  */
 | |
| 
 | |
| //-----------------------------------------------------------------------------
 | |
| // Class
 | |
| //-----------------------------------------------------------------------------
 | |
| 
 | |
| /**
 | |
|  * Container class for several different merge strategies.
 | |
|  */
 | |
| class MergeStrategy {
 | |
| 	/**
 | |
| 	 * Merges two keys by overwriting the first with the second.
 | |
| 	 * @param {*} value1 The value from the first object key.
 | |
| 	 * @param {*} value2 The value from the second object key.
 | |
| 	 * @returns {*} The second value.
 | |
| 	 */
 | |
| 	static overwrite(value1, value2) {
 | |
| 		return value2;
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Merges two keys by replacing the first with the second only if the
 | |
| 	 * second is defined.
 | |
| 	 * @param {*} value1 The value from the first object key.
 | |
| 	 * @param {*} value2 The value from the second object key.
 | |
| 	 * @returns {*} The second value if it is defined.
 | |
| 	 */
 | |
| 	static replace(value1, value2) {
 | |
| 		if (typeof value2 !== "undefined") {
 | |
| 			return value2;
 | |
| 		}
 | |
| 
 | |
| 		return value1;
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Merges two properties by assigning properties from the second to the first.
 | |
| 	 * @param {*} value1 The value from the first object key.
 | |
| 	 * @param {*} value2 The value from the second object key.
 | |
| 	 * @returns {*} A new object containing properties from both value1 and
 | |
| 	 *      value2.
 | |
| 	 */
 | |
| 	static assign(value1, value2) {
 | |
| 		return Object.assign({}, value1, value2);
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * @fileoverview Validation Strategy
 | |
|  */
 | |
| 
 | |
| //-----------------------------------------------------------------------------
 | |
| // Class
 | |
| //-----------------------------------------------------------------------------
 | |
| 
 | |
| /**
 | |
|  * Container class for several different validation strategies.
 | |
|  */
 | |
| class ValidationStrategy {
 | |
| 	/**
 | |
| 	 * Validates that a value is an array.
 | |
| 	 * @param {*} value The value to validate.
 | |
| 	 * @returns {void}
 | |
| 	 * @throws {TypeError} If the value is invalid.
 | |
| 	 */
 | |
| 	static array(value) {
 | |
| 		if (!Array.isArray(value)) {
 | |
| 			throw new TypeError("Expected an array.");
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Validates that a value is a boolean.
 | |
| 	 * @param {*} value The value to validate.
 | |
| 	 * @returns {void}
 | |
| 	 * @throws {TypeError} If the value is invalid.
 | |
| 	 */
 | |
| 	static boolean(value) {
 | |
| 		if (typeof value !== "boolean") {
 | |
| 			throw new TypeError("Expected a Boolean.");
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Validates that a value is a number.
 | |
| 	 * @param {*} value The value to validate.
 | |
| 	 * @returns {void}
 | |
| 	 * @throws {TypeError} If the value is invalid.
 | |
| 	 */
 | |
| 	static number(value) {
 | |
| 		if (typeof value !== "number") {
 | |
| 			throw new TypeError("Expected a number.");
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Validates that a value is a object.
 | |
| 	 * @param {*} value The value to validate.
 | |
| 	 * @returns {void}
 | |
| 	 * @throws {TypeError} If the value is invalid.
 | |
| 	 */
 | |
| 	static object(value) {
 | |
| 		if (!value || typeof value !== "object") {
 | |
| 			throw new TypeError("Expected an object.");
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Validates that a value is a object or null.
 | |
| 	 * @param {*} value The value to validate.
 | |
| 	 * @returns {void}
 | |
| 	 * @throws {TypeError} If the value is invalid.
 | |
| 	 */
 | |
| 	static "object?"(value) {
 | |
| 		if (typeof value !== "object") {
 | |
| 			throw new TypeError("Expected an object or null.");
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Validates that a value is a string.
 | |
| 	 * @param {*} value The value to validate.
 | |
| 	 * @returns {void}
 | |
| 	 * @throws {TypeError} If the value is invalid.
 | |
| 	 */
 | |
| 	static string(value) {
 | |
| 		if (typeof value !== "string") {
 | |
| 			throw new TypeError("Expected a string.");
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Validates that a value is a non-empty string.
 | |
| 	 * @param {*} value The value to validate.
 | |
| 	 * @returns {void}
 | |
| 	 * @throws {TypeError} If the value is invalid.
 | |
| 	 */
 | |
| 	static "string!"(value) {
 | |
| 		if (typeof value !== "string" || value.length === 0) {
 | |
| 			throw new TypeError("Expected a non-empty string.");
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * @fileoverview Object Schema
 | |
|  */
 | |
| 
 | |
| 
 | |
| //-----------------------------------------------------------------------------
 | |
| // Types
 | |
| //-----------------------------------------------------------------------------
 | |
| 
 | |
| /** @typedef {import("./types.ts").ObjectDefinition} ObjectDefinition */
 | |
| /** @typedef {import("./types.ts").PropertyDefinition} PropertyDefinition */
 | |
| 
 | |
| //-----------------------------------------------------------------------------
 | |
| // Private
 | |
| //-----------------------------------------------------------------------------
 | |
| 
 | |
| /**
 | |
|  * Validates a schema strategy.
 | |
|  * @param {string} name The name of the key this strategy is for.
 | |
|  * @param {PropertyDefinition} definition The strategy for the object key.
 | |
|  * @returns {void}
 | |
|  * @throws {Error} When the strategy is missing a name.
 | |
|  * @throws {Error} When the strategy is missing a merge() method.
 | |
|  * @throws {Error} When the strategy is missing a validate() method.
 | |
|  */
 | |
| function validateDefinition(name, definition) {
 | |
| 	let hasSchema = false;
 | |
| 	if (definition.schema) {
 | |
| 		if (typeof definition.schema === "object") {
 | |
| 			hasSchema = true;
 | |
| 		} else {
 | |
| 			throw new TypeError("Schema must be an object.");
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if (typeof definition.merge === "string") {
 | |
| 		if (!(definition.merge in MergeStrategy)) {
 | |
| 			throw new TypeError(
 | |
| 				`Definition for key "${name}" missing valid merge strategy.`,
 | |
| 			);
 | |
| 		}
 | |
| 	} else if (!hasSchema && typeof definition.merge !== "function") {
 | |
| 		throw new TypeError(
 | |
| 			`Definition for key "${name}" must have a merge property.`,
 | |
| 		);
 | |
| 	}
 | |
| 
 | |
| 	if (typeof definition.validate === "string") {
 | |
| 		if (!(definition.validate in ValidationStrategy)) {
 | |
| 			throw new TypeError(
 | |
| 				`Definition for key "${name}" missing valid validation strategy.`,
 | |
| 			);
 | |
| 		}
 | |
| 	} else if (!hasSchema && typeof definition.validate !== "function") {
 | |
| 		throw new TypeError(
 | |
| 			`Definition for key "${name}" must have a validate() method.`,
 | |
| 		);
 | |
| 	}
 | |
| }
 | |
| 
 | |
| //-----------------------------------------------------------------------------
 | |
| // Errors
 | |
| //-----------------------------------------------------------------------------
 | |
| 
 | |
| /**
 | |
|  * Error when an unexpected key is found.
 | |
|  */
 | |
| class UnexpectedKeyError extends Error {
 | |
| 	/**
 | |
| 	 * Creates a new instance.
 | |
| 	 * @param {string} key The key that was unexpected.
 | |
| 	 */
 | |
| 	constructor(key) {
 | |
| 		super(`Unexpected key "${key}" found.`);
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Error when a required key is missing.
 | |
|  */
 | |
| class MissingKeyError extends Error {
 | |
| 	/**
 | |
| 	 * Creates a new instance.
 | |
| 	 * @param {string} key The key that was missing.
 | |
| 	 */
 | |
| 	constructor(key) {
 | |
| 		super(`Missing required key "${key}".`);
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Error when a key requires other keys that are missing.
 | |
|  */
 | |
| class MissingDependentKeysError extends Error {
 | |
| 	/**
 | |
| 	 * Creates a new instance.
 | |
| 	 * @param {string} key The key that was unexpected.
 | |
| 	 * @param {Array<string>} requiredKeys The keys that are required.
 | |
| 	 */
 | |
| 	constructor(key, requiredKeys) {
 | |
| 		super(`Key "${key}" requires keys "${requiredKeys.join('", "')}".`);
 | |
| 	}
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Wrapper error for errors occuring during a merge or validate operation.
 | |
|  */
 | |
| class WrapperError extends Error {
 | |
| 	/**
 | |
| 	 * Creates a new instance.
 | |
| 	 * @param {string} key The object key causing the error.
 | |
| 	 * @param {Error} source The source error.
 | |
| 	 */
 | |
| 	constructor(key, source) {
 | |
| 		super(`Key "${key}": ${source.message}`, { cause: source });
 | |
| 
 | |
| 		// copy over custom properties that aren't represented
 | |
| 		for (const sourceKey of Object.keys(source)) {
 | |
| 			if (!(sourceKey in this)) {
 | |
| 				this[sourceKey] = source[sourceKey];
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| //-----------------------------------------------------------------------------
 | |
| // Main
 | |
| //-----------------------------------------------------------------------------
 | |
| 
 | |
| /**
 | |
|  * Represents an object validation/merging schema.
 | |
|  */
 | |
| class ObjectSchema {
 | |
| 	/**
 | |
| 	 * Track all definitions in the schema by key.
 | |
| 	 * @type {Map<string, PropertyDefinition>}
 | |
| 	 */
 | |
| 	#definitions = new Map();
 | |
| 
 | |
| 	/**
 | |
| 	 * Separately track any keys that are required for faster validtion.
 | |
| 	 * @type {Map<string, PropertyDefinition>}
 | |
| 	 */
 | |
| 	#requiredKeys = new Map();
 | |
| 
 | |
| 	/**
 | |
| 	 * Creates a new instance.
 | |
| 	 * @param {ObjectDefinition} definitions The schema definitions.
 | |
| 	 */
 | |
| 	constructor(definitions) {
 | |
| 		if (!definitions) {
 | |
| 			throw new Error("Schema definitions missing.");
 | |
| 		}
 | |
| 
 | |
| 		// add in all strategies
 | |
| 		for (const key of Object.keys(definitions)) {
 | |
| 			validateDefinition(key, definitions[key]);
 | |
| 
 | |
| 			// normalize merge and validate methods if subschema is present
 | |
| 			if (typeof definitions[key].schema === "object") {
 | |
| 				const schema = new ObjectSchema(definitions[key].schema);
 | |
| 				definitions[key] = {
 | |
| 					...definitions[key],
 | |
| 					merge(first = {}, second = {}) {
 | |
| 						return schema.merge(first, second);
 | |
| 					},
 | |
| 					validate(value) {
 | |
| 						ValidationStrategy.object(value);
 | |
| 						schema.validate(value);
 | |
| 					},
 | |
| 				};
 | |
| 			}
 | |
| 
 | |
| 			// normalize the merge method in case there's a string
 | |
| 			if (typeof definitions[key].merge === "string") {
 | |
| 				definitions[key] = {
 | |
| 					...definitions[key],
 | |
| 					merge: MergeStrategy[
 | |
| 						/** @type {string} */ (definitions[key].merge)
 | |
| 					],
 | |
| 				};
 | |
| 			}
 | |
| 
 | |
| 			// normalize the validate method in case there's a string
 | |
| 			if (typeof definitions[key].validate === "string") {
 | |
| 				definitions[key] = {
 | |
| 					...definitions[key],
 | |
| 					validate:
 | |
| 						ValidationStrategy[
 | |
| 							/** @type {string} */ (definitions[key].validate)
 | |
| 						],
 | |
| 				};
 | |
| 			}
 | |
| 
 | |
| 			this.#definitions.set(key, definitions[key]);
 | |
| 
 | |
| 			if (definitions[key].required) {
 | |
| 				this.#requiredKeys.set(key, definitions[key]);
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Determines if a strategy has been registered for the given object key.
 | |
| 	 * @param {string} key The object key to find a strategy for.
 | |
| 	 * @returns {boolean} True if the key has a strategy registered, false if not.
 | |
| 	 */
 | |
| 	hasKey(key) {
 | |
| 		return this.#definitions.has(key);
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Merges objects together to create a new object comprised of the keys
 | |
| 	 * of the all objects. Keys are merged based on the each key's merge
 | |
| 	 * strategy.
 | |
| 	 * @param {...Object} objects The objects to merge.
 | |
| 	 * @returns {Object} A new object with a mix of all objects' keys.
 | |
| 	 * @throws {Error} If any object is invalid.
 | |
| 	 */
 | |
| 	merge(...objects) {
 | |
| 		// double check arguments
 | |
| 		if (objects.length < 2) {
 | |
| 			throw new TypeError("merge() requires at least two arguments.");
 | |
| 		}
 | |
| 
 | |
| 		if (
 | |
| 			objects.some(
 | |
| 				object => object === null || typeof object !== "object",
 | |
| 			)
 | |
| 		) {
 | |
| 			throw new TypeError("All arguments must be objects.");
 | |
| 		}
 | |
| 
 | |
| 		return objects.reduce((result, object) => {
 | |
| 			this.validate(object);
 | |
| 
 | |
| 			for (const [key, strategy] of this.#definitions) {
 | |
| 				try {
 | |
| 					if (key in result || key in object) {
 | |
| 						const merge = /** @type {Function} */ (strategy.merge);
 | |
| 						const value = merge.call(
 | |
| 							this,
 | |
| 							result[key],
 | |
| 							object[key],
 | |
| 						);
 | |
| 						if (value !== undefined) {
 | |
| 							result[key] = value;
 | |
| 						}
 | |
| 					}
 | |
| 				} catch (ex) {
 | |
| 					throw new WrapperError(key, ex);
 | |
| 				}
 | |
| 			}
 | |
| 			return result;
 | |
| 		}, {});
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Validates an object's keys based on the validate strategy for each key.
 | |
| 	 * @param {Object} object The object to validate.
 | |
| 	 * @returns {void}
 | |
| 	 * @throws {Error} When the object is invalid.
 | |
| 	 */
 | |
| 	validate(object) {
 | |
| 		// check existing keys first
 | |
| 		for (const key of Object.keys(object)) {
 | |
| 			// check to see if the key is defined
 | |
| 			if (!this.hasKey(key)) {
 | |
| 				throw new UnexpectedKeyError(key);
 | |
| 			}
 | |
| 
 | |
| 			// validate existing keys
 | |
| 			const definition = this.#definitions.get(key);
 | |
| 
 | |
| 			// first check to see if any other keys are required
 | |
| 			if (Array.isArray(definition.requires)) {
 | |
| 				if (
 | |
| 					!definition.requires.every(otherKey => otherKey in object)
 | |
| 				) {
 | |
| 					throw new MissingDependentKeysError(
 | |
| 						key,
 | |
| 						definition.requires,
 | |
| 					);
 | |
| 				}
 | |
| 			}
 | |
| 
 | |
| 			// now apply remaining validation strategy
 | |
| 			try {
 | |
| 				const validate = /** @type {Function} */ (definition.validate);
 | |
| 				validate.call(definition, object[key]);
 | |
| 			} catch (ex) {
 | |
| 				throw new WrapperError(key, ex);
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		// ensure required keys aren't missing
 | |
| 		for (const [key] of this.#requiredKeys) {
 | |
| 			if (!(key in object)) {
 | |
| 				throw new MissingKeyError(key);
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| export { MergeStrategy, ObjectSchema, ValidationStrategy };
 |