object-schema.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. /**
  2. * @filedescription Object Schema
  3. */
  4. "use strict";
  5. //-----------------------------------------------------------------------------
  6. // Requirements
  7. //-----------------------------------------------------------------------------
  8. const { MergeStrategy } = require("./merge-strategy");
  9. const { ValidationStrategy } = require("./validation-strategy");
  10. //-----------------------------------------------------------------------------
  11. // Private
  12. //-----------------------------------------------------------------------------
  13. const strategies = Symbol("strategies");
  14. const requiredKeys = Symbol("requiredKeys");
  15. /**
  16. * Validates a schema strategy.
  17. * @param {string} name The name of the key this strategy is for.
  18. * @param {Object} strategy The strategy for the object key.
  19. * @param {boolean} [strategy.required=true] Whether the key is required.
  20. * @param {string[]} [strategy.requires] Other keys that are required when
  21. * this key is present.
  22. * @param {Function} strategy.merge A method to call when merging two objects
  23. * with the same key.
  24. * @param {Function} strategy.validate A method to call when validating an
  25. * object with the key.
  26. * @returns {void}
  27. * @throws {Error} When the strategy is missing a name.
  28. * @throws {Error} When the strategy is missing a merge() method.
  29. * @throws {Error} When the strategy is missing a validate() method.
  30. */
  31. function validateDefinition(name, strategy) {
  32. let hasSchema = false;
  33. if (strategy.schema) {
  34. if (typeof strategy.schema === "object") {
  35. hasSchema = true;
  36. } else {
  37. throw new TypeError("Schema must be an object.");
  38. }
  39. }
  40. if (typeof strategy.merge === "string") {
  41. if (!(strategy.merge in MergeStrategy)) {
  42. throw new TypeError(`Definition for key "${name}" missing valid merge strategy.`);
  43. }
  44. } else if (!hasSchema && typeof strategy.merge !== "function") {
  45. throw new TypeError(`Definition for key "${name}" must have a merge property.`);
  46. }
  47. if (typeof strategy.validate === "string") {
  48. if (!(strategy.validate in ValidationStrategy)) {
  49. throw new TypeError(`Definition for key "${name}" missing valid validation strategy.`);
  50. }
  51. } else if (!hasSchema && typeof strategy.validate !== "function") {
  52. throw new TypeError(`Definition for key "${name}" must have a validate() method.`);
  53. }
  54. }
  55. //-----------------------------------------------------------------------------
  56. // Class
  57. //-----------------------------------------------------------------------------
  58. /**
  59. * Represents an object validation/merging schema.
  60. */
  61. class ObjectSchema {
  62. /**
  63. * Creates a new instance.
  64. */
  65. constructor(definitions) {
  66. if (!definitions) {
  67. throw new Error("Schema definitions missing.");
  68. }
  69. /**
  70. * Track all strategies in the schema by key.
  71. * @type {Map}
  72. * @property strategies
  73. */
  74. this[strategies] = new Map();
  75. /**
  76. * Separately track any keys that are required for faster validation.
  77. * @type {Map}
  78. * @property requiredKeys
  79. */
  80. this[requiredKeys] = new Map();
  81. // add in all strategies
  82. for (const key of Object.keys(definitions)) {
  83. validateDefinition(key, definitions[key]);
  84. // normalize merge and validate methods if subschema is present
  85. if (typeof definitions[key].schema === "object") {
  86. const schema = new ObjectSchema(definitions[key].schema);
  87. definitions[key] = {
  88. ...definitions[key],
  89. merge(first = {}, second = {}) {
  90. return schema.merge(first, second);
  91. },
  92. validate(value) {
  93. ValidationStrategy.object(value);
  94. schema.validate(value);
  95. }
  96. };
  97. }
  98. // normalize the merge method in case there's a string
  99. if (typeof definitions[key].merge === "string") {
  100. definitions[key] = {
  101. ...definitions[key],
  102. merge: MergeStrategy[definitions[key].merge]
  103. };
  104. };
  105. // normalize the validate method in case there's a string
  106. if (typeof definitions[key].validate === "string") {
  107. definitions[key] = {
  108. ...definitions[key],
  109. validate: ValidationStrategy[definitions[key].validate]
  110. };
  111. };
  112. this[strategies].set(key, definitions[key]);
  113. if (definitions[key].required) {
  114. this[requiredKeys].set(key, definitions[key]);
  115. }
  116. }
  117. }
  118. /**
  119. * Determines if a strategy has been registered for the given object key.
  120. * @param {string} key The object key to find a strategy for.
  121. * @returns {boolean} True if the key has a strategy registered, false if not.
  122. */
  123. hasKey(key) {
  124. return this[strategies].has(key);
  125. }
  126. /**
  127. * Merges objects together to create a new object comprised of the keys
  128. * of the all objects. Keys are merged based on the each key's merge
  129. * strategy.
  130. * @param {...Object} objects The objects to merge.
  131. * @returns {Object} A new object with a mix of all objects' keys.
  132. * @throws {Error} If any object is invalid.
  133. */
  134. merge(...objects) {
  135. // double check arguments
  136. if (objects.length < 2) {
  137. throw new Error("merge() requires at least two arguments.");
  138. }
  139. if (objects.some(object => (object == null || typeof object !== "object"))) {
  140. throw new Error("All arguments must be objects.");
  141. }
  142. return objects.reduce((result, object) => {
  143. this.validate(object);
  144. for (const [key, strategy] of this[strategies]) {
  145. try {
  146. if (key in result || key in object) {
  147. const value = strategy.merge.call(this, result[key], object[key]);
  148. if (value !== undefined) {
  149. result[key] = value;
  150. }
  151. }
  152. } catch (ex) {
  153. ex.message = `Key "${key}": ` + ex.message;
  154. throw ex;
  155. }
  156. }
  157. return result;
  158. }, {});
  159. }
  160. /**
  161. * Validates an object's keys based on the validate strategy for each key.
  162. * @param {Object} object The object to validate.
  163. * @returns {void}
  164. * @throws {Error} When the object is invalid.
  165. */
  166. validate(object) {
  167. // check existing keys first
  168. for (const key of Object.keys(object)) {
  169. // check to see if the key is defined
  170. if (!this.hasKey(key)) {
  171. throw new Error(`Unexpected key "${key}" found.`);
  172. }
  173. // validate existing keys
  174. const strategy = this[strategies].get(key);
  175. // first check to see if any other keys are required
  176. if (Array.isArray(strategy.requires)) {
  177. if (!strategy.requires.every(otherKey => otherKey in object)) {
  178. throw new Error(`Key "${key}" requires keys "${strategy.requires.join("\", \"")}".`);
  179. }
  180. }
  181. // now apply remaining validation strategy
  182. try {
  183. strategy.validate.call(strategy, object[key]);
  184. } catch (ex) {
  185. ex.message = `Key "${key}": ` + ex.message;
  186. throw ex;
  187. }
  188. }
  189. // ensure required keys aren't missing
  190. for (const [key] of this[requiredKeys]) {
  191. if (!(key in object)) {
  192. throw new Error(`Missing required key "${key}".`);
  193. }
  194. }
  195. }
  196. }
  197. exports.ObjectSchema = ObjectSchema;