Umama-at-Bluchip/Quick-UI
0
1/**2 * @filedescription Object Schema3 */4 5"use strict";6 7//-----------------------------------------------------------------------------8// Requirements9//-----------------------------------------------------------------------------10 11const { MergeStrategy } = require("./merge-strategy");12const { ValidationStrategy } = require("./validation-strategy");13 14//-----------------------------------------------------------------------------15// Private16//-----------------------------------------------------------------------------17 18const strategies = Symbol("strategies");19const requiredKeys = Symbol("requiredKeys");20 21/**22 * Validates a schema strategy.23 * @param {string} name The name of the key this strategy is for.24 * @param {Object} strategy The strategy for the object key.25 * @param {boolean} [strategy.required=true] Whether the key is required.26 * @param {string[]} [strategy.requires] Other keys that are required when27 * this key is present.28 * @param {Function} strategy.merge A method to call when merging two objects29 * with the same key.30 * @param {Function} strategy.validate A method to call when validating an31 * object with the key.32 * @returns {void}33 * @throws {Error} When the strategy is missing a name.34 * @throws {Error} When the strategy is missing a merge() method.35 * @throws {Error} When the strategy is missing a validate() method.36 */37function validateDefinition(name, strategy) {38 39 let hasSchema = false;40 if (strategy.schema) {41 if (typeof strategy.schema === "object") {42 hasSchema = true;43 } else {44 throw new TypeError("Schema must be an object.");45 }46 }47 48 if (typeof strategy.merge === "string") {49 if (!(strategy.merge in MergeStrategy)) {50 throw new TypeError(`Definition for key "${name}" missing valid merge strategy.`);51 }52 } else if (!hasSchema && typeof strategy.merge !== "function") {53 throw new TypeError(`Definition for key "${name}" must have a merge property.`);54 }55 56 if (typeof strategy.validate === "string") {57 if (!(strategy.validate in ValidationStrategy)) {58 throw new TypeError(`Definition for key "${name}" missing valid validation strategy.`);59 }60 } else if (!hasSchema && typeof strategy.validate !== "function") {61 throw new TypeError(`Definition for key "${name}" must have a validate() method.`);62 }63}64 65//-----------------------------------------------------------------------------66// Errors67//-----------------------------------------------------------------------------68 69/**70 * Error when an unexpected key is found.71 */72class UnexpectedKeyError extends Error {73 74 /**75 * Creates a new instance.76 * @param {string} key The key that was unexpected. 77 */78 constructor(key) {79 super(`Unexpected key "${key}" found.`);80 }81}82 83/**84 * Error when a required key is missing.85 */86class MissingKeyError extends Error {87 88 /**89 * Creates a new instance.90 * @param {string} key The key that was missing. 91 */92 constructor(key) {93 super(`Missing required key "${key}".`);94 }95}96 97/**98 * Error when a key requires other keys that are missing.99 */100class MissingDependentKeysError extends Error {101 102 /**103 * Creates a new instance.104 * @param {string} key The key that was unexpected.105 * @param {Array<string>} requiredKeys The keys that are required.106 */107 constructor(key, requiredKeys) {108 super(`Key "${key}" requires keys "${requiredKeys.join("\", \"")}".`);109 }110}111 112/**113 * Wrapper error for errors occuring during a merge or validate operation.114 */115class WrapperError extends Error {116 117 /**118 * Creates a new instance.119 * @param {string} key The object key causing the error. 120 * @param {Error} source The source error. 121 */122 constructor(key, source) {123 super(`Key "${key}": ${source.message}`, { cause: source });124 125 // copy over custom properties that aren't represented126 for (const key of Object.keys(source)) {127 if (!(key in this)) {128 this[key] = source[key];129 }130 }131 }132}133 134//-----------------------------------------------------------------------------135// Main136//-----------------------------------------------------------------------------137 138/**139 * Represents an object validation/merging schema.140 */141class ObjectSchema {142 143 /**144 * Creates a new instance.145 */146 constructor(definitions) {147 148 if (!definitions) {149 throw new Error("Schema definitions missing.");150 }151 152 /**153 * Track all strategies in the schema by key.154 * @type {Map}155 * @property strategies156 */157 this[strategies] = new Map();158 159 /**160 * Separately track any keys that are required for faster validation.161 * @type {Map}162 * @property requiredKeys163 */164 this[requiredKeys] = new Map();165 166 // add in all strategies167 for (const key of Object.keys(definitions)) {168 validateDefinition(key, definitions[key]);169 170 // normalize merge and validate methods if subschema is present171 if (typeof definitions[key].schema === "object") {172 const schema = new ObjectSchema(definitions[key].schema);173 definitions[key] = {174 ...definitions[key],175 merge(first = {}, second = {}) {176 return schema.merge(first, second);177 },178 validate(value) {179 ValidationStrategy.object(value);180 schema.validate(value);181 }182 };183 }184 185 // normalize the merge method in case there's a string186 if (typeof definitions[key].merge === "string") {187 definitions[key] = {188 ...definitions[key],189 merge: MergeStrategy[definitions[key].merge]190 };191 };192 193 // normalize the validate method in case there's a string194 if (typeof definitions[key].validate === "string") {195 definitions[key] = {196 ...definitions[key],197 validate: ValidationStrategy[definitions[key].validate]198 };199 };200 201 this[strategies].set(key, definitions[key]);202 203 if (definitions[key].required) {204 this[requiredKeys].set(key, definitions[key]);205 }206 }207 }208 209 /**210 * Determines if a strategy has been registered for the given object key.211 * @param {string} key The object key to find a strategy for.212 * @returns {boolean} True if the key has a strategy registered, false if not. 213 */214 hasKey(key) {215 return this[strategies].has(key);216 }217 218 /**219 * Merges objects together to create a new object comprised of the keys220 * of the all objects. Keys are merged based on the each key's merge221 * strategy.222 * @param {...Object} objects The objects to merge.223 * @returns {Object} A new object with a mix of all objects' keys.224 * @throws {Error} If any object is invalid.225 */226 merge(...objects) {227 228 // double check arguments229 if (objects.length < 2) {230 throw new TypeError("merge() requires at least two arguments.");231 }232 233 if (objects.some(object => (object == null || typeof object !== "object"))) {234 throw new TypeError("All arguments must be objects.");235 }236 237 return objects.reduce((result, object) => {238 239 this.validate(object);240 241 for (const [key, strategy] of this[strategies]) {242 try {243 if (key in result || key in object) {244 const value = strategy.merge.call(this, result[key], object[key]);245 if (value !== undefined) {246 result[key] = value;247 }248 }249 } catch (ex) {250 throw new WrapperError(key, ex);251 }252 }253 return result;254 }, {});255 }256 257 /**258 * Validates an object's keys based on the validate strategy for each key.259 * @param {Object} object The object to validate.260 * @returns {void}261 * @throws {Error} When the object is invalid. 262 */263 validate(object) {264 265 // check existing keys first266 for (const key of Object.keys(object)) {267 268 // check to see if the key is defined269 if (!this.hasKey(key)) {270 throw new UnexpectedKeyError(key);271 }272 273 // validate existing keys274 const strategy = this[strategies].get(key);275 276 // first check to see if any other keys are required277 if (Array.isArray(strategy.requires)) {278 if (!strategy.requires.every(otherKey => otherKey in object)) {279 throw new MissingDependentKeysError(key, strategy.requires);280 }281 }282 283 // now apply remaining validation strategy284 try {285 strategy.validate.call(strategy, object[key]);286 } catch (ex) {287 throw new WrapperError(key, ex);288 }289 }290 291 // ensure required keys aren't missing292 for (const [key] of this[requiredKeys]) {293 if (!(key in object)) {294 throw new MissingKeyError(key);295 }296 }297 298 }299}300 301exports.ObjectSchema = ObjectSchema;302 