@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
|
@@ -1,7 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { Exception } from '../../exceptions/exception.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates a semantic parameter name for an entity.
|
|
5
|
+
* It converts snake_case, kebab-case, or PascalCase to camelCase and appends 'Id'.
|
|
6
|
+
* @param entityName The entity name (e.g. from entity.info.name)
|
|
7
|
+
*/
|
|
8
|
+
export function paramNameFor(entityName: string): string {
|
|
9
|
+
if (!entityName) {
|
|
10
|
+
throw new Exception('Cannot generate parameter name from an empty string.', {
|
|
11
|
+
code: 'E_INVALID_PARAM_NAME',
|
|
12
|
+
help: 'The entity name used for path parameter generation is empty.',
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Add a space between lowercase and uppercase letters to handle camelCase/PascalCase
|
|
17
|
+
let withSpaces = entityName.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
18
|
+
// Also split consecutive uppercase letters if followed by a lowercase (e.g. XMLHttp -> XML Http)
|
|
19
|
+
withSpaces = withSpaces.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
20
|
+
|
|
21
|
+
// Replace all non-alphanumeric characters with spaces
|
|
22
|
+
const cleanStr = withSpaces.replace(/[^a-zA-Z0-9]/g, ' ')
|
|
23
|
+
|
|
24
|
+
// Split by spaces and filter out empty strings
|
|
25
|
+
const words = cleanStr.split(/\s+/).filter(Boolean)
|
|
26
|
+
|
|
27
|
+
if (words.length === 0) {
|
|
28
|
+
throw new Exception(`Cannot generate a valid parameter name from "${entityName}".`, {
|
|
29
|
+
code: 'E_INVALID_PARAM_NAME',
|
|
30
|
+
help: 'The entity name must contain alphanumeric characters to generate a path parameter.',
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// CamelCase: lower first word, Capitalize subsequent words
|
|
35
|
+
const camelCased = words
|
|
36
|
+
.map((word, index) => {
|
|
37
|
+
const lower = word.toLowerCase()
|
|
38
|
+
if (index === 0) {
|
|
39
|
+
return lower
|
|
40
|
+
}
|
|
41
|
+
return lower.charAt(0).toUpperCase() + lower.slice(1)
|
|
42
|
+
})
|
|
43
|
+
.join('')
|
|
44
|
+
|
|
45
|
+
// Ensure it starts with a letter or underscore
|
|
46
|
+
let param = camelCased
|
|
47
|
+
if (/^[0-9]/.test(param)) {
|
|
48
|
+
param = '_' + param
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return `${param}Id`
|
|
5
52
|
}
|
|
6
53
|
|
|
7
54
|
/**
|
package/src/modeling/types.ts
CHANGED
|
@@ -974,3 +974,19 @@ export interface AssociationSchema {
|
|
|
974
974
|
*/
|
|
975
975
|
unionType?: 'allOf' | 'anyOf' | 'oneOf' | 'not'
|
|
976
976
|
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Options used when initializing a DataDomain.
|
|
980
|
+
*/
|
|
981
|
+
export interface DataDomainOptions {
|
|
982
|
+
/**
|
|
983
|
+
* The mode used during deserialization to control error tolerance.
|
|
984
|
+
* 'strict' throws errors on invalid references. 'lenient' collects them.
|
|
985
|
+
*/
|
|
986
|
+
mode?: DeserializationMode
|
|
987
|
+
/**
|
|
988
|
+
* Whether the DataDomain is in read-only mode, which makes it immutable
|
|
989
|
+
* and enables internal caching optimizations.
|
|
990
|
+
*/
|
|
991
|
+
readOnly?: boolean
|
|
992
|
+
}
|
|
@@ -581,6 +581,25 @@ export function validateExposedEntity(exposure: ExposedEntity, apiModel: ApiMode
|
|
|
581
581
|
}
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
+
// Branch Path Parameter Collision
|
|
585
|
+
const absoluteResourcePath = exposure.getAbsoluteResourcePath()
|
|
586
|
+
if (absoluteResourcePath) {
|
|
587
|
+
const params = [...absoluteResourcePath.matchAll(/\{([^}]+)\}/g)].map((m) => m[1])
|
|
588
|
+
const uniqueParams = new Set(params)
|
|
589
|
+
if (uniqueParams.size !== params.length) {
|
|
590
|
+
const duplicates = params.filter((item, index) => params.indexOf(item) !== index)
|
|
591
|
+
const duplicateParam = duplicates[0]
|
|
592
|
+
issues.push({
|
|
593
|
+
code: createCode('EXPOSURE', 'DUPLICATE_PATH_PARAMETER'),
|
|
594
|
+
message: `[${entityName}]: The path parameter "{${duplicateParam}}" is duplicated in the resource path hierarchy.`,
|
|
595
|
+
suggestion:
|
|
596
|
+
'Change the parameter name in either this resource or its ancestor to ensure unique parameter names.',
|
|
597
|
+
severity: 'error',
|
|
598
|
+
context: { ...context, property: 'resourcePath' },
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
584
603
|
// Minimum Actions
|
|
585
604
|
if (!exposure.actions || exposure.actions.length === 0) {
|
|
586
605
|
issues.push({
|
|
@@ -34,6 +34,20 @@ test.group('RuntimeApiModel', () => {
|
|
|
34
34
|
assert.deepEqual(runtimeModel.toJSON().routingMap, schema.routingMap)
|
|
35
35
|
}).tags(['@modeling', '@runtime'])
|
|
36
36
|
|
|
37
|
+
test('throws error if routingMap is missing', ({ assert }) => {
|
|
38
|
+
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
39
|
+
const schema = {
|
|
40
|
+
key: 'api-1',
|
|
41
|
+
info: { name: 'Test API' },
|
|
42
|
+
// routingMap is intentionally missing
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
assert.throws(
|
|
46
|
+
() => new RuntimeApiModel(schema as any, domain.toJSON()),
|
|
47
|
+
'The runtime API model must have a routing map.'
|
|
48
|
+
)
|
|
49
|
+
}).tags(['@modeling', '@runtime'])
|
|
50
|
+
|
|
37
51
|
test('resolves path with matchit', ({ assert }) => {
|
|
38
52
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
39
53
|
const model = domain.addModel({ info: { name: 'Test Model' } })
|
|
@@ -179,6 +193,97 @@ test.group('RuntimeApiModel', () => {
|
|
|
179
193
|
assert.equal(runtimeModel.sessionProperties.get(prop2.key)?.key, prop2.key)
|
|
180
194
|
}).tags(['@modeling', '@runtime'])
|
|
181
195
|
|
|
196
|
+
test('precomputes semanticPropertiesCache correctly', ({ assert }) => {
|
|
197
|
+
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
198
|
+
const modelNode = domain.addModel({ key: 'users' })
|
|
199
|
+
const userEntity = modelNode.addEntity({ info: { name: 'User' } })
|
|
200
|
+
const prop1 = userEntity.addProperty({ info: { name: 'Prop1' }, semantics: [{ id: SemanticType.Password }] })
|
|
201
|
+
const prop2 = userEntity.addProperty({ info: { name: 'Prop2' }, semantics: [{ id: SemanticType.Username }] })
|
|
202
|
+
const prop3 = userEntity.addProperty({ info: { name: 'Prop3' } })
|
|
203
|
+
|
|
204
|
+
const schema = {
|
|
205
|
+
key: 'api-1',
|
|
206
|
+
info: { name: 'Test API' },
|
|
207
|
+
exposes: [
|
|
208
|
+
{
|
|
209
|
+
key: 'expose-1',
|
|
210
|
+
entity: { key: userEntity.key, domain: domain.key },
|
|
211
|
+
actions: [],
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
routingMap: {},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
|
|
218
|
+
|
|
219
|
+
assert.equal(runtimeModel.semanticPropertiesCache.size, 1)
|
|
220
|
+
|
|
221
|
+
// Convert keys iterator to array to find the matched entity reference
|
|
222
|
+
const cachedEntities = Array.from(runtimeModel.semanticPropertiesCache.keys())
|
|
223
|
+
assert.equal(cachedEntities.length, 1)
|
|
224
|
+
assert.equal(cachedEntities[0].key, userEntity.key)
|
|
225
|
+
|
|
226
|
+
const cachedProps = runtimeModel.semanticPropertiesCache.get(cachedEntities[0])
|
|
227
|
+
assert.isDefined(cachedProps)
|
|
228
|
+
assert.equal(cachedProps?.length, 2)
|
|
229
|
+
assert.equal(cachedProps?.[0].key, prop1.key)
|
|
230
|
+
assert.equal(cachedProps?.[1].key, prop2.key)
|
|
231
|
+
}).tags(['@modeling', '@runtime'])
|
|
232
|
+
|
|
233
|
+
test('precomputes semanticAssociationsCache correctly', ({ assert }) => {
|
|
234
|
+
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
235
|
+
const modelNode = domain.addModel({ key: 'users' })
|
|
236
|
+
const userEntity = modelNode.addEntity({ info: { name: 'User' } })
|
|
237
|
+
|
|
238
|
+
const assoc1 = userEntity.addAssociation({ info: { name: 'Assoc1' }, targets: [] })
|
|
239
|
+
assoc1.semantics = [{ id: SemanticType.Tags }]
|
|
240
|
+
|
|
241
|
+
const assoc2 = userEntity.addAssociation({ info: { name: 'Assoc2' }, targets: [] })
|
|
242
|
+
assoc2.semantics = [{ id: SemanticType.Categories }]
|
|
243
|
+
|
|
244
|
+
const assoc3 = userEntity.addAssociation({ info: { name: 'Assoc3' }, targets: [] })
|
|
245
|
+
|
|
246
|
+
const schema = {
|
|
247
|
+
key: 'api-1',
|
|
248
|
+
info: { name: 'Test API' },
|
|
249
|
+
exposes: [
|
|
250
|
+
{
|
|
251
|
+
key: 'expose-1',
|
|
252
|
+
entity: { key: userEntity.key, domain: domain.key },
|
|
253
|
+
actions: [],
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
routingMap: {},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const runtimeModel = new RuntimeApiModel(schema as any, domain.toJSON())
|
|
260
|
+
|
|
261
|
+
assert.equal(runtimeModel.semanticAssociationsCache.size, 1)
|
|
262
|
+
|
|
263
|
+
// Convert keys iterator to array to find the matched entity reference
|
|
264
|
+
const cachedEntities = Array.from(runtimeModel.semanticAssociationsCache.keys())
|
|
265
|
+
assert.equal(cachedEntities.length, 1)
|
|
266
|
+
assert.equal(cachedEntities[0].key, userEntity.key)
|
|
267
|
+
|
|
268
|
+
const cachedAssocs = runtimeModel.semanticAssociationsCache.get(cachedEntities[0])
|
|
269
|
+
assert.isDefined(cachedAssocs)
|
|
270
|
+
assert.equal(cachedAssocs?.length, 2)
|
|
271
|
+
assert.equal(cachedAssocs?.[0].key, assoc1.key)
|
|
272
|
+
assert.equal(cachedAssocs?.[1].key, assoc2.key)
|
|
273
|
+
|
|
274
|
+
// Test getAssociationsBySemantic
|
|
275
|
+
const tagsAssocs = runtimeModel.getAssociationsBySemantic(cachedEntities[0], SemanticType.Tags)
|
|
276
|
+
assert.lengthOf(tagsAssocs, 1)
|
|
277
|
+
assert.equal(tagsAssocs[0].key, assoc1.key)
|
|
278
|
+
|
|
279
|
+
const categoriesAssocs = runtimeModel.getAssociationsBySemantic(cachedEntities[0], SemanticType.Categories)
|
|
280
|
+
assert.lengthOf(categoriesAssocs, 1)
|
|
281
|
+
assert.equal(categoriesAssocs[0].key, assoc2.key)
|
|
282
|
+
|
|
283
|
+
const emptyAssocs = runtimeModel.getAssociationsBySemantic(cachedEntities[0], SemanticType.ResourceOwnerIdentifier)
|
|
284
|
+
assert.lengthOf(emptyAssocs, 0)
|
|
285
|
+
}).tags(['@modeling', '@runtime', '@semantic'])
|
|
286
|
+
|
|
182
287
|
test('throws exception if session property is missing from domain', ({ assert }) => {
|
|
183
288
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
184
289
|
const modelNode = domain.addModel({ key: 'users' })
|
|
@@ -220,7 +325,7 @@ test.group('RuntimeApiModel', () => {
|
|
|
220
325
|
{ key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
|
|
221
326
|
domain
|
|
222
327
|
)
|
|
223
|
-
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
328
|
+
const runtimeModel = new RuntimeApiModel({ ...baseModel.toJSON(), routingMap: {} } as any, domain.toJSON())
|
|
224
329
|
|
|
225
330
|
const mockAction = {
|
|
226
331
|
exposure: { accessRule: [], parent: undefined },
|
|
@@ -236,7 +341,7 @@ test.group('RuntimeApiModel', () => {
|
|
|
236
341
|
test('getEffectiveRules - defaults to preFetch phase if none specified', async ({ assert }) => {
|
|
237
342
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
238
343
|
const baseModel = new ApiModel({ key: 'api-1' }, domain)
|
|
239
|
-
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
344
|
+
const runtimeModel = new RuntimeApiModel({ ...baseModel.toJSON(), routingMap: {} } as any, domain.toJSON())
|
|
240
345
|
|
|
241
346
|
const mockAction = {
|
|
242
347
|
exposure: {
|
|
@@ -254,7 +359,7 @@ test.group('RuntimeApiModel', () => {
|
|
|
254
359
|
test('getEffectiveRules - orders from most specific to most general', async ({ assert }) => {
|
|
255
360
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
256
361
|
const baseModel = new ApiModel({ accessRule: [{ type: 'allowAuthenticated' }] }, domain)
|
|
257
|
-
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
362
|
+
const runtimeModel = new RuntimeApiModel({ ...baseModel.toJSON(), routingMap: {} } as any, domain.toJSON())
|
|
258
363
|
|
|
259
364
|
const mockAction = {
|
|
260
365
|
exposure: {
|
|
@@ -575,3 +575,59 @@ test.group('DataDomain.moveEntity()', () => {
|
|
|
575
575
|
assert.isTrue(dataDomain.graph.hasNode(assoc.key))
|
|
576
576
|
}).tags(['@modeling', '@entity', '@move'])
|
|
577
577
|
})
|
|
578
|
+
|
|
579
|
+
test.group('DomainEntity.listAllSemantics()', () => {
|
|
580
|
+
test('returns semantics from entity and its parents', ({ assert }) => {
|
|
581
|
+
const dataDomain = new DataDomain()
|
|
582
|
+
const model = dataDomain.addModel()
|
|
583
|
+
const parentEntity = model.addEntity({ key: 'parent' })
|
|
584
|
+
const childEntity = model.addEntity({ key: 'child' })
|
|
585
|
+
|
|
586
|
+
parentEntity.addSemantic({ id: SemanticType.User })
|
|
587
|
+
childEntity.addSemantic({ id: SemanticType.Address })
|
|
588
|
+
|
|
589
|
+
childEntity.addParent(parentEntity.key)
|
|
590
|
+
|
|
591
|
+
const inherited = childEntity.listAllSemantics()
|
|
592
|
+
assert.lengthOf(inherited, 2)
|
|
593
|
+
const semanticIds = inherited.map((s) => s.id).sort()
|
|
594
|
+
assert.deepEqual(semanticIds, [SemanticType.Address, SemanticType.User].sort())
|
|
595
|
+
}).tags(['@modeling', '@entity', '@semantic'])
|
|
596
|
+
|
|
597
|
+
test('overrides parent semantics with the same id', ({ assert }) => {
|
|
598
|
+
const dataDomain = new DataDomain()
|
|
599
|
+
const model = dataDomain.addModel()
|
|
600
|
+
const parentEntity = model.addEntity({ key: 'parent' })
|
|
601
|
+
const childEntity = model.addEntity({ key: 'child' })
|
|
602
|
+
|
|
603
|
+
parentEntity.addSemantic({ id: SemanticType.User, config: { value: 'parent-val' } })
|
|
604
|
+
childEntity.addSemantic({ id: SemanticType.User, config: { value: 'child-val' } })
|
|
605
|
+
|
|
606
|
+
childEntity.addParent(parentEntity.key)
|
|
607
|
+
|
|
608
|
+
const inherited = childEntity.listAllSemantics()
|
|
609
|
+
assert.lengthOf(inherited, 1)
|
|
610
|
+
assert.deepEqual(inherited[0].config, { value: 'child-val' })
|
|
611
|
+
}).tags(['@modeling', '@entity', '@semantic'])
|
|
612
|
+
|
|
613
|
+
test('uses cached inherited semantics when domain is readOnly', ({ assert }) => {
|
|
614
|
+
const dataDomain = new DataDomain(undefined, [], { readOnly: true })
|
|
615
|
+
const model = dataDomain.addModel()
|
|
616
|
+
const parentEntity = model.addEntity({ key: 'parent' })
|
|
617
|
+
const childEntity = model.addEntity({ key: 'child' })
|
|
618
|
+
|
|
619
|
+
parentEntity.addSemantic({ id: SemanticType.User })
|
|
620
|
+
|
|
621
|
+
childEntity.addParent(parentEntity.key)
|
|
622
|
+
|
|
623
|
+
const inherited1 = childEntity.listAllSemantics()
|
|
624
|
+
assert.lengthOf(inherited1, 1)
|
|
625
|
+
|
|
626
|
+
// Add another semantic bypassing normal immutable logic for test purposes
|
|
627
|
+
parentEntity.addSemantic({ id: SemanticType.Address })
|
|
628
|
+
|
|
629
|
+
const inherited2 = childEntity.listAllSemantics()
|
|
630
|
+
// Should still be length 1 due to the cache
|
|
631
|
+
assert.lengthOf(inherited2, 1)
|
|
632
|
+
}).tags(['@modeling', '@entity', '@semantic', '@readonly'])
|
|
633
|
+
})
|
|
@@ -583,7 +583,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
|
|
|
583
583
|
}
|
|
584
584
|
|
|
585
585
|
// Should not throw in lenient mode
|
|
586
|
-
const restored = new DataDomain(corruptedSerialized, [], 'lenient')
|
|
586
|
+
const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
|
|
587
587
|
|
|
588
588
|
// Should have issues recorded
|
|
589
589
|
assert.isAbove(restored.issues.length, 0)
|
|
@@ -611,7 +611,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
|
|
|
611
611
|
}
|
|
612
612
|
|
|
613
613
|
// Should not throw in lenient mode
|
|
614
|
-
const restored = new DataDomain(corruptedSerialized, [], 'lenient')
|
|
614
|
+
const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
|
|
615
615
|
|
|
616
616
|
// Should have issues recorded
|
|
617
617
|
assert.isAbove(restored.issues.length, 0)
|
|
@@ -641,7 +641,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
|
|
|
641
641
|
}
|
|
642
642
|
|
|
643
643
|
// Should not throw in lenient mode
|
|
644
|
-
const restored = new DataDomain(corruptedSerialized, [], 'lenient')
|
|
644
|
+
const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
|
|
645
645
|
|
|
646
646
|
// Should have issues recorded
|
|
647
647
|
assert.isAbove(restored.issues.length, 0)
|
|
@@ -680,7 +680,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
|
|
|
680
680
|
}
|
|
681
681
|
|
|
682
682
|
// Should not throw in lenient mode
|
|
683
|
-
const restored = new DataDomain(corruptedSerialized, [], 'lenient')
|
|
683
|
+
const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
|
|
684
684
|
|
|
685
685
|
// Should have issues recorded about missing parent node
|
|
686
686
|
assert.isAbove(restored.issues.length, 0)
|
|
@@ -706,7 +706,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
|
|
|
706
706
|
const serialized = domain.toJSON()
|
|
707
707
|
|
|
708
708
|
// Deserialize without providing the foreign dependency
|
|
709
|
-
const restored = new DataDomain(serialized, [], 'lenient')
|
|
709
|
+
const restored = new DataDomain(serialized, [], { mode: 'lenient' })
|
|
710
710
|
|
|
711
711
|
// Should have issues recorded about missing dependency
|
|
712
712
|
assert.isAbove(restored.issues.length, 0)
|
|
@@ -742,7 +742,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
|
|
|
742
742
|
}
|
|
743
743
|
|
|
744
744
|
// Should not throw in lenient mode
|
|
745
|
-
const restored = new DataDomain(corruptedSerialized, [], 'lenient')
|
|
745
|
+
const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
|
|
746
746
|
|
|
747
747
|
// Should have issues recorded
|
|
748
748
|
assert.isAbove(restored.issues.length, 0)
|
|
@@ -797,7 +797,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
|
|
|
797
797
|
}
|
|
798
798
|
|
|
799
799
|
// Should not throw in lenient mode
|
|
800
|
-
const restored = new DataDomain(corruptedSerialized, [], 'lenient')
|
|
800
|
+
const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
|
|
801
801
|
|
|
802
802
|
// Should have multiple issues recorded
|
|
803
803
|
assert.isAtLeast(restored.issues.length, 3)
|
|
@@ -823,7 +823,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
|
|
|
823
823
|
e1.addProperty({ type: 'string' })
|
|
824
824
|
|
|
825
825
|
const serialized = domain.toJSON()
|
|
826
|
-
const restored = new DataDomain(serialized, [], 'strict')
|
|
826
|
+
const restored = new DataDomain(serialized, [], { mode: 'strict' })
|
|
827
827
|
|
|
828
828
|
// Should have no issues in strict mode for valid data
|
|
829
829
|
assert.equal(restored.issues.length, 0)
|
|
@@ -855,11 +855,11 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
|
|
|
855
855
|
|
|
856
856
|
// Should throw in strict mode
|
|
857
857
|
assert.throws(() => {
|
|
858
|
-
new DataDomain(corruptedSerialized, [], 'strict')
|
|
858
|
+
new DataDomain(corruptedSerialized, [], { mode: 'strict' })
|
|
859
859
|
})
|
|
860
860
|
|
|
861
861
|
// Should not throw in lenient mode
|
|
862
|
-
const restored = new DataDomain(corruptedSerialized, [], 'lenient')
|
|
862
|
+
const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
|
|
863
863
|
assert.isAbove(restored.issues.length, 0)
|
|
864
864
|
}).tags(['@modeling', '@serialization', '@lenient', '@strict'])
|
|
865
865
|
|
|
@@ -879,7 +879,7 @@ test.group('DataDomain Lenient Mode Deserialization', () => {
|
|
|
879
879
|
)
|
|
880
880
|
}
|
|
881
881
|
|
|
882
|
-
const restored = new DataDomain(corruptedSerialized, [], 'lenient')
|
|
882
|
+
const restored = new DataDomain(corruptedSerialized, [], { mode: 'lenient' })
|
|
883
883
|
|
|
884
884
|
// Should have detailed issue information
|
|
885
885
|
assert.isAbove(restored.issues.length, 0)
|
|
@@ -328,3 +328,66 @@ test.group('DomainEntity.hasAssociations()', () => {
|
|
|
328
328
|
assert.isFalse(entity.hasAssociations())
|
|
329
329
|
})
|
|
330
330
|
})
|
|
331
|
+
|
|
332
|
+
test.group('DomainEntity.withInheritedAssociations()', () => {
|
|
333
|
+
test('returns associations from entity and its parents', ({ assert }) => {
|
|
334
|
+
const dataDomain = new DataDomain()
|
|
335
|
+
const model = dataDomain.addModel()
|
|
336
|
+
const parentEntity = model.addEntity({ key: 'parent' })
|
|
337
|
+
const childEntity = model.addEntity({ key: 'child' })
|
|
338
|
+
|
|
339
|
+
const parentAssoc = parentEntity.addAssociation()
|
|
340
|
+
parentAssoc.info.name = 'parentAssoc'
|
|
341
|
+
|
|
342
|
+
const childAssoc = childEntity.addAssociation()
|
|
343
|
+
childAssoc.info.name = 'childAssoc'
|
|
344
|
+
|
|
345
|
+
childEntity.addParent(parentEntity.key)
|
|
346
|
+
|
|
347
|
+
const inherited = Array.from(childEntity.withInheritedAssociations())
|
|
348
|
+
assert.lengthOf(inherited, 2)
|
|
349
|
+
assert.deepEqual(inherited.map((a) => a.info.name).sort(), ['childAssoc', 'parentAssoc'])
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('overrides parent association with the same name', ({ assert }) => {
|
|
353
|
+
const dataDomain = new DataDomain()
|
|
354
|
+
const model = dataDomain.addModel()
|
|
355
|
+
const parentEntity = model.addEntity({ key: 'parent' })
|
|
356
|
+
const childEntity = model.addEntity({ key: 'child' })
|
|
357
|
+
|
|
358
|
+
const parentAssoc = parentEntity.addAssociation()
|
|
359
|
+
parentAssoc.info.name = 'assoc'
|
|
360
|
+
|
|
361
|
+
const childAssoc = childEntity.addAssociation()
|
|
362
|
+
childAssoc.info.name = 'assoc'
|
|
363
|
+
|
|
364
|
+
childEntity.addParent(parentEntity.key)
|
|
365
|
+
|
|
366
|
+
const inherited = Array.from(childEntity.withInheritedAssociations())
|
|
367
|
+
assert.lengthOf(inherited, 1)
|
|
368
|
+
assert.equal(inherited[0].key, childAssoc.key)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('uses cached inherited associations when domain is readOnly', ({ assert }) => {
|
|
372
|
+
const dataDomain = new DataDomain(undefined, [], { readOnly: true })
|
|
373
|
+
const model = dataDomain.addModel()
|
|
374
|
+
const parentEntity = model.addEntity({ key: 'parent' })
|
|
375
|
+
const childEntity = model.addEntity({ key: 'child' })
|
|
376
|
+
|
|
377
|
+
const parentAssoc = parentEntity.addAssociation()
|
|
378
|
+
parentAssoc.info.name = 'parentAssoc'
|
|
379
|
+
|
|
380
|
+
childEntity.addParent(parentEntity.key)
|
|
381
|
+
|
|
382
|
+
const inherited1 = Array.from(childEntity.withInheritedAssociations())
|
|
383
|
+
assert.lengthOf(inherited1, 1)
|
|
384
|
+
|
|
385
|
+
// Add another association bypassing normal immutability rules for test
|
|
386
|
+
const anotherAssoc = parentEntity.addAssociation()
|
|
387
|
+
anotherAssoc.info.name = 'anotherAssoc'
|
|
388
|
+
|
|
389
|
+
const inherited2 = Array.from(childEntity.withInheritedAssociations())
|
|
390
|
+
// Should still be length 1 because of the readOnly cache
|
|
391
|
+
assert.lengthOf(inherited2, 1)
|
|
392
|
+
})
|
|
393
|
+
})
|
|
@@ -18,6 +18,41 @@ test.group('ExposedEntity', () => {
|
|
|
18
18
|
assert.equal(ex.resourcePath, '/products/{customId}')
|
|
19
19
|
}).tags(['@modeling', '@exposed-entity'])
|
|
20
20
|
|
|
21
|
+
test('setCollectionPath throws if semantic param cannot be generated', ({ assert }) => {
|
|
22
|
+
const model = new ApiModel()
|
|
23
|
+
const ex = new ExposedEntity(model, {
|
|
24
|
+
entity: { key: 'missing' },
|
|
25
|
+
hasCollection: true,
|
|
26
|
+
collectionPath: '/items',
|
|
27
|
+
resourcePath: '/', // Forces param generation
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
assert.throws(() => ex.setCollectionPath('products'), 'Cannot generate a semantic parameter name')
|
|
31
|
+
}).tags(['@modeling', '@exposed-entity'])
|
|
32
|
+
|
|
33
|
+
test('setCollectionPath generates semantic param from domain entity', ({ assert }) => {
|
|
34
|
+
const domain = new DataDomain()
|
|
35
|
+
domain.info.version = '1.0.0'
|
|
36
|
+
const dm = domain.addModel()
|
|
37
|
+
const entity = domain.addEntity(dm.key)
|
|
38
|
+
entity.info.name = 'user_post'
|
|
39
|
+
|
|
40
|
+
const model = new ApiModel()
|
|
41
|
+
model.attachDataDomain(domain)
|
|
42
|
+
|
|
43
|
+
const ex = new ExposedEntity(model, {
|
|
44
|
+
entity: { key: entity.key },
|
|
45
|
+
hasCollection: true,
|
|
46
|
+
collectionPath: '/items',
|
|
47
|
+
resourcePath: '/', // Forces param generation
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
ex.setCollectionPath('user-posts')
|
|
51
|
+
|
|
52
|
+
assert.equal(ex.collectionPath, '/user-posts')
|
|
53
|
+
assert.equal(ex.resourcePath, '/user-posts/{userPostId}')
|
|
54
|
+
}).tags(['@modeling', '@exposed-entity'])
|
|
55
|
+
|
|
21
56
|
test('setResourcePath with collection allows only parameter name change', ({ assert }) => {
|
|
22
57
|
const model = new ApiModel()
|
|
23
58
|
const ex = new ExposedEntity(model, {
|
|
@@ -104,6 +139,66 @@ test.group('ExposedEntity', () => {
|
|
|
104
139
|
assert.equal(grandEx.getAbsoluteResourcePath(), '/users/{userId}/posts/{postId}/details')
|
|
105
140
|
}).tags(['@modeling', '@exposed-entity'])
|
|
106
141
|
|
|
142
|
+
test('rejects branch collision when modifying collection path', ({ assert }) => {
|
|
143
|
+
const model = new ApiModel()
|
|
144
|
+
const rootSchema: Partial<ExposedEntitySchema> = {
|
|
145
|
+
key: 'root',
|
|
146
|
+
entity: { key: 'user' },
|
|
147
|
+
hasCollection: true,
|
|
148
|
+
collectionPath: '/users',
|
|
149
|
+
resourcePath: '/users/{userId}',
|
|
150
|
+
isRoot: true,
|
|
151
|
+
}
|
|
152
|
+
const childSchema: Partial<ExposedEntitySchema> = {
|
|
153
|
+
key: 'child',
|
|
154
|
+
entity: { key: 'post' },
|
|
155
|
+
hasCollection: true,
|
|
156
|
+
collectionPath: '/posts',
|
|
157
|
+
resourcePath: '/posts/{postId}',
|
|
158
|
+
parent: { key: 'root', association: { key: 'toPosts' } },
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const rootEx = new ExposedEntity(model, rootSchema)
|
|
162
|
+
const childEx = new ExposedEntity(model, childSchema)
|
|
163
|
+
model.exposes = new Map([
|
|
164
|
+
[rootEx.key, rootEx],
|
|
165
|
+
[childEx.key, childEx],
|
|
166
|
+
])
|
|
167
|
+
|
|
168
|
+
// Modifying child's collection path to reuse 'userId'
|
|
169
|
+
assert.throws(() => childEx.setResourcePath('/posts/{userId}'), 'Duplicate path parameter "{userId}" detected')
|
|
170
|
+
}).tags(['@modeling', '@exposed-entity', '@collision'])
|
|
171
|
+
|
|
172
|
+
test('rejects branch collision from descendant when modifying ancestor resource path', ({ assert }) => {
|
|
173
|
+
const model = new ApiModel()
|
|
174
|
+
const rootSchema: Partial<ExposedEntitySchema> = {
|
|
175
|
+
key: 'root',
|
|
176
|
+
entity: { key: 'user' },
|
|
177
|
+
hasCollection: true,
|
|
178
|
+
collectionPath: '/users',
|
|
179
|
+
resourcePath: '/users/{userId}',
|
|
180
|
+
isRoot: true,
|
|
181
|
+
}
|
|
182
|
+
const childSchema: Partial<ExposedEntitySchema> = {
|
|
183
|
+
key: 'child',
|
|
184
|
+
entity: { key: 'post' },
|
|
185
|
+
hasCollection: true,
|
|
186
|
+
collectionPath: '/posts',
|
|
187
|
+
resourcePath: '/posts/{postId}',
|
|
188
|
+
parent: { key: 'root', association: { key: 'toPosts' } },
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const rootEx = new ExposedEntity(model, rootSchema)
|
|
192
|
+
const childEx = new ExposedEntity(model, childSchema)
|
|
193
|
+
model.exposes = new Map([
|
|
194
|
+
[rootEx.key, rootEx],
|
|
195
|
+
[childEx.key, childEx],
|
|
196
|
+
])
|
|
197
|
+
|
|
198
|
+
// Modifying root's resource path to 'postId' which collides with child
|
|
199
|
+
assert.throws(() => rootEx.setResourcePath('/users/{postId}'), 'Duplicate path parameter "{postId}" detected')
|
|
200
|
+
}).tags(['@modeling', '@exposed-entity', '@collision'])
|
|
201
|
+
|
|
107
202
|
test('ApiModel notifies when nested ExposedEntity collection path changes', async ({ assert }) => {
|
|
108
203
|
const model = new ApiModel({
|
|
109
204
|
exposes: [
|