@api-client/core 0.18.48 → 0.18.49

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.
@@ -42062,9 +42062,6 @@
42062
42062
  "@id": "#209"
42063
42063
  },
42064
42064
  {
42065
- "@id": "#206"
42066
- },
42067
- {
42068
42065
  "@id": "#191"
42069
42066
  },
42070
42067
  {
@@ -42080,6 +42077,9 @@
42080
42077
  "@id": "#203"
42081
42078
  },
42082
42079
  {
42080
+ "@id": "#206"
42081
+ },
42082
+ {
42083
42083
  "@id": "#209"
42084
42084
  }
42085
42085
  ],
@@ -43436,7 +43436,7 @@
43436
43436
  "doc:ExternalDomainElement",
43437
43437
  "doc:DomainElement"
43438
43438
  ],
43439
- "doc:raw": "addressType: 'REGISTERED-OFFICE-ADDRESS'\nstreetName: 'UITBREIDINGSTRAAT'\nhouseNumber: '84'\nhouseNumberAddition: '/1'\npostalCode: '2600'\ncity: 'BERCHEM (ANTWERPEN)'\ncountry: 'Belgium'\ncountryCode: 'BE'\nfullFormatedAddress: \"UITBREIDINGSTRAAT 84 /1, 2600 BERCHEM (ANTWERPEN), BELIUM\"\n",
43439
+ "doc:raw": "countryCode: \"BE\"\ngraydonEnterpriseId: 1057155523\nregistrationId: \"0422319093\"\nvatNumber: \"BE0422319093\"\ngraydonCompanyId: \"0422319093\"\nisBranchOffice: false\n",
43440
43440
  "core:mediaType": "application/yaml",
43441
43441
  "sourcemaps:sources": [
43442
43442
  {
@@ -43457,7 +43457,7 @@
43457
43457
  "doc:ExternalDomainElement",
43458
43458
  "doc:DomainElement"
43459
43459
  ],
43460
- "doc:raw": "code: '5'\ndescription: 'Limited company'\n",
43460
+ "doc:raw": "addressType: 'REGISTERED-OFFICE-ADDRESS'\nstreetName: 'UITBREIDINGSTRAAT'\nhouseNumber: '84'\nhouseNumberAddition: '/1'\npostalCode: '2600'\ncity: 'BERCHEM (ANTWERPEN)'\ncountry: 'Belgium'\ncountryCode: 'BE'\nfullFormatedAddress: \"UITBREIDINGSTRAAT 84 /1, 2600 BERCHEM (ANTWERPEN), BELIUM\"\n",
43461
43461
  "core:mediaType": "application/yaml",
43462
43462
  "sourcemaps:sources": [
43463
43463
  {
@@ -43478,7 +43478,7 @@
43478
43478
  "doc:ExternalDomainElement",
43479
43479
  "doc:DomainElement"
43480
43480
  ],
43481
- "doc:raw": "class: '3'\ndescription: '150 - 300'\nnumberOfFte: 5500\nnumberOfEmployees: 5232\n",
43481
+ "doc:raw": "code: '5'\ndescription: 'Limited company'\n",
43482
43482
  "core:mediaType": "application/yaml",
43483
43483
  "sourcemaps:sources": [
43484
43484
  {
@@ -43499,7 +43499,7 @@
43499
43499
  "doc:ExternalDomainElement",
43500
43500
  "doc:DomainElement"
43501
43501
  ],
43502
- "doc:raw": "code: 'J'\ndescription: 'Information and communication'\n",
43502
+ "doc:raw": "class: '3'\ndescription: '150 - 300'\nnumberOfFte: 5500\nnumberOfEmployees: 5232\n",
43503
43503
  "core:mediaType": "application/yaml",
43504
43504
  "sourcemaps:sources": [
43505
43505
  {
@@ -43520,7 +43520,7 @@
43520
43520
  "doc:ExternalDomainElement",
43521
43521
  "doc:DomainElement"
43522
43522
  ],
43523
- "doc:raw": "code: '7487'\ndescription: 'Financial and insurance activities'\ntype: \"PRIMARY\"\nclassificationCode: 'BE_NACEBEL2008'\nactivityGroupCode: 'ABCDE'\n",
43523
+ "doc:raw": "code: 'J'\ndescription: 'Information and communication'\n",
43524
43524
  "core:mediaType": "application/yaml",
43525
43525
  "sourcemaps:sources": [
43526
43526
  {
@@ -43541,7 +43541,7 @@
43541
43541
  "doc:ExternalDomainElement",
43542
43542
  "doc:DomainElement"
43543
43543
  ],
43544
- "doc:raw": "countryCode: \"BE\"\ngraydonEnterpriseId: 1057155523\nregistrationId: \"0422319093\"\nvatNumber: \"BE0422319093\"\ngraydonCompanyId: \"0422319093\"\nisBranchOffice: false\n",
43544
+ "doc:raw": "code: '7487'\ndescription: 'Financial and insurance activities'\ntype: \"PRIMARY\"\nclassificationCode: 'BE_NACEBEL2008'\nactivityGroupCode: 'ABCDE'\n",
43545
43545
  "core:mediaType": "application/yaml",
43546
43546
  "sourcemaps:sources": [
43547
43547
  {
@@ -44756,32 +44756,32 @@
44756
44756
  {
44757
44757
  "@id": "#193/source-map/lexical/element_0",
44758
44758
  "sourcemaps:element": "amf://id#193",
44759
- "sourcemaps:value": "[(1,0)-(10,0)]"
44759
+ "sourcemaps:value": "[(1,0)-(7,0)]"
44760
44760
  },
44761
44761
  {
44762
44762
  "@id": "#196/source-map/lexical/element_0",
44763
44763
  "sourcemaps:element": "amf://id#196",
44764
- "sourcemaps:value": "[(1,0)-(3,0)]"
44764
+ "sourcemaps:value": "[(1,0)-(10,0)]"
44765
44765
  },
44766
44766
  {
44767
44767
  "@id": "#199/source-map/lexical/element_0",
44768
44768
  "sourcemaps:element": "amf://id#199",
44769
- "sourcemaps:value": "[(1,0)-(5,0)]"
44769
+ "sourcemaps:value": "[(1,0)-(3,0)]"
44770
44770
  },
44771
44771
  {
44772
44772
  "@id": "#202/source-map/lexical/element_0",
44773
44773
  "sourcemaps:element": "amf://id#202",
44774
- "sourcemaps:value": "[(1,0)-(3,0)]"
44774
+ "sourcemaps:value": "[(1,0)-(5,0)]"
44775
44775
  },
44776
44776
  {
44777
44777
  "@id": "#205/source-map/lexical/element_0",
44778
44778
  "sourcemaps:element": "amf://id#205",
44779
- "sourcemaps:value": "[(1,0)-(6,0)]"
44779
+ "sourcemaps:value": "[(1,0)-(3,0)]"
44780
44780
  },
44781
44781
  {
44782
44782
  "@id": "#208/source-map/lexical/element_0",
44783
44783
  "sourcemaps:element": "amf://id#208",
44784
- "sourcemaps:value": "[(1,0)-(7,0)]"
44784
+ "sourcemaps:value": "[(1,0)-(6,0)]"
44785
44785
  },
44786
44786
  {
44787
44787
  "@id": "#223/source-map/lexical/element_0",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@api-client/core",
3
3
  "description": "The API Client's core client library. Works in NodeJS and in a ES enabled browser.",
4
- "version": "0.18.48",
4
+ "version": "0.18.49",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {
7
7
  "./browser.js": {
@@ -99,7 +99,7 @@
99
99
  "chalk": "^5.4.1",
100
100
  "console-table-printer": "^2.11.2",
101
101
  "dompurify": "^3.2.6",
102
- "jsdom": "^27.0.0",
102
+ "jsdom": "^28.0.0",
103
103
  "nanoid": "^5.1.5",
104
104
  "tslog": "^4.9.3",
105
105
  "ws": "^8.12.0",
@@ -280,6 +280,16 @@ export class ApiModel extends DependentModel {
280
280
  } else if (domain) {
281
281
  throw new Error(`Invalid domain provided. Expected a DataDomain instance or schema.`)
282
282
  }
283
+ // Note that since we're using the `DependentModel` class, but the API Model can have only one dependency,
284
+ // we keep the reference to the data domain under the `dependencyList` property.
285
+ // It is all handled by the parent class `DependentModel`. This way we simplify the dependency management (loading)
286
+ // process when the API model is loaded from the API.
287
+ if (domain) {
288
+ if (!domain.info.version) {
289
+ throw new Error(`Domain ${domain.key} must have a version.`)
290
+ }
291
+ init.dependencyList = [{ key: domain.key, version: domain.info.version }]
292
+ }
283
293
  super(init.dependencyList, instances)
284
294
  this.kind = init.kind
285
295
  this.key = init.key
@@ -380,6 +390,12 @@ export class ApiModel extends DependentModel {
380
390
  /**
381
391
  * Exposes a new entity in the API model.
382
392
  * If the entity already exists, it returns the existing one.
393
+ *
394
+ * The logic regarding exposing an entity:
395
+ * - If the entity is already exposed as a root entity, it returns the existing one.
396
+ * - If the entity has an association, we expose that entity as a nested entity (by setting the parent property).
397
+ * - If the associated entity is already exposed as a root entity, we do not follow the association.
398
+ *
383
399
  * @param entity The entity key and domain to expose.
384
400
  * @returns The exposed entity.
385
401
  */
@@ -403,8 +419,18 @@ export class ApiModel extends DependentModel {
403
419
  }
404
420
  const name = domainEntity.info.name || ''
405
421
  const segment = pluralize(name.toLocaleLowerCase())
406
- const relativeCollectionPath = `/${segment}`
407
- const relativeResourcePath = `/${segment}/{id}`
422
+ let relativeCollectionPath = `/${segment}`
423
+ let relativeResourcePath = `/${segment}/{id}`
424
+
425
+ // Check for root path collision and resolve by appending a number
426
+ let counter = 1
427
+ const originalCollectionPath = relativeCollectionPath
428
+ while (this.exposes.some((e) => e.isRoot && e.collectionPath === relativeCollectionPath)) {
429
+ relativeCollectionPath = `${originalCollectionPath}-${counter}`
430
+ relativeResourcePath = `${relativeCollectionPath}/{id}`
431
+ counter++
432
+ }
433
+
408
434
  const newEntity: ExposedEntitySchema = {
409
435
  kind: ExposedEntityKind,
410
436
  key: nanoid(),
@@ -444,12 +470,7 @@ export class ApiModel extends DependentModel {
444
470
  return
445
471
  }
446
472
  const maxDepth = options.maxDepth ?? 6
447
- const visited = new Set<string>()
448
- // Add parent entity's key to the visited set so we won't skip it when traversing
449
- // associations.
450
- visited.add(createDomainKey(parentExposure.entity))
451
-
452
- const follow = (currentEntity: AssociationTarget, parentKey: string, depth: number) => {
473
+ const follow = (currentEntity: AssociationTarget, parentKey: string, depth: number, currentPath: string[]) => {
453
474
  // Find the domain entity
454
475
  const domainEntity = domain.findEntity(currentEntity.key, currentEntity.domain)
455
476
  if (!domainEntity) return
@@ -462,24 +483,45 @@ export class ApiModel extends DependentModel {
462
483
  continue
463
484
  }
464
485
 
465
- // Create unique identifier for circular detection
466
- const visitKey = createDomainKey(target)
467
- if (visited.has(visitKey)) {
468
- continue // Skip circular references
486
+ const targetKeys = createDomainKey(target)
487
+ // Circular dependency check: if this entity is already in our *current* traversal path
488
+ if (currentPath.includes(targetKeys)) {
489
+ continue
469
490
  }
470
- visited.add(visitKey)
471
-
472
- // Check if this nested exposure already exists
473
- const existingNested = this.exposes.find(
474
- (e) =>
475
- !e.isRoot &&
476
- e.entity.key === target.key &&
477
- e.entity.domain === target.domain &&
478
- e.parent?.key === parentKey
479
- )
480
-
481
- if (existingNested) {
482
- continue // Already exposed under this parent
491
+
492
+ // Check if this entity is ALREADY exposed anywhere in the model
493
+ const existingExposure = this.getExposedEntity(target)
494
+
495
+ if (existingExposure) {
496
+ // If it's already exposed and NOT root (i.e., it's currently a nested child of someone else),
497
+ // promote it to ROOT to avoid duplicating it or deeply nesting it in multiple places.
498
+ if (!existingExposure.isRoot) {
499
+ // 1. Calculate new root paths (handling collisions)
500
+ const targetDomEntity = domain.findEntity(target.key, target.domain)
501
+ if (targetDomEntity) {
502
+ const name = targetDomEntity.info.name || ''
503
+ const segment = pluralize(name.toLocaleLowerCase())
504
+ let relativeCollectionPath = `/${segment}`
505
+ let relativeResourcePath = `/${segment}/{id}`
506
+
507
+ let counter = 1
508
+ const originalCollectionPath = relativeCollectionPath
509
+ while (this.exposes.some((e) => e.isRoot && e.collectionPath === relativeCollectionPath)) {
510
+ relativeCollectionPath = `${originalCollectionPath}-${counter}`
511
+ relativeResourcePath = `${relativeCollectionPath}/{id}`
512
+ counter++
513
+ }
514
+
515
+ // 2. Update properties to make it root
516
+ existingExposure.isRoot = true
517
+ existingExposure.parent = undefined
518
+ existingExposure.collectionPath = relativeCollectionPath
519
+ existingExposure.resourcePath = relativeResourcePath
520
+ existingExposure.hasCollection = true
521
+ }
522
+ }
523
+ // Whether it was already root or we just promoted it, we stop following here.
524
+ continue
483
525
  }
484
526
 
485
527
  // Find the target domain entity for path generation
@@ -516,27 +558,30 @@ export class ApiModel extends DependentModel {
516
558
  nestedExposure.truncated = true
517
559
  } else {
518
560
  // Recursively follow associations
519
- follow(target, nestedExposure.key, depth + 1)
561
+ follow(target, nestedExposure.key, depth + 1, [...currentPath, targetKeys])
520
562
  }
521
563
  }
522
564
  }
523
565
  }
524
566
 
525
567
  // Start following from the root exposure
526
- follow(parentExposure.entity, parentExposure.key, 0)
568
+ // Initial path contains the root entity itself
569
+ const rootKey = createDomainKey(parentExposure.entity)
570
+ follow(parentExposure.entity, parentExposure.key, 0, [rootKey])
527
571
  }
528
572
 
529
573
  /**
530
574
  * Removes an exposed entity from the API model.
531
- * @param entity The entity to remove.
575
+ * This also removes any nested exposed entities that are children of the removed entity.
576
+ *
577
+ * @param key The key of the exposed entity to remove.
532
578
  */
533
- removeEntity(entity: AssociationTarget): void {
534
- const current = this.exposes.find((e) => e.entity.key === entity.key && e.entity.domain === entity.domain)
535
- if (!current) {
536
- return
579
+ removeExposedEntity(key: string): void {
580
+ const index = this.exposes.findIndex((e) => e.key === key)
581
+ if (index < 0) {
582
+ throw new Error(`Exposed entity with key "${key}" not found.`)
537
583
  }
538
- this.removeWithChildren(current.key)
539
- this.notifyChange()
584
+ this.removeWithChildren(key)
540
585
  }
541
586
 
542
587
  private removeWithChildren(key: string): void {
@@ -573,11 +618,11 @@ export class ApiModel extends DependentModel {
573
618
  }
574
619
 
575
620
  /**
576
- * Clears the API model for a new entity change.
621
+ * Clears the API model for a new data domain change.
577
622
  * This method resets the dependencies, exposes, user,
578
623
  * authentication, authorization, and session properties.
579
624
  */
580
- cleanForEntityChange(): void {
625
+ cleanForDomainChange(): void {
581
626
  this.dependencies.clear()
582
627
  this.dependencyList = []
583
628
  this.exposes = []
@@ -606,7 +651,7 @@ export class ApiModel extends DependentModel {
606
651
  if (!domain.info.version) {
607
652
  throw new Error(`Cannot attach DataDomain without a version. Please set the version in the domain info.`)
608
653
  }
609
- this.cleanForEntityChange()
654
+ this.cleanForDomainChange()
610
655
  this.dependencies.set(domain.key, domain)
611
656
  this.dependencyList = [{ key: domain.key, version: domain.info.version }]
612
657
  this.notifyChange()
@@ -16,6 +16,15 @@ import type {
16
16
  /**
17
17
  * A class that specializes in representing an exposed Data Entity within an API Model.
18
18
  *
19
+ * ## Design Note
20
+ * This class enforces strict path constraints (e.g., single-segment collection paths like `/users`,
21
+ * two-segment resource paths like `/users/{id}`).
22
+ * This is an intentional design choice to support a UI paradigm for non-technical users, ensuring that
23
+ * path segments are configured individually at each level of the exposure hierarchy.
24
+ *
25
+ * Flexibility is achieved by chaining exposed entities (parent/child relationships), where the final
26
+ * absolute path is composed of all ancestral paths. See `getAbsoluteResourcePath()` and `getAbsoluteCollectionPath()`.
27
+ *
19
28
  * @fires change - Emitted when the exposed entity has changed.
20
29
  */
21
30
  export class ExposedEntity extends EventTarget {
@@ -250,6 +259,17 @@ export class ExposedEntity extends EventTarget {
250
259
  throw new Error(`Collection path must contain exactly one segment. Received: "${path}"`)
251
260
  }
252
261
  const normalizedCollection = `/${segments[0]}`
262
+
263
+ // Check for collision if this is a root entity
264
+ if (this.isRoot) {
265
+ const collision = this.api.exposes.find(
266
+ (e) => e.isRoot && e.key !== this.key && e.collectionPath === normalizedCollection
267
+ )
268
+ if (collision) {
269
+ throw new Error(`Collection path "${normalizedCollection}" is already in use by another root entity.`)
270
+ }
271
+ }
272
+
253
273
  // Preserve current parameter name if present, otherwise default to {id}
254
274
  let param = '{id}'
255
275
  if (this.resourcePath) {
@@ -309,6 +329,14 @@ export class ExposedEntity extends EventTarget {
309
329
  `Resource path must contain exactly two segments when no collection is present. Received: "${cleaned}"`
310
330
  )
311
331
  }
332
+ // Check for collision if this is a root entity (singleton case)
333
+ if (this.isRoot) {
334
+ const collision = this.api.exposes.find((e) => e.isRoot && e.key !== this.key && e.resourcePath === cleaned)
335
+ if (collision) {
336
+ throw new Error(`Resource path "${cleaned}" is already in use by another root entity.`)
337
+ }
338
+ }
339
+
312
340
  if (this.resourcePath !== cleaned) {
313
341
  this.resourcePath = `/${segments[0]}/${segments[1]}`
314
342
  }
@@ -163,6 +163,26 @@ test.group('ApiModel.constructor()', () => {
163
163
  assert.equal(model.domain!.key, 'my-domain')
164
164
  }).tags(['@modeling', '@api', '@creation'])
165
165
 
166
+ test('initializes domain dependency correctly when passed to constructor', ({ assert }) => {
167
+ const domain = new DataDomain()
168
+ domain.info.version = '1.0.0'
169
+
170
+ const model = new ApiModel({}, domain)
171
+
172
+ assert.isDefined(model.domain)
173
+ assert.equal(model.domain?.key, domain.key)
174
+ assert.lengthOf(model.dependencyList, 1)
175
+ assert.equal(model.dependencyList[0].key, domain.key)
176
+ assert.equal(model.dependencyList[0].version, '1.0.0')
177
+ }).tags(['@modeling', '@api', '@creation'])
178
+
179
+ test('throws if passed domain has no version', ({ assert }) => {
180
+ const domain = new DataDomain()
181
+ // No version set
182
+
183
+ assert.throws(() => new ApiModel({}, domain), /must have a version/)
184
+ }).tags(['@modeling', '@api', '@creation'])
185
+
166
186
  test('notifies change when info is modified', async ({ assert }) => {
167
187
  const model = new ApiModel()
168
188
  let notified = false
@@ -186,4 +186,29 @@ test.group('ApiModel.exposeEntity()', () => {
186
186
  assert.isDefined(nestedC)
187
187
  assert.isUndefined(nestedD)
188
188
  })
189
+ test('resolves root path collision by appending a number', ({ assert }) => {
190
+ const domain = new DataDomain()
191
+ domain.info.version = '1.0.0'
192
+ const dm = domain.addModel()
193
+ // Two entities that will generate the same plural path "/items"
194
+ const e1 = dm.addEntity({ info: { name: 'Item' } })
195
+ const e2 = dm.addEntity({ info: { name: 'Item' } }) // Same name
196
+
197
+ const model = new ApiModel()
198
+ model.attachDataDomain(domain)
199
+
200
+ // Expose first entity -> /items
201
+ const exp1 = model.exposeEntity({ key: e1.key })
202
+ assert.equal(exp1.collectionPath, '/items')
203
+
204
+ // Expose second entity -> should resolve collision to /items-1
205
+ const exp2 = model.exposeEntity({ key: e2.key })
206
+ assert.equal(exp2.collectionPath, '/items-1')
207
+ assert.equal(exp2.resourcePath, '/items-1/{id}')
208
+
209
+ // Expose third entity -> /items-2
210
+ const e3 = dm.addEntity({ info: { name: 'Item' } })
211
+ const exp3 = model.exposeEntity({ key: e3.key })
212
+ assert.equal(exp3.collectionPath, '/items-2')
213
+ }).tags(['@modeling', '@api'])
189
214
  })
@@ -9,10 +9,10 @@ test.group('ApiModel.removeEntity()', () => {
9
9
  const e1 = dm.addEntity()
10
10
  const model = new ApiModel()
11
11
  model.attachDataDomain(domain)
12
- model.exposeEntity({ key: e1.key })
12
+ const exposure = model.exposeEntity({ key: e1.key })
13
13
  assert.lengthOf(model.exposes, 1)
14
14
 
15
- model.removeEntity({ key: e1.key })
15
+ model.removeExposedEntity(exposure.key)
16
16
  assert.lengthOf(model.exposes, 0)
17
17
  }).tags(['@modeling', '@api'])
18
18
 
@@ -26,18 +26,18 @@ test.group('ApiModel.removeEntity()', () => {
26
26
  eA.addAssociation({ key: eB.key }, { info: { name: 'toB' } })
27
27
  const model = new ApiModel()
28
28
  model.attachDataDomain(domain)
29
- model.exposeEntity({ key: eA.key }, { followAssociations: true })
29
+ const rootExposure = model.exposeEntity({ key: eA.key }, { followAssociations: true })
30
30
  // Ensure nested exposure for B was created
31
31
  const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
32
32
  assert.isDefined(nestedB)
33
33
  assert.isAbove(model.exposes.length, 1)
34
34
 
35
- // Remove root entity A and expect children to be removed as well
36
- model.removeEntity({ key: eA.key })
35
+ // Remove root exposure for A and expect children to be removed as well
36
+ model.removeExposedEntity(rootExposure.key)
37
37
  assert.lengthOf(model.exposes, 0)
38
38
  }).tags(['@modeling', '@api'])
39
39
 
40
- test('does nothing if entity does not exist', ({ assert }) => {
40
+ test('throws error if entity does not exist', ({ assert }) => {
41
41
  const domain = new DataDomain()
42
42
  domain.info.version = '1.0.0'
43
43
  const dm = domain.addModel()
@@ -46,7 +46,10 @@ test.group('ApiModel.removeEntity()', () => {
46
46
  model.attachDataDomain(domain)
47
47
  model.exposeEntity({ key: e1.key })
48
48
 
49
- model.removeEntity({ key: 'non-existing-entity' })
49
+ assert.throws(
50
+ () => model.removeExposedEntity('non-existing-key'),
51
+ 'Exposed entity with key "non-existing-key" not found.'
52
+ )
50
53
  assert.lengthOf(model.exposes, 1, 'exposes count should remain unchanged')
51
54
  }).tags(['@modeling', '@api'])
52
55
 
@@ -57,13 +60,13 @@ test.group('ApiModel.removeEntity()', () => {
57
60
  const e1 = dm.addEntity()
58
61
  const model = new ApiModel()
59
62
  model.attachDataDomain(domain)
60
- model.exposeEntity({ key: e1.key })
63
+ const exposure = model.exposeEntity({ key: e1.key })
61
64
 
62
65
  let notified = false
63
66
  model.addEventListener('change', () => {
64
67
  notified = true
65
68
  })
66
- model.removeEntity({ key: e1.key })
69
+ model.removeExposedEntity(exposure.key)
67
70
  await Promise.resolve() // Allow microtask to run
68
71
  assert.isTrue(notified)
69
72
  }).tags(['@modeling', '@api'])
@@ -74,7 +77,11 @@ test.group('ApiModel.removeEntity()', () => {
74
77
  model.addEventListener('change', () => {
75
78
  notified = true
76
79
  })
77
- model.removeEntity({ key: 'no-notify-remove-entity' })
80
+ try {
81
+ model.removeExposedEntity('no-notify-remove-entity')
82
+ } catch {
83
+ // ignore error
84
+ }
78
85
  await Promise.resolve() // Allow microtask to run
79
86
  assert.isFalse(notified)
80
87
  }).tags(['@modeling', '@api'])
@@ -0,0 +1,107 @@
1
+ import { test } from '@japa/runner'
2
+ import { ApiModel, DataDomain } from '../../../src/index.js'
3
+
4
+ test.group('ExposedEntity Path Setter Validation', () => {
5
+ test('throws when setting collection path that collides with another root entity', ({ assert }) => {
6
+ const domain = new DataDomain()
7
+ domain.info.version = '1.0.0'
8
+ const dm = domain.addModel()
9
+ const e1 = dm.addEntity({ info: { name: 'A' } })
10
+ const e2 = dm.addEntity({ info: { name: 'B' } })
11
+
12
+ const model = new ApiModel()
13
+ model.attachDataDomain(domain)
14
+
15
+ model.exposeEntity({ key: e1.key }) // /as
16
+ const exp2 = model.exposeEntity({ key: e2.key }) // /bs
17
+
18
+ // Try to rename exp2's collection path to /as (collision)
19
+ assert.throws(
20
+ () => exp2.setCollectionPath('/as'),
21
+ 'Collection path "/as" is already in use by another root entity.'
22
+ )
23
+ })
24
+
25
+ test('throws when setting resource path that collides with another root entity (singleton)', ({ assert }) => {
26
+ const domain = new DataDomain()
27
+ domain.info.version = '1.0.0'
28
+ const dm = domain.addModel()
29
+ const e1 = dm.addEntity({ info: { name: 'A' } })
30
+ const e2 = dm.addEntity({ info: { name: 'B' } }) // Collection-less
31
+
32
+ const model = new ApiModel()
33
+ model.attachDataDomain(domain)
34
+
35
+ const exp1 = model.exposeEntity({ key: e1.key }) // /as, /as/{id}
36
+ // Manually force a collision for testing resource path (singleton vs singleton or singleton vs resource)
37
+ // Let's make exp2 a singleton
38
+ const exp2 = model.exposeEntity({ key: e2.key })
39
+ // Remove collection from exp2 so we can set arbitrary resource path
40
+ // Note: implementation of setResourcePath for collection-less allows any 2 segments
41
+ // We need to simulate the state where hasCollection is false
42
+ exp2.hasCollection = false
43
+
44
+ // Set exp1 resource path to something specific
45
+ exp1.hasCollection = false
46
+ exp1.setResourcePath('/shared/path')
47
+
48
+ // Try to set exp2 resource path to same
49
+ assert.throws(
50
+ () => exp2.setResourcePath('/shared/path'),
51
+ 'Resource path "/shared/path" is already in use by another root entity.'
52
+ )
53
+ })
54
+
55
+ test('allows setting non-colliding paths for root entity', ({ assert }) => {
56
+ const domain = new DataDomain()
57
+ domain.info.version = '1.0.0'
58
+ const dm = domain.addModel()
59
+ const e1 = dm.addEntity({ info: { name: 'A' } })
60
+
61
+ const model = new ApiModel()
62
+ model.attachDataDomain(domain)
63
+
64
+ const exp1 = model.exposeEntity({ key: e1.key }) // /as
65
+
66
+ exp1.setCollectionPath('/new-path')
67
+ assert.equal(exp1.collectionPath, '/new-path')
68
+
69
+ exp1.hasCollection = false // allow arbitrary resource path
70
+ exp1.setResourcePath('/custom/resource')
71
+ assert.equal(exp1.resourcePath, '/custom/resource')
72
+ })
73
+
74
+ test('does not validate collision for non-root entities', ({ assert }) => {
75
+ const domain = new DataDomain()
76
+ domain.info.version = '1.0.0'
77
+ const dm = domain.addModel()
78
+ const eA = dm.addEntity({ info: { name: 'A' } })
79
+ const eB = dm.addEntity({ info: { name: 'B' } })
80
+ eA.addAssociation({ key: eB.key }, { info: { name: 'toB' } })
81
+
82
+ const model = new ApiModel()
83
+ model.attachDataDomain(domain)
84
+
85
+ // expose A -> B
86
+ const rootExp = model.exposeEntity({ key: eA.key }, { followAssociations: true })
87
+ const nestedExp = model.exposes.find((e) => !e.isRoot)
88
+
89
+ assert.isDefined(nestedExp)
90
+ // Assuming root entity has collection path /as
91
+ assert.equal(rootExp.collectionPath, '/as')
92
+
93
+ // Try to set nested entity's collection path to /as
94
+ // Since it's not root, it should NOT check for collision with rootExp
95
+ // (In reality this path is technically valid relative to parent, but here we just check that
96
+ // it doesn't throw the specific root collision error)
97
+
98
+ // setCollectionPath logic for non-root:
99
+ // It doesn't check checks.
100
+
101
+ try {
102
+ nestedExp?.setCollectionPath('/as')
103
+ } catch (e) {
104
+ assert.notInclude((e as Error).message, 'already in use by another root entity')
105
+ }
106
+ })
107
+ })