@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.
- 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 +4 -3
- package/build/src/modeling/ApiModel.d.ts.map +1 -1
- package/build/src/modeling/ApiModel.js +25 -22
- package/build/src/modeling/ApiModel.js.map +1 -1
- package/build/src/modeling/ExposedEntity.d.ts +124 -0
- package/build/src/modeling/ExposedEntity.d.ts.map +1 -0
- package/build/src/modeling/ExposedEntity.js +364 -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 +12 -15
- 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/data/models/example-generator-api.json +6 -6
- package/package.json +1 -1
- package/src/modeling/ApiModel.ts +22 -26
- package/src/modeling/ExposedEntity.ts +358 -0
- package/src/modeling/helpers/endpointHelpers.ts +22 -0
- package/src/modeling/types.ts +12 -16
- 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 +2 -4
- package/tests/unit/modeling/api_model_remove_entity.spec.ts +1 -2
- package/tests/unit/modeling/exposed_entity.spec.ts +155 -0
|
@@ -42068,10 +42068,10 @@
|
|
|
42068
42068
|
"@id": "#194"
|
|
42069
42069
|
},
|
|
42070
42070
|
{
|
|
42071
|
-
"@id": "#
|
|
42071
|
+
"@id": "#197"
|
|
42072
42072
|
},
|
|
42073
42073
|
{
|
|
42074
|
-
"@id": "#
|
|
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": "
|
|
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": "
|
|
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)-(
|
|
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)-(
|
|
44774
|
+
"sourcemaps:value": "[(1,0)-(5,0)]"
|
|
44775
44775
|
},
|
|
44776
44776
|
{
|
|
44777
44777
|
"@id": "#205/source-map/lexical/element_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
|
|
@@ -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 =
|
|
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 }
|
|
@@ -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
|
|
408
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|
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,28 +451,22 @@ export interface ExposedEntity {
|
|
|
449
451
|
*/
|
|
450
452
|
entity: AssociationTarget
|
|
451
453
|
/**
|
|
452
|
-
*
|
|
453
|
-
*
|
|
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
|
-
|
|
456
|
-
|
|
458
|
+
hasCollection: boolean
|
|
457
459
|
/**
|
|
458
|
-
*
|
|
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
|
-
|
|
463
|
+
collectionPath?: string
|
|
468
464
|
|
|
469
465
|
/**
|
|
470
|
-
*
|
|
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
|
-
|
|
469
|
+
resourcePath: string
|
|
474
470
|
|
|
475
471
|
/**
|
|
476
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'
|