@api-client/core 0.18.27 → 0.18.29

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.
@@ -428,25 +428,101 @@ export interface UsernamePasswordConfiguration extends AuthenticationConfigurati
428
428
  */
429
429
  export interface ExposedEntity {
430
430
  /**
431
- * The key of the Data Entity from the Data Domain.
431
+ * The unique identifier for this exposure instance.
432
+ * In the exposure model, we need to uniquely identify each exposure instance, because
433
+ * an entity can be exposed multiple times in different contexts. Consider the following structure:
434
+ *
435
+ * ```
436
+ * /categories/{categoryId}
437
+ * /products/{productId}/categories
438
+ * /products/{productId}/categories/{categoryId}
439
+ * /promotions/{promotionId}/categories
440
+ * /promotions/{promotionId}/categories/{categoryId}
441
+ * ```
442
+ *
443
+ * The `category` entity would be exposed multiple times (as root and nested under products and promotions).
444
+ * We need a way to distinguish between these different exposure instances.
432
445
  */
433
446
  key: string
447
+ /**
448
+ * A pointer to a Data Entity from the Data Domain.
449
+ */
450
+ entity: AssociationTarget
451
+ /**
452
+ * The path segment for this exposure.
453
+ */
454
+ path: string
434
455
 
435
456
  /**
436
- * Optional configuration for resource-wide rate limiting and throttling.
437
- * Defines rules to protect the resource from overuse.
457
+ * Whether this exposure is a root exposure (top-level collection).
458
+ * If this is set then the `parent` reference must be populated.
438
459
  */
439
- rateLimiting?: RateLimitingConfiguration
460
+ isRoot?: boolean
461
+
440
462
  /**
441
- * Access control rules defining who can perform actions on this resource or collection.
442
- * It override the top-level access rules defined in the API model.
463
+ * Parent reference when this exposure was created via following an association.
443
464
  */
444
- accessRule?: AccessRule[]
465
+ parent?: ExposeParentRef
445
466
 
446
467
  /**
447
- * The collection of API actions (e.g., List, Read, Create) enabled for this entity.
468
+ * Expose-time config used to create this exposure (persisted for auditing/UI).
469
+ * This is only populated for the root exposure. All children exposures inherit this config.
470
+ */
471
+ exposeOptions?: ExposeOptions
472
+
473
+ /**
474
+ * The list of enabled API actions for this exposure (List/Read/Create/etc.)
448
475
  */
449
476
  actions: ApiAction[]
477
+
478
+ /**
479
+ * Optional array of access rules that define the access control policies for this exposure.
480
+ */
481
+ accessRule?: AccessRule[]
482
+
483
+ /**
484
+ * Optional configuration for rate limiting for this exposure.
485
+ */
486
+ rateLimiting?: RateLimitingConfiguration
487
+
488
+ /**
489
+ * When true, generation for this exposure hit configured limits
490
+ */
491
+ truncated?: boolean
492
+ }
493
+
494
+ /**
495
+ * Parent reference stored on a nested exposure
496
+ */
497
+ export interface ExposeParentRef {
498
+ /**
499
+ * The key of the parent exposed entity. This references the `ExposedEntity.key` property.
500
+ */
501
+ key: string
502
+ /**
503
+ * The association from the parent that produced this exposure.
504
+ * A sub-entity must always have a parent association.
505
+ */
506
+ association: AssociationTarget
507
+ /**
508
+ * The numeric depth from the root exposure (root = 0)
509
+ */
510
+ depth?: number
511
+ }
512
+
513
+ /**
514
+ * Options passed when creating a new exposure
515
+ */
516
+ export interface ExposeOptions {
517
+ /**
518
+ * Whether to follow associations when creating the exposure.
519
+ * When not set, it only exposes the passed entity.
520
+ */
521
+ followAssociations?: boolean
522
+ /**
523
+ * The maximum depth to follow associations when creating the exposure.
524
+ */
525
+ maxDepth?: number
450
526
  }
451
527
 
452
528
  /**
@@ -665,7 +741,7 @@ export interface MatchUserRoleAccessRule extends BaseAccessRule {
665
741
  * The domain model should annotate this property with the "UserRole" semantic
666
742
  * to indicate that it is used for role-based access control.
667
743
  */
668
- role: string
744
+ role: string[]
669
745
  }
670
746
  /**
671
747
  * The action is allowed if a specific property on the authenticated user matches an expected value.
@@ -10,10 +10,7 @@ import {
10
10
  type ApiLicense,
11
11
  } from '../../../src/index.js'
12
12
 
13
- test.group('ApiModel.createSchema()', (g) => {
14
- g.tests.forEach((test) => {
15
- test.tags(['@modeling', '@api', '@schema'])
16
- })
13
+ test.group('ApiModel.createSchema()', () => {
17
14
  test('creates a schema with default values', ({ assert }) => {
18
15
  const schema = ApiModel.createSchema()
19
16
  assert.equal(schema.kind, ApiModelKind)
@@ -31,13 +28,13 @@ test.group('ApiModel.createSchema()', (g) => {
31
28
  assert.isUndefined(schema.termsOfService)
32
29
  assert.isUndefined(schema.contact)
33
30
  assert.isUndefined(schema.license)
34
- })
31
+ }).tags(['@modeling', '@api', '@schema'])
35
32
 
36
33
  test('creates a schema with provided values', ({ assert }) => {
37
34
  const input: Partial<ApiModelSchema> = {
38
35
  key: 'test-api',
39
36
  info: { name: 'Test API', description: 'A test API' },
40
- exposes: [{ key: 'entity1', actions: [] }],
37
+ exposes: [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }],
41
38
  user: { key: 'user-entity' },
42
39
  dependencyList: [{ key: 'domain1', version: '1.0.0' }],
43
40
  authentication: { strategy: 'UsernamePassword' },
@@ -54,7 +51,7 @@ test.group('ApiModel.createSchema()', (g) => {
54
51
  assert.equal(schema.kind, ApiModelKind)
55
52
  assert.equal(schema.key, 'test-api')
56
53
  assert.deepInclude(schema.info, { name: 'Test API', description: 'A test API' })
57
- assert.deepEqual(schema.exposes, [{ key: 'entity1', actions: [] }])
54
+ assert.deepEqual(schema.exposes, [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }])
58
55
  assert.deepEqual(schema.user, { key: 'user-entity' })
59
56
  assert.deepEqual(schema.dependencyList, [{ key: 'domain1', version: '1.0.0' }])
60
57
  assert.deepEqual(schema.authentication, { strategy: 'UsernamePassword' })
@@ -65,24 +62,20 @@ test.group('ApiModel.createSchema()', (g) => {
65
62
  assert.equal(schema.termsOfService, 'https://example.com/terms')
66
63
  assert.deepEqual(schema.contact, { name: 'John Doe', email: 'john.doe@example.com' })
67
64
  assert.deepEqual(schema.license, { name: 'MIT', url: 'https://opensource.org/licenses/MIT' })
68
- })
65
+ }).tags(['@modeling', '@api', '@schema'])
69
66
 
70
67
  test('creates a schema with partial info', ({ assert }) => {
71
68
  const schema = ApiModel.createSchema({ info: { name: 'Partial API' } })
72
69
  assert.deepInclude(schema.info, { name: 'Partial API' })
73
- })
70
+ }).tags(['@modeling', '@api', '@schema'])
74
71
 
75
72
  test('creates a schema with empty info', ({ assert }) => {
76
73
  const schema = ApiModel.createSchema({ info: {} })
77
74
  assert.deepInclude(schema.info, { name: 'Unnamed API' })
78
- })
75
+ }).tags(['@modeling', '@api', '@schema'])
79
76
  })
80
77
 
81
- test.group('ApiModel.constructor()', (g) => {
82
- g.tests.forEach((test) => {
83
- test.tags(['@modeling', '@api', '@creation'])
84
- })
85
-
78
+ test.group('ApiModel.constructor()', () => {
86
79
  test('creates an instance with default values', ({ assert }) => {
87
80
  const model = new ApiModel()
88
81
  assert.equal(model.kind, ApiModelKind)
@@ -100,14 +93,14 @@ test.group('ApiModel.constructor()', (g) => {
100
93
  assert.isUndefined(model.contact)
101
94
  assert.isUndefined(model.license)
102
95
  assert.deepEqual(model.dependencyList, [])
103
- })
96
+ }).tags(['@modeling', '@api', '@creation'])
104
97
 
105
98
  test('creates an instance with provided schema values', ({ assert }) => {
106
99
  const schema: ApiModelSchema = {
107
100
  kind: ApiModelKind,
108
101
  key: 'test-api',
109
102
  info: { name: 'Test API', description: 'A test API' },
110
- exposes: [{ key: 'entity1', actions: [] }],
103
+ exposes: [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }],
111
104
  user: { key: 'user-entity' },
112
105
  dependencyList: [{ key: 'domain1', version: '1.0.0' }],
113
106
  authentication: { strategy: 'UsernamePassword' },
@@ -123,7 +116,7 @@ test.group('ApiModel.constructor()', (g) => {
123
116
 
124
117
  assert.equal(model.key, 'test-api')
125
118
  assert.equal(model.info.name, 'Test API')
126
- assert.deepEqual(model.exposes, [{ key: 'entity1', actions: [] }])
119
+ assert.deepEqual(model.exposes, [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }])
127
120
  assert.deepEqual(model.user, { key: 'user-entity' })
128
121
  assert.deepEqual(model.dependencyList, [{ key: 'domain1', version: '1.0.0' }])
129
122
  assert.deepEqual(model.authentication, { strategy: 'UsernamePassword' })
@@ -134,7 +127,7 @@ test.group('ApiModel.constructor()', (g) => {
134
127
  assert.equal(model.termsOfService, 'https://example.com/terms')
135
128
  assert.deepEqual(model.contact, { name: 'John Doe', email: 'john.doe@example.com' })
136
129
  assert.deepEqual(model.license, { name: 'MIT', url: 'https://opensource.org/licenses/MIT' })
137
- })
130
+ }).tags(['@modeling', '@api', '@creation'])
138
131
 
139
132
  test('creates an instance with a DataDomain', ({ assert }) => {
140
133
  const domainSchema = DataDomain.createSchema({ key: 'my-domain' })
@@ -146,7 +139,7 @@ test.group('ApiModel.constructor()', (g) => {
146
139
  assert.isDefined(model.domain)
147
140
  assert.instanceOf(model.domain, DataDomain)
148
141
  assert.equal(model.domain!.key, 'my-domain')
149
- })
142
+ }).tags(['@modeling', '@api', '@creation'])
150
143
 
151
144
  test('notifies change when info is modified', async ({ assert }) => {
152
145
  const model = new ApiModel()
@@ -157,13 +150,10 @@ test.group('ApiModel.constructor()', (g) => {
157
150
  model.info.name = 'New Name'
158
151
  await Promise.resolve() // Allow microtask to run
159
152
  assert.isTrue(notified)
160
- })
153
+ }).tags(['@modeling', '@api', '@creation'])
161
154
  })
162
155
 
163
- test.group('ApiModel.toJSON()', (g) => {
164
- g.tests.forEach((test) => {
165
- test.tags(['@modeling', '@api', '@serialization'])
166
- })
156
+ test.group('ApiModel.toJSON()', () => {
167
157
  test('serializes default values', ({ assert }) => {
168
158
  const model = new ApiModel()
169
159
  const json = model.toJSON()
@@ -182,14 +172,14 @@ test.group('ApiModel.toJSON()', (g) => {
182
172
  assert.isUndefined(json.termsOfService)
183
173
  assert.isUndefined(json.contact)
184
174
  assert.isUndefined(json.license)
185
- })
175
+ }).tags(['@modeling', '@api', '@serialization'])
186
176
 
187
177
  test('serializes all provided values', ({ assert }) => {
188
178
  const schema: ApiModelSchema = {
189
179
  kind: ApiModelKind,
190
180
  key: 'test-api',
191
181
  info: { name: 'Test API', description: 'A test API' },
192
- exposes: [{ key: 'entity1', actions: [] }],
182
+ exposes: [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }],
193
183
  user: { key: 'user-entity' },
194
184
  dependencyList: [{ key: 'domain1', version: '1.0.0' }],
195
185
  authentication: { strategy: 'UsernamePassword' },
@@ -206,7 +196,7 @@ test.group('ApiModel.toJSON()', (g) => {
206
196
 
207
197
  assert.equal(json.key, 'test-api')
208
198
  assert.deepInclude(json.info, { name: 'Test API', description: 'A test API' })
209
- assert.deepEqual(json.exposes, [{ key: 'entity1', actions: [] }])
199
+ assert.deepEqual(json.exposes, [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }])
210
200
  assert.deepEqual(json.user, { key: 'user-entity' })
211
201
  assert.deepEqual(json.dependencyList, [{ key: 'domain1', version: '1.0.0' }])
212
202
  assert.deepEqual(json.authentication, { strategy: 'UsernamePassword' })
@@ -217,125 +207,23 @@ test.group('ApiModel.toJSON()', (g) => {
217
207
  assert.equal(json.termsOfService, 'https://example.com/terms')
218
208
  assert.deepEqual(json.contact, { name: 'John Doe', email: 'john.doe@example.com' })
219
209
  assert.deepEqual(json.license, { name: 'MIT', url: 'https://opensource.org/licenses/MIT' })
220
- })
221
- })
222
-
223
- test.group('ApiModel.exposeEntity()', (g) => {
224
- g.tests.forEach((test) => {
225
- test.tags(['@modeling', '@api'])
226
- })
227
- test('exposes a new entity', ({ assert }) => {
228
- const model = new ApiModel()
229
- const entityKey = 'new-entity'
230
- const exposedEntity = model.exposeEntity(entityKey)
231
-
232
- assert.isDefined(exposedEntity)
233
- assert.equal(exposedEntity.key, entityKey)
234
- assert.deepEqual(exposedEntity.actions, [])
235
- assert.includeDeepMembers(model.exposes, [exposedEntity])
236
- })
237
-
238
- test('returns an existing entity if already exposed', ({ assert }) => {
239
- const model = new ApiModel()
240
- const entityKey = 'existing-entity'
241
- const initialExposedEntity = model.exposeEntity(entityKey)
242
- const retrievedExposedEntity = model.exposeEntity(entityKey)
243
-
244
- assert.strictEqual(retrievedExposedEntity, initialExposedEntity)
245
- assert.lengthOf(model.exposes, 1)
246
- })
247
-
248
- test('notifies change when a new entity is exposed', async ({ assert }) => {
249
- const model = new ApiModel()
250
- let notified = false
251
- model.addEventListener('change', () => {
252
- notified = true
253
- })
254
- model.exposeEntity('notify-entity')
255
- await Promise.resolve() // Allow microtask to run
256
- assert.isTrue(notified)
257
- })
258
-
259
- test('does not notify change if entity already exposed', async ({ assert }) => {
260
- const model = new ApiModel()
261
- model.exposeEntity('no-notify-entity') // First exposure
262
- await Promise.resolve() // Allow microtask to run
263
- let notified = false
264
- model.addEventListener('change', () => {
265
- notified = true
266
- })
267
- model.exposeEntity('no-notify-entity') // Second exposure
268
- await Promise.resolve() // Allow microtask to run
269
- assert.isFalse(notified)
270
- })
271
- })
272
-
273
- test.group('ApiModel.removeEntity()', (g) => {
274
- g.tests.forEach((test) => {
275
- test.tags(['@modeling', '@api'])
276
- })
277
- test('removes an existing entity', ({ assert }) => {
278
- const model = new ApiModel()
279
- const entityKey = 'entity-to-remove'
280
- model.exposeEntity(entityKey)
281
- assert.lengthOf(model.exposes, 1)
282
-
283
- model.removeEntity(entityKey)
284
- assert.lengthOf(model.exposes, 0)
285
- })
286
-
287
- test('does nothing if entity does not exist', ({ assert }) => {
288
- const model = new ApiModel()
289
- model.exposeEntity('existing-entity')
290
- const initialExposes = [...model.exposes]
291
-
292
- model.removeEntity('non-existing-entity')
293
- assert.deepEqual(model.exposes, initialExposes)
294
- })
295
-
296
- test('notifies change when an entity is removed', async ({ assert }) => {
297
- const model = new ApiModel()
298
- const entityKey = 'notify-remove-entity'
299
- model.exposeEntity(entityKey)
300
-
301
- let notified = false
302
- model.addEventListener('change', () => {
303
- notified = true
304
- })
305
- model.removeEntity(entityKey)
306
- await Promise.resolve() // Allow microtask to run
307
- assert.isTrue(notified)
308
- })
309
-
310
- test('does not notify change if entity to remove does not exist', async ({ assert }) => {
311
- const model = new ApiModel()
312
- let notified = false
313
- model.addEventListener('change', () => {
314
- notified = true
315
- })
316
- model.removeEntity('no-notify-remove-entity')
317
- await Promise.resolve() // Allow microtask to run
318
- assert.isFalse(notified)
319
- })
210
+ }).tags(['@modeling', '@api', '@serialization'])
320
211
  })
321
212
 
322
- test.group('ApiModel.getExposedEntity()', (g) => {
323
- g.tests.forEach((test) => {
324
- test.tags(['@modeling', '@api'])
325
- })
213
+ test.group('ApiModel.getExposedEntity()', () => {
326
214
  test('returns an existing exposed entity', ({ assert }) => {
327
215
  const model = new ApiModel()
328
216
  const entityKey = 'get-entity'
329
- const exposed: ExposedEntity = { key: entityKey, actions: [] }
217
+ const exposed: ExposedEntity = { key: entityKey, actions: [], path: '', entity: { key: entityKey } }
330
218
  model.exposes.push(exposed)
331
219
 
332
- const retrievedEntity = model.getExposedEntity(entityKey)
220
+ const retrievedEntity = model.getExposedEntity({ key: entityKey })
333
221
  assert.deepEqual(retrievedEntity, exposed)
334
- })
222
+ }).tags(['@modeling', '@api'])
335
223
 
336
224
  test('returns undefined if entity is not exposed', ({ assert }) => {
337
225
  const model = new ApiModel()
338
- const retrievedEntity = model.getExposedEntity('non-exposed-entity')
226
+ const retrievedEntity = model.getExposedEntity({ key: 'non-exposed-entity' })
339
227
  assert.isUndefined(retrievedEntity)
340
- })
228
+ }).tags(['@modeling', '@api'])
341
229
  })
@@ -0,0 +1,190 @@
1
+ import { test } from '@japa/runner'
2
+ import { ApiModel, DataDomain } from '../../../src/index.js'
3
+
4
+ test.group('ApiModel.exposeEntity()', () => {
5
+ test('exposes a new entity', ({ assert }) => {
6
+ const domain = new DataDomain()
7
+ domain.info.version = '1.0.0'
8
+ const dm = domain.addModel()
9
+ const e1 = dm.addEntity()
10
+ const model = new ApiModel()
11
+ model.attachDataDomain(domain)
12
+ const exposedEntity = model.exposeEntity({ key: e1.key })
13
+
14
+ assert.isDefined(exposedEntity)
15
+ assert.typeOf(exposedEntity.key, 'string')
16
+ assert.deepEqual(exposedEntity.entity, { key: e1.key })
17
+ assert.deepEqual(exposedEntity.actions, [])
18
+ assert.includeDeepMembers(model.exposes, [exposedEntity])
19
+ }).tags(['@modeling', '@api'])
20
+
21
+ test('returns an existing entity if already exposed', ({ assert }) => {
22
+ const domain = new DataDomain()
23
+ domain.info.version = '1.0.0'
24
+ const dm = domain.addModel()
25
+ const e1 = dm.addEntity()
26
+ const model = new ApiModel()
27
+ model.attachDataDomain(domain)
28
+ const initialExposedEntity = model.exposeEntity({ key: e1.key })
29
+ const retrievedExposedEntity = model.exposeEntity({ key: e1.key })
30
+
31
+ assert.strictEqual(retrievedExposedEntity, initialExposedEntity)
32
+ assert.lengthOf(model.exposes, 1)
33
+ }).tags(['@modeling', '@api'])
34
+
35
+ test('notifies change when a new entity is exposed', async ({ assert }) => {
36
+ const domain = new DataDomain()
37
+ domain.info.version = '1.0.0'
38
+ const dm = domain.addModel()
39
+ const e1 = dm.addEntity()
40
+ const model = new ApiModel()
41
+ model.attachDataDomain(domain)
42
+ let notified = false
43
+ model.addEventListener('change', () => {
44
+ notified = true
45
+ })
46
+ model.exposeEntity({ key: e1.key })
47
+ await Promise.resolve() // Allow microtask to run
48
+ assert.isTrue(notified)
49
+ }).tags(['@modeling', '@api'])
50
+
51
+ test('does not notify change if entity already exposed', async ({ assert }) => {
52
+ const domain = new DataDomain()
53
+ domain.info.version = '1.0.0'
54
+ const dm = domain.addModel()
55
+ const e1 = dm.addEntity()
56
+ const model = new ApiModel()
57
+ model.attachDataDomain(domain)
58
+ model.exposeEntity({ key: e1.key }) // First exposure
59
+ await Promise.resolve() // Allow microtask to run
60
+ let notified = false
61
+ model.addEventListener('change', () => {
62
+ notified = true
63
+ })
64
+ model.exposeEntity({ key: e1.key }) // Second exposure
65
+ await Promise.resolve() // Allow microtask to run
66
+ assert.isFalse(notified)
67
+ }).tags(['@modeling', '@api'])
68
+
69
+ test('exposes nested entities through associations', ({ assert }) => {
70
+ const domain = new DataDomain()
71
+ domain.info.version = '1.0.0'
72
+ const dm = domain.addModel()
73
+ const eA = dm.addEntity({ info: { name: 'A' } })
74
+ const eB = dm.addEntity({ info: { name: 'B' } })
75
+ // Add association from A to B
76
+ eA.addAssociation({ key: eB.key }, { info: { name: 'entityB' } })
77
+ const model = new ApiModel()
78
+ model.attachDataDomain(domain)
79
+ const exposedA = model.exposeEntity({ key: eA.key }, { followAssociations: true })
80
+ // Find nested exposure for B
81
+ const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
82
+ assert.isDefined(nestedB)
83
+ assert.deepEqual(nestedB?.parent?.key, exposedA.key)
84
+ assert.strictEqual(nestedB?.path, 'entitybs')
85
+ })
86
+
87
+ test('does not infinitely expose circular associations', ({ assert }) => {
88
+ const domain = new DataDomain()
89
+ domain.info.version = '1.0.0'
90
+ const dm = domain.addModel()
91
+ const eA = dm.addEntity({ info: { name: 'A' } })
92
+ const eB = dm.addEntity({ info: { name: 'B' } })
93
+ // A -> B, B -> A
94
+ eA.addAssociation({ key: eB.key }, { info: { name: 'assocAB' } })
95
+ eB.addAssociation({ key: eA.key }, { info: { name: 'assocBA' } })
96
+ const model = new ApiModel()
97
+ model.attachDataDomain(domain)
98
+ model.exposeEntity({ key: eA.key }, { followAssociations: true, maxDepth: 4 })
99
+
100
+ assert.lengthOf(model.exposes, 2, 'has only 2 exposures')
101
+ // Should expose A (root), B (nested under A), but not infinitely nest
102
+ const exposedA = model.exposes.find((e) => e.isRoot && e.entity.key === eA.key)
103
+ const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
104
+ // There should be only one nested exposure for B under A
105
+ assert.isDefined(exposedA)
106
+ assert.isDefined(nestedB)
107
+ // There should NOT be a nested exposure for A under B
108
+ const circularA = model.exposes.find((e) => !e.isRoot && e.entity.key === eA.key && e.parent?.key === nestedB?.key)
109
+ assert.isUndefined(circularA)
110
+ })
111
+
112
+ test('does not expose self-association as nested entity', ({ assert }) => {
113
+ const domain = new DataDomain()
114
+ domain.info.version = '1.0.0'
115
+ const dm = domain.addModel()
116
+ const eA = dm.addEntity({ info: { name: 'A' } })
117
+ // A -> A (self-association)
118
+ eA.addAssociation({ key: eA.key }, { info: { name: 'assocAA' } })
119
+ const model = new ApiModel()
120
+ model.attachDataDomain(domain)
121
+ model.exposeEntity({ key: eA.key }, { followAssociations: true })
122
+ // Should only expose A as root, not as nested
123
+ const exposedA = model.exposes.find((e) => e.isRoot && e.entity.key === eA.key)
124
+ const nestedA = model.exposes.find((e) => !e.isRoot && e.entity.key === eA.key)
125
+ assert.isDefined(exposedA)
126
+ assert.isUndefined(nestedA)
127
+ })
128
+
129
+ test('exposes multi-level associations (A -> B -> C)', ({ assert }) => {
130
+ const domain = new DataDomain()
131
+ domain.info.version = '1.0.0'
132
+ const dm = domain.addModel()
133
+ const eA = dm.addEntity({ info: { name: 'A' } })
134
+ const eB = dm.addEntity({ info: { name: 'B' } })
135
+ const eC = dm.addEntity({ info: { name: 'C' } })
136
+ eA.addAssociation({ key: eB.key }, { info: { name: 'toB' } })
137
+ eB.addAssociation({ key: eC.key }, { info: { name: 'toC' } })
138
+ const model = new ApiModel()
139
+ model.attachDataDomain(domain)
140
+ model.exposeEntity({ key: eA.key }, { followAssociations: true })
141
+ const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
142
+ const nestedC = model.exposes.find((e) => !e.isRoot && e.entity.key === eC.key)
143
+ assert.isDefined(nestedB)
144
+ assert.isDefined(nestedC)
145
+ assert.deepEqual(nestedB?.parent?.key, model.exposes.find((e) => e.isRoot && e.entity.key === eA.key)?.key)
146
+ assert.deepEqual(nestedC?.parent?.key, nestedB?.key)
147
+ })
148
+
149
+ test('exposes multiple associations from one entity (A -> B, A -> C)', ({ assert }) => {
150
+ const domain = new DataDomain()
151
+ domain.info.version = '1.0.0'
152
+ const dm = domain.addModel()
153
+ const eA = dm.addEntity({ info: { name: 'A' } })
154
+ const eB = dm.addEntity({ info: { name: 'B' } })
155
+ const eC = dm.addEntity({ info: { name: 'C' } })
156
+ eA.addAssociation({ key: eB.key }, { info: { name: 'toB' } })
157
+ eA.addAssociation({ key: eC.key }, { info: { name: 'toC' } })
158
+ const model = new ApiModel()
159
+ model.attachDataDomain(domain)
160
+ model.exposeEntity({ key: eA.key }, { followAssociations: true })
161
+ const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
162
+ const nestedC = model.exposes.find((e) => !e.isRoot && e.entity.key === eC.key)
163
+ assert.isDefined(nestedB)
164
+ assert.isDefined(nestedC)
165
+ assert.deepEqual(nestedB?.parent?.key, model.exposes.find((e) => e.isRoot && e.entity.key === eA.key)?.key)
166
+ assert.deepEqual(nestedC?.parent?.key, model.exposes.find((e) => e.isRoot && e.entity.key === eA.key)?.key)
167
+ })
168
+
169
+ test('respects maxDepth option (A -> B -> C -> D, maxDepth=2)', ({ assert }) => {
170
+ const domain = new DataDomain()
171
+ domain.info.version = '1.0.0'
172
+ const dm = domain.addModel()
173
+ const eA = dm.addEntity({ info: { name: 'A' } })
174
+ const eB = dm.addEntity({ info: { name: 'B' } })
175
+ const eC = dm.addEntity({ info: { name: 'C' } })
176
+ const eD = dm.addEntity({ info: { name: 'D' } })
177
+ eA.addAssociation({ key: eB.key }, { info: { name: 'toB' } })
178
+ eB.addAssociation({ key: eC.key }, { info: { name: 'toC' } })
179
+ eC.addAssociation({ key: eD.key }, { info: { name: 'toD' } })
180
+ const model = new ApiModel()
181
+ model.attachDataDomain(domain)
182
+ model.exposeEntity({ key: eA.key }, { followAssociations: true, maxDepth: 2 })
183
+ const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
184
+ const nestedC = model.exposes.find((e) => !e.isRoot && e.entity.key === eC.key)
185
+ const nestedD = model.exposes.find((e) => !e.isRoot && e.entity.key === eD.key)
186
+ assert.isDefined(nestedB)
187
+ assert.isDefined(nestedC)
188
+ assert.isUndefined(nestedD)
189
+ })
190
+ })
@@ -0,0 +1,82 @@
1
+ import { test } from '@japa/runner'
2
+ import { ApiModel, DataDomain } from '../../../src/index.js'
3
+
4
+ test.group('ApiModel.removeEntity()', () => {
5
+ test('removes an existing entity', ({ assert }) => {
6
+ const domain = new DataDomain()
7
+ domain.info.version = '1.0.0'
8
+ const dm = domain.addModel()
9
+ const e1 = dm.addEntity()
10
+ const model = new ApiModel()
11
+ model.attachDataDomain(domain)
12
+ model.exposeEntity({ key: e1.key })
13
+ assert.lengthOf(model.exposes, 1)
14
+
15
+ model.removeEntity({ key: e1.key })
16
+ assert.lengthOf(model.exposes, 0)
17
+ }).tags(['@modeling', '@api'])
18
+
19
+ test('removes an entity and its nested children', ({ assert }) => {
20
+ const domain = new DataDomain()
21
+ domain.info.version = '1.0.0'
22
+ const dm = domain.addModel()
23
+ const eA = dm.addEntity({ info: { name: 'A' } })
24
+ const eB = dm.addEntity({ info: { name: 'B' } })
25
+ // A -> B
26
+ eA.addAssociation({ key: eB.key }, { info: { name: 'toB' } })
27
+ const model = new ApiModel()
28
+ model.attachDataDomain(domain)
29
+ model.exposeEntity({ key: eA.key }, { followAssociations: true })
30
+ // Ensure nested exposure for B was created
31
+ const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
32
+ assert.isDefined(nestedB)
33
+ assert.isAbove(model.exposes.length, 1)
34
+
35
+ // Remove root entity A and expect children to be removed as well
36
+ model.removeEntity({ key: eA.key })
37
+ assert.lengthOf(model.exposes, 0)
38
+ }).tags(['@modeling', '@api'])
39
+
40
+ test('does nothing if entity does not exist', ({ assert }) => {
41
+ const domain = new DataDomain()
42
+ domain.info.version = '1.0.0'
43
+ const dm = domain.addModel()
44
+ const e1 = dm.addEntity()
45
+ const model = new ApiModel()
46
+ model.attachDataDomain(domain)
47
+ model.exposeEntity({ key: e1.key })
48
+ const initialExposes = [...model.exposes]
49
+
50
+ model.removeEntity({ key: 'non-existing-entity' })
51
+ assert.deepEqual(model.exposes, initialExposes)
52
+ }).tags(['@modeling', '@api'])
53
+
54
+ test('notifies change when an entity is removed', async ({ assert }) => {
55
+ const domain = new DataDomain()
56
+ domain.info.version = '1.0.0'
57
+ const dm = domain.addModel()
58
+ const e1 = dm.addEntity()
59
+ const model = new ApiModel()
60
+ model.attachDataDomain(domain)
61
+ model.exposeEntity({ key: e1.key })
62
+
63
+ let notified = false
64
+ model.addEventListener('change', () => {
65
+ notified = true
66
+ })
67
+ model.removeEntity({ key: e1.key })
68
+ await Promise.resolve() // Allow microtask to run
69
+ assert.isTrue(notified)
70
+ }).tags(['@modeling', '@api'])
71
+
72
+ test('does not notify change if entity to remove does not exist', async ({ assert }) => {
73
+ const model = new ApiModel()
74
+ let notified = false
75
+ model.addEventListener('change', () => {
76
+ notified = true
77
+ })
78
+ model.removeEntity({ key: 'no-notify-remove-entity' })
79
+ await Promise.resolve() // Allow microtask to run
80
+ assert.isFalse(notified)
81
+ }).tags(['@modeling', '@api'])
82
+ })