@api-client/core 0.18.30 → 0.18.32

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 (37) 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 +3 -2
  10. package/build/src/modeling/ApiModel.d.ts.map +1 -1
  11. package/build/src/modeling/ApiModel.js +23 -8
  12. package/build/src/modeling/ApiModel.js.map +1 -1
  13. package/build/src/modeling/ExposedEntity.d.ts +114 -0
  14. package/build/src/modeling/ExposedEntity.d.ts.map +1 -0
  15. package/build/src/modeling/ExposedEntity.js +300 -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 +17 -4
  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/package.json +1 -1
  30. package/src/modeling/ApiModel.ts +28 -13
  31. package/src/modeling/ExposedEntity.ts +344 -0
  32. package/src/modeling/helpers/endpointHelpers.ts +22 -0
  33. package/src/modeling/types.ts +18 -3
  34. package/src/models/kinds.ts +1 -0
  35. package/tests/unit/modeling/api_model.spec.ts +49 -10
  36. package/tests/unit/modeling/api_model_expose_entity.spec.ts +1 -1
  37. package/tests/unit/modeling/exposed_entity.spec.ts +100 -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.30",
4
+ "version": "0.18.32",
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
@@ -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 }
@@ -401,17 +402,24 @@ export class ApiModel extends DependentModel {
401
402
  throw new Error(`Entity not found in domain: ${entity.key}`)
402
403
  }
403
404
  const name = domainEntity.info.name || ''
404
- const newEntity: ExposedEntity = {
405
+ const segment = pluralize(name.toLocaleLowerCase())
406
+ const relativeCollectionPath = `/${segment}`
407
+ const relativeResourcePath = `/${segment}/{id}`
408
+ const newEntity: ExposedEntitySchema = {
409
+ kind: ExposedEntityKind,
405
410
  key: nanoid(),
406
411
  entity: { ...entity },
407
412
  actions: [],
408
413
  isRoot: true,
409
- path: pluralize(name.toLocaleLowerCase()),
414
+ relativeCollectionPath,
415
+ relativeResourcePath,
416
+ hasCollection: true,
410
417
  }
411
418
  if (options) {
412
419
  newEntity.exposeOptions = { ...options }
413
420
  }
414
- this.exposes.push(newEntity)
421
+ const created = new ExposedEntity(this, newEntity)
422
+ this.exposes.push(created)
415
423
 
416
424
  // Follow associations if requested
417
425
  if (options?.followAssociations) {
@@ -420,7 +428,7 @@ export class ApiModel extends DependentModel {
420
428
  }
421
429
  }
422
430
  this.notifyChange()
423
- return newEntity
431
+ return created
424
432
  }
425
433
 
426
434
  /**
@@ -430,7 +438,7 @@ export class ApiModel extends DependentModel {
430
438
  * @param parentExposure The root exposure to follow associations from
431
439
  * @param options The expose options containing follow configuration
432
440
  */
433
- private followEntityAssociations(parentExposure: ExposedEntity, options: ExposeOptions): void {
441
+ private followEntityAssociations(parentExposure: ExposedEntitySchema, options: ExposeOptions): void {
434
442
  const domain = this.domain
435
443
  if (!domain) {
436
444
  return
@@ -479,13 +487,20 @@ export class ApiModel extends DependentModel {
479
487
  if (!targetDomainEntity) continue
480
488
 
481
489
  const name = association.info.name || ''
490
+ const segment = pluralize(name.toLocaleLowerCase())
491
+ const isCollection = association.multiple !== false
492
+ const relativeCollectionPath = isCollection ? `/${segment}` : undefined
493
+ const relativeResourcePath = isCollection ? `/${segment}/{id}` : `/${segment}`
482
494
  // Create nested exposure
483
- const nestedExposure: ExposedEntity = {
495
+ const nestedExposure: ExposedEntitySchema = {
496
+ kind: ExposedEntityKind,
484
497
  key: nanoid(),
485
498
  entity: { ...target },
486
499
  actions: [],
487
500
  isRoot: false,
488
- path: pluralize(name.toLocaleLowerCase()),
501
+ relativeCollectionPath,
502
+ relativeResourcePath,
503
+ hasCollection: isCollection,
489
504
  parent: {
490
505
  key: parentKey,
491
506
  association: {
@@ -496,7 +511,7 @@ export class ApiModel extends DependentModel {
496
511
  },
497
512
  }
498
513
 
499
- this.exposes.push(nestedExposure)
514
+ this.exposes.push(new ExposedEntity(this, nestedExposure))
500
515
  if (depth + 1 >= maxDepth) {
501
516
  nestedExposure.truncated = true
502
517
  } else {
@@ -0,0 +1,344 @@
1
+ import { ExposedEntityKind } from '../models/kinds.js'
2
+ import { nanoid } from '../nanoid.js'
3
+ import type { ApiModel } from './ApiModel.js'
4
+ import { ensureLeadingSlash, joinPaths } from './helpers/endpointHelpers.js'
5
+ import type {
6
+ AccessRule,
7
+ ApiAction,
8
+ AssociationTarget,
9
+ ExposeOptions,
10
+ ExposeParentRef,
11
+ RateLimitingConfiguration,
12
+ ExposedEntitySchema,
13
+ } from './types.js'
14
+
15
+ /**
16
+ * A class that specializes in representing an exposed Data Entity within an API Model.
17
+ *
18
+ * @fires change - Emitted when the exposed entity has changed.
19
+ */
20
+ export class ExposedEntity extends EventTarget {
21
+ /**
22
+ * The exposed entity kind recognizable by the ecosystem.
23
+ */
24
+ kind: typeof ExposedEntityKind
25
+
26
+ /**
27
+ * The unique key of the exposed entity.
28
+ * This is a stable identifier that does not change across versions.
29
+ */
30
+ key: string
31
+
32
+ /**
33
+ * A pointer to a Data Entity from the Data Domain.
34
+ */
35
+ entity: AssociationTarget
36
+
37
+ /**
38
+ * Indicates whether this exposure has a collection endpoint.
39
+ * A collection endpoint is optional for nested exposures where the association is 1:1
40
+ * and the schema is embedded directly under the parent resource.
41
+ */
42
+ hasCollection: boolean
43
+
44
+ /**
45
+ * Relative path to the collection endpoint for this exposure.
46
+ * Starts with '/'. Not set for 1:1 nested exposures where collection does not exist.
47
+ */
48
+ relativeCollectionPath?: string
49
+
50
+ /**
51
+ * Relative path to the resource endpoint for this exposure.
52
+ * Starts with '/'. For 1:1 nested exposures the resource path typically does not include an id segment.
53
+ */
54
+ relativeResourcePath: string
55
+
56
+ /**
57
+ * Whether this exposure is a root exposure (top-level collection).
58
+ * If this is set then the `parent` reference must be populated.
59
+ */
60
+ isRoot?: boolean
61
+
62
+ /**
63
+ * Parent reference when this exposure was created via following an association.
64
+ */
65
+ parent?: ExposeParentRef
66
+
67
+ /**
68
+ * Expose-time config used to create this exposure (persisted for auditing/UI).
69
+ * This is only populated for the root exposure. All children exposures inherit this config.
70
+ */
71
+ exposeOptions?: ExposeOptions
72
+
73
+ /**
74
+ * The list of enabled API actions for this exposure (List/Read/Create/etc.)
75
+ */
76
+ actions: ApiAction[]
77
+
78
+ /**
79
+ * Optional array of access rules that define the access control policies for this exposure.
80
+ */
81
+ accessRule?: AccessRule[]
82
+
83
+ /**
84
+ * Optional configuration for rate limiting for this exposure.
85
+ */
86
+ rateLimiting?: RateLimitingConfiguration
87
+
88
+ /**
89
+ * When true, generation for this exposure hit configured limits
90
+ */
91
+ truncated?: boolean
92
+
93
+ /**
94
+ * When the notifying flag is set to true,
95
+ * the domain is pending a notification.
96
+ * No other notifications will be sent until
97
+ * the current notification is sent.
98
+ */
99
+ #notifying = false
100
+ /**
101
+ * A reference to the parent API Model instance.
102
+ */
103
+ api: ApiModel
104
+
105
+ static createSchema(input: Partial<ExposedEntitySchema> = {}): ExposedEntitySchema {
106
+ const {
107
+ key = nanoid(),
108
+ entity = { key: '' },
109
+ relativeCollectionPath,
110
+ relativeResourcePath = '/',
111
+ hasCollection = true,
112
+ isRoot,
113
+ parent,
114
+ exposeOptions,
115
+ actions = [],
116
+ accessRule,
117
+ rateLimiting,
118
+ truncated,
119
+ } = input
120
+ const result: ExposedEntitySchema = {
121
+ kind: ExposedEntityKind,
122
+ key,
123
+ entity: { ...entity },
124
+ hasCollection,
125
+ relativeResourcePath,
126
+ actions: actions.map((a) => ({ ...a })),
127
+ }
128
+ if (relativeCollectionPath !== undefined) {
129
+ result.relativeCollectionPath = relativeCollectionPath
130
+ }
131
+ if (isRoot !== undefined) {
132
+ result.isRoot = isRoot
133
+ }
134
+ if (parent !== undefined) {
135
+ result.parent = { ...parent }
136
+ }
137
+ if (exposeOptions !== undefined) {
138
+ result.exposeOptions = { ...exposeOptions }
139
+ }
140
+ if (accessRule !== undefined) {
141
+ result.accessRule = accessRule.map((ar) => ({ ...ar }))
142
+ }
143
+ if (rateLimiting !== undefined) {
144
+ result.rateLimiting = { ...rateLimiting }
145
+ }
146
+ if (truncated !== undefined) {
147
+ result.truncated = truncated
148
+ }
149
+ return result
150
+ }
151
+
152
+ constructor(model: ApiModel, state?: Partial<ExposedEntitySchema>) {
153
+ super()
154
+ this.api = model
155
+ const init = ExposedEntity.createSchema(state)
156
+ this.kind = init.kind
157
+ this.key = init.key
158
+ this.entity = init.entity
159
+ this.hasCollection = init.hasCollection
160
+ this.relativeCollectionPath = init.relativeCollectionPath
161
+ this.relativeResourcePath = init.relativeResourcePath
162
+ this.isRoot = init.isRoot
163
+ this.parent = init.parent
164
+ this.exposeOptions = init.exposeOptions
165
+ this.actions = init.actions
166
+ this.accessRule = init.accessRule
167
+ this.rateLimiting = init.rateLimiting
168
+ this.truncated = init.truncated
169
+ }
170
+
171
+ notifyChange() {
172
+ if (this.#notifying) {
173
+ return
174
+ }
175
+ this.#notifying = true
176
+ queueMicrotask(() => {
177
+ this.#notifying = false
178
+ const event = new Event('change')
179
+ this.dispatchEvent(event)
180
+ })
181
+ }
182
+
183
+ toJSON(): ExposedEntitySchema {
184
+ const result: ExposedEntitySchema = {
185
+ kind: this.kind,
186
+ key: this.key,
187
+ entity: { ...this.entity },
188
+ relativeResourcePath: this.relativeResourcePath,
189
+ actions: this.actions.map((a) => ({ ...a })),
190
+ hasCollection: this.hasCollection,
191
+ }
192
+ if (this.relativeCollectionPath !== undefined) {
193
+ result.relativeCollectionPath = this.relativeCollectionPath
194
+ }
195
+ if (this.isRoot !== undefined) {
196
+ result.isRoot = this.isRoot
197
+ }
198
+ if (this.parent !== undefined) {
199
+ result.parent = { ...this.parent }
200
+ }
201
+ if (this.exposeOptions !== undefined) {
202
+ result.exposeOptions = { ...this.exposeOptions }
203
+ }
204
+ if (this.accessRule !== undefined) {
205
+ result.accessRule = this.accessRule.map((ar) => ({ ...ar }))
206
+ }
207
+ if (this.rateLimiting !== undefined) {
208
+ result.rateLimiting = { ...this.rateLimiting }
209
+ }
210
+ if (this.truncated !== undefined) {
211
+ result.truncated = this.truncated
212
+ }
213
+ return result
214
+ }
215
+
216
+ /**
217
+ * Sets a new relative collection path for this exposed entity.
218
+ *
219
+ * It:
220
+ * - updates the relativeCollectionPath property
221
+ * - updates the absoluteCollectionPath property accordingly
222
+ * - updates the relativeResourcePath and absoluteResourcePath accordingly.
223
+ * @param path The new path to set.
224
+ */
225
+ setRelativeCollectionPath(path: string) {
226
+ if (!this.hasCollection) {
227
+ throw new Error(`Cannot set collection path on an exposure that does not have a collection`)
228
+ }
229
+ const cleaned = ensureLeadingSlash(path)
230
+ // Ensure exactly one non-empty segment
231
+ const segments = cleaned.split('/').filter(Boolean)
232
+ if (segments.length !== 1) {
233
+ throw new Error(`Collection path must contain exactly one segment. Received: "${path}"`)
234
+ }
235
+ const normalizedCollection = `/${segments[0]}`
236
+ // Preserve current parameter name if present, otherwise default to {id}
237
+ let param = '{id}'
238
+ if (this.relativeResourcePath) {
239
+ const curSegments = this.relativeResourcePath.split('/').filter(Boolean)
240
+ const maybeParam = curSegments[1]
241
+ if (maybeParam && /^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(maybeParam)) {
242
+ param = maybeParam
243
+ }
244
+ }
245
+ 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()
250
+ }
251
+
252
+ /**
253
+ * Sets a new relative resource path for this exposed entity.
254
+ *
255
+ * Rules:
256
+ * - Must start with '/'.
257
+ * - If this exposure has a collection, the path must be exactly the collection path plus a single
258
+ * parameter segment (e.g. `/products/{productId}`) and only the parameter name may vary.
259
+ * - If this exposure does NOT have a collection, the path can be any two segments (e.g. `/profile/{id}` or `/a/b`).
260
+ */
261
+ setRelativeResourcePath(path: string) {
262
+ const cleaned = ensureLeadingSlash(path)
263
+ const segments = cleaned.split('/').filter(Boolean)
264
+
265
+ if (this.hasCollection) {
266
+ if (!this.relativeCollectionPath) {
267
+ throw new Error('Cannot set resource path: missing collection path for this exposure')
268
+ }
269
+ const colSegments = this.relativeCollectionPath.split('/').filter(Boolean)
270
+ if (colSegments.length !== 1) {
271
+ throw new Error(`Invalid stored collection path "${this.relativeCollectionPath}"`)
272
+ }
273
+ if (segments.length !== 2) {
274
+ throw new Error(`Resource path must be exactly two segments (collection + parameter). Received: "${cleaned}"`)
275
+ }
276
+ const [s1, s2] = segments
277
+ if (s1 !== colSegments[0]) {
278
+ throw new Error(`Resource path must start with the collection segment "${colSegments[0]}". Received: "${s1}"`)
279
+ }
280
+ // s2 must be a parameter segment {name}
281
+ if (!/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(s2)) {
282
+ throw new Error(`The second segment must be a parameter in braces, e.g. {id}. Received: "${s2}"`)
283
+ }
284
+ if (this.relativeResourcePath !== cleaned) {
285
+ this.relativeResourcePath = `/${s1}/${s2}`
286
+ this.notifyChange()
287
+ }
288
+ return
289
+ }
290
+
291
+ // No collection: allow any two segments
292
+ if (segments.length !== 2) {
293
+ throw new Error(
294
+ `Resource path must contain exactly two segments when no collection is present. Received: "${cleaned}"`
295
+ )
296
+ }
297
+ if (this.relativeResourcePath !== cleaned) {
298
+ this.relativeResourcePath = `/${segments[0]}/${segments[1]}`
299
+ this.notifyChange()
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Computes the absolute path for this exposure's resource endpoint by
305
+ * walking up the exposure tree using `parent.key` until reaching a root exposure.
306
+ * The absolute path is composed by concatenating each ancestor's resource path
307
+ * with this exposure's relative resource path.
308
+ */
309
+ getAbsoluteResourcePath(): string {
310
+ let absolute = ensureLeadingSlash(this.relativeResourcePath)
311
+ // Traverse parents, always joining with the parent's resource path
312
+ let parentKey = this.parent?.key
313
+ while (parentKey) {
314
+ const parent = this.api.exposes.find((e) => e.key === parentKey)
315
+ if (!parent) break
316
+ const parentResource = ensureLeadingSlash(parent.relativeResourcePath)
317
+ absolute = joinPaths(parentResource, absolute)
318
+ parentKey = parent.parent?.key
319
+ }
320
+ return absolute
321
+ }
322
+
323
+ /**
324
+ * Computes the absolute path for this exposure's collection endpoint (if any)
325
+ * by walking up the exposure tree using `parent.key` until reaching a root exposure.
326
+ * The absolute path is composed by concatenating each ancestor's resource path
327
+ * with this exposure's relative collection path.
328
+ * Returns undefined if this exposure has no collection.
329
+ */
330
+ getAbsoluteCollectionPath(): string | undefined {
331
+ if (!this.hasCollection || !this.relativeCollectionPath) return undefined
332
+ let absolute = ensureLeadingSlash(this.relativeCollectionPath)
333
+ // Traverse parents, always joining with the parent's resource path
334
+ let parentKey = this.parent?.key
335
+ while (parentKey) {
336
+ const parent = this.api.exposes.find((e) => e.key === parentKey)
337
+ if (!parent) break
338
+ const parentResource = ensureLeadingSlash(parent.relativeResourcePath)
339
+ absolute = joinPaths(parentResource, absolute)
340
+ parentKey = parent.parent?.key
341
+ }
342
+ return absolute
343
+ }
344
+ }
@@ -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,9 +451,22 @@ export interface ExposedEntity {
449
451
  */
450
452
  entity: AssociationTarget
451
453
  /**
452
- * The path segment for this exposure.
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.
453
457
  */
454
- path: string
458
+ hasCollection: boolean
459
+ /**
460
+ * Relative path to the collection endpoint for this exposure.
461
+ * Starts with '/'. Not set for 1:1 nested exposures where collection does not exist.
462
+ */
463
+ relativeCollectionPath?: string
464
+
465
+ /**
466
+ * Relative 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.
468
+ */
469
+ relativeResourcePath: string
455
470
 
456
471
  /**
457
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'
@@ -5,9 +5,11 @@ import {
5
5
  DataDomain,
6
6
  type RolesBasedAccessControl,
7
7
  type ApiModelSchema,
8
- type ExposedEntity,
8
+ type ExposedEntitySchema,
9
9
  type ApiContact,
10
10
  type ApiLicense,
11
+ ExposedEntityKind,
12
+ ExposedEntity,
11
13
  } from '../../../src/index.js'
12
14
 
13
15
  test.group('ApiModel.createSchema()', () => {
@@ -34,7 +36,16 @@ test.group('ApiModel.createSchema()', () => {
34
36
  const input: Partial<ApiModelSchema> = {
35
37
  key: 'test-api',
36
38
  info: { name: 'Test API', description: 'A test API' },
37
- exposes: [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }],
39
+ exposes: [
40
+ {
41
+ key: 'entity1',
42
+ actions: [],
43
+ hasCollection: true,
44
+ kind: ExposedEntityKind,
45
+ relativeResourcePath: '/',
46
+ entity: { key: 'entity1' },
47
+ },
48
+ ],
38
49
  user: { key: 'user-entity' },
39
50
  dependencyList: [{ key: 'domain1', version: '1.0.0' }],
40
51
  authentication: { strategy: 'UsernamePassword' },
@@ -51,7 +62,8 @@ test.group('ApiModel.createSchema()', () => {
51
62
  assert.equal(schema.kind, ApiModelKind)
52
63
  assert.equal(schema.key, 'test-api')
53
64
  assert.deepInclude(schema.info, { name: 'Test API', description: 'A test API' })
54
- assert.deepEqual(schema.exposes, [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }])
65
+ assert.lengthOf(schema.exposes, 1, 'should have one exposed entity')
66
+ assert.equal(schema.exposes[0].key, 'entity1', 'exposed entity should have correct key')
55
67
  assert.deepEqual(schema.user, { key: 'user-entity' })
56
68
  assert.deepEqual(schema.dependencyList, [{ key: 'domain1', version: '1.0.0' }])
57
69
  assert.deepEqual(schema.authentication, { strategy: 'UsernamePassword' })
@@ -100,7 +112,16 @@ test.group('ApiModel.constructor()', () => {
100
112
  kind: ApiModelKind,
101
113
  key: 'test-api',
102
114
  info: { name: 'Test API', description: 'A test API' },
103
- exposes: [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }],
115
+ exposes: [
116
+ {
117
+ key: 'entity1',
118
+ actions: [],
119
+ relativeResourcePath: '/',
120
+ entity: { key: 'entity1' },
121
+ hasCollection: true,
122
+ kind: ExposedEntityKind,
123
+ },
124
+ ],
104
125
  user: { key: 'user-entity' },
105
126
  dependencyList: [{ key: 'domain1', version: '1.0.0' }],
106
127
  authentication: { strategy: 'UsernamePassword' },
@@ -116,7 +137,8 @@ test.group('ApiModel.constructor()', () => {
116
137
 
117
138
  assert.equal(model.key, 'test-api')
118
139
  assert.equal(model.info.name, 'Test API')
119
- assert.deepEqual(model.exposes, [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }])
140
+ assert.lengthOf(model.exposes, 1, 'should have one exposed entity')
141
+ assert.equal(model.exposes[0].key, 'entity1', 'exposed entity should have correct key')
120
142
  assert.deepEqual(model.user, { key: 'user-entity' })
121
143
  assert.deepEqual(model.dependencyList, [{ key: 'domain1', version: '1.0.0' }])
122
144
  assert.deepEqual(model.authentication, { strategy: 'UsernamePassword' })
@@ -179,7 +201,16 @@ test.group('ApiModel.toJSON()', () => {
179
201
  kind: ApiModelKind,
180
202
  key: 'test-api',
181
203
  info: { name: 'Test API', description: 'A test API' },
182
- exposes: [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }],
204
+ exposes: [
205
+ {
206
+ key: 'entity1',
207
+ actions: [],
208
+ relativeResourcePath: '/',
209
+ entity: { key: 'entity1' },
210
+ hasCollection: true,
211
+ kind: ExposedEntityKind,
212
+ },
213
+ ],
183
214
  user: { key: 'user-entity' },
184
215
  dependencyList: [{ key: 'domain1', version: '1.0.0' }],
185
216
  authentication: { strategy: 'UsernamePassword' },
@@ -196,7 +227,8 @@ test.group('ApiModel.toJSON()', () => {
196
227
 
197
228
  assert.equal(json.key, 'test-api')
198
229
  assert.deepInclude(json.info, { name: 'Test API', description: 'A test API' })
199
- assert.deepEqual(json.exposes, [{ key: 'entity1', actions: [], path: '', entity: { key: 'entity1' } }])
230
+ assert.lengthOf(json.exposes, 1, 'should have one exposed entity')
231
+ assert.equal(json.exposes[0].key, 'entity1', 'exposed entity should have correct key')
200
232
  assert.deepEqual(json.user, { key: 'user-entity' })
201
233
  assert.deepEqual(json.dependencyList, [{ key: 'domain1', version: '1.0.0' }])
202
234
  assert.deepEqual(json.authentication, { strategy: 'UsernamePassword' })
@@ -214,11 +246,18 @@ test.group('ApiModel.getExposedEntity()', () => {
214
246
  test('returns an existing exposed entity', ({ assert }) => {
215
247
  const model = new ApiModel()
216
248
  const entityKey = 'get-entity'
217
- const exposed: ExposedEntity = { key: entityKey, actions: [], path: '', entity: { key: entityKey } }
218
- model.exposes.push(exposed)
249
+ const exposed: ExposedEntitySchema = {
250
+ key: entityKey,
251
+ actions: [],
252
+ hasCollection: true,
253
+ kind: ExposedEntityKind,
254
+ relativeResourcePath: '/',
255
+ entity: { key: entityKey },
256
+ }
257
+ model.exposes.push(new ExposedEntity(model, exposed))
219
258
 
220
259
  const retrievedEntity = model.getExposedEntity({ key: entityKey })
221
- assert.deepEqual(retrievedEntity, exposed)
260
+ assert.deepEqual(retrievedEntity?.toJSON(), exposed)
222
261
  }).tags(['@modeling', '@api'])
223
262
 
224
263
  test('returns undefined if entity is not exposed', ({ assert }) => {
@@ -81,7 +81,7 @@ test.group('ApiModel.exposeEntity()', () => {
81
81
  const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
82
82
  assert.isDefined(nestedB)
83
83
  assert.deepEqual(nestedB?.parent?.key, exposedA.key)
84
- assert.strictEqual(nestedB?.path, 'entitybs')
84
+ assert.strictEqual(nestedB?.relativeCollectionPath, '/entitybs')
85
85
  })
86
86
 
87
87
  test('does not infinitely expose circular associations', ({ assert }) => {