@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.
Files changed (29) hide show
  1. package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
  2. package/build/src/modeling/ExposedEntity.js +55 -4
  3. package/build/src/modeling/ExposedEntity.js.map +1 -1
  4. package/build/src/modeling/RuntimeApiModel.d.ts.map +1 -1
  5. package/build/src/modeling/RuntimeApiModel.js +6 -2
  6. package/build/src/modeling/RuntimeApiModel.js.map +1 -1
  7. package/build/src/modeling/generators/RuntimeModelGenerator.d.ts +15 -0
  8. package/build/src/modeling/generators/RuntimeModelGenerator.d.ts.map +1 -0
  9. package/build/src/modeling/generators/RuntimeModelGenerator.js +78 -0
  10. package/build/src/modeling/generators/RuntimeModelGenerator.js.map +1 -0
  11. package/build/src/modeling/helpers/endpointHelpers.d.ts +6 -1
  12. package/build/src/modeling/helpers/endpointHelpers.d.ts.map +1 -1
  13. package/build/src/modeling/helpers/endpointHelpers.js +43 -4
  14. package/build/src/modeling/helpers/endpointHelpers.js.map +1 -1
  15. package/build/src/modeling/validation/api_model_rules.d.ts.map +1 -1
  16. package/build/src/modeling/validation/api_model_rules.js +17 -0
  17. package/build/src/modeling/validation/api_model_rules.js.map +1 -1
  18. package/build/tsconfig.tsbuildinfo +1 -1
  19. package/package.json +3 -3
  20. package/src/modeling/ExposedEntity.ts +62 -4
  21. package/src/modeling/RuntimeApiModel.ts +7 -2
  22. package/src/modeling/generators/RuntimeModelGenerator.ts +79 -0
  23. package/src/modeling/helpers/endpointHelpers.ts +51 -4
  24. package/src/modeling/validation/api_model_rules.ts +19 -0
  25. package/tests/unit/modeling/RuntimeApiModel.spec.ts +17 -3
  26. package/tests/unit/modeling/exposed_entity.spec.ts +95 -0
  27. package/tests/unit/modeling/generators/RuntimeModelGenerator.spec.ts +192 -0
  28. package/tests/unit/modeling/helpers/endpointHelpers.spec.ts +10 -3
  29. 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.6",
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": "^20.1.0",
109
- "@commitlint/config-conventional": "^20.0.0",
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 {id}
306
- let param = '{id}'
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
- this.#initializeRouter(schema.routingMap)
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
- export function paramNameFor(entityKeyLocal: string): string {
2
- const parts = entityKeyLocal.split(':')
3
- const key = parts[parts.length - 1]
4
- return `${key}Id`
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 extracts last key', ({ assert }) => {
5
+ test('paramNameFor handles various arbitrary strings', ({ assert }) => {
6
6
  assert.equal(helpers.paramNameFor('product'), 'productId')
7
- assert.equal(helpers.paramNameFor('domain:product'), 'productId')
8
- assert.equal(helpers.paramNameFor('a:b:c'), 'cId')
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
  })