@api-client/core 0.18.31 → 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.
Files changed (39) hide show
  1. package/build/src/browser.d.ts +1 -0
  2. package/build/src/browser.d.ts.map +1 -1
  3. package/build/src/browser.js +1 -0
  4. package/build/src/browser.js.map +1 -1
  5. package/build/src/index.d.ts +1 -0
  6. package/build/src/index.d.ts.map +1 -1
  7. package/build/src/index.js +1 -0
  8. package/build/src/index.js.map +1 -1
  9. package/build/src/modeling/ApiModel.d.ts +4 -3
  10. package/build/src/modeling/ApiModel.d.ts.map +1 -1
  11. package/build/src/modeling/ApiModel.js +25 -22
  12. package/build/src/modeling/ApiModel.js.map +1 -1
  13. package/build/src/modeling/ExposedEntity.d.ts +124 -0
  14. package/build/src/modeling/ExposedEntity.d.ts.map +1 -0
  15. package/build/src/modeling/ExposedEntity.js +364 -0
  16. package/build/src/modeling/ExposedEntity.js.map +1 -0
  17. package/build/src/modeling/helpers/endpointHelpers.d.ts +11 -0
  18. package/build/src/modeling/helpers/endpointHelpers.d.ts.map +1 -1
  19. package/build/src/modeling/helpers/endpointHelpers.js +21 -0
  20. package/build/src/modeling/helpers/endpointHelpers.js.map +1 -1
  21. package/build/src/modeling/types.d.ts +12 -15
  22. package/build/src/modeling/types.d.ts.map +1 -1
  23. package/build/src/modeling/types.js.map +1 -1
  24. package/build/src/models/kinds.d.ts +1 -0
  25. package/build/src/models/kinds.d.ts.map +1 -1
  26. package/build/src/models/kinds.js +1 -0
  27. package/build/src/models/kinds.js.map +1 -1
  28. package/build/tsconfig.tsbuildinfo +1 -1
  29. package/data/models/example-generator-api.json +6 -6
  30. package/package.json +1 -1
  31. package/src/modeling/ApiModel.ts +22 -26
  32. package/src/modeling/ExposedEntity.ts +358 -0
  33. package/src/modeling/helpers/endpointHelpers.ts +22 -0
  34. package/src/modeling/types.ts +12 -16
  35. package/src/models/kinds.ts +1 -0
  36. package/tests/unit/modeling/api_model.spec.ts +49 -10
  37. package/tests/unit/modeling/api_model_expose_entity.spec.ts +2 -4
  38. package/tests/unit/modeling/api_model_remove_entity.spec.ts +1 -2
  39. package/tests/unit/modeling/exposed_entity.spec.ts +155 -0
@@ -42068,10 +42068,10 @@
42068
42068
  "@id": "#194"
42069
42069
  },
42070
42070
  {
42071
- "@id": "#200"
42071
+ "@id": "#197"
42072
42072
  },
42073
42073
  {
42074
- "@id": "#197"
42074
+ "@id": "#200"
42075
42075
  },
42076
42076
  {
42077
42077
  "@id": "#203"
@@ -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": "code: '5'\ndescription: 'Limited company'\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
  {
@@ -44766,12 +44766,12 @@
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)-(3,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.31",
4
+ "version": "0.18.33",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {
7
7
  "./browser.js": {
@@ -1,12 +1,12 @@
1
1
  import { nanoid } from '../nanoid.js'
2
- import { ApiModelKind, DataDomainKind } from '../models/kinds.js'
2
+ import { ApiModelKind, DataDomainKind, ExposedEntityKind } from '../models/kinds.js'
3
3
  import { type IThing, Thing } from '../models/Thing.js'
4
4
  import type {
5
5
  AccessRule,
6
6
  AssociationTarget,
7
7
  AuthenticationConfiguration,
8
8
  AuthorizationConfiguration,
9
- ExposedEntity,
9
+ ExposedEntitySchema,
10
10
  RateLimitingConfiguration,
11
11
  RolesBasedAccessControl,
12
12
  SessionConfiguration,
@@ -18,6 +18,7 @@ import { DependentModel, type DependentModelSchema, type DomainDependency } from
18
18
  import { observed, toRaw } from '../decorators/observed.js'
19
19
  import pluralize from '@jarrodek/pluralize'
20
20
  import { createDomainKey } from './helpers/keying.js'
21
+ import { ExposedEntity } from './ExposedEntity.js'
21
22
 
22
23
  /**
23
24
  * Contact information for the exposed API.
@@ -95,7 +96,7 @@ export interface ApiModelSchema extends DependentModelSchema {
95
96
  * The specific subset of Data Entities to be exposed by this API.
96
97
  * These are the entities that are included in the data domain schema.
97
98
  */
98
- exposes: ExposedEntity[]
99
+ exposes: ExposedEntitySchema[]
99
100
 
100
101
  /**
101
102
  * Optional array of access rules that define the access control policies
@@ -169,7 +170,7 @@ export class ApiModel extends DependentModel {
169
170
  * The specific subset of Data Entities to be exposed by this API.
170
171
  * These are the entities that are included in the data domain schema.
171
172
  */
172
- exposes: ExposedEntity[]
173
+ @observed({ deep: true }) accessor exposes: ExposedEntity[]
173
174
  /**
174
175
  * Optional array of access rules that define the access control policies
175
176
  * for the API. These rules are used to enforce security and permissions
@@ -294,7 +295,7 @@ export class ApiModel extends DependentModel {
294
295
  this.session = structuredClone(init.session)
295
296
  }
296
297
  if (Array.isArray(init.exposes)) {
297
- this.exposes = structuredClone(init.exposes)
298
+ this.exposes = init.exposes.map((e) => new ExposedEntity(this, e))
298
299
  } else {
299
300
  this.exposes = []
300
301
  }
@@ -324,7 +325,7 @@ export class ApiModel extends DependentModel {
324
325
  kind: this.kind,
325
326
  key: this.key,
326
327
  info: this.info.toJSON(),
327
- exposes: structuredClone(this.exposes),
328
+ exposes: this.exposes.map((e) => e.toJSON()),
328
329
  }
329
330
  if (this.user) {
330
331
  result.user = { ...this.user }
@@ -404,22 +405,21 @@ export class ApiModel extends DependentModel {
404
405
  const segment = pluralize(name.toLocaleLowerCase())
405
406
  const relativeCollectionPath = `/${segment}`
406
407
  const relativeResourcePath = `/${segment}/{id}`
407
- const absoluteCollectionPath = relativeCollectionPath
408
- const absoluteResourcePath = relativeResourcePath
409
- const newEntity: ExposedEntity = {
408
+ const newEntity: ExposedEntitySchema = {
409
+ kind: ExposedEntityKind,
410
410
  key: nanoid(),
411
411
  entity: { ...entity },
412
412
  actions: [],
413
413
  isRoot: true,
414
- relativeCollectionPath,
415
- relativeResourcePath,
416
- absoluteCollectionPath,
417
- absoluteResourcePath,
414
+ collectionPath: relativeCollectionPath,
415
+ resourcePath: relativeResourcePath,
416
+ hasCollection: true,
418
417
  }
419
418
  if (options) {
420
419
  newEntity.exposeOptions = { ...options }
421
420
  }
422
- this.exposes.push(newEntity)
421
+ const created = new ExposedEntity(this, newEntity)
422
+ this.exposes.push(created)
423
423
 
424
424
  // Follow associations if requested
425
425
  if (options?.followAssociations) {
@@ -428,7 +428,7 @@ export class ApiModel extends DependentModel {
428
428
  }
429
429
  }
430
430
  this.notifyChange()
431
- return newEntity
431
+ return created
432
432
  }
433
433
 
434
434
  /**
@@ -438,7 +438,7 @@ export class ApiModel extends DependentModel {
438
438
  * @param parentExposure The root exposure to follow associations from
439
439
  * @param options The expose options containing follow configuration
440
440
  */
441
- private followEntityAssociations(parentExposure: ExposedEntity, options: ExposeOptions): void {
441
+ private followEntityAssociations(parentExposure: ExposedEntitySchema, options: ExposeOptions): void {
442
442
  const domain = this.domain
443
443
  if (!domain) {
444
444
  return
@@ -487,24 +487,20 @@ export class ApiModel extends DependentModel {
487
487
  if (!targetDomainEntity) continue
488
488
 
489
489
  const name = association.info.name || ''
490
- const parentExposure = this.exposes.find((e) => e.key === parentKey)
491
- const parentAbsResource = parentExposure?.absoluteResourcePath || ''
492
490
  const segment = pluralize(name.toLocaleLowerCase())
493
491
  const isCollection = association.multiple !== false
494
492
  const relativeCollectionPath = isCollection ? `/${segment}` : undefined
495
493
  const relativeResourcePath = isCollection ? `/${segment}/{id}` : `/${segment}`
496
- const absoluteCollectionPath = isCollection ? `${parentAbsResource}${relativeCollectionPath}` : undefined
497
- const absoluteResourcePath = `${parentAbsResource}${relativeResourcePath}`
498
494
  // Create nested exposure
499
- const nestedExposure: ExposedEntity = {
495
+ const nestedExposure: ExposedEntitySchema = {
496
+ kind: ExposedEntityKind,
500
497
  key: nanoid(),
501
498
  entity: { ...target },
502
499
  actions: [],
503
500
  isRoot: false,
504
- relativeCollectionPath,
505
- relativeResourcePath,
506
- absoluteCollectionPath,
507
- absoluteResourcePath,
501
+ collectionPath: relativeCollectionPath,
502
+ resourcePath: relativeResourcePath,
503
+ hasCollection: isCollection,
508
504
  parent: {
509
505
  key: parentKey,
510
506
  association: {
@@ -515,7 +511,7 @@ export class ApiModel extends DependentModel {
515
511
  },
516
512
  }
517
513
 
518
- this.exposes.push(nestedExposure)
514
+ this.exposes.push(new ExposedEntity(this, nestedExposure))
519
515
  if (depth + 1 >= maxDepth) {
520
516
  nestedExposure.truncated = true
521
517
  } else {
@@ -0,0 +1,358 @@
1
+ import { observed } from '../decorators/observed.js'
2
+ import { ExposedEntityKind } from '../models/kinds.js'
3
+ import { nanoid } from '../nanoid.js'
4
+ import type { ApiModel } from './ApiModel.js'
5
+ import { ensureLeadingSlash, joinPaths } from './helpers/endpointHelpers.js'
6
+ import type {
7
+ AccessRule,
8
+ ApiAction,
9
+ AssociationTarget,
10
+ ExposeOptions,
11
+ ExposeParentRef,
12
+ RateLimitingConfiguration,
13
+ ExposedEntitySchema,
14
+ } from './types.js'
15
+
16
+ /**
17
+ * A class that specializes in representing an exposed Data Entity within an API Model.
18
+ *
19
+ * @fires change - Emitted when the exposed entity has changed.
20
+ */
21
+ export class ExposedEntity extends EventTarget {
22
+ /**
23
+ * The exposed entity kind recognizable by the ecosystem.
24
+ */
25
+ kind: typeof ExposedEntityKind
26
+
27
+ /**
28
+ * The unique key of the exposed entity.
29
+ * This is a stable identifier that does not change across versions.
30
+ */
31
+ key: string
32
+
33
+ /**
34
+ * A pointer to a Data Entity from the Data Domain.
35
+ */
36
+ @observed() accessor entity: AssociationTarget
37
+
38
+ /**
39
+ * Indicates whether this exposure has a collection endpoint.
40
+ * A collection endpoint is optional for nested exposures where the association is 1:1
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.
44
+ */
45
+ hasCollection: boolean
46
+
47
+ /**
48
+ * Path to the collection endpoint for this exposure.
49
+ * Starts with '/'. Not set for 1:1 nested exposures where collection does not exist.
50
+ */
51
+ @observed() accessor collectionPath: string | undefined
52
+
53
+ /**
54
+ * Path to the resource endpoint for this exposure.
55
+ * Starts with '/'. For 1:1 nested exposures the resource path typically does not include an id segment.
56
+ */
57
+ @observed() accessor resourcePath: string
58
+
59
+ /**
60
+ * Whether this exposure is a root exposure (top-level collection).
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.
64
+ */
65
+ isRoot?: boolean
66
+
67
+ /**
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.
71
+ */
72
+ parent?: ExposeParentRef
73
+
74
+ /**
75
+ * Expose-time config used to create this exposure (persisted for auditing/UI).
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.
79
+ */
80
+ exposeOptions?: ExposeOptions
81
+
82
+ /**
83
+ * The list of enabled API actions for this exposure (List/Read/Create/etc.)
84
+ */
85
+ @observed({ deep: true }) accessor actions: ApiAction[]
86
+
87
+ /**
88
+ * Optional array of access rules that define the access control policies for this exposure.
89
+ */
90
+ @observed({ deep: true }) accessor accessRule: AccessRule[] | undefined
91
+
92
+ /**
93
+ * Optional configuration for rate limiting for this exposure.
94
+ */
95
+ @observed({ deep: true }) accessor rateLimiting: RateLimitingConfiguration | undefined
96
+
97
+ /**
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.
101
+ */
102
+ truncated?: boolean
103
+
104
+ /**
105
+ * When the notifying flag is set to true,
106
+ * the domain is pending a notification.
107
+ * No other notifications will be sent until
108
+ * the current notification is sent.
109
+ */
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
116
+ /**
117
+ * A reference to the parent API Model instance.
118
+ */
119
+ api: ApiModel
120
+
121
+ static createSchema(input: Partial<ExposedEntitySchema> = {}): ExposedEntitySchema {
122
+ const {
123
+ key = nanoid(),
124
+ entity = { key: '' },
125
+ collectionPath,
126
+ resourcePath = '/',
127
+ hasCollection = true,
128
+ isRoot,
129
+ parent,
130
+ exposeOptions,
131
+ actions = [],
132
+ accessRule,
133
+ rateLimiting,
134
+ truncated,
135
+ } = input
136
+ const result: ExposedEntitySchema = {
137
+ kind: ExposedEntityKind,
138
+ key,
139
+ entity: { ...entity },
140
+ hasCollection,
141
+ resourcePath,
142
+ actions: actions.map((a) => ({ ...a })),
143
+ }
144
+ if (collectionPath !== undefined) {
145
+ result.collectionPath = collectionPath
146
+ }
147
+ if (isRoot !== undefined) {
148
+ result.isRoot = isRoot
149
+ }
150
+ if (parent !== undefined) {
151
+ result.parent = { ...parent }
152
+ }
153
+ if (exposeOptions !== undefined) {
154
+ result.exposeOptions = { ...exposeOptions }
155
+ }
156
+ if (accessRule !== undefined) {
157
+ result.accessRule = accessRule.map((ar) => ({ ...ar }))
158
+ }
159
+ if (rateLimiting !== undefined) {
160
+ result.rateLimiting = { ...rateLimiting }
161
+ }
162
+ if (truncated !== undefined) {
163
+ result.truncated = truncated
164
+ }
165
+ return result
166
+ }
167
+
168
+ constructor(model: ApiModel, state?: Partial<ExposedEntitySchema>) {
169
+ super()
170
+ this.api = model
171
+ const init = ExposedEntity.createSchema(state)
172
+ this.kind = init.kind
173
+ this.key = init.key
174
+ this.entity = init.entity
175
+ this.hasCollection = init.hasCollection
176
+ this.collectionPath = init.collectionPath
177
+ this.resourcePath = init.resourcePath
178
+ this.isRoot = init.isRoot
179
+ this.parent = init.parent
180
+ this.exposeOptions = init.exposeOptions
181
+ this.actions = init.actions
182
+ this.accessRule = init.accessRule
183
+ this.rateLimiting = init.rateLimiting
184
+ this.truncated = init.truncated
185
+ this.#initializing = false
186
+ }
187
+
188
+ notifyChange() {
189
+ if (this.#notifying || this.#initializing) {
190
+ return
191
+ }
192
+ this.#notifying = true
193
+ queueMicrotask(() => {
194
+ this.#notifying = false
195
+ const event = new Event('change')
196
+ this.dispatchEvent(event)
197
+ })
198
+ }
199
+
200
+ toJSON(): ExposedEntitySchema {
201
+ const result: ExposedEntitySchema = {
202
+ kind: this.kind,
203
+ key: this.key,
204
+ entity: { ...this.entity },
205
+ resourcePath: this.resourcePath,
206
+ actions: this.actions.map((a) => ({ ...a })),
207
+ hasCollection: this.hasCollection,
208
+ }
209
+ if (this.collectionPath !== undefined) {
210
+ result.collectionPath = this.collectionPath
211
+ }
212
+ if (this.isRoot !== undefined) {
213
+ result.isRoot = this.isRoot
214
+ }
215
+ if (this.parent !== undefined) {
216
+ result.parent = { ...this.parent }
217
+ }
218
+ if (this.exposeOptions !== undefined) {
219
+ result.exposeOptions = { ...this.exposeOptions }
220
+ }
221
+ if (this.accessRule !== undefined) {
222
+ result.accessRule = this.accessRule.map((ar) => ({ ...ar }))
223
+ }
224
+ if (this.rateLimiting !== undefined) {
225
+ result.rateLimiting = { ...this.rateLimiting }
226
+ }
227
+ if (this.truncated !== undefined) {
228
+ result.truncated = this.truncated
229
+ }
230
+ return result
231
+ }
232
+
233
+ /**
234
+ * Sets a new collection path for this exposed entity.
235
+ *
236
+ * It:
237
+ * - updates the collectionPath property
238
+ * - updates the absoluteCollectionPath property accordingly
239
+ * - updates the resourcePath accordingly.
240
+ * @param path The new path to set.
241
+ */
242
+ setCollectionPath(path: string) {
243
+ if (!this.hasCollection) {
244
+ throw new Error(`Cannot set collection path on an exposure that does not have a collection`)
245
+ }
246
+ const cleaned = ensureLeadingSlash(path)
247
+ // Ensure exactly one non-empty segment
248
+ const segments = cleaned.split('/').filter(Boolean)
249
+ if (segments.length !== 1) {
250
+ throw new Error(`Collection path must contain exactly one segment. Received: "${path}"`)
251
+ }
252
+ const normalizedCollection = `/${segments[0]}`
253
+ // Preserve current parameter name if present, otherwise default to {id}
254
+ let param = '{id}'
255
+ if (this.resourcePath) {
256
+ const curSegments = this.resourcePath.split('/').filter(Boolean)
257
+ const maybeParam = curSegments[1]
258
+ if (maybeParam && /^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(maybeParam)) {
259
+ param = maybeParam
260
+ }
261
+ }
262
+ const nextResource = `${normalizedCollection}/${param}`
263
+ this.collectionPath = normalizedCollection
264
+ this.resourcePath = nextResource
265
+ // rely on ApiModel.exposes deep observation to notify on property sets
266
+ }
267
+
268
+ /**
269
+ * Sets a new resource path for this exposed entity.
270
+ *
271
+ * Rules:
272
+ * - Must start with '/'.
273
+ * - If this exposure has a collection, the path must be exactly the collection path plus a single
274
+ * parameter segment (e.g. `/products/{productId}`) and only the parameter name may vary.
275
+ * - If this exposure does NOT have a collection, the path can be any two segments (e.g. `/profile/{id}` or `/a/b`).
276
+ */
277
+ setResourcePath(path: string) {
278
+ const cleaned = ensureLeadingSlash(path)
279
+ const segments = cleaned.split('/').filter(Boolean)
280
+
281
+ if (this.hasCollection) {
282
+ if (!this.collectionPath) {
283
+ throw new Error('Cannot set resource path: missing collection path for this exposure')
284
+ }
285
+ const colSegments = this.collectionPath.split('/').filter(Boolean)
286
+ if (colSegments.length !== 1) {
287
+ throw new Error(`Invalid stored collection path "${this.collectionPath}"`)
288
+ }
289
+ if (segments.length !== 2) {
290
+ throw new Error(`Resource path must be exactly two segments (collection + parameter). Received: "${cleaned}"`)
291
+ }
292
+ const [s1, s2] = segments
293
+ if (s1 !== colSegments[0]) {
294
+ throw new Error(`Resource path must start with the collection segment "${colSegments[0]}". Received: "${s1}"`)
295
+ }
296
+ // s2 must be a parameter segment {name}
297
+ if (!/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(s2)) {
298
+ throw new Error(`The second segment must be a parameter in braces, e.g. {id}. Received: "${s2}"`)
299
+ }
300
+ if (this.resourcePath !== cleaned) {
301
+ this.resourcePath = `/${s1}/${s2}`
302
+ }
303
+ return
304
+ }
305
+
306
+ // No collection: allow any two segments
307
+ if (segments.length !== 2) {
308
+ throw new Error(
309
+ `Resource path must contain exactly two segments when no collection is present. Received: "${cleaned}"`
310
+ )
311
+ }
312
+ if (this.resourcePath !== cleaned) {
313
+ this.resourcePath = `/${segments[0]}/${segments[1]}`
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Computes the absolute path for this exposure's resource endpoint by
319
+ * walking up the exposure tree using `parent.key` until reaching a root exposure.
320
+ * The absolute path is composed by concatenating each ancestor's resource path
321
+ * with this exposure's resource path.
322
+ */
323
+ getAbsoluteResourcePath(): string {
324
+ let absolute = ensureLeadingSlash(this.resourcePath)
325
+ // Traverse parents, always joining with the parent's resource path
326
+ let parentKey = this.parent?.key
327
+ while (parentKey) {
328
+ const parent = this.api.exposes.find((e) => e.key === parentKey)
329
+ if (!parent) break
330
+ const parentResource = ensureLeadingSlash(parent.resourcePath)
331
+ absolute = joinPaths(parentResource, absolute)
332
+ parentKey = parent.parent?.key
333
+ }
334
+ return absolute
335
+ }
336
+
337
+ /**
338
+ * Computes the absolute path for this exposure's collection endpoint (if any)
339
+ * by walking up the exposure tree using `parent.key` until reaching a root exposure.
340
+ * The absolute path is composed by concatenating each ancestor's resource path
341
+ * with this exposure's collection path.
342
+ * Returns undefined if this exposure has no collection.
343
+ */
344
+ getAbsoluteCollectionPath(): string | undefined {
345
+ if (!this.hasCollection || !this.collectionPath) return undefined
346
+ let absolute = ensureLeadingSlash(this.collectionPath)
347
+ // Traverse parents, always joining with the parent's resource path
348
+ let parentKey = this.parent?.key
349
+ while (parentKey) {
350
+ const parent = this.api.exposes.find((e) => e.key === parentKey)
351
+ if (!parent) break
352
+ const parentResource = ensureLeadingSlash(parent.resourcePath)
353
+ absolute = joinPaths(parentResource, absolute)
354
+ parentKey = parent.parent?.key
355
+ }
356
+ return absolute
357
+ }
358
+ }
@@ -3,3 +3,25 @@ export function paramNameFor(entityKeyLocal: string): string {
3
3
  const key = parts[parts.length - 1]
4
4
  return `${key}Id`
5
5
  }
6
+
7
+ /**
8
+ * Ensures the path starts with a single leading slash and has no trailing slash (except root '/')
9
+ * @param path The path fragment to normalize
10
+ */
11
+ export function ensureLeadingSlash(path: string): string {
12
+ const trimmed = path.trim()
13
+ const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
14
+ if (withSlash === '/') return '/'
15
+ return withSlash.replace(/\/+$/, '')
16
+ }
17
+
18
+ /**
19
+ * Joins two absolute-like fragments ensuring single slashes between segments
20
+ * @param left The left path fragment
21
+ * @param right The right path fragment
22
+ */
23
+ export function joinPaths(left: string, right: string): string {
24
+ const l = left.endsWith('/') ? left.slice(0, -1) : left
25
+ const r = right.startsWith('/') ? right : `/${right}`
26
+ return `${l}${r}`
27
+ }
@@ -12,6 +12,7 @@ import type {
12
12
  DomainPropertyKind,
13
13
  DomainAssociationKind,
14
14
  DataDomainKind,
15
+ ExposedEntityKind,
15
16
  } from '../models/kinds.js'
16
17
  import type { DataDomain } from './DataDomain.js'
17
18
 
@@ -426,7 +427,8 @@ export interface UsernamePasswordConfiguration extends AuthenticationConfigurati
426
427
  /**
427
428
  * Represents a Data Entity from the Data Domain that the API will expose and operate upon.
428
429
  */
429
- export interface ExposedEntity {
430
+ export interface ExposedEntitySchema {
431
+ kind: typeof ExposedEntityKind
430
432
  /**
431
433
  * The unique identifier for this exposure instance.
432
434
  * In the exposure model, we need to uniquely identify each exposure instance, because
@@ -449,28 +451,22 @@ export interface ExposedEntity {
449
451
  */
450
452
  entity: AssociationTarget
451
453
  /**
452
- * Relative path to the collection endpoint for this exposure.
453
- * Starts with '/'. Not set for 1:1 nested exposures where collection does not exist.
454
+ * Indicates whether this exposure has a collection endpoint.
455
+ * A collection endpoint is optional for nested exposures where the association is 1:1
456
+ * and the schema is embedded directly under the parent resource.
454
457
  */
455
- relativeCollectionPath?: string
456
-
458
+ hasCollection: boolean
457
459
  /**
458
- * Relative path to the resource endpoint for this exposure.
459
- * Starts with '/'. For 1:1 nested exposures the resource path typically does not include an id segment.
460
- */
461
- relativeResourcePath: string
462
-
463
- /**
464
- * Absolute path to the collection endpoint for this exposure (includes parent paths).
460
+ * A path to the collection endpoint for this exposure.
465
461
  * Starts with '/'. Not set for 1:1 nested exposures where collection does not exist.
466
462
  */
467
- absoluteCollectionPath?: string
463
+ collectionPath?: string
468
464
 
469
465
  /**
470
- * Absolute path to the resource endpoint for this exposure (includes parent paths).
471
- * Starts with '/'.
466
+ * A path to the resource endpoint for this exposure.
467
+ * Starts with '/'. For 1:1 nested exposures the resource path typically does not include an id segment.
472
468
  */
473
- absoluteResourcePath: string
469
+ resourcePath: string
474
470
 
475
471
  /**
476
472
  * Whether this exposure is a root exposure (top-level collection).
@@ -21,5 +21,6 @@ export const DataCatalogVersionKind = 'Core#DataCatalogVersion'
21
21
  export const OrganizationKind = 'Core#Organization'
22
22
  export const InvitationKind = 'Core#Invitation'
23
23
  export const ApiModelKind = 'Core#ApiModel'
24
+ export const ExposedEntityKind = 'Core#ExposedEntity'
24
25
  export const ApiFileKind = 'Core#ApiFile'
25
26
  export const GroupKind = 'Core#Group'