@api-client/core 0.18.32 → 0.18.33

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/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.32",
4
+ "version": "0.18.33",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {
7
7
  "./browser.js": {
@@ -170,7 +170,7 @@ export class ApiModel extends DependentModel {
170
170
  * The specific subset of Data Entities to be exposed by this API.
171
171
  * These are the entities that are included in the data domain schema.
172
172
  */
173
- exposes: ExposedEntity[]
173
+ @observed({ deep: true }) accessor exposes: ExposedEntity[]
174
174
  /**
175
175
  * Optional array of access rules that define the access control policies
176
176
  * for the API. These rules are used to enforce security and permissions
@@ -411,8 +411,8 @@ export class ApiModel extends DependentModel {
411
411
  entity: { ...entity },
412
412
  actions: [],
413
413
  isRoot: true,
414
- relativeCollectionPath,
415
- relativeResourcePath,
414
+ collectionPath: relativeCollectionPath,
415
+ resourcePath: relativeResourcePath,
416
416
  hasCollection: true,
417
417
  }
418
418
  if (options) {
@@ -498,8 +498,8 @@ export class ApiModel extends DependentModel {
498
498
  entity: { ...target },
499
499
  actions: [],
500
500
  isRoot: false,
501
- relativeCollectionPath,
502
- relativeResourcePath,
501
+ collectionPath: relativeCollectionPath,
502
+ resourcePath: relativeResourcePath,
503
503
  hasCollection: isCollection,
504
504
  parent: {
505
505
  key: parentKey,
@@ -1,3 +1,4 @@
1
+ import { observed } from '../decorators/observed.js'
1
2
  import { ExposedEntityKind } from '../models/kinds.js'
2
3
  import { nanoid } from '../nanoid.js'
3
4
  import type { ApiModel } from './ApiModel.js'
@@ -32,61 +33,71 @@ export class ExposedEntity extends EventTarget {
32
33
  /**
33
34
  * A pointer to a Data Entity from the Data Domain.
34
35
  */
35
- entity: AssociationTarget
36
+ @observed() accessor entity: AssociationTarget
36
37
 
37
38
  /**
38
39
  * Indicates whether this exposure has a collection endpoint.
39
40
  * A collection endpoint is optional for nested exposures where the association is 1:1
40
41
  * and the schema is embedded directly under the parent resource.
42
+ *
43
+ * Note that this property is not observed for changes as it is immutable after creation.
41
44
  */
42
45
  hasCollection: boolean
43
46
 
44
47
  /**
45
- * Relative path to the collection endpoint for this exposure.
48
+ * Path to the collection endpoint for this exposure.
46
49
  * Starts with '/'. Not set for 1:1 nested exposures where collection does not exist.
47
50
  */
48
- relativeCollectionPath?: string
51
+ @observed() accessor collectionPath: string | undefined
49
52
 
50
53
  /**
51
- * Relative path to the resource endpoint for this exposure.
54
+ * Path to the resource endpoint for this exposure.
52
55
  * Starts with '/'. For 1:1 nested exposures the resource path typically does not include an id segment.
53
56
  */
54
- relativeResourcePath: string
57
+ @observed() accessor resourcePath: string
55
58
 
56
59
  /**
57
60
  * Whether this exposure is a root exposure (top-level collection).
58
61
  * If this is set then the `parent` reference must be populated.
62
+ *
63
+ * Note that this property is not observed for changes as it is immutable after creation.
59
64
  */
60
65
  isRoot?: boolean
61
66
 
62
67
  /**
63
68
  * Parent reference when this exposure was created via following an association.
69
+ *
70
+ * Note that this property is not observed for changes as it is immutable after creation.
64
71
  */
65
72
  parent?: ExposeParentRef
66
73
 
67
74
  /**
68
75
  * Expose-time config used to create this exposure (persisted for auditing/UI).
69
76
  * This is only populated for the root exposure. All children exposures inherit this config.
77
+ *
78
+ * Note that this property is not observed for changes as it is immutable after creation.
70
79
  */
71
80
  exposeOptions?: ExposeOptions
72
81
 
73
82
  /**
74
83
  * The list of enabled API actions for this exposure (List/Read/Create/etc.)
75
84
  */
76
- actions: ApiAction[]
85
+ @observed({ deep: true }) accessor actions: ApiAction[]
77
86
 
78
87
  /**
79
88
  * Optional array of access rules that define the access control policies for this exposure.
80
89
  */
81
- accessRule?: AccessRule[]
90
+ @observed({ deep: true }) accessor accessRule: AccessRule[] | undefined
82
91
 
83
92
  /**
84
93
  * Optional configuration for rate limiting for this exposure.
85
94
  */
86
- rateLimiting?: RateLimitingConfiguration
95
+ @observed({ deep: true }) accessor rateLimiting: RateLimitingConfiguration | undefined
87
96
 
88
97
  /**
89
98
  * When true, generation for this exposure hit configured limits
99
+ *
100
+ * Note that this property is not observed for changes as it is immutable after creation.
90
101
  */
91
102
  truncated?: boolean
92
103
 
@@ -97,6 +108,11 @@ export class ExposedEntity extends EventTarget {
97
108
  * the current notification is sent.
98
109
  */
99
110
  #notifying = false
111
+ /**
112
+ * When the initializing flag is set to true,
113
+ * the domain is not notified of changes.
114
+ */
115
+ #initializing = true
100
116
  /**
101
117
  * A reference to the parent API Model instance.
102
118
  */
@@ -106,8 +122,8 @@ export class ExposedEntity extends EventTarget {
106
122
  const {
107
123
  key = nanoid(),
108
124
  entity = { key: '' },
109
- relativeCollectionPath,
110
- relativeResourcePath = '/',
125
+ collectionPath,
126
+ resourcePath = '/',
111
127
  hasCollection = true,
112
128
  isRoot,
113
129
  parent,
@@ -122,11 +138,11 @@ export class ExposedEntity extends EventTarget {
122
138
  key,
123
139
  entity: { ...entity },
124
140
  hasCollection,
125
- relativeResourcePath,
141
+ resourcePath,
126
142
  actions: actions.map((a) => ({ ...a })),
127
143
  }
128
- if (relativeCollectionPath !== undefined) {
129
- result.relativeCollectionPath = relativeCollectionPath
144
+ if (collectionPath !== undefined) {
145
+ result.collectionPath = collectionPath
130
146
  }
131
147
  if (isRoot !== undefined) {
132
148
  result.isRoot = isRoot
@@ -157,8 +173,8 @@ export class ExposedEntity extends EventTarget {
157
173
  this.key = init.key
158
174
  this.entity = init.entity
159
175
  this.hasCollection = init.hasCollection
160
- this.relativeCollectionPath = init.relativeCollectionPath
161
- this.relativeResourcePath = init.relativeResourcePath
176
+ this.collectionPath = init.collectionPath
177
+ this.resourcePath = init.resourcePath
162
178
  this.isRoot = init.isRoot
163
179
  this.parent = init.parent
164
180
  this.exposeOptions = init.exposeOptions
@@ -166,10 +182,11 @@ export class ExposedEntity extends EventTarget {
166
182
  this.accessRule = init.accessRule
167
183
  this.rateLimiting = init.rateLimiting
168
184
  this.truncated = init.truncated
185
+ this.#initializing = false
169
186
  }
170
187
 
171
188
  notifyChange() {
172
- if (this.#notifying) {
189
+ if (this.#notifying || this.#initializing) {
173
190
  return
174
191
  }
175
192
  this.#notifying = true
@@ -185,12 +202,12 @@ export class ExposedEntity extends EventTarget {
185
202
  kind: this.kind,
186
203
  key: this.key,
187
204
  entity: { ...this.entity },
188
- relativeResourcePath: this.relativeResourcePath,
205
+ resourcePath: this.resourcePath,
189
206
  actions: this.actions.map((a) => ({ ...a })),
190
207
  hasCollection: this.hasCollection,
191
208
  }
192
- if (this.relativeCollectionPath !== undefined) {
193
- result.relativeCollectionPath = this.relativeCollectionPath
209
+ if (this.collectionPath !== undefined) {
210
+ result.collectionPath = this.collectionPath
194
211
  }
195
212
  if (this.isRoot !== undefined) {
196
213
  result.isRoot = this.isRoot
@@ -214,15 +231,15 @@ export class ExposedEntity extends EventTarget {
214
231
  }
215
232
 
216
233
  /**
217
- * Sets a new relative collection path for this exposed entity.
234
+ * Sets a new collection path for this exposed entity.
218
235
  *
219
236
  * It:
220
- * - updates the relativeCollectionPath property
237
+ * - updates the collectionPath property
221
238
  * - updates the absoluteCollectionPath property accordingly
222
- * - updates the relativeResourcePath and absoluteResourcePath accordingly.
239
+ * - updates the resourcePath accordingly.
223
240
  * @param path The new path to set.
224
241
  */
225
- setRelativeCollectionPath(path: string) {
242
+ setCollectionPath(path: string) {
226
243
  if (!this.hasCollection) {
227
244
  throw new Error(`Cannot set collection path on an exposure that does not have a collection`)
228
245
  }
@@ -235,22 +252,21 @@ export class ExposedEntity extends EventTarget {
235
252
  const normalizedCollection = `/${segments[0]}`
236
253
  // Preserve current parameter name if present, otherwise default to {id}
237
254
  let param = '{id}'
238
- if (this.relativeResourcePath) {
239
- const curSegments = this.relativeResourcePath.split('/').filter(Boolean)
255
+ if (this.resourcePath) {
256
+ const curSegments = this.resourcePath.split('/').filter(Boolean)
240
257
  const maybeParam = curSegments[1]
241
258
  if (maybeParam && /^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(maybeParam)) {
242
259
  param = maybeParam
243
260
  }
244
261
  }
245
262
  const nextResource = `${normalizedCollection}/${param}`
246
- const changed = this.relativeCollectionPath !== normalizedCollection || this.relativeResourcePath !== nextResource
247
- this.relativeCollectionPath = normalizedCollection
248
- this.relativeResourcePath = nextResource
249
- if (changed) this.notifyChange()
263
+ this.collectionPath = normalizedCollection
264
+ this.resourcePath = nextResource
265
+ // rely on ApiModel.exposes deep observation to notify on property sets
250
266
  }
251
267
 
252
268
  /**
253
- * Sets a new relative resource path for this exposed entity.
269
+ * Sets a new resource path for this exposed entity.
254
270
  *
255
271
  * Rules:
256
272
  * - Must start with '/'.
@@ -258,17 +274,17 @@ export class ExposedEntity extends EventTarget {
258
274
  * parameter segment (e.g. `/products/{productId}`) and only the parameter name may vary.
259
275
  * - If this exposure does NOT have a collection, the path can be any two segments (e.g. `/profile/{id}` or `/a/b`).
260
276
  */
261
- setRelativeResourcePath(path: string) {
277
+ setResourcePath(path: string) {
262
278
  const cleaned = ensureLeadingSlash(path)
263
279
  const segments = cleaned.split('/').filter(Boolean)
264
280
 
265
281
  if (this.hasCollection) {
266
- if (!this.relativeCollectionPath) {
282
+ if (!this.collectionPath) {
267
283
  throw new Error('Cannot set resource path: missing collection path for this exposure')
268
284
  }
269
- const colSegments = this.relativeCollectionPath.split('/').filter(Boolean)
285
+ const colSegments = this.collectionPath.split('/').filter(Boolean)
270
286
  if (colSegments.length !== 1) {
271
- throw new Error(`Invalid stored collection path "${this.relativeCollectionPath}"`)
287
+ throw new Error(`Invalid stored collection path "${this.collectionPath}"`)
272
288
  }
273
289
  if (segments.length !== 2) {
274
290
  throw new Error(`Resource path must be exactly two segments (collection + parameter). Received: "${cleaned}"`)
@@ -281,9 +297,8 @@ export class ExposedEntity extends EventTarget {
281
297
  if (!/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(s2)) {
282
298
  throw new Error(`The second segment must be a parameter in braces, e.g. {id}. Received: "${s2}"`)
283
299
  }
284
- if (this.relativeResourcePath !== cleaned) {
285
- this.relativeResourcePath = `/${s1}/${s2}`
286
- this.notifyChange()
300
+ if (this.resourcePath !== cleaned) {
301
+ this.resourcePath = `/${s1}/${s2}`
287
302
  }
288
303
  return
289
304
  }
@@ -294,9 +309,8 @@ export class ExposedEntity extends EventTarget {
294
309
  `Resource path must contain exactly two segments when no collection is present. Received: "${cleaned}"`
295
310
  )
296
311
  }
297
- if (this.relativeResourcePath !== cleaned) {
298
- this.relativeResourcePath = `/${segments[0]}/${segments[1]}`
299
- this.notifyChange()
312
+ if (this.resourcePath !== cleaned) {
313
+ this.resourcePath = `/${segments[0]}/${segments[1]}`
300
314
  }
301
315
  }
302
316
 
@@ -304,16 +318,16 @@ export class ExposedEntity extends EventTarget {
304
318
  * Computes the absolute path for this exposure's resource endpoint by
305
319
  * walking up the exposure tree using `parent.key` until reaching a root exposure.
306
320
  * The absolute path is composed by concatenating each ancestor's resource path
307
- * with this exposure's relative resource path.
321
+ * with this exposure's resource path.
308
322
  */
309
323
  getAbsoluteResourcePath(): string {
310
- let absolute = ensureLeadingSlash(this.relativeResourcePath)
324
+ let absolute = ensureLeadingSlash(this.resourcePath)
311
325
  // Traverse parents, always joining with the parent's resource path
312
326
  let parentKey = this.parent?.key
313
327
  while (parentKey) {
314
328
  const parent = this.api.exposes.find((e) => e.key === parentKey)
315
329
  if (!parent) break
316
- const parentResource = ensureLeadingSlash(parent.relativeResourcePath)
330
+ const parentResource = ensureLeadingSlash(parent.resourcePath)
317
331
  absolute = joinPaths(parentResource, absolute)
318
332
  parentKey = parent.parent?.key
319
333
  }
@@ -324,18 +338,18 @@ export class ExposedEntity extends EventTarget {
324
338
  * Computes the absolute path for this exposure's collection endpoint (if any)
325
339
  * by walking up the exposure tree using `parent.key` until reaching a root exposure.
326
340
  * The absolute path is composed by concatenating each ancestor's resource path
327
- * with this exposure's relative collection path.
341
+ * with this exposure's collection path.
328
342
  * Returns undefined if this exposure has no collection.
329
343
  */
330
344
  getAbsoluteCollectionPath(): string | undefined {
331
- if (!this.hasCollection || !this.relativeCollectionPath) return undefined
332
- let absolute = ensureLeadingSlash(this.relativeCollectionPath)
345
+ if (!this.hasCollection || !this.collectionPath) return undefined
346
+ let absolute = ensureLeadingSlash(this.collectionPath)
333
347
  // Traverse parents, always joining with the parent's resource path
334
348
  let parentKey = this.parent?.key
335
349
  while (parentKey) {
336
350
  const parent = this.api.exposes.find((e) => e.key === parentKey)
337
351
  if (!parent) break
338
- const parentResource = ensureLeadingSlash(parent.relativeResourcePath)
352
+ const parentResource = ensureLeadingSlash(parent.resourcePath)
339
353
  absolute = joinPaths(parentResource, absolute)
340
354
  parentKey = parent.parent?.key
341
355
  }
@@ -457,16 +457,16 @@ export interface ExposedEntitySchema {
457
457
  */
458
458
  hasCollection: boolean
459
459
  /**
460
- * Relative path to the collection endpoint for this exposure.
460
+ * A path to the collection endpoint for this exposure.
461
461
  * Starts with '/'. Not set for 1:1 nested exposures where collection does not exist.
462
462
  */
463
- relativeCollectionPath?: string
463
+ collectionPath?: string
464
464
 
465
465
  /**
466
- * Relative path to the resource endpoint for this exposure.
466
+ * A path to the resource endpoint for this exposure.
467
467
  * Starts with '/'. For 1:1 nested exposures the resource path typically does not include an id segment.
468
468
  */
469
- relativeResourcePath: string
469
+ resourcePath: string
470
470
 
471
471
  /**
472
472
  * Whether this exposure is a root exposure (top-level collection).
@@ -42,7 +42,7 @@ test.group('ApiModel.createSchema()', () => {
42
42
  actions: [],
43
43
  hasCollection: true,
44
44
  kind: ExposedEntityKind,
45
- relativeResourcePath: '/',
45
+ resourcePath: '/',
46
46
  entity: { key: 'entity1' },
47
47
  },
48
48
  ],
@@ -116,7 +116,7 @@ test.group('ApiModel.constructor()', () => {
116
116
  {
117
117
  key: 'entity1',
118
118
  actions: [],
119
- relativeResourcePath: '/',
119
+ resourcePath: '/',
120
120
  entity: { key: 'entity1' },
121
121
  hasCollection: true,
122
122
  kind: ExposedEntityKind,
@@ -205,7 +205,7 @@ test.group('ApiModel.toJSON()', () => {
205
205
  {
206
206
  key: 'entity1',
207
207
  actions: [],
208
- relativeResourcePath: '/',
208
+ resourcePath: '/',
209
209
  entity: { key: 'entity1' },
210
210
  hasCollection: true,
211
211
  kind: ExposedEntityKind,
@@ -251,7 +251,7 @@ test.group('ApiModel.getExposedEntity()', () => {
251
251
  actions: [],
252
252
  hasCollection: true,
253
253
  kind: ExposedEntityKind,
254
- relativeResourcePath: '/',
254
+ resourcePath: '/',
255
255
  entity: { key: entityKey },
256
256
  }
257
257
  model.exposes.push(new ExposedEntity(model, exposed))
@@ -15,7 +15,6 @@ test.group('ApiModel.exposeEntity()', () => {
15
15
  assert.typeOf(exposedEntity.key, 'string')
16
16
  assert.deepEqual(exposedEntity.entity, { key: e1.key })
17
17
  assert.deepEqual(exposedEntity.actions, [])
18
- assert.includeDeepMembers(model.exposes, [exposedEntity])
19
18
  }).tags(['@modeling', '@api'])
20
19
 
21
20
  test('returns an existing entity if already exposed', ({ assert }) => {
@@ -28,7 +27,7 @@ test.group('ApiModel.exposeEntity()', () => {
28
27
  const initialExposedEntity = model.exposeEntity({ key: e1.key })
29
28
  const retrievedExposedEntity = model.exposeEntity({ key: e1.key })
30
29
 
31
- assert.strictEqual(retrievedExposedEntity, initialExposedEntity)
30
+ assert.deepEqual(retrievedExposedEntity.toJSON(), initialExposedEntity.toJSON())
32
31
  assert.lengthOf(model.exposes, 1)
33
32
  }).tags(['@modeling', '@api'])
34
33
 
@@ -81,7 +80,7 @@ test.group('ApiModel.exposeEntity()', () => {
81
80
  const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
82
81
  assert.isDefined(nestedB)
83
82
  assert.deepEqual(nestedB?.parent?.key, exposedA.key)
84
- assert.strictEqual(nestedB?.relativeCollectionPath, '/entitybs')
83
+ assert.strictEqual(nestedB?.collectionPath, '/entitybs')
85
84
  })
86
85
 
87
86
  test('does not infinitely expose circular associations', ({ assert }) => {
@@ -45,10 +45,9 @@ test.group('ApiModel.removeEntity()', () => {
45
45
  const model = new ApiModel()
46
46
  model.attachDataDomain(domain)
47
47
  model.exposeEntity({ key: e1.key })
48
- const initialExposes = [...model.exposes]
49
48
 
50
49
  model.removeEntity({ key: 'non-existing-entity' })
51
- assert.deepEqual(model.exposes, initialExposes)
50
+ assert.lengthOf(model.exposes, 1, 'exposes count should remain unchanged')
52
51
  }).tags(['@modeling', '@api'])
53
52
 
54
53
  test('notifies change when an entity is removed', async ({ assert }) => {
@@ -1,80 +1,81 @@
1
1
  import { test } from '@japa/runner'
2
- import { ApiModel, ExposedEntity } from '../../../src/index.js'
2
+ import { ApiModel, ExposedEntity, type ExposedEntitySchema } from '../../../src/index.js'
3
+ import { ExposedEntityKind } from '../../../src/models/kinds.js'
3
4
 
4
5
  test.group('ExposedEntity', () => {
5
- test('setRelativeCollectionPath normalizes and preserves resource param', ({ assert }) => {
6
+ test('setCollectionPath normalizes and preserves resource param', ({ assert }) => {
6
7
  const model = new ApiModel()
7
8
  const ex = new ExposedEntity(model, {
8
9
  hasCollection: true,
9
- relativeCollectionPath: '/items',
10
- relativeResourcePath: '/items/{customId}',
10
+ collectionPath: '/items',
11
+ resourcePath: '/items/{customId}',
11
12
  })
12
13
 
13
- ex.setRelativeCollectionPath('products')
14
+ ex.setCollectionPath('products')
14
15
 
15
- assert.equal(ex.relativeCollectionPath, '/products')
16
- assert.equal(ex.relativeResourcePath, '/products/{customId}')
16
+ assert.equal(ex.collectionPath, '/products')
17
+ assert.equal(ex.resourcePath, '/products/{customId}')
17
18
  }).tags(['@modeling', '@exposed-entity'])
18
19
 
19
- test('setRelativeResourcePath with collection allows only parameter name change', ({ assert }) => {
20
+ test('setResourcePath with collection allows only parameter name change', ({ assert }) => {
20
21
  const model = new ApiModel()
21
22
  const ex = new ExposedEntity(model, {
22
23
  hasCollection: true,
23
- relativeCollectionPath: '/products',
24
- relativeResourcePath: '/products/{id}',
24
+ collectionPath: '/products',
25
+ resourcePath: '/products/{id}',
25
26
  })
26
27
 
27
28
  // valid: same collection segment, different param name
28
- ex.setRelativeResourcePath('/products/{productId}')
29
- assert.equal(ex.relativeResourcePath, '/products/{productId}')
29
+ ex.setResourcePath('/products/{productId}')
30
+ assert.equal(ex.resourcePath, '/products/{productId}')
30
31
 
31
32
  // invalid: different first segment
32
- assert.throws(() => ex.setRelativeResourcePath('/wrong/{id}'))
33
+ assert.throws(() => ex.setResourcePath('/wrong/{id}'))
33
34
 
34
35
  // invalid: second segment not a parameter
35
- assert.throws(() => ex.setRelativeResourcePath('/products/notParam'))
36
+ assert.throws(() => ex.setResourcePath('/products/notParam'))
36
37
  }).tags(['@modeling', '@exposed-entity'])
37
38
 
38
- test('setRelativeResourcePath without collection must have exactly two segments', ({ assert }) => {
39
+ test('setResourcePath without collection must have exactly two segments', ({ assert }) => {
39
40
  const model = new ApiModel()
40
41
  const ex = new ExposedEntity(model, {
41
42
  hasCollection: false,
42
- relativeResourcePath: '/profile/{id}',
43
+ resourcePath: '/profile/{id}',
43
44
  })
44
45
 
45
- ex.setRelativeResourcePath('settings/secret')
46
- assert.equal(ex.relativeResourcePath, '/settings/secret')
46
+ ex.setResourcePath('settings/secret')
47
+ assert.equal(ex.resourcePath, '/settings/secret')
47
48
 
48
- assert.throws(() => ex.setRelativeResourcePath('onlyone'))
49
+ assert.throws(() => ex.setResourcePath('onlyone'))
49
50
  }).tags(['@modeling', '@exposed-entity'])
50
51
 
51
52
  test('computes absolute resource and collection paths along parent chain', ({ assert }) => {
52
53
  const model = new ApiModel()
53
54
 
54
55
  // Build exposure schemas
55
- const rootSchema = {
56
+ const rootSchema: Partial<ExposedEntitySchema> = {
56
57
  key: 'root',
57
58
  entity: { key: 'user' },
58
59
  hasCollection: true,
59
- relativeCollectionPath: '/users',
60
- relativeResourcePath: '/users/{userId}',
60
+ collectionPath: '/users',
61
+ resourcePath: '/users/{userId}',
61
62
  isRoot: true,
62
63
  actions: [],
63
64
  }
64
- const childSchema = {
65
+ const childSchema: Partial<ExposedEntitySchema> = {
65
66
  key: 'child',
66
67
  entity: { key: 'post' },
67
68
  hasCollection: true,
68
- relativeCollectionPath: '/posts',
69
- relativeResourcePath: '/posts/{postId}',
69
+ collectionPath: '/posts',
70
+ resourcePath: '/posts/{postId}',
70
71
  parent: { key: 'root', association: { key: 'toPosts' } },
71
72
  actions: [],
72
73
  }
73
- const grandSchema = {
74
+ const grandSchema: Partial<ExposedEntitySchema> = {
74
75
  key: 'grand',
75
76
  entity: { key: 'details' },
76
77
  hasCollection: false,
77
- relativeResourcePath: '/details',
78
+ resourcePath: '/details',
78
79
  parent: { key: 'child', association: { key: 'toDetails' } },
79
80
  actions: [],
80
81
  }
@@ -97,4 +98,58 @@ test.group('ExposedEntity', () => {
97
98
  assert.isUndefined(grandEx.getAbsoluteCollectionPath())
98
99
  assert.equal(grandEx.getAbsoluteResourcePath(), '/users/{userId}/posts/{postId}/details')
99
100
  }).tags(['@modeling', '@exposed-entity'])
101
+
102
+ test('ApiModel notifies when nested ExposedEntity collection path changes', async ({ assert }) => {
103
+ const model = new ApiModel({
104
+ exposes: [
105
+ {
106
+ kind: ExposedEntityKind,
107
+ key: 'e1',
108
+ entity: { key: 'e1' },
109
+ hasCollection: true,
110
+ collectionPath: '/things',
111
+ resourcePath: '/things/{id}',
112
+ isRoot: true,
113
+ actions: [],
114
+ },
115
+ ],
116
+ })
117
+
118
+ let notified = 0
119
+ model.addEventListener('change', () => {
120
+ notified += 1
121
+ })
122
+
123
+ const ex = model.exposes[0]
124
+ ex.setCollectionPath('items')
125
+ await Promise.resolve() // allow ApiModel.notifyChange microtask to run
126
+ assert.isAtLeast(notified, 1)
127
+ }).tags(['@modeling', '@exposed-entity', '@observed'])
128
+
129
+ test('ApiModel notifies when nested ExposedEntity resource path changes', async ({ assert }) => {
130
+ const model = new ApiModel({
131
+ exposes: [
132
+ {
133
+ kind: ExposedEntityKind,
134
+ key: 'e1',
135
+ entity: { key: 'e1' },
136
+ hasCollection: true,
137
+ collectionPath: '/products',
138
+ resourcePath: '/products/{id}',
139
+ isRoot: true,
140
+ actions: [],
141
+ },
142
+ ],
143
+ })
144
+
145
+ let notified = 0
146
+ model.addEventListener('change', () => {
147
+ notified += 1
148
+ })
149
+
150
+ const ex = model.exposes[0]
151
+ ex.setResourcePath('/products/{productId}')
152
+ await Promise.resolve()
153
+ assert.isAtLeast(notified, 1)
154
+ }).tags(['@modeling', '@exposed-entity', '@observed'])
100
155
  })