@api-client/core 0.18.47 → 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.
Files changed (33) hide show
  1. package/build/src/mocking/ModelingMock.d.ts +2 -0
  2. package/build/src/mocking/ModelingMock.d.ts.map +1 -1
  3. package/build/src/mocking/ModelingMock.js +2 -0
  4. package/build/src/mocking/ModelingMock.js.map +1 -1
  5. package/build/src/mocking/lib/File.d.ts +1 -0
  6. package/build/src/mocking/lib/File.d.ts.map +1 -1
  7. package/build/src/mocking/lib/File.js +1 -0
  8. package/build/src/mocking/lib/File.js.map +1 -1
  9. package/build/src/mocking/lib/Permission.d.ts +35 -0
  10. package/build/src/mocking/lib/Permission.d.ts.map +1 -0
  11. package/build/src/mocking/lib/Permission.js +89 -0
  12. package/build/src/mocking/lib/Permission.js.map +1 -0
  13. package/build/src/modeling/ApiModel.d.ts +12 -4
  14. package/build/src/modeling/ApiModel.d.ts.map +1 -1
  15. package/build/src/modeling/ApiModel.js +76 -31
  16. package/build/src/modeling/ApiModel.js.map +1 -1
  17. package/build/src/modeling/ExposedEntity.d.ts +9 -0
  18. package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
  19. package/build/src/modeling/ExposedEntity.js +23 -0
  20. package/build/src/modeling/ExposedEntity.js.map +1 -1
  21. package/build/tsconfig.tsbuildinfo +1 -1
  22. package/data/models/example-generator-api.json +9 -9
  23. package/package.json +3 -3
  24. package/src/mocking/ModelingMock.ts +2 -0
  25. package/src/mocking/lib/File.ts +1 -0
  26. package/src/mocking/lib/Permission.ts +100 -0
  27. package/src/modeling/ApiModel.ts +82 -37
  28. package/src/modeling/ExposedEntity.ts +28 -0
  29. package/tests/unit/mocking/current/Permission.spec.ts +285 -0
  30. package/tests/unit/modeling/api_model.spec.ts +20 -0
  31. package/tests/unit/modeling/api_model_expose_entity.spec.ts +25 -0
  32. package/tests/unit/modeling/api_model_remove_entity.spec.ts +17 -10
  33. package/tests/unit/modeling/exposed_entity_setter_validation.spec.ts +107 -0
@@ -42065,15 +42065,15 @@
42065
42065
  "@id": "#191"
42066
42066
  },
42067
42067
  {
42068
- "@id": "#200"
42069
- },
42070
- {
42071
42068
  "@id": "#194"
42072
42069
  },
42073
42070
  {
42074
42071
  "@id": "#197"
42075
42072
  },
42076
42073
  {
42074
+ "@id": "#200"
42075
+ },
42076
+ {
42077
42077
  "@id": "#203"
42078
42078
  },
42079
42079
  {
@@ -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": "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",
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
  {
@@ -44761,17 +44761,17 @@
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)-(10,0)]"
44774
+ "sourcemaps:value": "[(1,0)-(5,0)]"
44775
44775
  },
44776
44776
  {
44777
44777
  "@id": "#205/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.47",
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",
@@ -113,7 +113,7 @@
113
113
  "@japa/assert": "^4.0.1",
114
114
  "@japa/browser-client": "^2.1.1",
115
115
  "@japa/expect-type": "^2.0.3",
116
- "@japa/runner": "^4.2.0",
116
+ "@japa/runner": "^5.3.0",
117
117
  "@pawel-up/semver": "^0.1.4",
118
118
  "@rollup/plugin-typescript": "^12.1.2",
119
119
  "@types/chai-as-promised": "^8.0.2",
@@ -7,6 +7,7 @@ import { Invitation } from './lib/Invitation.js'
7
7
  import { Trash } from './lib/Trash.js'
8
8
  import { Patch } from './lib/Patch.js'
9
9
  import { DataCatalog } from './lib/DataCatalog.js'
10
+ import { Permission } from './lib/Permission.js'
10
11
 
11
12
  export class ModelingMock {
12
13
  faker: Faker = faker
@@ -18,4 +19,5 @@ export class ModelingMock {
18
19
  trash = new Trash()
19
20
  patch = new Patch()
20
21
  dataCatalog = new DataCatalog()
22
+ permission = new Permission()
21
23
  }
@@ -6,6 +6,7 @@ import { nanoid } from '../../nanoid.js'
6
6
  export class File {
7
7
  /**
8
8
  * Generates a random file object.
9
+ * Note that this generator doesn't create the permissions object. Use Permission class for that.
9
10
  * @param init Optional values to be present in the object.
10
11
  * @returns Random file
11
12
  */
@@ -0,0 +1,100 @@
1
+ import { faker } from '@faker-js/faker'
2
+ import { nanoid } from '../../nanoid.js'
3
+ import {
4
+ IPermission,
5
+ Kind as PermissionKind,
6
+ type PermissionRole,
7
+ type PermissionType,
8
+ type PermissionSourceRule,
9
+ } from '../../models/store/Permission.js'
10
+
11
+ export class Permission {
12
+ /**
13
+ * Generates a random permission object.
14
+ * @param init Configuration options for the permission object.
15
+ * @returns Random permission
16
+ */
17
+ permission(init: Partial<IPermission> = {}): IPermission {
18
+ const type = init.type ?? faker.helpers.arrayElement(['user', 'group', 'organization'] as PermissionType[])
19
+ const role = init.role ?? faker.helpers.arrayElement(['reader', 'commenter', 'writer', 'owner'] as PermissionRole[])
20
+
21
+ const result: IPermission = {
22
+ kind: PermissionKind,
23
+ key: init.key ?? nanoid(),
24
+ type,
25
+ granteeId: init.granteeId ?? nanoid(),
26
+ itemId: init.itemId ?? nanoid(),
27
+ role,
28
+ addingUser: init.addingUser ?? nanoid(),
29
+ depth: init.depth ?? 0,
30
+ sourceRule:
31
+ init.sourceRule ??
32
+ faker.helpers.arrayElement([
33
+ 'direct_user_grant',
34
+ 'creator_default_owner',
35
+ 'parent_owner_editor_rule',
36
+ ] as PermissionSourceRule[]),
37
+ }
38
+
39
+ if ('displayName' in init) {
40
+ result.displayName = init.displayName
41
+ } else {
42
+ if (type === 'user') {
43
+ result.displayName = faker.person.fullName()
44
+ } else if (type === 'group') {
45
+ result.displayName = faker.lorem.word()
46
+ } else if (type === 'organization') {
47
+ result.displayName = faker.company.name()
48
+ }
49
+ }
50
+
51
+ if ('expirationTime' in init) {
52
+ result.expirationTime = init.expirationTime
53
+ } else if (type === 'user' || type === 'group') {
54
+ result.expirationTime = faker.date.future().getTime()
55
+ }
56
+
57
+ return result
58
+ }
59
+
60
+ /**
61
+ * Generates a random user permission object.
62
+ * @param init Configuration options for the permission object.
63
+ * @returns Random user permission
64
+ */
65
+ userPermission(init: Partial<IPermission> = {}): IPermission {
66
+ return this.permission({ ...init, type: 'user' })
67
+ }
68
+
69
+ /**
70
+ * Generates a random group permission object.
71
+ * @param init Configuration options for the permission object.
72
+ * @returns Random group permission
73
+ */
74
+ groupPermission(init: Partial<IPermission> = {}): IPermission {
75
+ return this.permission({ ...init, type: 'group' })
76
+ }
77
+
78
+ /**
79
+ * Generates a random organization permission object.
80
+ * @param init Configuration options for the permission object.
81
+ * @returns Random organization permission
82
+ */
83
+ organizationPermission(init: Partial<IPermission> = {}): IPermission {
84
+ return this.permission({ ...init, type: 'organization' })
85
+ }
86
+
87
+ /**
88
+ * Generates a list of random permission objects.
89
+ * @param size The size of the returning array. Default is 25.
90
+ * @param init Configuration options for the permission object.
91
+ * @returns List of random permissions
92
+ */
93
+ permissions(size = 25, init: Partial<IPermission> = {}): IPermission[] {
94
+ const result: IPermission[] = []
95
+ for (let i = 0; i < size; i++) {
96
+ result.push(this.permission(init))
97
+ }
98
+ return result
99
+ }
100
+ }
@@ -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
  }