1 | /**
|
---|
2 | * @filedescription Object Schema
|
---|
3 | */
|
---|
4 |
|
---|
5 | "use strict";
|
---|
6 |
|
---|
7 | //-----------------------------------------------------------------------------
|
---|
8 | // Requirements
|
---|
9 | //-----------------------------------------------------------------------------
|
---|
10 |
|
---|
11 | const { MergeStrategy } = require("./merge-strategy");
|
---|
12 | const { ValidationStrategy } = require("./validation-strategy");
|
---|
13 |
|
---|
14 | //-----------------------------------------------------------------------------
|
---|
15 | // Private
|
---|
16 | //-----------------------------------------------------------------------------
|
---|
17 |
|
---|
18 | const strategies = Symbol("strategies");
|
---|
19 | const 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 when
|
---|
27 | * this key is present.
|
---|
28 | * @param {Function} strategy.merge A method to call when merging two objects
|
---|
29 | * with the same key.
|
---|
30 | * @param {Function} strategy.validate A method to call when validating an
|
---|
31 | * 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 | */
|
---|
37 | function 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 | // Errors
|
---|
67 | //-----------------------------------------------------------------------------
|
---|
68 |
|
---|
69 | /**
|
---|
70 | * Error when an unexpected key is found.
|
---|
71 | */
|
---|
72 | class 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 | */
|
---|
86 | class 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 | */
|
---|
100 | class 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 | */
|
---|
115 | class 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 represented
|
---|
126 | for (const key of Object.keys(source)) {
|
---|
127 | if (!(key in this)) {
|
---|
128 | this[key] = source[key];
|
---|
129 | }
|
---|
130 | }
|
---|
131 | }
|
---|
132 | }
|
---|
133 |
|
---|
134 | //-----------------------------------------------------------------------------
|
---|
135 | // Main
|
---|
136 | //-----------------------------------------------------------------------------
|
---|
137 |
|
---|
138 | /**
|
---|
139 | * Represents an object validation/merging schema.
|
---|
140 | */
|
---|
141 | class 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 strategies
|
---|
156 | */
|
---|
157 | this[strategies] = new Map();
|
---|
158 |
|
---|
159 | /**
|
---|
160 | * Separately track any keys that are required for faster validation.
|
---|
161 | * @type {Map}
|
---|
162 | * @property requiredKeys
|
---|
163 | */
|
---|
164 | this[requiredKeys] = new Map();
|
---|
165 |
|
---|
166 | // add in all strategies
|
---|
167 | for (const key of Object.keys(definitions)) {
|
---|
168 | validateDefinition(key, definitions[key]);
|
---|
169 |
|
---|
170 | // normalize merge and validate methods if subschema is present
|
---|
171 | 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 string
|
---|
186 | 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 string
|
---|
194 | 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 keys
|
---|
220 | * of the all objects. Keys are merged based on the each key's merge
|
---|
221 | * 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 arguments
|
---|
229 | 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 first
|
---|
266 | for (const key of Object.keys(object)) {
|
---|
267 |
|
---|
268 | // check to see if the key is defined
|
---|
269 | if (!this.hasKey(key)) {
|
---|
270 | throw new UnexpectedKeyError(key);
|
---|
271 | }
|
---|
272 |
|
---|
273 | // validate existing keys
|
---|
274 | const strategy = this[strategies].get(key);
|
---|
275 |
|
---|
276 | // first check to see if any other keys are required
|
---|
277 | 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 strategy
|
---|
284 | 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 missing
|
---|
292 | for (const [key] of this[requiredKeys]) {
|
---|
293 | if (!(key in object)) {
|
---|
294 | throw new MissingKeyError(key);
|
---|
295 | }
|
---|
296 | }
|
---|
297 |
|
---|
298 | }
|
---|
299 | }
|
---|
300 |
|
---|
301 | exports.ObjectSchema = ObjectSchema;
|
---|