@squiz/dx-json-schema-lib 1.82.1 → 1.82.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import JSONQuery, { Input } from '@sagold/json-query';
2
+ import cloneDeep from 'lodash.clonedeep';
2
3
 
3
4
  import DxComponentInputSchema from './manifest/v1/DxComponentInputSchema.json';
4
5
  import DxComponentIcons from './manifest/v1/DxComponentIcons.json';
@@ -103,6 +104,42 @@ export class JSONSchemaService<P extends AnyPrimitiveType, R extends AnyResolvab
103
104
  return this.schema.compileSchema(fullValidationSchema);
104
105
  }
105
106
 
107
+ /**
108
+ * Recursively check if a schema contains allOf combinators that could cause mutation
109
+ */
110
+ private hasAllOfCombinator(schema: any, visited: WeakSet<object> = new WeakSet()): boolean {
111
+ if (!schema || typeof schema !== 'object') return false;
112
+
113
+ // Prevent infinite recursion from circular references
114
+ if (visited.has(schema)) return false;
115
+ visited.add(schema);
116
+
117
+ // Direct allOf check
118
+ if (schema.allOf) return true;
119
+
120
+ // Check in properties
121
+ if (schema.properties) {
122
+ for (const prop of Object.values(schema.properties)) {
123
+ if (this.hasAllOfCombinator(prop, visited)) return true;
124
+ }
125
+ }
126
+
127
+ // Check in items (arrays)
128
+ if (schema.items && this.hasAllOfCombinator(schema.items, visited)) return true;
129
+
130
+ // Check in nested combinators
131
+ if (schema.oneOf?.some((subSchema: any) => this.hasAllOfCombinator(subSchema, visited))) return true;
132
+ if (schema.anyOf?.some((subSchema: any) => this.hasAllOfCombinator(subSchema, visited))) return true;
133
+ if (schema.not && this.hasAllOfCombinator(schema.not, visited)) return true;
134
+
135
+ // Check in conditional schemas (if/then/else)
136
+ if (schema.if && this.hasAllOfCombinator(schema.if, visited)) return true;
137
+ if (schema.then && this.hasAllOfCombinator(schema.then, visited)) return true;
138
+ if (schema.else && this.hasAllOfCombinator(schema.else, visited)) return true;
139
+
140
+ return false;
141
+ }
142
+
106
143
  /**
107
144
  * Validate an input value against a specified schema
108
145
  * @throws {SchemaValidationError} if the input is invalid
@@ -110,7 +147,13 @@ export class JSONSchemaService<P extends AnyPrimitiveType, R extends AnyResolvab
110
147
  */
111
148
  public validateInput(input: unknown, inputSchema: JSONSchema = this.schema.rootSchema): true | never {
112
149
  inputSchema = this.schema.compileSchema(inputSchema);
113
- const errors = this.schema.validate(input, inputSchema);
150
+
151
+ // Only clone if schema contains allOf combinators that could cause mutation
152
+ // This optimizes performance by avoiding unnecessary cloning for simple schemas
153
+ const needsCloning = this.hasAllOfCombinator(inputSchema);
154
+ const inputToValidate = needsCloning ? cloneDeep(input) : input;
155
+
156
+ const errors = this.schema.validate(inputToValidate, inputSchema);
114
157
  return processValidationResult(errors);
115
158
  }
116
159
 
@@ -243,7 +243,7 @@
243
243
  },
244
244
  "formattingOptions": { "$ref": "#/definitions/FormattingOptions" },
245
245
  "type": { "const": "link-to-dam-asset" },
246
- "damSystemType": { "enum": ["bynder"] },
246
+ "damSystemType": { "type": "string", "minLength": 1 },
247
247
  "damSystemIdentifier": { "type": "string", "minLength": 1 },
248
248
  "damObjectId": { "type": "string", "minLength": 1 },
249
249
  "damAdditional": { "type": "object", "properties": { "variant": { "type": "string", "minLength": 1 } } },
@@ -309,7 +309,7 @@
309
309
 
310
310
  "properties": {
311
311
  "type": { "const": "dam-image" },
312
- "damSystemType": { "enum": ["bynder"] },
312
+ "damSystemType": { "type": "string", "minLength": 1 },
313
313
  "damSystemIdentifier": { "type": "string", "minLength": 1 },
314
314
  "damObjectId": { "type": "string", "minLength": 1 },
315
315
  "damAdditional": { "type": "object", "properties": { "variant": { "type": "string", "minLength": 1 } } },
@@ -40,7 +40,7 @@ export interface FormattedTextLinkToDamAsset {
40
40
  children: WithChildrenNode;
41
41
  formattingOptions?: FormattingOptions;
42
42
  type: 'link-to-dam-asset';
43
- damSystemType: 'bynder';
43
+ damSystemType: string;
44
44
  damSystemIdentifier: string;
45
45
  damObjectId: string;
46
46
  damAdditional?: {
@@ -72,7 +72,7 @@ export interface FormattedTextMatrixImage {
72
72
  }
73
73
  export interface FormattedTextDamImage {
74
74
  type: 'dam-image';
75
- damSystemType: 'bynder';
75
+ damSystemType: string;
76
76
  damSystemIdentifier: string;
77
77
  damObjectId: string;
78
78
  damAdditional?: {
@@ -0,0 +1,456 @@
1
+ import { JSONSchemaService } from './JsonSchemaService';
2
+ import { ComponentInputMetaSchema } from './JsonSchemaService';
3
+ import { TypeResolverBuilder } from './jsonTypeResolution/TypeResolverBuilder';
4
+
5
+ describe('JSONSchemaService - hasAllOfCombinator', () => {
6
+ let jsonSchemaService: JSONSchemaService<any, any>;
7
+
8
+ beforeAll(() => {
9
+ const typeResolverBuilder = new TypeResolverBuilder();
10
+ const typeResolver = typeResolverBuilder.build();
11
+ jsonSchemaService = new JSONSchemaService(typeResolver, ComponentInputMetaSchema);
12
+ });
13
+
14
+ // Helper to access private method
15
+ const hasAllOfCombinator = (schema: any) => (jsonSchemaService as any).hasAllOfCombinator(schema);
16
+
17
+ describe('Direct allOf detection', () => {
18
+ it('should detect direct allOf usage', () => {
19
+ const schema = {
20
+ type: 'object',
21
+ allOf: [{ properties: { name: { type: 'string' } } }, { properties: { age: { type: 'number' } } }],
22
+ };
23
+
24
+ expect(hasAllOfCombinator(schema)).toBe(true);
25
+ });
26
+
27
+ it('should detect empty allOf array', () => {
28
+ const schema = {
29
+ type: 'object',
30
+ allOf: [],
31
+ };
32
+
33
+ expect(hasAllOfCombinator(schema)).toBe(true);
34
+ });
35
+ });
36
+
37
+ describe('Nested allOf detection', () => {
38
+ it('should detect allOf in properties', () => {
39
+ const schema = {
40
+ type: 'object',
41
+ properties: {
42
+ user: {
43
+ allOf: [{ properties: { name: { type: 'string' } } }, { properties: { email: { type: 'string' } } }],
44
+ },
45
+ },
46
+ };
47
+
48
+ expect(hasAllOfCombinator(schema)).toBe(true);
49
+ });
50
+
51
+ it('should detect allOf in deeply nested properties', () => {
52
+ const schema = {
53
+ type: 'object',
54
+ properties: {
55
+ componentContent: {
56
+ type: 'object',
57
+ properties: {
58
+ heroSection: {
59
+ allOf: [
60
+ { properties: { title: { type: 'string' } } },
61
+ { properties: { subtitle: { type: 'string' } } },
62
+ ],
63
+ },
64
+ },
65
+ },
66
+ },
67
+ };
68
+
69
+ expect(hasAllOfCombinator(schema)).toBe(true);
70
+ });
71
+
72
+ it('should detect allOf in array items', () => {
73
+ const schema = {
74
+ type: 'object',
75
+ properties: {
76
+ items: {
77
+ type: 'array',
78
+ items: {
79
+ allOf: [{ properties: { id: { type: 'number' } } }, { properties: { name: { type: 'string' } } }],
80
+ },
81
+ },
82
+ },
83
+ };
84
+
85
+ expect(hasAllOfCombinator(schema)).toBe(true);
86
+ });
87
+ });
88
+
89
+ describe('Combinator traversal', () => {
90
+ it('should detect allOf within oneOf', () => {
91
+ const schema = {
92
+ type: 'object',
93
+ oneOf: [
94
+ {
95
+ allOf: [{ properties: { type: { const: 'A' } } }, { properties: { valueA: { type: 'string' } } }],
96
+ },
97
+ { properties: { type: { const: 'B' } } },
98
+ ],
99
+ };
100
+
101
+ expect(hasAllOfCombinator(schema)).toBe(true);
102
+ });
103
+
104
+ it('should detect allOf within anyOf', () => {
105
+ const schema = {
106
+ type: 'object',
107
+ anyOf: [
108
+ { properties: { simple: { type: 'string' } } },
109
+ {
110
+ allOf: [{ properties: { complex: { type: 'object' } } }, { properties: { nested: { type: 'array' } } }],
111
+ },
112
+ ],
113
+ };
114
+
115
+ expect(hasAllOfCombinator(schema)).toBe(true);
116
+ });
117
+
118
+ it('should detect allOf within not schema', () => {
119
+ const schema = {
120
+ type: 'object',
121
+ not: {
122
+ allOf: [{ properties: { forbidden: { type: 'string' } } }, { properties: { invalid: { type: 'number' } } }],
123
+ },
124
+ };
125
+
126
+ expect(hasAllOfCombinator(schema)).toBe(true);
127
+ });
128
+ });
129
+
130
+ describe('Conditional schema detection', () => {
131
+ it('should detect allOf in if condition', () => {
132
+ const schema = {
133
+ type: 'object',
134
+ if: {
135
+ allOf: [{ properties: { type: { const: 'special' } } }, { properties: { mode: { const: 'advanced' } } }],
136
+ },
137
+ then: { properties: { specialValue: { type: 'string' } } },
138
+ };
139
+
140
+ expect(hasAllOfCombinator(schema)).toBe(true);
141
+ });
142
+
143
+ it('should detect allOf in then branch', () => {
144
+ const schema = {
145
+ type: 'object',
146
+ if: { properties: { type: { const: 'special' } } },
147
+ then: {
148
+ allOf: [{ properties: { requiredProp: { type: 'string' } } }, { required: ['requiredProp'] }],
149
+ },
150
+ };
151
+
152
+ expect(hasAllOfCombinator(schema)).toBe(true);
153
+ });
154
+
155
+ it('should detect allOf in else branch', () => {
156
+ const schema = {
157
+ type: 'object',
158
+ if: { properties: { type: { const: 'simple' } } },
159
+ then: { properties: { simpleValue: { type: 'string' } } },
160
+ else: {
161
+ allOf: [
162
+ { properties: { complexValue: { type: 'object' } } },
163
+ { properties: { metadata: { type: 'object' } } },
164
+ ],
165
+ },
166
+ };
167
+
168
+ expect(hasAllOfCombinator(schema)).toBe(true);
169
+ });
170
+ });
171
+
172
+ describe('Complex nested scenarios', () => {
173
+ it('should detect allOf in Hero Banner-like schema', () => {
174
+ const heroBannerSchema = {
175
+ type: 'object',
176
+ properties: {
177
+ componentContent: {
178
+ allOf: [
179
+ {
180
+ if: { properties: { heroType: { const: 'Supporting content' } } },
181
+ then: {
182
+ type: 'object',
183
+ properties: {
184
+ heroType: { type: 'string' },
185
+ heading: { type: 'string' },
186
+ content: { type: 'string' },
187
+ links: {
188
+ type: 'array',
189
+ items: {
190
+ type: 'object',
191
+ properties: {
192
+ text: { type: 'string' },
193
+ url: { type: 'string' },
194
+ target: { type: 'string' },
195
+ },
196
+ },
197
+ },
198
+ },
199
+ },
200
+ },
201
+ {
202
+ if: { properties: { heroType: { const: 'Supporting image' } } },
203
+ then: {
204
+ properties: {
205
+ image: { type: 'object' },
206
+ },
207
+ },
208
+ },
209
+ ],
210
+ },
211
+ },
212
+ };
213
+
214
+ expect(hasAllOfCombinator(heroBannerSchema)).toBe(true);
215
+ });
216
+
217
+ it('should detect allOf in very deeply nested structure', () => {
218
+ const deepSchema = {
219
+ type: 'object',
220
+ properties: {
221
+ level1: {
222
+ properties: {
223
+ level2: {
224
+ oneOf: [
225
+ {
226
+ properties: {
227
+ level3: {
228
+ anyOf: [
229
+ {
230
+ allOf: [{ properties: { deep: { type: 'string' } } }],
231
+ },
232
+ ],
233
+ },
234
+ },
235
+ },
236
+ ],
237
+ },
238
+ },
239
+ },
240
+ },
241
+ };
242
+
243
+ expect(hasAllOfCombinator(deepSchema)).toBe(true);
244
+ });
245
+ });
246
+
247
+ describe('Negative cases - should return false', () => {
248
+ it('should return false for simple object schema', () => {
249
+ const schema = {
250
+ type: 'object',
251
+ properties: {
252
+ name: { type: 'string' },
253
+ age: { type: 'number' },
254
+ },
255
+ required: ['name'],
256
+ };
257
+
258
+ expect(hasAllOfCombinator(schema)).toBe(false);
259
+ });
260
+
261
+ it('should return false for schema with only oneOf', () => {
262
+ const schema = {
263
+ type: 'object',
264
+ oneOf: [{ properties: { typeA: { type: 'string' } } }, { properties: { typeB: { type: 'number' } } }],
265
+ };
266
+
267
+ expect(hasAllOfCombinator(schema)).toBe(false);
268
+ });
269
+
270
+ it('should return false for schema with only anyOf', () => {
271
+ const schema = {
272
+ type: 'object',
273
+ anyOf: [{ properties: { option1: { type: 'string' } } }, { properties: { option2: { type: 'boolean' } } }],
274
+ };
275
+
276
+ expect(hasAllOfCombinator(schema)).toBe(false);
277
+ });
278
+
279
+ it('should return false for array schema without allOf', () => {
280
+ const schema = {
281
+ type: 'array',
282
+ items: {
283
+ type: 'object',
284
+ properties: {
285
+ id: { type: 'number' },
286
+ name: { type: 'string' },
287
+ },
288
+ },
289
+ };
290
+
291
+ expect(hasAllOfCombinator(schema)).toBe(false);
292
+ });
293
+
294
+ it('should return false for conditional schema without allOf', () => {
295
+ const schema = {
296
+ type: 'object',
297
+ if: { properties: { type: { const: 'special' } } },
298
+ then: { properties: { specialValue: { type: 'string' } } },
299
+ else: { properties: { normalValue: { type: 'string' } } },
300
+ };
301
+
302
+ expect(hasAllOfCombinator(schema)).toBe(false);
303
+ });
304
+ });
305
+
306
+ describe('Edge cases', () => {
307
+ it('should return false for null', () => {
308
+ expect(hasAllOfCombinator(null)).toBe(false);
309
+ });
310
+
311
+ it('should return false for undefined', () => {
312
+ expect(hasAllOfCombinator(undefined)).toBe(false);
313
+ });
314
+
315
+ it('should return false for string', () => {
316
+ expect(hasAllOfCombinator('not an object')).toBe(false);
317
+ });
318
+
319
+ it('should return false for number', () => {
320
+ expect(hasAllOfCombinator(42)).toBe(false);
321
+ });
322
+
323
+ it('should return false for boolean', () => {
324
+ expect(hasAllOfCombinator(true)).toBe(false);
325
+ });
326
+
327
+ it('should return false for array', () => {
328
+ expect(hasAllOfCombinator(['not', 'a', 'schema'])).toBe(false);
329
+ });
330
+
331
+ it('should return false for empty object', () => {
332
+ expect(hasAllOfCombinator({})).toBe(false);
333
+ });
334
+
335
+ it('should handle circular references gracefully', () => {
336
+ const schema: any = {
337
+ type: 'object',
338
+ properties: {
339
+ self: null,
340
+ },
341
+ };
342
+ schema.properties.self = schema; // Create circular reference
343
+
344
+ // Should not crash and should return false (no allOf)
345
+ expect(hasAllOfCombinator(schema)).toBe(false);
346
+ });
347
+
348
+ it('should handle circular references with allOf', () => {
349
+ const schema: any = {
350
+ type: 'object',
351
+ allOf: [{ properties: { name: { type: 'string' } } }],
352
+ properties: {
353
+ self: null,
354
+ },
355
+ };
356
+ schema.properties.self = schema; // Create circular reference
357
+
358
+ // Should detect allOf despite circular reference
359
+ expect(hasAllOfCombinator(schema)).toBe(true);
360
+ });
361
+
362
+ it('should handle deeply circular references', () => {
363
+ const schemaA: any = {
364
+ type: 'object',
365
+ properties: {
366
+ b: null,
367
+ },
368
+ };
369
+
370
+ const schemaB: any = {
371
+ type: 'object',
372
+ properties: {
373
+ a: schemaA,
374
+ c: null,
375
+ },
376
+ };
377
+
378
+ const schemaC: any = {
379
+ type: 'object',
380
+ properties: {
381
+ b: schemaB,
382
+ },
383
+ };
384
+
385
+ // Create circular chain: A -> B -> C -> B
386
+ schemaA.properties.b = schemaB;
387
+ schemaB.properties.c = schemaC;
388
+
389
+ // Should not crash and should return false (no allOf in any of them)
390
+ expect(hasAllOfCombinator(schemaA)).toBe(false);
391
+ expect(hasAllOfCombinator(schemaB)).toBe(false);
392
+ expect(hasAllOfCombinator(schemaC)).toBe(false);
393
+ });
394
+
395
+ it('should handle malformed properties object', () => {
396
+ const schema = {
397
+ type: 'object',
398
+ properties: null, // Malformed properties
399
+ };
400
+
401
+ expect(hasAllOfCombinator(schema)).toBe(false);
402
+ });
403
+ });
404
+
405
+ describe('Performance characteristics', () => {
406
+ it('should quickly process large schema without allOf', () => {
407
+ const largeSchema = {
408
+ type: 'object',
409
+ properties: {},
410
+ };
411
+
412
+ // Generate 100 properties
413
+ for (let i = 0; i < 100; i++) {
414
+ (largeSchema.properties as any)[`prop${i}`] = {
415
+ type: 'object',
416
+ properties: {
417
+ [`nested${i}`]: { type: 'string' },
418
+ [`array${i}`]: {
419
+ type: 'array',
420
+ items: { type: 'number' },
421
+ },
422
+ },
423
+ };
424
+ }
425
+
426
+ const startTime = performance.now();
427
+ const result = hasAllOfCombinator(largeSchema);
428
+ const endTime = performance.now();
429
+
430
+ expect(result).toBe(false);
431
+ expect(endTime - startTime).toBeLessThan(10); // Should be very fast (< 10ms)
432
+ });
433
+
434
+ it('should quickly detect allOf in large schema', () => {
435
+ const largeSchemaWithAllOf = {
436
+ type: 'object',
437
+ allOf: [{ properties: { detected: { type: 'string' } } }],
438
+ properties: {},
439
+ };
440
+
441
+ // Generate 100 properties
442
+ for (let i = 0; i < 100; i++) {
443
+ (largeSchemaWithAllOf.properties as any)[`prop${i}`] = {
444
+ type: 'string',
445
+ };
446
+ }
447
+
448
+ const startTime = performance.now();
449
+ const result = hasAllOfCombinator(largeSchemaWithAllOf);
450
+ const endTime = performance.now();
451
+
452
+ expect(result).toBe(true);
453
+ expect(endTime - startTime).toBeLessThan(5); // Should be very fast due to early detection
454
+ });
455
+ });
456
+ });