@api-client/core 0.19.9 → 0.19.10

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 (98) hide show
  1. package/Testing.md +1 -1
  2. package/build/src/decorators/observed.d.ts.map +1 -1
  3. package/build/src/decorators/observed.js +91 -0
  4. package/build/src/decorators/observed.js.map +1 -1
  5. package/build/src/modeling/ApiModel.d.ts +21 -7
  6. package/build/src/modeling/ApiModel.d.ts.map +1 -1
  7. package/build/src/modeling/ApiModel.js +70 -29
  8. package/build/src/modeling/ApiModel.js.map +1 -1
  9. package/build/src/modeling/DomainValidation.d.ts +1 -1
  10. package/build/src/modeling/DomainValidation.d.ts.map +1 -1
  11. package/build/src/modeling/DomainValidation.js.map +1 -1
  12. package/build/src/modeling/ExposedEntity.d.ts +14 -0
  13. package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
  14. package/build/src/modeling/ExposedEntity.js +59 -6
  15. package/build/src/modeling/ExposedEntity.js.map +1 -1
  16. package/build/src/modeling/actions/Action.d.ts +11 -1
  17. package/build/src/modeling/actions/Action.d.ts.map +1 -1
  18. package/build/src/modeling/actions/Action.js +21 -3
  19. package/build/src/modeling/actions/Action.js.map +1 -1
  20. package/build/src/modeling/actions/CreateAction.d.ts +2 -1
  21. package/build/src/modeling/actions/CreateAction.d.ts.map +1 -1
  22. package/build/src/modeling/actions/CreateAction.js +2 -2
  23. package/build/src/modeling/actions/CreateAction.js.map +1 -1
  24. package/build/src/modeling/actions/DeleteAction.d.ts +2 -1
  25. package/build/src/modeling/actions/DeleteAction.d.ts.map +1 -1
  26. package/build/src/modeling/actions/DeleteAction.js +2 -2
  27. package/build/src/modeling/actions/DeleteAction.js.map +1 -1
  28. package/build/src/modeling/actions/ListAction.d.ts +2 -1
  29. package/build/src/modeling/actions/ListAction.d.ts.map +1 -1
  30. package/build/src/modeling/actions/ListAction.js +2 -2
  31. package/build/src/modeling/actions/ListAction.js.map +1 -1
  32. package/build/src/modeling/actions/ReadAction.d.ts +2 -1
  33. package/build/src/modeling/actions/ReadAction.d.ts.map +1 -1
  34. package/build/src/modeling/actions/ReadAction.js +2 -2
  35. package/build/src/modeling/actions/ReadAction.js.map +1 -1
  36. package/build/src/modeling/actions/SearchAction.d.ts +2 -1
  37. package/build/src/modeling/actions/SearchAction.d.ts.map +1 -1
  38. package/build/src/modeling/actions/SearchAction.js +2 -2
  39. package/build/src/modeling/actions/SearchAction.js.map +1 -1
  40. package/build/src/modeling/actions/UpdateAction.d.ts +2 -1
  41. package/build/src/modeling/actions/UpdateAction.d.ts.map +1 -1
  42. package/build/src/modeling/actions/UpdateAction.js +2 -2
  43. package/build/src/modeling/actions/UpdateAction.js.map +1 -1
  44. package/build/src/modeling/actions/index.d.ts +2 -1
  45. package/build/src/modeling/actions/index.d.ts.map +1 -1
  46. package/build/src/modeling/actions/index.js +7 -7
  47. package/build/src/modeling/actions/index.js.map +1 -1
  48. package/build/src/modeling/index.d.ts +1 -0
  49. package/build/src/modeling/index.d.ts.map +1 -1
  50. package/build/src/modeling/index.js +1 -0
  51. package/build/src/modeling/index.js.map +1 -1
  52. package/build/src/modeling/types.d.ts +67 -0
  53. package/build/src/modeling/types.d.ts.map +1 -1
  54. package/build/src/modeling/types.js.map +1 -1
  55. package/build/src/modeling/validation/api_model_rules.d.ts +15 -0
  56. package/build/src/modeling/validation/api_model_rules.d.ts.map +1 -0
  57. package/build/src/modeling/validation/api_model_rules.js +599 -0
  58. package/build/src/modeling/validation/api_model_rules.js.map +1 -0
  59. package/build/src/modeling/validation/association_validation.d.ts.map +1 -1
  60. package/build/src/modeling/validation/association_validation.js +1 -3
  61. package/build/src/modeling/validation/association_validation.js.map +1 -1
  62. package/build/tsconfig.tsbuildinfo +1 -1
  63. package/data/models/example-generator-api.json +8 -8
  64. package/eslint.config.js +0 -1
  65. package/package.json +17 -122
  66. package/src/decorators/observed.ts +91 -0
  67. package/src/modeling/ApiModel.ts +73 -33
  68. package/src/modeling/DomainValidation.ts +1 -1
  69. package/src/modeling/ExposedEntity.ts +63 -9
  70. package/src/modeling/actions/Action.ts +25 -2
  71. package/src/modeling/actions/CreateAction.ts +3 -2
  72. package/src/modeling/actions/DeleteAction.ts +3 -2
  73. package/src/modeling/actions/ListAction.ts +3 -2
  74. package/src/modeling/actions/ReadAction.ts +3 -2
  75. package/src/modeling/actions/SearchAction.ts +3 -2
  76. package/src/modeling/actions/UpdateAction.ts +3 -2
  77. package/src/modeling/types.ts +70 -0
  78. package/src/modeling/validation/api_model_rules.ts +640 -0
  79. package/src/modeling/validation/api_model_validation_rules.md +58 -0
  80. package/src/modeling/validation/association_validation.ts +1 -3
  81. package/tests/unit/modeling/actions/Action.spec.ts +40 -8
  82. package/tests/unit/modeling/actions/CreateAction.spec.ts +5 -5
  83. package/tests/unit/modeling/actions/DeleteAction.spec.ts +6 -6
  84. package/tests/unit/modeling/actions/ListAction.spec.ts +7 -7
  85. package/tests/unit/modeling/actions/ReadAction.spec.ts +6 -6
  86. package/tests/unit/modeling/actions/SearchAction.spec.ts +6 -6
  87. package/tests/unit/modeling/actions/UpdateAction.spec.ts +6 -6
  88. package/tests/unit/modeling/api_model.spec.ts +190 -13
  89. package/tests/unit/modeling/api_model_expose_entity.spec.ts +43 -19
  90. package/tests/unit/modeling/api_model_remove_entity.spec.ts +6 -6
  91. package/tests/unit/modeling/exposed_entity.spec.ts +123 -3
  92. package/tests/unit/modeling/exposed_entity_actions.spec.ts +41 -18
  93. package/tests/unit/modeling/exposed_entity_setter_validation.spec.ts +1 -1
  94. package/tests/unit/modeling/rules/restoring_rules.spec.ts +9 -5
  95. package/tests/unit/modeling/validation/api_model_rules.spec.ts +324 -0
  96. package/tsconfig.browser.json +1 -1
  97. package/tsconfig.node.json +1 -1
  98. package/bin/test-web.ts +0 -6
@@ -13,7 +13,7 @@ import {
13
13
  } from '../../../src/index.js'
14
14
  import { AllowPublicAccessRule } from '../../../src/modeling/rules/AllowPublic.js'
15
15
  import { RateLimitingConfiguration } from '../../../src/modeling/rules/RateLimitingConfiguration.js'
16
- import { Action } from '../../../src/modeling/index.js'
16
+ import { ReadActionSchema } from '../../../src/modeling/index.js'
17
17
 
18
18
  test.group('ApiModel.createSchema()', () => {
19
19
  test('creates a schema with default values', ({ assert }) => {
@@ -97,7 +97,7 @@ test.group('ApiModel.constructor()', () => {
97
97
  assert.typeOf(model.key, 'string')
98
98
  assert.isNotEmpty(model.key)
99
99
  assert.equal(model.info.name, 'Unnamed API')
100
- assert.deepEqual(model.exposes, [])
100
+ assert.equal(model.exposes.size, 0)
101
101
  assert.isUndefined(model.user)
102
102
  assert.isUndefined(model.authentication)
103
103
  assert.isUndefined(model.authorization)
@@ -140,8 +140,8 @@ test.group('ApiModel.constructor()', () => {
140
140
 
141
141
  assert.equal(model.key, 'test-api')
142
142
  assert.equal(model.info.name, 'Test API')
143
- assert.lengthOf(model.exposes, 1, 'should have one exposed entity')
144
- assert.equal(model.exposes[0].key, 'entity1', 'exposed entity should have correct key')
143
+ assert.equal(model.exposes.size, 1, 'should have one exposed entity')
144
+ assert.equal(Array.from(model.exposes.values())[0]!.entity.key, 'entity1', 'exposed entity should have correct key')
145
145
  assert.deepEqual(model.user, { key: 'user-entity' })
146
146
  assert.deepEqual(model.dependencyList, [{ key: 'domain1', version: '1.0.0' }])
147
147
  assert.deepEqual(model.authentication, { strategy: 'UsernamePassword' })
@@ -216,7 +216,7 @@ test.group('ApiModel.constructor()', () => {
216
216
  notified = true
217
217
  })
218
218
 
219
- model.exposes[0].actions[0].kind = 'write'
219
+ Array.from(model.exposes.values())[0]!.actions[0]!.kind = 'write'
220
220
  await Promise.resolve()
221
221
  assert.isTrue(notified)
222
222
  }).tags(['@modeling', '@api', '@observed'])
@@ -290,20 +290,22 @@ test.group('ApiModel.toJSON()', () => {
290
290
 
291
291
  test('actions are immutable', ({ assert }) => {
292
292
  const model = new ApiModel()
293
- const action = new Action({ kind: 'read' })
293
+ const action: ReadActionSchema = {
294
+ kind: 'read',
295
+ }
294
296
  const entityKey = 'get-entity'
295
297
  const exposed: ExposedEntitySchema = {
296
298
  key: entityKey,
297
- actions: [action.toJSON()],
299
+ actions: [action],
298
300
  hasCollection: true,
299
301
  kind: ExposedEntityKind,
300
302
  resourcePath: '/',
301
303
  entity: { key: entityKey },
302
304
  }
303
- model.exposes.push(new ExposedEntity(model, exposed))
305
+ model.exposes.set(exposed.key, new ExposedEntity(model, exposed))
304
306
  const json = model.toJSON()
305
- json.exposes[0].actions[0].kind = 'write'
306
- assert.equal(model.exposes[0].actions[0].kind, 'read')
307
+ json.exposes![0]!.actions![0]!.kind = 'write'
308
+ assert.equal(Array.from(model.exposes.values())[0]!.actions[0]!.kind, 'read')
307
309
  }).tags(['@modeling', '@api', '@serialization'])
308
310
  })
309
311
 
@@ -319,15 +321,190 @@ test.group('ApiModel.getExposedEntity()', () => {
319
321
  resourcePath: '/',
320
322
  entity: { key: entityKey },
321
323
  }
322
- model.exposes.push(new ExposedEntity(model, exposed))
324
+ model.exposes.set(exposed.key, new ExposedEntity(model, exposed))
323
325
 
324
- const retrievedEntity = model.getExposedEntity({ key: entityKey })
326
+ const retrievedEntity = model.exposes.get(entityKey)
325
327
  assert.deepEqual(retrievedEntity?.toJSON(), exposed)
326
328
  }).tags(['@modeling', '@api'])
327
329
 
328
330
  test('returns undefined if entity is not exposed', ({ assert }) => {
329
331
  const model = new ApiModel()
330
- const retrievedEntity = model.getExposedEntity({ key: 'non-exposed-entity' })
332
+ const retrievedEntity = model.exposes.get('non-exposed-entity')
331
333
  assert.isUndefined(retrievedEntity)
332
334
  }).tags(['@modeling', '@api'])
333
335
  })
336
+
337
+ test.group('ApiModel.findResourcePathCollision()', () => {
338
+ test('returns the entity if resource path collides', ({ assert }) => {
339
+ const model = new ApiModel()
340
+ model.exposes.set(
341
+ 'ent1',
342
+ new ExposedEntity(model, {
343
+ key: 'ent1',
344
+ kind: ExposedEntityKind,
345
+ resourcePath: '/test',
346
+ isRoot: true,
347
+ hasCollection: false,
348
+ actions: [],
349
+ entity: { key: 'domain1' },
350
+ })
351
+ )
352
+
353
+ const collision = model.findResourcePathCollision('/test')
354
+ assert.isDefined(collision)
355
+ assert.equal(collision?.key, 'ent1')
356
+ }).tags(['@modeling', '@api'])
357
+
358
+ test('returns undefined if no collision', ({ assert }) => {
359
+ const model = new ApiModel()
360
+ model.exposes.set(
361
+ 'ent1',
362
+ new ExposedEntity(model, {
363
+ key: 'ent1',
364
+ kind: ExposedEntityKind,
365
+ resourcePath: '/test',
366
+ isRoot: true,
367
+ hasCollection: false,
368
+ actions: [],
369
+ entity: { key: 'domain1' },
370
+ })
371
+ )
372
+
373
+ assert.isUndefined(model.findResourcePathCollision('/other'))
374
+ }).tags(['@modeling', '@api'])
375
+
376
+ test('ignores the entity with the ignore key', ({ assert }) => {
377
+ const model = new ApiModel()
378
+ model.exposes.set(
379
+ 'ent1',
380
+ new ExposedEntity(model, {
381
+ key: 'ent1',
382
+ kind: ExposedEntityKind,
383
+ resourcePath: '/test',
384
+ isRoot: true,
385
+ hasCollection: false,
386
+ actions: [],
387
+ entity: { key: 'domain1' },
388
+ })
389
+ )
390
+
391
+ assert.isUndefined(model.findResourcePathCollision('/test', 'ent1'))
392
+ }).tags(['@modeling', '@api'])
393
+
394
+ test('ignores non-root entities', ({ assert }) => {
395
+ const model = new ApiModel()
396
+ model.exposes.set(
397
+ 'ent1',
398
+ new ExposedEntity(model, {
399
+ key: 'ent1',
400
+ kind: ExposedEntityKind,
401
+ resourcePath: '/test',
402
+ isRoot: false,
403
+ hasCollection: false,
404
+ actions: [],
405
+ entity: { key: 'domain1' },
406
+ })
407
+ )
408
+
409
+ assert.isUndefined(model.findResourcePathCollision('/test'))
410
+ }).tags(['@modeling', '@api'])
411
+ })
412
+
413
+ test.group('ApiModel.findCollectionPathCollision()', () => {
414
+ test('returns the entity if collection path collides', ({ assert }) => {
415
+ const model = new ApiModel()
416
+ model.exposes.set(
417
+ 'ent1',
418
+ new ExposedEntity(model, {
419
+ key: 'ent1',
420
+ kind: ExposedEntityKind,
421
+ collectionPath: '/tests',
422
+ resourcePath: '/tests/{id}',
423
+ isRoot: true,
424
+ hasCollection: true,
425
+ actions: [],
426
+ entity: { key: 'domain1' },
427
+ })
428
+ )
429
+
430
+ const collision = model.findCollectionPathCollision('/tests')
431
+ assert.isDefined(collision)
432
+ assert.equal(collision?.key, 'ent1')
433
+ }).tags(['@modeling', '@api'])
434
+
435
+ test('returns undefined if no collision', ({ assert }) => {
436
+ const model = new ApiModel()
437
+ model.exposes.set(
438
+ 'ent1',
439
+ new ExposedEntity(model, {
440
+ key: 'ent1',
441
+ kind: ExposedEntityKind,
442
+ collectionPath: '/tests',
443
+ resourcePath: '/tests/{id}',
444
+ isRoot: true,
445
+ hasCollection: true,
446
+ actions: [],
447
+ entity: { key: 'domain1' },
448
+ })
449
+ )
450
+
451
+ assert.isUndefined(model.findCollectionPathCollision('/other'))
452
+ }).tags(['@modeling', '@api'])
453
+
454
+ test('ignores the entity with the ignore key', ({ assert }) => {
455
+ const model = new ApiModel()
456
+ model.exposes.set(
457
+ 'ent1',
458
+ new ExposedEntity(model, {
459
+ key: 'ent1',
460
+ kind: ExposedEntityKind,
461
+ collectionPath: '/tests',
462
+ resourcePath: '/tests/{id}',
463
+ isRoot: true,
464
+ hasCollection: true,
465
+ actions: [],
466
+ entity: { key: 'domain1' },
467
+ })
468
+ )
469
+
470
+ assert.isUndefined(model.findCollectionPathCollision('/tests', 'ent1'))
471
+ }).tags(['@modeling', '@api'])
472
+
473
+ test('ignores non-root entities', ({ assert }) => {
474
+ const model = new ApiModel()
475
+ model.exposes.set(
476
+ 'ent1',
477
+ new ExposedEntity(model, {
478
+ key: 'ent1',
479
+ kind: ExposedEntityKind,
480
+ collectionPath: '/tests',
481
+ resourcePath: '/tests/{id}',
482
+ isRoot: false,
483
+ hasCollection: true,
484
+ actions: [],
485
+ entity: { key: 'domain1' },
486
+ })
487
+ )
488
+
489
+ assert.isUndefined(model.findCollectionPathCollision('/tests'))
490
+ }).tags(['@modeling', '@api'])
491
+
492
+ test('ignores entities without collections', ({ assert }) => {
493
+ const model = new ApiModel()
494
+ model.exposes.set(
495
+ 'ent1',
496
+ new ExposedEntity(model, {
497
+ key: 'ent1',
498
+ kind: ExposedEntityKind,
499
+ resourcePath: '/tests',
500
+ collectionPath: '/tests',
501
+ isRoot: true,
502
+ hasCollection: false,
503
+ actions: [],
504
+ entity: { key: 'domain1' },
505
+ })
506
+ )
507
+
508
+ assert.isUndefined(model.findCollectionPathCollision('/tests'))
509
+ }).tags(['@modeling', '@api'])
510
+ })
@@ -1,5 +1,23 @@
1
1
  import { test } from '@japa/runner'
2
- import { ApiModel, DataDomain } from '../../../src/index.js'
2
+ import { ApiModel, DataDomain, type ExposedEntity } from '../../../src/index.js'
3
+
4
+ function findRootExposedEntity(model: ApiModel, entityKey: string) {
5
+ for (const e of model.exposes.values()) {
6
+ if (e.isRoot && e.entity.key === entityKey) {
7
+ return e
8
+ }
9
+ }
10
+ return undefined
11
+ }
12
+
13
+ function findNonRootExposedEntity(model: ApiModel, entityKey: string) {
14
+ for (const e of model.exposes.values()) {
15
+ if (!e.isRoot && e.entity.key === entityKey) {
16
+ return e
17
+ }
18
+ }
19
+ return undefined
20
+ }
3
21
 
4
22
  test.group('ApiModel.exposeEntity()', () => {
5
23
  test('exposes a new entity', ({ assert }) => {
@@ -28,7 +46,7 @@ test.group('ApiModel.exposeEntity()', () => {
28
46
  const retrievedExposedEntity = model.exposeEntity({ key: e1.key })
29
47
 
30
48
  assert.deepEqual(retrievedExposedEntity.toJSON(), initialExposedEntity.toJSON())
31
- assert.lengthOf(model.exposes, 1)
49
+ assert.equal(model.exposes.size, 1)
32
50
  }).tags(['@modeling', '@api'])
33
51
 
34
52
  test('notifies change when a new entity is exposed', async ({ assert }) => {
@@ -77,7 +95,7 @@ test.group('ApiModel.exposeEntity()', () => {
77
95
  model.attachDataDomain(domain)
78
96
  const exposedA = model.exposeEntity({ key: eA.key }, { followAssociations: true })
79
97
  // Find nested exposure for B
80
- const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
98
+ const nestedB = findNonRootExposedEntity(model, eB.key)
81
99
  assert.isDefined(nestedB)
82
100
  assert.deepEqual(nestedB?.parent?.key, exposedA.key)
83
101
  assert.strictEqual(nestedB?.collectionPath, '/entitybs')
@@ -96,15 +114,21 @@ test.group('ApiModel.exposeEntity()', () => {
96
114
  model.attachDataDomain(domain)
97
115
  model.exposeEntity({ key: eA.key }, { followAssociations: true, maxDepth: 4 })
98
116
 
99
- assert.lengthOf(model.exposes, 2, 'has only 2 exposures')
117
+ assert.equal(model.exposes.size, 2, 'has only 2 exposures')
100
118
  // Should expose A (root), B (nested under A), but not infinitely nest
101
- const exposedA = model.exposes.find((e) => e.isRoot && e.entity.key === eA.key)
102
- const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
119
+ const exposedA = findRootExposedEntity(model, eA.key)
120
+ const nestedB = findNonRootExposedEntity(model, eB.key)
103
121
  // There should be only one nested exposure for B under A
104
122
  assert.isDefined(exposedA)
105
123
  assert.isDefined(nestedB)
106
124
  // There should NOT be a nested exposure for A under B
107
- const circularA = model.exposes.find((e) => !e.isRoot && e.entity.key === eA.key && e.parent?.key === nestedB?.key)
125
+ let circularA: ExposedEntity | undefined
126
+ for (const e of model.exposes.values()) {
127
+ if (!e.isRoot && e.entity.key === eA.key && e.parent?.key === nestedB?.key) {
128
+ circularA = e
129
+ break
130
+ }
131
+ }
108
132
  assert.isUndefined(circularA)
109
133
  })
110
134
 
@@ -119,8 +143,8 @@ test.group('ApiModel.exposeEntity()', () => {
119
143
  model.attachDataDomain(domain)
120
144
  model.exposeEntity({ key: eA.key }, { followAssociations: true })
121
145
  // Should only expose A as root, not as nested
122
- const exposedA = model.exposes.find((e) => e.isRoot && e.entity.key === eA.key)
123
- const nestedA = model.exposes.find((e) => !e.isRoot && e.entity.key === eA.key)
146
+ const exposedA = findRootExposedEntity(model, eA.key)
147
+ const nestedA = findNonRootExposedEntity(model, eA.key)
124
148
  assert.isDefined(exposedA)
125
149
  assert.isUndefined(nestedA)
126
150
  })
@@ -137,11 +161,11 @@ test.group('ApiModel.exposeEntity()', () => {
137
161
  const model = new ApiModel()
138
162
  model.attachDataDomain(domain)
139
163
  model.exposeEntity({ key: eA.key }, { followAssociations: true })
140
- const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
141
- const nestedC = model.exposes.find((e) => !e.isRoot && e.entity.key === eC.key)
164
+ const nestedB = findNonRootExposedEntity(model, eB.key)
165
+ const nestedC = findNonRootExposedEntity(model, eC.key)
142
166
  assert.isDefined(nestedB)
143
167
  assert.isDefined(nestedC)
144
- assert.deepEqual(nestedB?.parent?.key, model.exposes.find((e) => e.isRoot && e.entity.key === eA.key)?.key)
168
+ assert.deepEqual(nestedB?.parent?.key, findRootExposedEntity(model, eA.key)?.key)
145
169
  assert.deepEqual(nestedC?.parent?.key, nestedB?.key)
146
170
  })
147
171
 
@@ -157,12 +181,12 @@ test.group('ApiModel.exposeEntity()', () => {
157
181
  const model = new ApiModel()
158
182
  model.attachDataDomain(domain)
159
183
  model.exposeEntity({ key: eA.key }, { followAssociations: true })
160
- const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
161
- const nestedC = model.exposes.find((e) => !e.isRoot && e.entity.key === eC.key)
184
+ const nestedB = findNonRootExposedEntity(model, eB.key)
185
+ const nestedC = findNonRootExposedEntity(model, eC.key)
162
186
  assert.isDefined(nestedB)
163
187
  assert.isDefined(nestedC)
164
- assert.deepEqual(nestedB?.parent?.key, model.exposes.find((e) => e.isRoot && e.entity.key === eA.key)?.key)
165
- assert.deepEqual(nestedC?.parent?.key, model.exposes.find((e) => e.isRoot && e.entity.key === eA.key)?.key)
188
+ assert.deepEqual(nestedB?.parent?.key, findRootExposedEntity(model, eA.key)?.key)
189
+ assert.deepEqual(nestedC?.parent?.key, findRootExposedEntity(model, eA.key)?.key)
166
190
  })
167
191
 
168
192
  test('respects maxDepth option (A -> B -> C -> D, maxDepth=2)', ({ assert }) => {
@@ -179,9 +203,9 @@ test.group('ApiModel.exposeEntity()', () => {
179
203
  const model = new ApiModel()
180
204
  model.attachDataDomain(domain)
181
205
  model.exposeEntity({ key: eA.key }, { followAssociations: true, maxDepth: 2 })
182
- const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
183
- const nestedC = model.exposes.find((e) => !e.isRoot && e.entity.key === eC.key)
184
- const nestedD = model.exposes.find((e) => !e.isRoot && e.entity.key === eD.key)
206
+ const nestedB = findNonRootExposedEntity(model, eB.key)
207
+ const nestedC = findNonRootExposedEntity(model, eC.key)
208
+ const nestedD = findNonRootExposedEntity(model, eD.key)
185
209
  assert.isDefined(nestedB)
186
210
  assert.isDefined(nestedC)
187
211
  assert.isUndefined(nestedD)
@@ -10,10 +10,10 @@ test.group('ApiModel.removeEntity()', () => {
10
10
  const model = new ApiModel()
11
11
  model.attachDataDomain(domain)
12
12
  const exposure = model.exposeEntity({ key: e1.key })
13
- assert.lengthOf(model.exposes, 1)
13
+ assert.equal(model.exposes.size, 1)
14
14
 
15
15
  model.removeExposedEntity(exposure.key)
16
- assert.lengthOf(model.exposes, 0)
16
+ assert.equal(model.exposes.size, 0)
17
17
  }).tags(['@modeling', '@api'])
18
18
 
19
19
  test('removes an entity and its nested children', ({ assert }) => {
@@ -28,13 +28,13 @@ test.group('ApiModel.removeEntity()', () => {
28
28
  model.attachDataDomain(domain)
29
29
  const rootExposure = model.exposeEntity({ key: eA.key }, { followAssociations: true })
30
30
  // Ensure nested exposure for B was created
31
- const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
31
+ const nestedB = Array.from(model.exposes.values()).find((e) => !e.isRoot && e.entity.key === eB.key)
32
32
  assert.isDefined(nestedB)
33
- assert.isAbove(model.exposes.length, 1)
33
+ assert.isAbove(model.exposes.size, 1)
34
34
 
35
35
  // Remove root exposure for A and expect children to be removed as well
36
36
  model.removeExposedEntity(rootExposure.key)
37
- assert.lengthOf(model.exposes, 0)
37
+ assert.equal(model.exposes.size, 0)
38
38
  }).tags(['@modeling', '@api'])
39
39
 
40
40
  test('throws error if entity does not exist', ({ assert }) => {
@@ -50,7 +50,7 @@ test.group('ApiModel.removeEntity()', () => {
50
50
  () => model.removeExposedEntity('non-existing-key'),
51
51
  'Exposed entity with key "non-existing-key" not found.'
52
52
  )
53
- assert.lengthOf(model.exposes, 1, 'exposes count should remain unchanged')
53
+ assert.equal(model.exposes.size, 1, 'exposes count should remain unchanged')
54
54
  }).tags(['@modeling', '@api'])
55
55
 
56
56
  test('notifies change when an entity is removed', async ({ assert }) => {
@@ -1,5 +1,6 @@
1
1
  import { test } from '@japa/runner'
2
2
  import { ApiModel, ExposedEntity, type ExposedEntitySchema } from '../../../src/index.js'
3
+ import { AccessRule, RateLimitingConfiguration } from '../../../src/modeling/index.js'
3
4
  import { ExposedEntityKind } from '../../../src/models/kinds.js'
4
5
 
5
6
  test.group('ExposedEntity', () => {
@@ -84,7 +85,11 @@ test.group('ExposedEntity', () => {
84
85
  const childEx = new ExposedEntity(model, childSchema)
85
86
  const grandEx = new ExposedEntity(model, grandSchema)
86
87
  // attach to model as instances
87
- model.exposes = [rootEx, childEx, grandEx]
88
+ model.exposes = new Map([
89
+ [rootEx.key, rootEx],
90
+ [childEx.key, childEx],
91
+ [grandEx.key, grandEx],
92
+ ])
88
93
 
89
94
  // root
90
95
  assert.equal(rootEx.getAbsoluteCollectionPath(), '/users')
@@ -120,7 +125,7 @@ test.group('ExposedEntity', () => {
120
125
  notified += 1
121
126
  })
122
127
 
123
- const ex = model.exposes[0]
128
+ const ex = Array.from(model.exposes.values())[0]
124
129
  ex.setCollectionPath('items')
125
130
  await Promise.resolve() // allow ApiModel.notifyChange microtask to run
126
131
  assert.isAtLeast(notified, 1)
@@ -147,7 +152,7 @@ test.group('ExposedEntity', () => {
147
152
  notified += 1
148
153
  })
149
154
 
150
- const ex = model.exposes[0]
155
+ const ex = Array.from(model.exposes.values())[0]
151
156
  ex.setResourcePath('/products/{productId}')
152
157
  await Promise.resolve()
153
158
  assert.isAtLeast(notified, 1)
@@ -225,4 +230,119 @@ test.group('ExposedEntity', () => {
225
230
  assert.lengthOf(ex.accessRule!, 1)
226
231
  assert.equal(ex.accessRule![0].type, 'allowPublic')
227
232
  }).tags(['@modeling', '@exposed-entity', '@immutability'])
233
+
234
+ test('getAllRules() aggregates rules from entity, parent, and API', ({ assert }) => {
235
+ const model = new ApiModel()
236
+ model.accessRule = [new AccessRule({ type: 'allowPublic' })]
237
+
238
+ const rootEx = new ExposedEntity(model, {
239
+ key: 'root',
240
+ entity: { key: 'user' },
241
+ isRoot: true,
242
+ actions: [],
243
+ accessRule: [{ type: 'matchResourceOwner' }],
244
+ })
245
+
246
+ const childEx = new ExposedEntity(model, {
247
+ key: 'child',
248
+ entity: { key: 'post' },
249
+ isRoot: false,
250
+ actions: [],
251
+ parent: { key: 'root', association: { key: 'toPosts' } },
252
+ accessRule: [{ type: 'allowAuthenticated' }],
253
+ })
254
+
255
+ model.exposes = new Map([
256
+ [rootEx.key, rootEx],
257
+ [childEx.key, childEx],
258
+ ])
259
+
260
+ const childRules = childEx.getAllRules()
261
+ assert.lengthOf(childRules, 3)
262
+ assert.equal(childRules[0].type, 'allowPublic')
263
+ assert.equal(childRules[1].type, 'allowAuthenticated')
264
+ assert.equal(childRules[2].type, 'matchResourceOwner')
265
+
266
+ const rootRules = rootEx.getAllRules()
267
+ assert.lengthOf(rootRules, 2)
268
+ assert.equal(rootRules[0].type, 'allowPublic')
269
+ assert.equal(rootRules[1].type, 'matchResourceOwner')
270
+ }).tags(['@modeling', '@exposed-entity', '@rules'])
271
+
272
+ test('getAllRateLimiters() aggregates rate limiters from entity, parent, and API', ({ assert }) => {
273
+ const model = new ApiModel()
274
+ model.rateLimiting = new RateLimitingConfiguration({ rules: [{ rate: 10, interval: 'second' }] })
275
+
276
+ const rootEx = new ExposedEntity(model, {
277
+ key: 'root',
278
+ entity: { key: 'user' },
279
+ isRoot: true,
280
+ actions: [],
281
+ rateLimiting: { rules: [{ rate: 20, interval: 'minute' }] },
282
+ })
283
+
284
+ const childEx = new ExposedEntity(model, {
285
+ key: 'child',
286
+ entity: { key: 'post' },
287
+ isRoot: false,
288
+ actions: [],
289
+ parent: { key: 'root', association: { key: 'toPosts' } },
290
+ rateLimiting: { rules: [{ rate: 30, interval: 'hour' }] },
291
+ })
292
+
293
+ model.exposes = new Map([
294
+ [rootEx.key, rootEx],
295
+ [childEx.key, childEx],
296
+ ])
297
+
298
+ const childLimiters = childEx.getAllRateLimiters()
299
+ assert.lengthOf(childLimiters, 3)
300
+ assert.equal(childLimiters[0].rules[0].rate, 10)
301
+ assert.equal(childLimiters[1].rules[0].rate, 30)
302
+ assert.equal(childLimiters[2].rules[0].rate, 20)
303
+
304
+ const rootLimiters = rootEx.getAllRateLimiters()
305
+ assert.lengthOf(rootLimiters, 2)
306
+ assert.equal(rootLimiters[0].rules[0].rate, 10)
307
+ assert.equal(rootLimiters[1].rules[0].rate, 20)
308
+ }).tags(['@modeling', '@exposed-entity', '@rate-limiting'])
309
+
310
+ test('getAllRateLimiterRules() aggregates rate limiter rules flattened', ({ assert }) => {
311
+ const model = new ApiModel()
312
+ model.rateLimiting = new RateLimitingConfiguration({
313
+ rules: [
314
+ { rate: 10, interval: 'second' },
315
+ { rate: 15, interval: 'minute' },
316
+ ],
317
+ })
318
+
319
+ const rootEx = new ExposedEntity(model, {
320
+ key: 'root',
321
+ entity: { key: 'user' },
322
+ isRoot: true,
323
+ actions: [],
324
+ rateLimiting: { rules: [{ rate: 20, interval: 'hour' }] },
325
+ })
326
+
327
+ const childEx = new ExposedEntity(model, {
328
+ key: 'child',
329
+ entity: { key: 'post' },
330
+ isRoot: false,
331
+ actions: [],
332
+ parent: { key: 'root', association: { key: 'toPosts' } },
333
+ rateLimiting: { rules: [{ rate: 30, interval: 'day' }] },
334
+ })
335
+
336
+ model.exposes = new Map([
337
+ [rootEx.key, rootEx],
338
+ [childEx.key, childEx],
339
+ ])
340
+
341
+ const childRules = childEx.getAllRateLimiterRules()
342
+ assert.lengthOf(childRules, 4)
343
+ assert.equal(childRules[0].rate, 10)
344
+ assert.equal(childRules[1].rate, 15)
345
+ assert.equal(childRules[2].rate, 30)
346
+ assert.equal(childRules[3].rate, 20)
347
+ }).tags(['@modeling', '@exposed-entity', '@rate-limiting'])
228
348
  })