@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.
- package/build/src/modeling/ApiModel.d.ts +12 -4
- package/build/src/modeling/ApiModel.d.ts.map +1 -1
- package/build/src/modeling/ApiModel.js +76 -31
- package/build/src/modeling/ApiModel.js.map +1 -1
- package/build/src/modeling/ExposedEntity.d.ts +9 -0
- package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
- package/build/src/modeling/ExposedEntity.js +23 -0
- package/build/src/modeling/ExposedEntity.js.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/data/models/example-generator-api.json +15 -15
- package/package.json +2 -2
- package/src/modeling/ApiModel.ts +82 -37
- package/src/modeling/ExposedEntity.ts +28 -0
- package/tests/unit/modeling/api_model.spec.ts +20 -0
- package/tests/unit/modeling/api_model_expose_entity.spec.ts +25 -0
- package/tests/unit/modeling/api_model_remove_entity.spec.ts +17 -10
- package/tests/unit/modeling/exposed_entity_setter_validation.spec.ts +107 -0
|
@@ -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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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: '
|
|
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": "
|
|
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)-(
|
|
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)-(
|
|
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)-(
|
|
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)-(
|
|
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)-(
|
|
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)-(
|
|
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.
|
|
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": "^
|
|
102
|
+
"jsdom": "^28.0.0",
|
|
103
103
|
"nanoid": "^5.1.5",
|
|
104
104
|
"tslog": "^4.9.3",
|
|
105
105
|
"ws": "^8.12.0",
|
package/src/modeling/ApiModel.ts
CHANGED
|
@@ -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
|
-
|
|
407
|
-
|
|
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
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
if (
|
|
468
|
-
continue
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
534
|
-
const
|
|
535
|
-
if (
|
|
536
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
36
|
-
model.
|
|
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('
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
})
|