@api-client/core 0.20.5 → 0.20.7

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.
Files changed (55) hide show
  1. package/build/src/modeling/ApiModel.d.ts.map +1 -1
  2. package/build/src/modeling/ApiModel.js +9 -2
  3. package/build/src/modeling/ApiModel.js.map +1 -1
  4. package/build/src/modeling/DataDomain.d.ts +9 -3
  5. package/build/src/modeling/DataDomain.d.ts.map +1 -1
  6. package/build/src/modeling/DataDomain.js +25 -6
  7. package/build/src/modeling/DataDomain.js.map +1 -1
  8. package/build/src/modeling/DependentModel.d.ts +4 -0
  9. package/build/src/modeling/DependentModel.d.ts.map +1 -1
  10. package/build/src/modeling/DependentModel.js.map +1 -1
  11. package/build/src/modeling/DomainEntity.d.ts +20 -0
  12. package/build/src/modeling/DomainEntity.d.ts.map +1 -1
  13. package/build/src/modeling/DomainEntity.js +93 -0
  14. package/build/src/modeling/DomainEntity.js.map +1 -1
  15. package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
  16. package/build/src/modeling/ExposedEntity.js +55 -4
  17. package/build/src/modeling/ExposedEntity.js.map +1 -1
  18. package/build/src/modeling/RuntimeApiModel.d.ts +22 -0
  19. package/build/src/modeling/RuntimeApiModel.d.ts.map +1 -1
  20. package/build/src/modeling/RuntimeApiModel.js +108 -3
  21. package/build/src/modeling/RuntimeApiModel.js.map +1 -1
  22. package/build/src/modeling/generators/RuntimeModelGenerator.d.ts +15 -0
  23. package/build/src/modeling/generators/RuntimeModelGenerator.d.ts.map +1 -0
  24. package/build/src/modeling/generators/RuntimeModelGenerator.js +78 -0
  25. package/build/src/modeling/generators/RuntimeModelGenerator.js.map +1 -0
  26. package/build/src/modeling/helpers/endpointHelpers.d.ts +6 -1
  27. package/build/src/modeling/helpers/endpointHelpers.d.ts.map +1 -1
  28. package/build/src/modeling/helpers/endpointHelpers.js +43 -4
  29. package/build/src/modeling/helpers/endpointHelpers.js.map +1 -1
  30. package/build/src/modeling/types.d.ts +15 -0
  31. package/build/src/modeling/types.d.ts.map +1 -1
  32. package/build/src/modeling/types.js.map +1 -1
  33. package/build/src/modeling/validation/api_model_rules.d.ts.map +1 -1
  34. package/build/src/modeling/validation/api_model_rules.js +17 -0
  35. package/build/src/modeling/validation/api_model_rules.js.map +1 -1
  36. package/build/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +3 -3
  38. package/src/modeling/ApiModel.ts +8 -2
  39. package/src/modeling/DataDomain.ts +27 -7
  40. package/src/modeling/DependentModel.ts +4 -0
  41. package/src/modeling/DomainEntity.ts +100 -0
  42. package/src/modeling/ExposedEntity.ts +62 -4
  43. package/src/modeling/RuntimeApiModel.ts +131 -4
  44. package/src/modeling/generators/RuntimeModelGenerator.ts +79 -0
  45. package/src/modeling/helpers/endpointHelpers.ts +51 -4
  46. package/src/modeling/types.ts +16 -0
  47. package/src/modeling/validation/api_model_rules.ts +19 -0
  48. package/tests/unit/modeling/RuntimeApiModel.spec.ts +108 -3
  49. package/tests/unit/modeling/data_domain_entities.spec.ts +56 -0
  50. package/tests/unit/modeling/data_domain_serialization.spec.ts +11 -11
  51. package/tests/unit/modeling/domain_entity_associations.spec.ts +63 -0
  52. package/tests/unit/modeling/exposed_entity.spec.ts +95 -0
  53. package/tests/unit/modeling/generators/RuntimeModelGenerator.spec.ts +192 -0
  54. package/tests/unit/modeling/helpers/endpointHelpers.spec.ts +10 -3
  55. package/tests/unit/modeling/validation/api_model_rules.spec.ts +35 -0
@@ -1,7 +1,54 @@
1
- export function paramNameFor(entityKeyLocal: string): string {
2
- const parts = entityKeyLocal.split(':')
3
- const key = parts[parts.length - 1]
4
- return `${key}Id`
1
+ import { Exception } from '../../exceptions/exception.js'
2
+
3
+ /**
4
+ * Generates a semantic parameter name for an entity.
5
+ * It converts snake_case, kebab-case, or PascalCase to camelCase and appends 'Id'.
6
+ * @param entityName The entity name (e.g. from entity.info.name)
7
+ */
8
+ export function paramNameFor(entityName: string): string {
9
+ if (!entityName) {
10
+ throw new Exception('Cannot generate parameter name from an empty string.', {
11
+ code: 'E_INVALID_PARAM_NAME',
12
+ help: 'The entity name used for path parameter generation is empty.',
13
+ })
14
+ }
15
+
16
+ // Add a space between lowercase and uppercase letters to handle camelCase/PascalCase
17
+ let withSpaces = entityName.replace(/([a-z])([A-Z])/g, '$1 $2')
18
+ // Also split consecutive uppercase letters if followed by a lowercase (e.g. XMLHttp -> XML Http)
19
+ withSpaces = withSpaces.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
20
+
21
+ // Replace all non-alphanumeric characters with spaces
22
+ const cleanStr = withSpaces.replace(/[^a-zA-Z0-9]/g, ' ')
23
+
24
+ // Split by spaces and filter out empty strings
25
+ const words = cleanStr.split(/\s+/).filter(Boolean)
26
+
27
+ if (words.length === 0) {
28
+ throw new Exception(`Cannot generate a valid parameter name from "${entityName}".`, {
29
+ code: 'E_INVALID_PARAM_NAME',
30
+ help: 'The entity name must contain alphanumeric characters to generate a path parameter.',
31
+ })
32
+ }
33
+
34
+ // CamelCase: lower first word, Capitalize subsequent words
35
+ const camelCased = words
36
+ .map((word, index) => {
37
+ const lower = word.toLowerCase()
38
+ if (index === 0) {
39
+ return lower
40
+ }
41
+ return lower.charAt(0).toUpperCase() + lower.slice(1)
42
+ })
43
+ .join('')
44
+
45
+ // Ensure it starts with a letter or underscore
46
+ let param = camelCased
47
+ if (/^[0-9]/.test(param)) {
48
+ param = '_' + param
49
+ }
50
+
51
+ return `${param}Id`
5
52
  }
6
53
 
7
54
  /**
@@ -974,3 +974,19 @@ export interface AssociationSchema {
974
974
  */
975
975
  unionType?: 'allOf' | 'anyOf' | 'oneOf' | 'not'
976
976
  }
977
+
978
+ /**
979
+ * Options used when initializing a DataDomain.
980
+ */
981
+ export interface DataDomainOptions {
982
+ /**
983
+ * The mode used during deserialization to control error tolerance.
984
+ * 'strict' throws errors on invalid references. 'lenient' collects them.
985
+ */
986
+ mode?: DeserializationMode
987
+ /**
988
+ * Whether the DataDomain is in read-only mode, which makes it immutable
989
+ * and enables internal caching optimizations.
990
+ */
991
+ readOnly?: boolean
992
+ }
@@ -581,6 +581,25 @@ export function validateExposedEntity(exposure: ExposedEntity, apiModel: ApiMode
581
581
  }
582
582
  }
583
583
 
584
+ // Branch Path Parameter Collision
585
+ const absoluteResourcePath = exposure.getAbsoluteResourcePath()
586
+ if (absoluteResourcePath) {
587
+ const params = [...absoluteResourcePath.matchAll(/\{([^}]+)\}/g)].map((m) => m[1])
588
+ const uniqueParams = new Set(params)
589
+ if (uniqueParams.size !== params.length) {
590
+ const duplicates = params.filter((item, index) => params.indexOf(item) !== index)
591
+ const duplicateParam = duplicates[0]
592
+ issues.push({
593
+ code: createCode('EXPOSURE', 'DUPLICATE_PATH_PARAMETER'),
594
+ message: `[${entityName}]: The path parameter "{${duplicateParam}}" is duplicated in the resource path hierarchy.`,
595
+ suggestion:
596
+ 'Change the parameter name in either this resource or its ancestor to ensure unique parameter names.',
597
+ severity: 'error',
598
+ context: { ...context, property: 'resourcePath' },
599
+ })
600
+ }
601
+ }
602
+
584
603
  // Minimum Actions
585
604
  if (!exposure.actions || exposure.actions.length === 0) {
586
605
  issues.push({
@@ -34,6 +34,20 @@ test.group('RuntimeApiModel', () => {
34
34
  assert.deepEqual(runtimeModel.toJSON().routingMap, schema.routingMap)
35
35
  }).tags(['@modeling', '@runtime'])
36
36
 
37
+ test('throws error if routingMap is missing', ({ assert }) => {
38
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
39
+ const schema = {
40
+ key: 'api-1',
41
+ info: { name: 'Test API' },
42
+ // routingMap is intentionally missing
43
+ }
44
+
45
+ assert.throws(
46
+ () => new RuntimeApiModel(schema as any, domain.toJSON()),
47
+ 'The runtime API model must have a routing map.'
48
+ )
49
+ }).tags(['@modeling', '@runtime'])
50
+
37
51
  test('resolves path with matchit', ({ assert }) => {
38
52
  const domain = new DataDomain({ info: { version: '1.0.0' } })
39
53
  const model = domain.addModel({ info: { name: 'Test Model' } })
@@ -179,6 +193,97 @@ test.group('RuntimeApiModel', () => {
179
193
  assert.equal(runtimeModel.sessionProperties.get(prop2.key)?.key, prop2.key)
180
194
  }).tags(['@modeling', '@runtime'])
181
195
 
196
+ test('precomputes semanticPropertiesCache correctly', ({ assert }) => {
197
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
198
+ const modelNode = domain.addModel({ key: 'users' })
199
+ const userEntity = modelNode.addEntity({ info: { name: 'User' } })
200
+ const prop1 = userEntity.addProperty({ info: { name: 'Prop1' }, semantics: [{ id: SemanticType.Password }] })
201
+ const prop2 = userEntity.addProperty({ info: { name: 'Prop2' }, semantics: [{ id: SemanticType.Username }] })
202
+ const prop3 = userEntity.addProperty({ info: { name: 'Prop3' } })
203
+
204
+ const schema = {
205
+ key: 'api-1',
206
+ info: { name: 'Test API' },
207
+ exposes: [
208
+ {
209
+ key: 'expose-1',
210
+ entity: { key: userEntity.key, domain: domain.key },
211
+ actions: [],
212
+ },
213
+ ],
214
+ routingMap: {},
215
+ }
216
+
217
+ const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
218
+
219
+ assert.equal(runtimeModel.semanticPropertiesCache.size, 1)
220
+
221
+ // Convert keys iterator to array to find the matched entity reference
222
+ const cachedEntities = Array.from(runtimeModel.semanticPropertiesCache.keys())
223
+ assert.equal(cachedEntities.length, 1)
224
+ assert.equal(cachedEntities[0].key, userEntity.key)
225
+
226
+ const cachedProps = runtimeModel.semanticPropertiesCache.get(cachedEntities[0])
227
+ assert.isDefined(cachedProps)
228
+ assert.equal(cachedProps?.length, 2)
229
+ assert.equal(cachedProps?.[0].key, prop1.key)
230
+ assert.equal(cachedProps?.[1].key, prop2.key)
231
+ }).tags(['@modeling', '@runtime'])
232
+
233
+ test('precomputes semanticAssociationsCache correctly', ({ assert }) => {
234
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
235
+ const modelNode = domain.addModel({ key: 'users' })
236
+ const userEntity = modelNode.addEntity({ info: { name: 'User' } })
237
+
238
+ const assoc1 = userEntity.addAssociation({ info: { name: 'Assoc1' }, targets: [] })
239
+ assoc1.semantics = [{ id: SemanticType.Tags }]
240
+
241
+ const assoc2 = userEntity.addAssociation({ info: { name: 'Assoc2' }, targets: [] })
242
+ assoc2.semantics = [{ id: SemanticType.Categories }]
243
+
244
+ const assoc3 = userEntity.addAssociation({ info: { name: 'Assoc3' }, targets: [] })
245
+
246
+ const schema = {
247
+ key: 'api-1',
248
+ info: { name: 'Test API' },
249
+ exposes: [
250
+ {
251
+ key: 'expose-1',
252
+ entity: { key: userEntity.key, domain: domain.key },
253
+ actions: [],
254
+ },
255
+ ],
256
+ routingMap: {},
257
+ }
258
+
259
+ const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
260
+
261
+ assert.equal(runtimeModel.semanticAssociationsCache.size, 1)
262
+
263
+ // Convert keys iterator to array to find the matched entity reference
264
+ const cachedEntities = Array.from(runtimeModel.semanticAssociationsCache.keys())
265
+ assert.equal(cachedEntities.length, 1)
266
+ assert.equal(cachedEntities[0].key, userEntity.key)
267
+
268
+ const cachedAssocs = runtimeModel.semanticAssociationsCache.get(cachedEntities[0])
269
+ assert.isDefined(cachedAssocs)
270
+ assert.equal(cachedAssocs?.length, 2)
271
+ assert.equal(cachedAssocs?.[0].key, assoc1.key)
272
+ assert.equal(cachedAssocs?.[1].key, assoc2.key)
273
+
274
+ // Test getAssociationsBySemantic
275
+ const tagsAssocs = runtimeModel.getAssociationsBySemantic(cachedEntities[0], SemanticType.Tags)
276
+ assert.lengthOf(tagsAssocs, 1)
277
+ assert.equal(tagsAssocs[0].key, assoc1.key)
278
+
279
+ const categoriesAssocs = runtimeModel.getAssociationsBySemantic(cachedEntities[0], SemanticType.Categories)
280
+ assert.lengthOf(categoriesAssocs, 1)
281
+ assert.equal(categoriesAssocs[0].key, assoc2.key)
282
+
283
+ const emptyAssocs = runtimeModel.getAssociationsBySemantic(cachedEntities[0], SemanticType.ResourceOwnerIdentifier)
284
+ assert.lengthOf(emptyAssocs, 0)
285
+ }).tags(['@modeling', '@runtime', '@semantic'])
286
+
182
287
  test('throws exception if session property is missing from domain', ({ assert }) => {
183
288
  const domain = new DataDomain({ info: { version: '1.0.0' } })
184
289
  const modelNode = domain.addModel({ key: 'users' })
@@ -220,7 +325,7 @@ test.group('RuntimeApiModel', () => {
220
325
  { key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
221
326
  domain
222
327
  )
223
- const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
328
+ const runtimeModel = new RuntimeApiModel({ ...baseModel.toJSON(), routingMap: {} } as any, domain.toJSON())
224
329
 
225
330
  const mockAction = {
226
331
  exposure: { accessRule: [], parent: undefined },
@@ -236,7 +341,7 @@ test.group('RuntimeApiModel', () => {
236
341
  test('getEffectiveRules - defaults to preFetch phase if none specified', async ({ assert }) => {
237
342
  const domain = new DataDomain({ info: { version: '1.0.0' } })
238
343
  const baseModel = new ApiModel({ key: 'api-1' }, domain)
239
- const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
344
+ const runtimeModel = new RuntimeApiModel({ ...baseModel.toJSON(), routingMap: {} } as any, domain.toJSON())
240
345
 
241
346
  const mockAction = {
242
347
  exposure: {
@@ -254,7 +359,7 @@ test.group('RuntimeApiModel', () => {
254
359
  test('getEffectiveRules - orders from most specific to most general', async ({ assert }) => {
255
360
  const domain = new DataDomain({ info: { version: '1.0.0' } })
256
361
  const baseModel = new ApiModel({ accessRule: [{ type: 'allowAuthenticated' }] }, domain)
257
- const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
362
+ const runtimeModel = new RuntimeApiModel({ ...baseModel.toJSON(), routingMap: {} } as any, domain.toJSON())
258
363
 
259
364
  const mockAction = {
260
365
  exposure: {
@@ -575,3 +575,59 @@ test.group('DataDomain.moveEntity()', () => {
575
575
  assert.isTrue(dataDomain.graph.hasNode(assoc.key))
576
576
  }).tags(['@modeling', '@entity', '@move'])
577
577
  })
578
+
579
+ test.group('DomainEntity.listAllSemantics()', () => {
580
+ test('returns semantics from entity and its parents', ({ assert }) => {
581
+ const dataDomain = new DataDomain()
582
+ const model = dataDomain.addModel()
583
+ const parentEntity = model.addEntity({ key: 'parent' })
584
+ const childEntity = model.addEntity({ key: 'child' })
585
+
586
+ parentEntity.addSemantic({ id: SemanticType.User })
587
+ childEntity.addSemantic({ id: SemanticType.Address })
588
+
589
+ childEntity.addParent(parentEntity.key)
590
+
591
+ const inherited = childEntity.listAllSemantics()
592
+ assert.lengthOf(inherited, 2)
593
+ const semanticIds = inherited.map((s) => s.id).sort()
594
+ assert.deepEqual(semanticIds, [SemanticType.Address, SemanticType.User].sort())
595
+ }).tags(['@modeling', '@entity', '@semantic'])
596
+
597
+ test('overrides parent semantics with the same id', ({ assert }) => {
598
+ const dataDomain = new DataDomain()
599
+ const model = dataDomain.addModel()
600
+ const parentEntity = model.addEntity({ key: 'parent' })
601
+ const childEntity = model.addEntity({ key: 'child' })
602
+
603
+ parentEntity.addSemantic({ id: SemanticType.User, config: { value: 'parent-val' } })
604
+ childEntity.addSemantic({ id: SemanticType.User, config: { value: 'child-val' } })
605
+
606
+ childEntity.addParent(parentEntity.key)
607
+
608
+ const inherited = childEntity.listAllSemantics()
609
+ assert.lengthOf(inherited, 1)
610
+ assert.deepEqual(inherited[0].config, { value: 'child-val' })
611
+ }).tags(['@modeling', '@entity', '@semantic'])
612
+
613
+ test('uses cached inherited semantics when domain is readOnly', ({ assert }) => {
614
+ const dataDomain = new DataDomain(undefined, [], { readOnly: true })
615
+ const model = dataDomain.addModel()
616
+ const parentEntity = model.addEntity({ key: 'parent' })
617
+ const childEntity = model.addEntity({ key: 'child' })
618
+
619
+ parentEntity.addSemantic({ id: SemanticType.User })
620
+
621
+ childEntity.addParent(parentEntity.key)
622
+
623
+ const inherited1 = childEntity.listAllSemantics()
624
+ assert.lengthOf(inherited1, 1)
625
+
626
+ // Add another semantic bypassing normal immutable logic for test purposes
627
+ parentEntity.addSemantic({ id: SemanticType.Address })
628
+
629
+ const inherited2 = childEntity.listAllSemantics()
630
+ // Should still be length 1 due to the cache
631
+ assert.lengthOf(inherited2, 1)
632
+ }).tags(['@modeling', '@entity', '@semantic', '@readonly'])
633
+ })
@@ -583,7 +583,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
583
583
  }
584
584
 
585
585
  // Should not throw in lenient mode
586
- const restored = new DataDomain(corruptedSerialized, [], 'lenient')
586
+ const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
587
587
 
588
588
  // Should have issues recorded
589
589
  assert.isAbove(restored.issues.length, 0)
@@ -611,7 +611,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
611
611
  }
612
612
 
613
613
  // Should not throw in lenient mode
614
- const restored = new DataDomain(corruptedSerialized, [], 'lenient')
614
+ const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
615
615
 
616
616
  // Should have issues recorded
617
617
  assert.isAbove(restored.issues.length, 0)
@@ -641,7 +641,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
641
641
  }
642
642
 
643
643
  // Should not throw in lenient mode
644
- const restored = new DataDomain(corruptedSerialized, [], 'lenient')
644
+ const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
645
645
 
646
646
  // Should have issues recorded
647
647
  assert.isAbove(restored.issues.length, 0)
@@ -680,7 +680,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
680
680
  }
681
681
 
682
682
  // Should not throw in lenient mode
683
- const restored = new DataDomain(corruptedSerialized, [], 'lenient')
683
+ const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
684
684
 
685
685
  // Should have issues recorded about missing parent node
686
686
  assert.isAbove(restored.issues.length, 0)
@@ -706,7 +706,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
706
706
  const serialized = domain.toJSON()
707
707
 
708
708
  // Deserialize without providing the foreign dependency
709
- const restored = new DataDomain(serialized, [], 'lenient')
709
+ const restored = new DataDomain(serialized, [], { mode: 'lenient' })
710
710
 
711
711
  // Should have issues recorded about missing dependency
712
712
  assert.isAbove(restored.issues.length, 0)
@@ -742,7 +742,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
742
742
  }
743
743
 
744
744
  // Should not throw in lenient mode
745
- const restored = new DataDomain(corruptedSerialized, [], 'lenient')
745
+ const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
746
746
 
747
747
  // Should have issues recorded
748
748
  assert.isAbove(restored.issues.length, 0)
@@ -797,7 +797,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
797
797
  }
798
798
 
799
799
  // Should not throw in lenient mode
800
- const restored = new DataDomain(corruptedSerialized, [], 'lenient')
800
+ const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
801
801
 
802
802
  // Should have multiple issues recorded
803
803
  assert.isAtLeast(restored.issues.length, 3)
@@ -823,7 +823,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
823
823
  e1.addProperty({ type: 'string' })
824
824
 
825
825
  const serialized = domain.toJSON()
826
- const restored = new DataDomain(serialized, [], 'strict')
826
+ const restored = new DataDomain(serialized, [], { mode: 'strict' })
827
827
 
828
828
  // Should have no issues in strict mode for valid data
829
829
  assert.equal(restored.issues.length, 0)
@@ -855,11 +855,11 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
855
855
 
856
856
  // Should throw in strict mode
857
857
  assert.throws(() => {
858
- new DataDomain(corruptedSerialized, [], 'strict')
858
+ new DataDomain(corruptedSerialized, [], { mode: 'strict' })
859
859
  })
860
860
 
861
861
  // Should not throw in lenient mode
862
- const restored = new DataDomain(corruptedSerialized, [], 'lenient')
862
+ const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
863
863
  assert.isAbove(restored.issues.length, 0)
864
864
  }).tags(['@modeling', '@serialization', '@lenient', '@strict'])
865
865
 
@@ -879,7 +879,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
879
879
  )
880
880
  }
881
881
 
882
- const restored = new DataDomain(corruptedSerialized, [], 'lenient')
882
+ const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
883
883
 
884
884
  // Should have detailed issue information
885
885
  assert.isAbove(restored.issues.length, 0)
@@ -328,3 +328,66 @@ test.group('DomainEntity.hasAssociations()', () => {
328
328
  assert.isFalse(entity.hasAssociations())
329
329
  })
330
330
  })
331
+
332
+ test.group('DomainEntity.withInheritedAssociations()', () => {
333
+ test('returns associations from entity and its parents', ({ assert }) => {
334
+ const dataDomain = new DataDomain()
335
+ const model = dataDomain.addModel()
336
+ const parentEntity = model.addEntity({ key: 'parent' })
337
+ const childEntity = model.addEntity({ key: 'child' })
338
+
339
+ const parentAssoc = parentEntity.addAssociation()
340
+ parentAssoc.info.name = 'parentAssoc'
341
+
342
+ const childAssoc = childEntity.addAssociation()
343
+ childAssoc.info.name = 'childAssoc'
344
+
345
+ childEntity.addParent(parentEntity.key)
346
+
347
+ const inherited = Array.from(childEntity.withInheritedAssociations())
348
+ assert.lengthOf(inherited, 2)
349
+ assert.deepEqual(inherited.map((a) => a.info.name).sort(), ['childAssoc', 'parentAssoc'])
350
+ })
351
+
352
+ test('overrides parent association with the same name', ({ assert }) => {
353
+ const dataDomain = new DataDomain()
354
+ const model = dataDomain.addModel()
355
+ const parentEntity = model.addEntity({ key: 'parent' })
356
+ const childEntity = model.addEntity({ key: 'child' })
357
+
358
+ const parentAssoc = parentEntity.addAssociation()
359
+ parentAssoc.info.name = 'assoc'
360
+
361
+ const childAssoc = childEntity.addAssociation()
362
+ childAssoc.info.name = 'assoc'
363
+
364
+ childEntity.addParent(parentEntity.key)
365
+
366
+ const inherited = Array.from(childEntity.withInheritedAssociations())
367
+ assert.lengthOf(inherited, 1)
368
+ assert.equal(inherited[0].key, childAssoc.key)
369
+ })
370
+
371
+ test('uses cached inherited associations when domain is readOnly', ({ assert }) => {
372
+ const dataDomain = new DataDomain(undefined, [], { readOnly: true })
373
+ const model = dataDomain.addModel()
374
+ const parentEntity = model.addEntity({ key: 'parent' })
375
+ const childEntity = model.addEntity({ key: 'child' })
376
+
377
+ const parentAssoc = parentEntity.addAssociation()
378
+ parentAssoc.info.name = 'parentAssoc'
379
+
380
+ childEntity.addParent(parentEntity.key)
381
+
382
+ const inherited1 = Array.from(childEntity.withInheritedAssociations())
383
+ assert.lengthOf(inherited1, 1)
384
+
385
+ // Add another association bypassing normal immutability rules for test
386
+ const anotherAssoc = parentEntity.addAssociation()
387
+ anotherAssoc.info.name = 'anotherAssoc'
388
+
389
+ const inherited2 = Array.from(childEntity.withInheritedAssociations())
390
+ // Should still be length 1 because of the readOnly cache
391
+ assert.lengthOf(inherited2, 1)
392
+ })
393
+ })
@@ -18,6 +18,41 @@ test.group('ExposedEntity', () => {
18
18
  assert.equal(ex.resourcePath, '/products/{customId}')
19
19
  }).tags(['@modeling', '@exposed-entity'])
20
20
 
21
+ test('setCollectionPath throws if semantic param cannot be generated', ({ assert }) => {
22
+ const model = new ApiModel()
23
+ const ex = new ExposedEntity(model, {
24
+ entity: { key: 'missing' },
25
+ hasCollection: true,
26
+ collectionPath: '/items',
27
+ resourcePath: '/', // Forces param generation
28
+ })
29
+
30
+ assert.throws(() => ex.setCollectionPath('products'), 'Cannot generate a semantic parameter name')
31
+ }).tags(['@modeling', '@exposed-entity'])
32
+
33
+ test('setCollectionPath generates semantic param from domain entity', ({ assert }) => {
34
+ const domain = new DataDomain()
35
+ domain.info.version = '1.0.0'
36
+ const dm = domain.addModel()
37
+ const entity = domain.addEntity(dm.key)
38
+ entity.info.name = 'user_post'
39
+
40
+ const model = new ApiModel()
41
+ model.attachDataDomain(domain)
42
+
43
+ const ex = new ExposedEntity(model, {
44
+ entity: { key: entity.key },
45
+ hasCollection: true,
46
+ collectionPath: '/items',
47
+ resourcePath: '/', // Forces param generation
48
+ })
49
+
50
+ ex.setCollectionPath('user-posts')
51
+
52
+ assert.equal(ex.collectionPath, '/user-posts')
53
+ assert.equal(ex.resourcePath, '/user-posts/{userPostId}')
54
+ }).tags(['@modeling', '@exposed-entity'])
55
+
21
56
  test('setResourcePath with collection allows only parameter name change', ({ assert }) => {
22
57
  const model = new ApiModel()
23
58
  const ex = new ExposedEntity(model, {
@@ -104,6 +139,66 @@ test.group('ExposedEntity', () => {
104
139
  assert.equal(grandEx.getAbsoluteResourcePath(), '/users/{userId}/posts/{postId}/details')
105
140
  }).tags(['@modeling', '@exposed-entity'])
106
141
 
142
+ test('rejects branch collision when modifying collection path', ({ assert }) => {
143
+ const model = new ApiModel()
144
+ const rootSchema: Partial<ExposedEntitySchema> = {
145
+ key: 'root',
146
+ entity: { key: 'user' },
147
+ hasCollection: true,
148
+ collectionPath: '/users',
149
+ resourcePath: '/users/{userId}',
150
+ isRoot: true,
151
+ }
152
+ const childSchema: Partial<ExposedEntitySchema> = {
153
+ key: 'child',
154
+ entity: { key: 'post' },
155
+ hasCollection: true,
156
+ collectionPath: '/posts',
157
+ resourcePath: '/posts/{postId}',
158
+ parent: { key: 'root', association: { key: 'toPosts' } },
159
+ }
160
+
161
+ const rootEx = new ExposedEntity(model, rootSchema)
162
+ const childEx = new ExposedEntity(model, childSchema)
163
+ model.exposes = new Map([
164
+ [rootEx.key, rootEx],
165
+ [childEx.key, childEx],
166
+ ])
167
+
168
+ // Modifying child's collection path to reuse 'userId'
169
+ assert.throws(() => childEx.setResourcePath('/posts/{userId}'), 'Duplicate path parameter "{userId}" detected')
170
+ }).tags(['@modeling', '@exposed-entity', '@collision'])
171
+
172
+ test('rejects branch collision from descendant when modifying ancestor resource path', ({ assert }) => {
173
+ const model = new ApiModel()
174
+ const rootSchema: Partial<ExposedEntitySchema> = {
175
+ key: 'root',
176
+ entity: { key: 'user' },
177
+ hasCollection: true,
178
+ collectionPath: '/users',
179
+ resourcePath: '/users/{userId}',
180
+ isRoot: true,
181
+ }
182
+ const childSchema: Partial<ExposedEntitySchema> = {
183
+ key: 'child',
184
+ entity: { key: 'post' },
185
+ hasCollection: true,
186
+ collectionPath: '/posts',
187
+ resourcePath: '/posts/{postId}',
188
+ parent: { key: 'root', association: { key: 'toPosts' } },
189
+ }
190
+
191
+ const rootEx = new ExposedEntity(model, rootSchema)
192
+ const childEx = new ExposedEntity(model, childSchema)
193
+ model.exposes = new Map([
194
+ [rootEx.key, rootEx],
195
+ [childEx.key, childEx],
196
+ ])
197
+
198
+ // Modifying root's resource path to 'postId' which collides with child
199
+ assert.throws(() => rootEx.setResourcePath('/users/{postId}'), 'Duplicate path parameter "{postId}" detected')
200
+ }).tags(['@modeling', '@exposed-entity', '@collision'])
201
+
107
202
  test('ApiModel notifies when nested ExposedEntity collection path changes', async ({ assert }) => {
108
203
  const model = new ApiModel({
109
204
  exposes: [