@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.
- package/build/src/browser.d.ts +1 -0
- package/build/src/browser.d.ts.map +1 -1
- package/build/src/browser.js +1 -0
- package/build/src/browser.js.map +1 -1
- package/build/src/index.d.ts +1 -0
- package/build/src/index.d.ts.map +1 -1
- package/build/src/index.js +1 -0
- package/build/src/index.js.map +1 -1
- package/build/src/modeling/ApiModel.d.ts +3 -2
- package/build/src/modeling/ApiModel.d.ts.map +1 -1
- package/build/src/modeling/ApiModel.js +23 -8
- package/build/src/modeling/ApiModel.js.map +1 -1
- package/build/src/modeling/ExposedEntity.d.ts +114 -0
- package/build/src/modeling/ExposedEntity.d.ts.map +1 -0
- package/build/src/modeling/ExposedEntity.js +300 -0
- package/build/src/modeling/ExposedEntity.js.map +1 -0
- package/build/src/modeling/helpers/endpointHelpers.d.ts +11 -0
- package/build/src/modeling/helpers/endpointHelpers.d.ts.map +1 -1
- package/build/src/modeling/helpers/endpointHelpers.js +21 -0
- package/build/src/modeling/helpers/endpointHelpers.js.map +1 -1
- package/build/src/modeling/types.d.ts +17 -4
- package/build/src/modeling/types.d.ts.map +1 -1
- package/build/src/modeling/types.js.map +1 -1
- package/build/src/models/kinds.d.ts +1 -0
- package/build/src/models/kinds.d.ts.map +1 -1
- package/build/src/models/kinds.js +1 -0
- package/build/src/models/kinds.js.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/modeling/ApiModel.ts +28 -13
- package/src/modeling/ExposedEntity.ts +344 -0
- package/src/modeling/helpers/endpointHelpers.ts +22 -0
- package/src/modeling/types.ts +18 -3
- package/src/models/kinds.ts +1 -0
- package/tests/unit/modeling/api_model.spec.ts +49 -10
- package/tests/unit/modeling/api_model_expose_entity.spec.ts +1 -1
- package/tests/unit/modeling/exposed_entity.spec.ts +100 -0
package/package.json
CHANGED
package/src/modeling/ApiModel.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
|
|
414
|
+
relativeCollectionPath,
|
|
415
|
+
relativeResourcePath,
|
|
416
|
+
hasCollection: true,
|
|
410
417
|
}
|
|
411
418
|
if (options) {
|
|
412
419
|
newEntity.exposeOptions = { ...options }
|
|
413
420
|
}
|
|
414
|
-
this
|
|
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
|
|
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:
|
|
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:
|
|
495
|
+
const nestedExposure: ExposedEntitySchema = {
|
|
496
|
+
kind: ExposedEntityKind,
|
|
484
497
|
key: nanoid(),
|
|
485
498
|
entity: { ...target },
|
|
486
499
|
actions: [],
|
|
487
500
|
isRoot: false,
|
|
488
|
-
|
|
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
|
+
}
|
package/src/modeling/types.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
|
|
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).
|
package/src/models/kinds.ts
CHANGED
|
@@ -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
|
|
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: [
|
|
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.
|
|
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: [
|
|
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.
|
|
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: [
|
|
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.
|
|
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:
|
|
218
|
-
|
|
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?.
|
|
84
|
+
assert.strictEqual(nestedB?.relativeCollectionPath, '/entitybs')
|
|
85
85
|
})
|
|
86
86
|
|
|
87
87
|
test('does not infinitely expose circular associations', ({ assert }) => {
|