// @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} 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} */ #definitions = new Map(); /** * Separately track any keys that are required for faster validtion. * @type {Map} */ #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 };