@api-client/core 0.20.5 → 0.20.7
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/modeling/ApiModel.d.ts.map +1 -1
- package/build/src/modeling/ApiModel.js +9 -2
- package/build/src/modeling/ApiModel.js.map +1 -1
- package/build/src/modeling/DataDomain.d.ts +9 -3
- package/build/src/modeling/DataDomain.d.ts.map +1 -1
- package/build/src/modeling/DataDomain.js +25 -6
- package/build/src/modeling/DataDomain.js.map +1 -1
- package/build/src/modeling/DependentModel.d.ts +4 -0
- package/build/src/modeling/DependentModel.d.ts.map +1 -1
- package/build/src/modeling/DependentModel.js.map +1 -1
- package/build/src/modeling/DomainEntity.d.ts +20 -0
- package/build/src/modeling/DomainEntity.d.ts.map +1 -1
- package/build/src/modeling/DomainEntity.js +93 -0
- package/build/src/modeling/DomainEntity.js.map +1 -1
- package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
- package/build/src/modeling/ExposedEntity.js +55 -4
- package/build/src/modeling/ExposedEntity.js.map +1 -1
- package/build/src/modeling/RuntimeApiModel.d.ts +22 -0
- package/build/src/modeling/RuntimeApiModel.d.ts.map +1 -1
- package/build/src/modeling/RuntimeApiModel.js +108 -3
- package/build/src/modeling/RuntimeApiModel.js.map +1 -1
- package/build/src/modeling/generators/RuntimeModelGenerator.d.ts +15 -0
- package/build/src/modeling/generators/RuntimeModelGenerator.d.ts.map +1 -0
- package/build/src/modeling/generators/RuntimeModelGenerator.js +78 -0
- package/build/src/modeling/generators/RuntimeModelGenerator.js.map +1 -0
- package/build/src/modeling/helpers/endpointHelpers.d.ts +6 -1
- package/build/src/modeling/helpers/endpointHelpers.d.ts.map +1 -1
- package/build/src/modeling/helpers/endpointHelpers.js +43 -4
- package/build/src/modeling/helpers/endpointHelpers.js.map +1 -1
- package/build/src/modeling/types.d.ts +15 -0
- package/build/src/modeling/types.d.ts.map +1 -1
- package/build/src/modeling/types.js.map +1 -1
- package/build/src/modeling/validation/api_model_rules.d.ts.map +1 -1
- package/build/src/modeling/validation/api_model_rules.js +17 -0
- package/build/src/modeling/validation/api_model_rules.js.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/modeling/ApiModel.ts +8 -2
- package/src/modeling/DataDomain.ts +27 -7
- package/src/modeling/DependentModel.ts +4 -0
- package/src/modeling/DomainEntity.ts +100 -0
- package/src/modeling/ExposedEntity.ts +62 -4
- package/src/modeling/RuntimeApiModel.ts +131 -4
- package/src/modeling/generators/RuntimeModelGenerator.ts +79 -0
- package/src/modeling/helpers/endpointHelpers.ts +51 -4
- package/src/modeling/types.ts +16 -0
- package/src/modeling/validation/api_model_rules.ts +19 -0
- package/tests/unit/modeling/RuntimeApiModel.spec.ts +108 -3
- package/tests/unit/modeling/data_domain_entities.spec.ts +56 -0
- package/tests/unit/modeling/data_domain_serialization.spec.ts +11 -11
- package/tests/unit/modeling/domain_entity_associations.spec.ts +63 -0
- package/tests/unit/modeling/exposed_entity.spec.ts +95 -0
- package/tests/unit/modeling/generators/RuntimeModelGenerator.spec.ts +192 -0
- package/tests/unit/modeling/helpers/endpointHelpers.spec.ts +10 -3
- package/tests/unit/modeling/validation/api_model_rules.spec.ts +35 -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.20.
|
|
4
|
+
"version": "0.20.7",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./browser.js": {
|
|
@@ -105,8 +105,8 @@
|
|
|
105
105
|
"xpath": "^0.0.34"
|
|
106
106
|
},
|
|
107
107
|
"devDependencies": {
|
|
108
|
-
"@commitlint/cli": "^
|
|
109
|
-
"@commitlint/config-conventional": "^
|
|
108
|
+
"@commitlint/cli": "^21.0.0",
|
|
109
|
+
"@commitlint/config-conventional": "^21.0.0",
|
|
110
110
|
"@eslint/js": "^10.0.1",
|
|
111
111
|
"@japa/assert": "^4.0.1",
|
|
112
112
|
"@japa/runner": "^5.3.0",
|
package/src/modeling/ApiModel.ts
CHANGED
|
@@ -289,9 +289,15 @@ export class ApiModel extends DependentModel {
|
|
|
289
289
|
const init = ApiModel.createSchema(state)
|
|
290
290
|
const instances: DataDomain[] = []
|
|
291
291
|
if (domain instanceof DataDomain) {
|
|
292
|
-
|
|
292
|
+
if (domain.readOnly) {
|
|
293
|
+
instances.push(domain)
|
|
294
|
+
} else {
|
|
295
|
+
// eslint-disable-next-line no-console
|
|
296
|
+
console.warn(`Domain ${domain.key} is not read only, cloning it. This may have performance implications.`)
|
|
297
|
+
instances.push(domain.clone({ readOnly: true }))
|
|
298
|
+
}
|
|
293
299
|
} else if (typeof domain === 'object' && domain.kind === DataDomainKind) {
|
|
294
|
-
instances.push(new DataDomain(domain))
|
|
300
|
+
instances.push(new DataDomain(domain, undefined, { readOnly: true }))
|
|
295
301
|
} else if (domain) {
|
|
296
302
|
throw new Exception(`Invalid domain provided. Expected a DataDomain instance or schema.`, {
|
|
297
303
|
code: 'E_DOMAIN_INVALID',
|
|
@@ -10,13 +10,13 @@ import {
|
|
|
10
10
|
} from '../models/kinds.js'
|
|
11
11
|
import type {
|
|
12
12
|
DeserializationIssue,
|
|
13
|
-
DeserializationMode,
|
|
14
13
|
DomainGraphEdge,
|
|
15
14
|
DomainGraphNodeType,
|
|
16
15
|
SerializedGraph,
|
|
17
16
|
DomainSearchCriteria,
|
|
18
17
|
DomainSearchResult,
|
|
19
18
|
SearchableNodeType,
|
|
19
|
+
DataDomainOptions,
|
|
20
20
|
} from './types.js'
|
|
21
21
|
import { type DomainNamespaceSchema, DomainNamespace, type NamespaceOrderedItem } from './DomainNamespace.js'
|
|
22
22
|
import { type DomainModelSchema, DomainModel } from './DomainModel.js'
|
|
@@ -142,6 +142,11 @@ export class DataDomain extends DependentModel {
|
|
|
142
142
|
*/
|
|
143
143
|
#notifying = false
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Whether the DataDomain is in read-only mode, making it immutable and enabling caching operations.
|
|
147
|
+
*/
|
|
148
|
+
readonly readOnly: boolean
|
|
149
|
+
|
|
145
150
|
/**
|
|
146
151
|
* This is to keep it consistent with the domain elements.
|
|
147
152
|
*/
|
|
@@ -193,15 +198,22 @@ export class DataDomain extends DependentModel {
|
|
|
193
198
|
*
|
|
194
199
|
* @param state The previously serialized state of the graph.
|
|
195
200
|
* @param dependencies An array of foreign data domains to register with this domain.
|
|
201
|
+
* @param options Initialization options for the data domain.
|
|
196
202
|
*/
|
|
197
|
-
constructor(state?: Partial<DataDomainSchema>, dependencies: DomainDependency[] = [],
|
|
203
|
+
constructor(state?: Partial<DataDomainSchema>, dependencies: DomainDependency[] = [], options?: DataDomainOptions) {
|
|
198
204
|
const init = DataDomain.createSchema(state)
|
|
199
205
|
const instances: DataDomain[] = []
|
|
200
206
|
for (const dep of dependencies) {
|
|
201
207
|
if (dep instanceof DataDomain) {
|
|
202
|
-
|
|
208
|
+
if (dep.readOnly) {
|
|
209
|
+
instances.push(dep)
|
|
210
|
+
} else {
|
|
211
|
+
// eslint-disable-next-line no-console
|
|
212
|
+
console.warn(`Domain ${dep.key} is not read only, cloning it. This may have performance implications.`)
|
|
213
|
+
instances.push(dep.clone({ readOnly: true }))
|
|
214
|
+
}
|
|
203
215
|
} else if (typeof dep === 'object' && dep.kind === DataDomainKind && dep.key) {
|
|
204
|
-
const domain = new DataDomain(dep)
|
|
216
|
+
const domain = new DataDomain(dep, undefined, { readOnly: true })
|
|
205
217
|
instances.push(domain)
|
|
206
218
|
} else {
|
|
207
219
|
throw new Error(`Invalid foreign domain dependency: ${dep}`)
|
|
@@ -211,10 +223,13 @@ export class DataDomain extends DependentModel {
|
|
|
211
223
|
this.kind = init.kind
|
|
212
224
|
this.key = init.key
|
|
213
225
|
this.info = new Thing(init.info)
|
|
226
|
+
this.readOnly = options?.readOnly || false
|
|
227
|
+
|
|
228
|
+
const mode = options?.mode || 'strict'
|
|
214
229
|
const result = deserialize(this, {
|
|
215
230
|
json: init.graph,
|
|
216
231
|
dependencies: instances,
|
|
217
|
-
mode
|
|
232
|
+
mode,
|
|
218
233
|
})
|
|
219
234
|
this.graph = result.graph
|
|
220
235
|
if (result.issues.length > 0 && mode === 'lenient') {
|
|
@@ -242,12 +257,17 @@ export class DataDomain extends DependentModel {
|
|
|
242
257
|
* It serializes the current domain and its graph, then deserializes it into a new instance.
|
|
243
258
|
* It also deeply clones all foreign domain dependencies recursively.
|
|
244
259
|
*
|
|
260
|
+
* @param options Optional initialization options to override the cloned ones.
|
|
245
261
|
* @returns A new DataDomain instance that is a deep copy of this one.
|
|
246
262
|
*/
|
|
247
|
-
clone(): DataDomain {
|
|
263
|
+
clone(options?: DataDomainOptions): DataDomain {
|
|
248
264
|
const state = this.toJSON()
|
|
249
265
|
const dependencies = Array.from(this.dependencies.values()).map((dep) => dep.clone())
|
|
250
|
-
|
|
266
|
+
const opts: DataDomainOptions = {
|
|
267
|
+
readOnly: this.readOnly,
|
|
268
|
+
...options,
|
|
269
|
+
}
|
|
270
|
+
return new DataDomain(state, dependencies, opts)
|
|
251
271
|
}
|
|
252
272
|
|
|
253
273
|
/**
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { DataDomain, DataDomainSchema } from './DataDomain.js'
|
|
2
2
|
import type { ForeignDomainDependency } from './types.js'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* If a dependency is a domain instance it should always be in read-only mode.
|
|
6
|
+
* Otherwise a read-only clone will be created for performance reason.
|
|
7
|
+
*/
|
|
4
8
|
export type DomainDependency = DataDomain | DataDomainSchema
|
|
5
9
|
|
|
6
10
|
export interface DependentModelSchema {
|
|
@@ -127,6 +127,26 @@ export class DomainEntity extends DomainElement {
|
|
|
127
127
|
*/
|
|
128
128
|
override kind: typeof DomainEntityKind
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Cached inherited properties.
|
|
132
|
+
*/
|
|
133
|
+
#inheritedPropertiesCache?: DomainProperty[]
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cached parent entities arrays.
|
|
137
|
+
*/
|
|
138
|
+
#allParentsCache?: Record<'up' | 'down', DomainEntity[]>
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Cached inherited associations.
|
|
142
|
+
*/
|
|
143
|
+
#inheritedAssociationsCache?: DomainAssociation[]
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Cached semantics across the inheritance chain.
|
|
147
|
+
*/
|
|
148
|
+
#allSemanticsCache?: AppliedDataSemantic[]
|
|
149
|
+
|
|
130
150
|
/**
|
|
131
151
|
* The description of the domain entity.
|
|
132
152
|
*/
|
|
@@ -363,6 +383,9 @@ export class DomainEntity extends DomainElement {
|
|
|
363
383
|
* @returns The iterator over the list of all properties.
|
|
364
384
|
*/
|
|
365
385
|
withInheritedProperties(): MapIterator<DomainProperty> {
|
|
386
|
+
if (this.root.readOnly && this.#inheritedPropertiesCache) {
|
|
387
|
+
return this.#inheritedPropertiesCache.values()
|
|
388
|
+
}
|
|
366
389
|
const cache = new Map<string, DomainProperty>()
|
|
367
390
|
const parents = this.listAllParents('up')
|
|
368
391
|
for (const parent of parents) {
|
|
@@ -381,6 +404,9 @@ export class DomainEntity extends DomainElement {
|
|
|
381
404
|
}
|
|
382
405
|
cache.set(prop.info.name, prop)
|
|
383
406
|
}
|
|
407
|
+
if (this.root.readOnly) {
|
|
408
|
+
this.#inheritedPropertiesCache = Array.from(cache.values())
|
|
409
|
+
}
|
|
384
410
|
return cache.values()
|
|
385
411
|
}
|
|
386
412
|
|
|
@@ -438,6 +464,9 @@ export class DomainEntity extends DomainElement {
|
|
|
438
464
|
* @returns An array of parent `DomainEntity` instances, ordered according to the requested traversal direction.
|
|
439
465
|
*/
|
|
440
466
|
listAllParents(direction: 'up' | 'down' = 'up'): DomainEntity[] {
|
|
467
|
+
if (this.root.readOnly && this.#allParentsCache?.[direction]) {
|
|
468
|
+
return this.#allParentsCache[direction]
|
|
469
|
+
}
|
|
441
470
|
const visited = new Set<string>()
|
|
442
471
|
const parents: DomainEntity[] = []
|
|
443
472
|
const queue: DomainEntity[] = [...this.listParents()]
|
|
@@ -456,6 +485,13 @@ export class DomainEntity extends DomainElement {
|
|
|
456
485
|
parents.reverse()
|
|
457
486
|
}
|
|
458
487
|
|
|
488
|
+
if (this.root.readOnly) {
|
|
489
|
+
if (!this.#allParentsCache) {
|
|
490
|
+
this.#allParentsCache = {} as Record<'up' | 'down', DomainEntity[]>
|
|
491
|
+
}
|
|
492
|
+
this.#allParentsCache[direction] = parents
|
|
493
|
+
}
|
|
494
|
+
|
|
459
495
|
return parents
|
|
460
496
|
}
|
|
461
497
|
|
|
@@ -982,6 +1018,70 @@ export class DomainEntity extends DomainElement {
|
|
|
982
1018
|
return this.semantics.some((s) => s.id === semanticId)
|
|
983
1019
|
}
|
|
984
1020
|
|
|
1021
|
+
/**
|
|
1022
|
+
* Collects a list of all associations that affect this entity,
|
|
1023
|
+
* including all inherited from parents associations as well as
|
|
1024
|
+
* this entity's associations.
|
|
1025
|
+
*
|
|
1026
|
+
* The resulting list already accounts for an association override.
|
|
1027
|
+
* An association overrides another association if it is lower in the
|
|
1028
|
+
* inheritance chain. As such, an association defined on this entity
|
|
1029
|
+
* always overrides previously defined entities.
|
|
1030
|
+
* @returns The iterator over the list of all associations.
|
|
1031
|
+
*/
|
|
1032
|
+
withInheritedAssociations(): MapIterator<DomainAssociation> {
|
|
1033
|
+
if (this.root.readOnly && this.#inheritedAssociationsCache) {
|
|
1034
|
+
return this.#inheritedAssociationsCache.values()
|
|
1035
|
+
}
|
|
1036
|
+
const cache = new Map<string, DomainAssociation>()
|
|
1037
|
+
const parents = this.listAllParents('up')
|
|
1038
|
+
for (const parent of parents) {
|
|
1039
|
+
for (const assoc of parent.associations) {
|
|
1040
|
+
if (!assoc.info.name) {
|
|
1041
|
+
continue
|
|
1042
|
+
}
|
|
1043
|
+
cache.set(assoc.info.name, assoc)
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
for (const assoc of this.associations) {
|
|
1047
|
+
if (!assoc.info.name) {
|
|
1048
|
+
continue
|
|
1049
|
+
}
|
|
1050
|
+
cache.set(assoc.info.name, assoc)
|
|
1051
|
+
}
|
|
1052
|
+
if (this.root.readOnly) {
|
|
1053
|
+
this.#inheritedAssociationsCache = Array.from(cache.values())
|
|
1054
|
+
}
|
|
1055
|
+
return cache.values()
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Lists all semantics applied to this entity and its inherited parents.
|
|
1060
|
+
* Semantics from this entity overwrite those inherited from parents.
|
|
1061
|
+
*
|
|
1062
|
+
* @returns An array of applied semantics.
|
|
1063
|
+
*/
|
|
1064
|
+
listAllSemantics(): AppliedDataSemantic[] {
|
|
1065
|
+
if (this.root.readOnly && this.#allSemanticsCache) {
|
|
1066
|
+
return this.#allSemanticsCache
|
|
1067
|
+
}
|
|
1068
|
+
const semanticsMap = new Map<SemanticType, AppliedDataSemantic>()
|
|
1069
|
+
const parents = this.listAllParents('up')
|
|
1070
|
+
for (const parent of parents) {
|
|
1071
|
+
for (const semantic of parent.semantics) {
|
|
1072
|
+
semanticsMap.set(semantic.id, semantic)
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
for (const semantic of this.semantics) {
|
|
1076
|
+
semanticsMap.set(semantic.id, semantic)
|
|
1077
|
+
}
|
|
1078
|
+
const result = Array.from(semanticsMap.values())
|
|
1079
|
+
if (this.root.readOnly) {
|
|
1080
|
+
this.#allSemanticsCache = result
|
|
1081
|
+
}
|
|
1082
|
+
return result
|
|
1083
|
+
}
|
|
1084
|
+
|
|
985
1085
|
/**
|
|
986
1086
|
* Generates a unique name by appending a number to the base name.
|
|
987
1087
|
* @param baseName The base name to make unique.
|
|
@@ -5,7 +5,7 @@ import { nanoid } from '../nanoid.js'
|
|
|
5
5
|
import { Action } from './actions/Action.js'
|
|
6
6
|
import { ActionKind, type ApiActionSchema, createActionFromKind, restoreAction } from './actions/index.js'
|
|
7
7
|
import type { ApiModel } from './ApiModel.js'
|
|
8
|
-
import { ensureLeadingSlash, joinPaths } from './helpers/endpointHelpers.js'
|
|
8
|
+
import { ensureLeadingSlash, joinPaths, paramNameFor } from './helpers/endpointHelpers.js'
|
|
9
9
|
import { AccessRule } from './rules/AccessRule.js'
|
|
10
10
|
import { type RateLimitRule, restoreAccessRule } from './rules/index.js'
|
|
11
11
|
import { RateLimitingConfiguration } from './rules/RateLimitingConfiguration.js'
|
|
@@ -302,16 +302,33 @@ export class ExposedEntity extends EventTarget {
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
// Preserve current parameter name if present, otherwise default to
|
|
306
|
-
let param = '
|
|
307
|
-
if (this.resourcePath) {
|
|
305
|
+
// Preserve current parameter name if present, otherwise default to semantic param name
|
|
306
|
+
let param = ''
|
|
307
|
+
if (this.resourcePath && this.resourcePath !== '/') {
|
|
308
308
|
const curSegments = this.resourcePath.split('/').filter(Boolean)
|
|
309
309
|
const maybeParam = curSegments[1]
|
|
310
310
|
if (maybeParam && /^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(maybeParam)) {
|
|
311
311
|
param = maybeParam
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
|
+
|
|
315
|
+
if (!param) {
|
|
316
|
+
const entityObj = this.api.domain?.findEntity(this.entity.key, this.entity.domain)
|
|
317
|
+
if (!entityObj || !entityObj.info.name) {
|
|
318
|
+
// Never fall back to a default like '{id}' here. Silent param generation failures must throw
|
|
319
|
+
// to prevent runtime/OAS collisions.
|
|
320
|
+
throw new Exception(
|
|
321
|
+
'Cannot generate a semantic parameter name because the associated entity or its name is missing.',
|
|
322
|
+
{
|
|
323
|
+
code: 'E_ENTITY_NOT_FOUND',
|
|
324
|
+
help: 'Ensure the exposed entity points to a valid domain entity with a defined name.',
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
param = `{${paramNameFor(entityObj.info.name)}}`
|
|
329
|
+
}
|
|
314
330
|
const nextResource = `${normalizedCollection}/${param}`
|
|
331
|
+
this.#validateParameterCollisions(nextResource)
|
|
315
332
|
this.collectionPath = normalizedCollection
|
|
316
333
|
this.resourcePath = nextResource
|
|
317
334
|
// rely on ApiModel.exposes deep observation to notify on property sets
|
|
@@ -372,6 +389,7 @@ export class ExposedEntity extends EventTarget {
|
|
|
372
389
|
})
|
|
373
390
|
}
|
|
374
391
|
if (this.resourcePath !== cleaned) {
|
|
392
|
+
this.#validateParameterCollisions(`/${s1}/${s2}`)
|
|
375
393
|
this.resourcePath = `/${s1}/${s2}`
|
|
376
394
|
}
|
|
377
395
|
return
|
|
@@ -399,10 +417,50 @@ export class ExposedEntity extends EventTarget {
|
|
|
399
417
|
}
|
|
400
418
|
|
|
401
419
|
if (this.resourcePath !== cleaned) {
|
|
420
|
+
this.#validateParameterCollisions(`/${segments[0]}`)
|
|
402
421
|
this.resourcePath = `/${segments[0]}`
|
|
403
422
|
}
|
|
404
423
|
}
|
|
405
424
|
|
|
425
|
+
#checkDuplicatesInPaths(paths: string[]) {
|
|
426
|
+
const absolute = paths.join('/')
|
|
427
|
+
const params = [...absolute.matchAll(/\{([^}]+)\}/g)].map((m) => m[1])
|
|
428
|
+
const seen = new Set<string>()
|
|
429
|
+
for (const p of params) {
|
|
430
|
+
if (seen.has(p)) {
|
|
431
|
+
throw new Exception(`Duplicate path parameter "{${p}}" detected in branch hierarchy.`, {
|
|
432
|
+
code: 'E_PATH_PARAM_COLLISION',
|
|
433
|
+
help: 'Change the parameter name in either this resource or its ancestor to ensure unique parameter names.',
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
seen.add(p)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#validateDescendantCollisions(parentKey: string, ancestorPaths: string[]) {
|
|
441
|
+
for (const exposure of this.api.exposes.values()) {
|
|
442
|
+
if (exposure.parent?.key === parentKey) {
|
|
443
|
+
const currentPaths = [...ancestorPaths, exposure.resourcePath]
|
|
444
|
+
this.#checkDuplicatesInPaths(currentPaths)
|
|
445
|
+
this.#validateDescendantCollisions(exposure.key, currentPaths)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#validateParameterCollisions(tempResourcePath: string) {
|
|
451
|
+
const paths: string[] = [tempResourcePath]
|
|
452
|
+
let parentKey = this.parent?.key
|
|
453
|
+
while (parentKey) {
|
|
454
|
+
const parent = this.api.exposes.get(parentKey)
|
|
455
|
+
if (!parent) break
|
|
456
|
+
paths.unshift(parent.resourcePath)
|
|
457
|
+
parentKey = parent.parent?.key
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
this.#checkDuplicatesInPaths(paths)
|
|
461
|
+
this.#validateDescendantCollisions(this.key, paths)
|
|
462
|
+
}
|
|
463
|
+
|
|
406
464
|
/**
|
|
407
465
|
* Computes the absolute path for this exposure's resource endpoint by
|
|
408
466
|
* walking up the exposure tree using `parent.key` until reaching a root exposure.
|
|
@@ -4,9 +4,10 @@ import type { DataDomainSchema } from './DataDomain.js'
|
|
|
4
4
|
import type { ActionKind } from './actions/index.js'
|
|
5
5
|
import type { ExposedEntity } from './ExposedEntity.js'
|
|
6
6
|
import type { Action } from './actions/Action.js'
|
|
7
|
-
import { SemanticType } from './Semantics.js'
|
|
8
7
|
import type { DomainEntity } from './DomainEntity.js'
|
|
9
8
|
import type { DomainProperty } from './DomainProperty.js'
|
|
9
|
+
import type { DomainAssociation } from './DomainAssociation.js'
|
|
10
|
+
import { SemanticType } from './Semantics.js'
|
|
10
11
|
import { AccessRule, AccessRuleExecutionPhase } from './rules/AccessRule.js'
|
|
11
12
|
import { Exception } from '../exceptions/exception.js'
|
|
12
13
|
|
|
@@ -108,16 +109,76 @@ export class RuntimeApiModel extends ApiModel {
|
|
|
108
109
|
return new Map(this.#sessionProperties)
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Cached properties that have at least one semantic, grouped by their entity.
|
|
114
|
+
*/
|
|
115
|
+
readonly #semanticPropertiesCache = new Map<DomainEntity, DomainProperty[]>()
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Cached properties that have at least one semantic, grouped by their entity and then by SemanticType.
|
|
119
|
+
*/
|
|
120
|
+
readonly #propertiesBySemanticCache = new Map<DomainEntity, Map<SemanticType, DomainProperty[]>>()
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Cached associations that have at least one semantic, grouped by their entity.
|
|
124
|
+
*/
|
|
125
|
+
readonly #semanticAssociationsCache = new Map<DomainEntity, DomainAssociation[]>()
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Cached associations that have at least one semantic, grouped by their entity and then by SemanticType.
|
|
129
|
+
*/
|
|
130
|
+
readonly #associationsBySemanticCache = new Map<DomainEntity, Map<SemanticType, DomainAssociation[]>>()
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Returns a readonly map of properties with semantics.
|
|
134
|
+
* Note, it creates a copy of the cached map to prevent modification of the internal state.
|
|
135
|
+
*/
|
|
136
|
+
public get semanticPropertiesCache(): ReadonlyMap<DomainEntity, DomainProperty[]> {
|
|
137
|
+
return new Map(this.#semanticPropertiesCache)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Returns properties for a specific entity that have a specific semantic type.
|
|
142
|
+
* Provides O(1) lookup using the precomputed cache.
|
|
143
|
+
*/
|
|
144
|
+
public getPropertiesBySemantic(entity: DomainEntity, semanticType: SemanticType): DomainProperty[] {
|
|
145
|
+
const semanticMap = this.#propertiesBySemanticCache.get(entity)
|
|
146
|
+
return semanticMap?.get(semanticType) || []
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Returns a readonly map of associations with semantics.
|
|
151
|
+
* Note, it creates a copy of the cached map to prevent modification of the internal state.
|
|
152
|
+
*/
|
|
153
|
+
public get semanticAssociationsCache(): ReadonlyMap<DomainEntity, DomainAssociation[]> {
|
|
154
|
+
return new Map(this.#semanticAssociationsCache)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Returns associations for a specific entity that have a specific semantic type.
|
|
159
|
+
* Provides O(1) lookup using the precomputed cache.
|
|
160
|
+
*/
|
|
161
|
+
public getAssociationsBySemantic(entity: DomainEntity, semanticType: SemanticType): DomainAssociation[] {
|
|
162
|
+
const semanticMap = this.#associationsBySemanticCache.get(entity)
|
|
163
|
+
return semanticMap?.get(semanticType) || []
|
|
164
|
+
}
|
|
165
|
+
|
|
111
166
|
constructor(schema: RuntimeApiModelSchema, domainSchema: DataDomainSchema) {
|
|
112
167
|
super(schema, domainSchema)
|
|
113
168
|
|
|
114
|
-
if (schema.routingMap) {
|
|
115
|
-
|
|
169
|
+
if (!schema.routingMap) {
|
|
170
|
+
throw new Exception('The runtime API model must have a routing map.', {
|
|
171
|
+
code: 'E_MISSING_ROUTING_MAP',
|
|
172
|
+
help: 'Ensure that the routingMap property is defined when creating a RuntimeApiModel.',
|
|
173
|
+
})
|
|
116
174
|
}
|
|
117
175
|
|
|
176
|
+
this.#initializeRouter(schema.routingMap)
|
|
177
|
+
|
|
118
178
|
this.#cacheEntitiesAndProperties()
|
|
119
179
|
this.#precomputeAccessRules()
|
|
120
180
|
this.#precomputeSessionProperties()
|
|
181
|
+
this.#precomputeSemanticProperties()
|
|
121
182
|
}
|
|
122
183
|
|
|
123
184
|
#precomputeSessionProperties(): void {
|
|
@@ -133,6 +194,72 @@ export class RuntimeApiModel extends ApiModel {
|
|
|
133
194
|
}
|
|
134
195
|
}
|
|
135
196
|
|
|
197
|
+
#precomputeSemanticProperties(): void {
|
|
198
|
+
if (!this.domain) {
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const processEntity = (entity: DomainEntity) => {
|
|
203
|
+
if (this.#semanticPropertiesCache.has(entity)) {
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const propertiesWithSemantics: DomainProperty[] = []
|
|
208
|
+
const semanticPropsMap = new Map<SemanticType, DomainProperty[]>()
|
|
209
|
+
|
|
210
|
+
for (const prop of entity.withInheritedProperties()) {
|
|
211
|
+
if (Array.isArray(prop.semantics) && prop.semantics.length > 0) {
|
|
212
|
+
propertiesWithSemantics.push(prop)
|
|
213
|
+
|
|
214
|
+
for (const semantic of prop.semantics) {
|
|
215
|
+
let propsForSemantic = semanticPropsMap.get(semantic.id)
|
|
216
|
+
if (!propsForSemantic) {
|
|
217
|
+
propsForSemantic = []
|
|
218
|
+
semanticPropsMap.set(semantic.id, propsForSemantic)
|
|
219
|
+
}
|
|
220
|
+
propsForSemantic.push(prop)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (propertiesWithSemantics.length > 0) {
|
|
226
|
+
this.#semanticPropertiesCache.set(entity, propertiesWithSemantics)
|
|
227
|
+
this.#propertiesBySemanticCache.set(entity, semanticPropsMap)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const associationsWithSemantics: DomainAssociation[] = []
|
|
231
|
+
const semanticAssocMap = new Map<SemanticType, DomainAssociation[]>()
|
|
232
|
+
|
|
233
|
+
for (const assoc of entity.withInheritedAssociations()) {
|
|
234
|
+
if (Array.isArray(assoc.semantics) && assoc.semantics.length > 0) {
|
|
235
|
+
associationsWithSemantics.push(assoc)
|
|
236
|
+
|
|
237
|
+
for (const semantic of assoc.semantics) {
|
|
238
|
+
let assocForSemantic = semanticAssocMap.get(semantic.id)
|
|
239
|
+
if (!assocForSemantic) {
|
|
240
|
+
assocForSemantic = []
|
|
241
|
+
semanticAssocMap.set(semantic.id, assocForSemantic)
|
|
242
|
+
}
|
|
243
|
+
assocForSemantic.push(assoc)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (associationsWithSemantics.length > 0) {
|
|
249
|
+
this.#semanticAssociationsCache.set(entity, associationsWithSemantics)
|
|
250
|
+
this.#associationsBySemanticCache.set(entity, semanticAssocMap)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const entity of this.domain.listEntities()) {
|
|
255
|
+
processEntity(entity)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const entity of this.domain.listAllForeignEntities()) {
|
|
259
|
+
processEntity(entity)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
136
263
|
#precomputeAccessRules(): void {
|
|
137
264
|
for (const entity of this.exposes.values()) {
|
|
138
265
|
for (const action of entity.actions) {
|
|
@@ -228,7 +355,7 @@ export class RuntimeApiModel extends ApiModel {
|
|
|
228
355
|
|
|
229
356
|
this.cachedEntities.user = userEntity
|
|
230
357
|
|
|
231
|
-
for (const prop of userEntity.
|
|
358
|
+
for (const prop of userEntity.withInheritedProperties()) {
|
|
232
359
|
if (prop.hasSemantic(SemanticType.Username)) {
|
|
233
360
|
this.cachedProperties.username = prop
|
|
234
361
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type ActionKind, type UpdateAction } from '../actions/index.js'
|
|
2
|
+
import { ApiModel, type ApiModelSchema } from '../ApiModel.js'
|
|
3
|
+
import { type RouteDefinition, type RuntimeApiModelSchema } from '../RuntimeApiModel.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A class that takes the API model and generates a runtime-optimized API model.
|
|
7
|
+
*
|
|
8
|
+
* Note, the API model must be already validated before passing it to this generator.
|
|
9
|
+
* This class doesn't perform any validation, but it will throw an exception if the API model is invalid.
|
|
10
|
+
*/
|
|
11
|
+
export class RuntimeModelGenerator {
|
|
12
|
+
#apiModel: ApiModel
|
|
13
|
+
#apiSchema: ApiModelSchema
|
|
14
|
+
#routingMap: Record<string, RouteDefinition[]> = {}
|
|
15
|
+
|
|
16
|
+
constructor(input: ApiModel | ApiModelSchema) {
|
|
17
|
+
if (input instanceof ApiModel) {
|
|
18
|
+
this.#apiModel = input
|
|
19
|
+
this.#apiSchema = input.toJSON()
|
|
20
|
+
} else {
|
|
21
|
+
this.#apiModel = new ApiModel(input)
|
|
22
|
+
this.#apiSchema = input
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public async generate(): Promise<RuntimeApiModelSchema> {
|
|
27
|
+
if (!this.#apiModel) {
|
|
28
|
+
throw new Error('API model is not defined')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const expose of this.#apiModel.exposes.values()) {
|
|
32
|
+
const colPath = expose.getAbsoluteCollectionPath()
|
|
33
|
+
const resPath = expose.getAbsoluteResourcePath()
|
|
34
|
+
for (const action of expose.actions) {
|
|
35
|
+
switch (action.kind) {
|
|
36
|
+
case 'list':
|
|
37
|
+
if (colPath) this.addRoute('GET', colPath, expose.key, action.kind)
|
|
38
|
+
break
|
|
39
|
+
case 'create':
|
|
40
|
+
if (colPath) this.addRoute('POST', colPath, expose.key, action.kind)
|
|
41
|
+
break
|
|
42
|
+
case 'search':
|
|
43
|
+
if (colPath) this.addRoute('POST', `${colPath}/search`, expose.key, action.kind)
|
|
44
|
+
break
|
|
45
|
+
case 'read':
|
|
46
|
+
if (resPath) this.addRoute('GET', resPath, expose.key, action.kind)
|
|
47
|
+
break
|
|
48
|
+
case 'update':
|
|
49
|
+
if (resPath) {
|
|
50
|
+
const updateAction = action as UpdateAction
|
|
51
|
+
for (const method of updateAction.allowedMethods) {
|
|
52
|
+
this.addRoute(method, resPath, expose.key, action.kind)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
break
|
|
56
|
+
case 'delete':
|
|
57
|
+
if (resPath) this.addRoute('DELETE', resPath, expose.key, action.kind)
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...this.#apiSchema,
|
|
65
|
+
routingMap: this.#routingMap,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private addRoute(method: string, path: string, exposedEntityKey: string, actionKind: ActionKind): void {
|
|
70
|
+
const upperMethod = method.toUpperCase()
|
|
71
|
+
if (!this.#routingMap[upperMethod]) {
|
|
72
|
+
this.#routingMap[upperMethod] = []
|
|
73
|
+
}
|
|
74
|
+
this.#routingMap[upperMethod].push({
|
|
75
|
+
path,
|
|
76
|
+
lookup: { exposedEntityKey, actionKind },
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|