456 lines
12 KiB
JavaScript
456 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
exports.MergeStrategy = MergeStrategy;
|
|
exports.ObjectSchema = ObjectSchema;
|
|
exports.ValidationStrategy = ValidationStrategy;
|