@api-client/core 0.20.6 → 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/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.map +1 -1
- package/build/src/modeling/RuntimeApiModel.js +6 -2
- 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/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/ExposedEntity.ts +62 -4
- package/src/modeling/RuntimeApiModel.ts +7 -2
- package/src/modeling/generators/RuntimeModelGenerator.ts +79 -0
- package/src/modeling/helpers/endpointHelpers.ts +51 -4
- package/src/modeling/validation/api_model_rules.ts +19 -0
- package/tests/unit/modeling/RuntimeApiModel.spec.ts +17 -3
- 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",
|
|
@@ -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.
|
|
@@ -166,10 +166,15 @@ export class RuntimeApiModel extends ApiModel {
|
|
|
166
166
|
constructor(schema: RuntimeApiModelSchema, domainSchema: DataDomainSchema) {
|
|
167
167
|
super(schema, domainSchema)
|
|
168
168
|
|
|
169
|
-
if (schema.routingMap) {
|
|
170
|
-
|
|
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
|
+
})
|
|
171
174
|
}
|
|
172
175
|
|
|
176
|
+
this.#initializeRouter(schema.routingMap)
|
|
177
|
+
|
|
173
178
|
this.#cacheEntitiesAndProperties()
|
|
174
179
|
this.#precomputeAccessRules()
|
|
175
180
|
this.#precomputeSessionProperties()
|
|
@@ -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
|
+
}
|
|
@@ -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
|
/**
|
|
@@ -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' } })
|
|
@@ -311,7 +325,7 @@ test.group('RuntimeApiModel', () => {
|
|
|
311
325
|
{ key: 'api-1', accessRule: [{ type: 'allowAuthenticated', mandatory: true }] },
|
|
312
326
|
domain
|
|
313
327
|
)
|
|
314
|
-
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
328
|
+
const runtimeModel = new RuntimeApiModel({ ...baseModel.toJSON(), routingMap: {} } as any, domain.toJSON())
|
|
315
329
|
|
|
316
330
|
const mockAction = {
|
|
317
331
|
exposure: { accessRule: [], parent: undefined },
|
|
@@ -327,7 +341,7 @@ test.group('RuntimeApiModel', () => {
|
|
|
327
341
|
test('getEffectiveRules - defaults to preFetch phase if none specified', async ({ assert }) => {
|
|
328
342
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
329
343
|
const baseModel = new ApiModel({ key: 'api-1' }, domain)
|
|
330
|
-
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
344
|
+
const runtimeModel = new RuntimeApiModel({ ...baseModel.toJSON(), routingMap: {} } as any, domain.toJSON())
|
|
331
345
|
|
|
332
346
|
const mockAction = {
|
|
333
347
|
exposure: {
|
|
@@ -345,7 +359,7 @@ test.group('RuntimeApiModel', () => {
|
|
|
345
359
|
test('getEffectiveRules - orders from most specific to most general', async ({ assert }) => {
|
|
346
360
|
const domain = new DataDomain({ info: { version: '1.0.0' } })
|
|
347
361
|
const baseModel = new ApiModel({ accessRule: [{ type: 'allowAuthenticated' }] }, domain)
|
|
348
|
-
const runtimeModel = new RuntimeApiModel(baseModel.toJSON() as any, domain.toJSON())
|
|
362
|
+
const runtimeModel = new RuntimeApiModel({ ...baseModel.toJSON(), routingMap: {} } as any, domain.toJSON())
|
|
349
363
|
|
|
350
364
|
const mockAction = {
|
|
351
365
|
exposure: {
|
|
@@ -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: [
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { test } from '@japa/runner'
|
|
2
|
+
import { ApiModel } from '../../../../src/modeling/ApiModel.js'
|
|
3
|
+
import { ExposedEntityKind } from '../../../../src/models/index.js'
|
|
4
|
+
import { RuntimeModelGenerator } from '../../../../src/modeling/generators/RuntimeModelGenerator.js'
|
|
5
|
+
import { ApiModelKind } from '../../../../src/models/index.js'
|
|
6
|
+
import type { UpdateActionSchema } from '../../../../src/modeling/actions/index.js'
|
|
7
|
+
|
|
8
|
+
test.group('RuntimeModelGenerator', () => {
|
|
9
|
+
test('generates routes from an ApiModel instance', async ({ assert }) => {
|
|
10
|
+
const apiModel = new ApiModel({
|
|
11
|
+
key: 'test-api',
|
|
12
|
+
exposes: [
|
|
13
|
+
{
|
|
14
|
+
key: 'users',
|
|
15
|
+
kind: ExposedEntityKind,
|
|
16
|
+
entity: { key: 'user' },
|
|
17
|
+
resourcePath: '/users/{id}',
|
|
18
|
+
collectionPath: '/users',
|
|
19
|
+
hasCollection: true,
|
|
20
|
+
isRoot: true,
|
|
21
|
+
actions: [
|
|
22
|
+
{ kind: 'list' },
|
|
23
|
+
{ kind: 'create' },
|
|
24
|
+
{ kind: 'search' },
|
|
25
|
+
{ kind: 'read' },
|
|
26
|
+
{ kind: 'update', allowedMethods: ['PUT', 'PATCH'] } as UpdateActionSchema,
|
|
27
|
+
{ kind: 'delete' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const generator = new RuntimeModelGenerator(apiModel)
|
|
34
|
+
const result = await generator.generate()
|
|
35
|
+
|
|
36
|
+
assert.equal(result.key, 'test-api')
|
|
37
|
+
assert.deepEqual(result.routingMap['GET']![0], {
|
|
38
|
+
path: '/users',
|
|
39
|
+
lookup: { exposedEntityKey: 'users', actionKind: 'list' },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(result.routingMap['GET']![1], {
|
|
43
|
+
path: '/users/{id}',
|
|
44
|
+
lookup: { exposedEntityKey: 'users', actionKind: 'read' },
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
assert.deepEqual(result.routingMap['POST']![0], {
|
|
48
|
+
path: '/users',
|
|
49
|
+
lookup: { exposedEntityKey: 'users', actionKind: 'create' },
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(result.routingMap['POST']![1], {
|
|
53
|
+
path: '/users/search',
|
|
54
|
+
lookup: { exposedEntityKey: 'users', actionKind: 'search' },
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
assert.deepEqual(result.routingMap['PUT']![0], {
|
|
58
|
+
path: '/users/{id}',
|
|
59
|
+
lookup: { exposedEntityKey: 'users', actionKind: 'update' },
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
assert.deepEqual(result.routingMap['PATCH']![0], {
|
|
63
|
+
path: '/users/{id}',
|
|
64
|
+
lookup: { exposedEntityKey: 'users', actionKind: 'update' },
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
assert.deepEqual(result.routingMap['DELETE']![0], {
|
|
68
|
+
path: '/users/{id}',
|
|
69
|
+
lookup: { exposedEntityKey: 'users', actionKind: 'delete' },
|
|
70
|
+
})
|
|
71
|
+
}).tags(['@modeling', '@generator'])
|
|
72
|
+
|
|
73
|
+
test('generates routes from an ApiModelSchema object', async ({ assert }) => {
|
|
74
|
+
const schema = {
|
|
75
|
+
kind: ApiModelKind,
|
|
76
|
+
key: 'test-api-schema',
|
|
77
|
+
exposes: [
|
|
78
|
+
{
|
|
79
|
+
key: 'posts',
|
|
80
|
+
kind: ExposedEntityKind,
|
|
81
|
+
entity: { key: 'post' },
|
|
82
|
+
resourcePath: '/posts/{id}',
|
|
83
|
+
collectionPath: '/posts',
|
|
84
|
+
hasCollection: true,
|
|
85
|
+
isRoot: true,
|
|
86
|
+
actions: [{ kind: 'list' }],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const generator = new RuntimeModelGenerator(schema as any)
|
|
92
|
+
const result = await generator.generate()
|
|
93
|
+
|
|
94
|
+
assert.equal(result.key, 'test-api-schema')
|
|
95
|
+
assert.lengthOf(result.routingMap['GET'] || [], 1)
|
|
96
|
+
assert.deepEqual(result.routingMap['GET']![0], {
|
|
97
|
+
path: '/posts',
|
|
98
|
+
lookup: { exposedEntityKey: 'posts', actionKind: 'list' },
|
|
99
|
+
})
|
|
100
|
+
}).tags(['@modeling', '@generator'])
|
|
101
|
+
|
|
102
|
+
test('handles entities without collection paths safely', async ({ assert }) => {
|
|
103
|
+
const apiModel = new ApiModel({
|
|
104
|
+
key: 'test-api',
|
|
105
|
+
exposes: [
|
|
106
|
+
{
|
|
107
|
+
key: 'singleton',
|
|
108
|
+
kind: ExposedEntityKind,
|
|
109
|
+
entity: { key: 'config' },
|
|
110
|
+
resourcePath: '/singleton',
|
|
111
|
+
hasCollection: false,
|
|
112
|
+
isRoot: true,
|
|
113
|
+
actions: [{ kind: 'list' }, { kind: 'create' }, { kind: 'search' }],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const generator = new RuntimeModelGenerator(apiModel)
|
|
119
|
+
const result = await generator.generate()
|
|
120
|
+
|
|
121
|
+
// Without a collection path, list, create, search should be ignored.
|
|
122
|
+
assert.deepEqual(result.routingMap, {})
|
|
123
|
+
}).tags(['@modeling', '@generator'])
|
|
124
|
+
|
|
125
|
+
test('generates routes for nested entities (sub-endpoints)', async ({ assert }) => {
|
|
126
|
+
const apiModel = new ApiModel({
|
|
127
|
+
key: 'test-api',
|
|
128
|
+
exposes: [
|
|
129
|
+
{
|
|
130
|
+
key: 'users',
|
|
131
|
+
kind: ExposedEntityKind,
|
|
132
|
+
entity: { key: 'user' },
|
|
133
|
+
resourcePath: '/users/{userId}',
|
|
134
|
+
collectionPath: '/users',
|
|
135
|
+
hasCollection: true,
|
|
136
|
+
isRoot: true,
|
|
137
|
+
actions: [{ kind: 'read' }],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
key: 'user-posts',
|
|
141
|
+
kind: ExposedEntityKind,
|
|
142
|
+
entity: { key: 'post' },
|
|
143
|
+
resourcePath: '/posts/{postId}',
|
|
144
|
+
collectionPath: '/posts',
|
|
145
|
+
hasCollection: true,
|
|
146
|
+
isRoot: false,
|
|
147
|
+
parent: { key: 'users', association: { key: 'user-posts' } },
|
|
148
|
+
actions: [
|
|
149
|
+
{ kind: 'list' },
|
|
150
|
+
{ kind: 'read' },
|
|
151
|
+
{ kind: 'create' },
|
|
152
|
+
{ kind: 'update', allowedMethods: ['PATCH'] } as UpdateActionSchema,
|
|
153
|
+
{ kind: 'delete' },
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const generator = new RuntimeModelGenerator(apiModel)
|
|
160
|
+
const result = await generator.generate()
|
|
161
|
+
|
|
162
|
+
assert.deepInclude(result.routingMap['GET']!, {
|
|
163
|
+
path: '/users/{userId}',
|
|
164
|
+
lookup: { exposedEntityKey: 'users', actionKind: 'read' },
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
assert.deepInclude(result.routingMap['GET']!, {
|
|
168
|
+
path: '/users/{userId}/posts',
|
|
169
|
+
lookup: { exposedEntityKey: 'user-posts', actionKind: 'list' },
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
assert.deepInclude(result.routingMap['GET']!, {
|
|
173
|
+
path: '/users/{userId}/posts/{postId}',
|
|
174
|
+
lookup: { exposedEntityKey: 'user-posts', actionKind: 'read' },
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
assert.deepInclude(result.routingMap['POST']!, {
|
|
178
|
+
path: '/users/{userId}/posts',
|
|
179
|
+
lookup: { exposedEntityKey: 'user-posts', actionKind: 'create' },
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
assert.deepInclude(result.routingMap['PATCH']!, {
|
|
183
|
+
path: '/users/{userId}/posts/{postId}',
|
|
184
|
+
lookup: { exposedEntityKey: 'user-posts', actionKind: 'update' },
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
assert.deepInclude(result.routingMap['DELETE']!, {
|
|
188
|
+
path: '/users/{userId}/posts/{postId}',
|
|
189
|
+
lookup: { exposedEntityKey: 'user-posts', actionKind: 'delete' },
|
|
190
|
+
})
|
|
191
|
+
}).tags(['@modeling', '@generator'])
|
|
192
|
+
})
|
|
@@ -2,9 +2,16 @@ import { test } from '@japa/runner'
|
|
|
2
2
|
import * as helpers from '../../../../src/modeling/helpers/endpointHelpers.js'
|
|
3
3
|
|
|
4
4
|
test.group('endpointHelpers', () => {
|
|
5
|
-
test('paramNameFor
|
|
5
|
+
test('paramNameFor handles various arbitrary strings', ({ assert }) => {
|
|
6
6
|
assert.equal(helpers.paramNameFor('product'), 'productId')
|
|
7
|
-
assert.equal(helpers.paramNameFor('
|
|
8
|
-
assert.equal(helpers.paramNameFor('
|
|
7
|
+
assert.equal(helpers.paramNameFor('user_post'), 'userPostId')
|
|
8
|
+
assert.equal(helpers.paramNameFor('UserPost'), 'userPostId')
|
|
9
|
+
assert.equal(helpers.paramNameFor('USER_POST'), 'userPostId')
|
|
10
|
+
assert.equal(helpers.paramNameFor('user-post'), 'userPostId')
|
|
11
|
+
assert.equal(helpers.paramNameFor('User Post! '), 'userPostId')
|
|
12
|
+
assert.equal(helpers.paramNameFor('123item'), '_123itemId')
|
|
13
|
+
assert.equal(helpers.paramNameFor('XMLHttp'), 'xmlHttpId')
|
|
14
|
+
assert.throws(() => helpers.paramNameFor('!@#$'), 'Cannot generate a valid parameter name from "!@#$".')
|
|
15
|
+
assert.throws(() => helpers.paramNameFor(''), 'Cannot generate parameter name from an empty string.')
|
|
9
16
|
})
|
|
10
17
|
})
|