@api-client/core 0.20.6 → 0.20.8

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 (35) hide show
  1. package/build/src/modeling/ApiModel.d.ts.map +1 -1
  2. package/build/src/modeling/ApiModel.js +9 -5
  3. package/build/src/modeling/ApiModel.js.map +1 -1
  4. package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
  5. package/build/src/modeling/ExposedEntity.js +55 -4
  6. package/build/src/modeling/ExposedEntity.js.map +1 -1
  7. package/build/src/modeling/RuntimeApiModel.d.ts.map +1 -1
  8. package/build/src/modeling/RuntimeApiModel.js +6 -2
  9. package/build/src/modeling/RuntimeApiModel.js.map +1 -1
  10. package/build/src/modeling/generators/RuntimeModelGenerator.d.ts +15 -0
  11. package/build/src/modeling/generators/RuntimeModelGenerator.d.ts.map +1 -0
  12. package/build/src/modeling/generators/RuntimeModelGenerator.js +78 -0
  13. package/build/src/modeling/generators/RuntimeModelGenerator.js.map +1 -0
  14. package/build/src/modeling/helpers/endpointHelpers.d.ts +6 -1
  15. package/build/src/modeling/helpers/endpointHelpers.d.ts.map +1 -1
  16. package/build/src/modeling/helpers/endpointHelpers.js +43 -4
  17. package/build/src/modeling/helpers/endpointHelpers.js.map +1 -1
  18. package/build/src/modeling/validation/api_model_rules.d.ts.map +1 -1
  19. package/build/src/modeling/validation/api_model_rules.js +17 -0
  20. package/build/src/modeling/validation/api_model_rules.js.map +1 -1
  21. package/build/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +3 -3
  23. package/src/modeling/ApiModel.ts +9 -5
  24. package/src/modeling/ExposedEntity.ts +62 -4
  25. package/src/modeling/RuntimeApiModel.ts +7 -2
  26. package/src/modeling/generators/RuntimeModelGenerator.ts +79 -0
  27. package/src/modeling/helpers/endpointHelpers.ts +51 -4
  28. package/src/modeling/validation/api_model_rules.ts +19 -0
  29. package/tests/unit/modeling/RuntimeApiModel.spec.ts +17 -3
  30. package/tests/unit/modeling/api_model_expose_entity.spec.ts +2 -1
  31. package/tests/unit/modeling/exposed_entity.spec.ts +95 -0
  32. package/tests/unit/modeling/generators/OasGenerator.spec.ts +8 -8
  33. package/tests/unit/modeling/generators/RuntimeModelGenerator.spec.ts +192 -0
  34. package/tests/unit/modeling/helpers/endpointHelpers.spec.ts +10 -3
  35. 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.8",
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",
@@ -16,6 +16,7 @@ import { DependentModel, type DependentModelSchema, type DomainDependency } from
16
16
  import { observed, toRaw } from '../decorators/observed.js'
17
17
  import pluralize from '@jarrodek/pluralize'
18
18
  import { createDomainKey } from './helpers/keying.js'
19
+ import { paramNameFor } from './helpers/endpointHelpers.js'
19
20
  import { ExposedEntity } from './ExposedEntity.js'
20
21
  import { AccessRule, AccessRuleSchema } from './rules/AccessRule.js'
21
22
  import { RateLimitingConfiguration, RateLimitingConfigurationSchema } from './rules/RateLimitingConfiguration.js'
@@ -462,15 +463,16 @@ export class ApiModel extends DependentModel {
462
463
  }
463
464
  const name = domainEntity.info.name || ''
464
465
  const segment = pluralize(name.toLocaleLowerCase())
466
+ const paramName = paramNameFor(name)
465
467
  let relativeCollectionPath = `/${segment}`
466
- let relativeResourcePath = `/${segment}/{id}`
468
+ let relativeResourcePath = `/${segment}/{${paramName}}`
467
469
 
468
470
  // Check for root path collision and resolve by appending a number
469
471
  let counter = 1
470
472
  const originalCollectionPath = relativeCollectionPath
471
473
  while (this.findCollectionPathCollision(relativeCollectionPath)) {
472
474
  relativeCollectionPath = `${originalCollectionPath}-${counter}`
473
- relativeResourcePath = `${relativeCollectionPath}/{id}`
475
+ relativeResourcePath = `${relativeCollectionPath}/{${paramName}}`
474
476
  counter++
475
477
  }
476
478
 
@@ -552,14 +554,15 @@ export class ApiModel extends DependentModel {
552
554
  if (targetDomEntity) {
553
555
  const name = targetDomEntity.info.name || ''
554
556
  const segment = pluralize(name.toLocaleLowerCase())
557
+ const paramName = paramNameFor(name)
555
558
  let relativeCollectionPath = `/${segment}`
556
- let relativeResourcePath = `/${segment}/{id}`
559
+ let relativeResourcePath = `/${segment}/{${paramName}}`
557
560
 
558
561
  let counter = 1
559
562
  const originalCollectionPath = relativeCollectionPath
560
563
  while (this.findResourcePathCollision(relativeCollectionPath)) {
561
564
  relativeCollectionPath = `${originalCollectionPath}-${counter}`
562
- relativeResourcePath = `${relativeCollectionPath}/{id}`
565
+ relativeResourcePath = `${relativeCollectionPath}/{${paramName}}`
563
566
  counter++
564
567
  }
565
568
 
@@ -582,8 +585,9 @@ export class ApiModel extends DependentModel {
582
585
  const name = association.info.name || ''
583
586
  const segment = pluralize(name.toLocaleLowerCase())
584
587
  const isCollection = association.multiple !== false
588
+ const paramName = paramNameFor(targetDomainEntity.info.name || '')
585
589
  const relativeCollectionPath = isCollection ? `/${segment}` : undefined
586
- const relativeResourcePath = isCollection ? `/${segment}/{id}` : `/${segment}`
590
+ const relativeResourcePath = isCollection ? `/${segment}/{${paramName}}` : `/${segment}`
587
591
  // Create nested exposure
588
592
  const nestedExposure: ExposedEntitySchema = {
589
593
  kind: ExposedEntityKind,
@@ -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: {
@@ -87,6 +87,7 @@ test.group('ApiModel.exposeEntity()', () => {
87
87
  assert.isDefined(nestedB)
88
88
  assert.deepEqual(nestedB?.parent?.key, exposedA.key)
89
89
  assert.strictEqual(nestedB?.collectionPath, '/entitybs')
90
+ assert.strictEqual(nestedB?.resourcePath, '/entitybs/{bId}')
90
91
  })
91
92
 
92
93
  test('does not infinitely expose circular associations', ({ assert }) => {
@@ -216,7 +217,7 @@ test.group('ApiModel.exposeEntity()', () => {
216
217
  // Expose second entity -> should resolve collision to /items-1
217
218
  const exp2 = model.exposeEntity({ key: e2.key })
218
219
  assert.equal(exp2.collectionPath, '/items-1')
219
- assert.equal(exp2.resourcePath, '/items-1/{id}')
220
+ assert.equal(exp2.resourcePath, '/items-1/{itemId}')
220
221
 
221
222
  // Expose third entity -> /items-2
222
223
  const e3 = dm.addEntity({ info: { name: 'Item' } })
@@ -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: [
@@ -10,7 +10,7 @@ test.group('OasGenerator', (group) => {
10
10
  let api: ApiModel
11
11
 
12
12
  group.each.setup(() => {
13
- domain = new DataDomain({ info: { name: 'Test Domain', version: '1.0.0' } })
13
+ domain = new DataDomain({ info: { name: 'Test Domain', version: '1.0.0' } }, undefined, { readOnly: true })
14
14
  const model = domain.addModel({ info: { name: 'model1' } })
15
15
  const user = model.addEntity({ info: { name: 'user' } })
16
16
  user.addSemantic({ id: SemanticType.User })
@@ -232,17 +232,17 @@ test.group('OasGenerator', (group) => {
232
232
  // User endpoints (CRUD)
233
233
  assert.isObject(paths['/users'].get, 'User list')
234
234
  assert.isObject(paths['/users'].post, 'User create')
235
- assert.isObject(paths['/users/{id}'].get, 'User read')
236
- assert.isObject(paths['/users/{id}'].put, 'User put')
237
- assert.isObject(paths['/users/{id}'].delete, 'User delete')
235
+ assert.isObject(paths['/users/{userId}'].get, 'User read')
236
+ assert.isObject(paths['/users/{userId}'].put, 'User put')
237
+ assert.isObject(paths['/users/{userId}'].delete, 'User delete')
238
238
 
239
239
  // Post endpoints (list, update, delete)
240
240
  assert.isObject(paths['/posts'].get)
241
241
  assert.isUndefined(paths['/posts'].post) // no create Action
242
- assert.isUndefined(paths['/posts/{id}'].get) // no read action
243
- assert.isObject(paths['/posts/{id}'].patch)
244
- assert.isObject(paths['/posts/{id}'].put)
245
- assert.isObject(paths['/posts/{id}'].delete)
242
+ assert.isUndefined(paths['/posts/{postId}'].get) // no read action
243
+ assert.isObject(paths['/posts/{postId}'].patch)
244
+ assert.isObject(paths['/posts/{postId}'].put)
245
+ assert.isObject(paths['/posts/{postId}'].delete)
246
246
  })
247
247
 
248
248
  test('generates standard error responses', ({ assert }) => {