@api-client/core 0.20.7 → 0.20.9

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/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.7",
4
+ "version": "0.20.9",
5
5
  "license": "UNLICENSED",
6
6
  "exports": {
7
7
  "./browser.js": {
@@ -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,
@@ -11,12 +11,55 @@ import { SemanticType } from './Semantics.js'
11
11
  import { AccessRule, AccessRuleExecutionPhase } from './rules/AccessRule.js'
12
12
  import { Exception } from '../exceptions/exception.js'
13
13
 
14
+ /**
15
+ * Describes a mapped parameter for a route.
16
+ */
17
+ export interface RouteParam {
18
+ /**
19
+ * The name of the parameter in the path (e.g. "userId")
20
+ * This can be anything as the user can freely change the param names.
21
+ */
22
+ paramName: string
23
+ /**
24
+ * The "key" of the exposed entity this parameter belongs to.
25
+ */
26
+ exposedEntityKey: string
27
+ /**
28
+ * The "name" of the exposed entity this parameter belongs to.
29
+ */
30
+ exposedEntityName: string
31
+ /**
32
+ * The primary key's property name of the exposed entity (e.g., "id").
33
+ */
34
+ propertyName: string
35
+ /**
36
+ * The name of the association that connects the parent entity to its child (e.g., "comments")
37
+ * If its missing it means that there's no association, so there's no sub-endpoint in the path
38
+ * (e.g., `/users/123` - It's a single root endpoint - it doesn't have any sub-endpoints.)
39
+ */
40
+ associationName?: string
41
+ }
42
+
14
43
  /**
15
44
  * Identifies a specific exposed entity and its action kind.
16
45
  */
17
46
  export interface RouteLookup {
47
+ /**
48
+ * The "key" of the exposed entity that is being accessed.
49
+ */
18
50
  exposedEntityKey: string
51
+ /**
52
+ * The "name" of the exposed entity that is being accessed.
53
+ */
54
+ exposedEntityName: string
55
+ /**
56
+ * The "kind" of the action that is being performed.
57
+ */
19
58
  actionKind: ActionKind
59
+ /**
60
+ * Information about each path parameter in this route.
61
+ */
62
+ params: RouteParam[]
20
63
  }
21
64
 
22
65
  /**
@@ -44,6 +87,7 @@ export interface RuntimeResolvedAction {
44
87
  entity: DomainEntity
45
88
  action: Action
46
89
  params: Record<string, string>
90
+ lookup: RouteLookup
47
91
  }
48
92
 
49
93
  export type RuleEvaluator = (rule: AccessRule) => Promise<boolean | undefined> | boolean | undefined
@@ -429,6 +473,7 @@ export class RuntimeApiModel extends ApiModel {
429
473
  entity,
430
474
  action,
431
475
  params,
476
+ lookup: def.lookup,
432
477
  }
433
478
  }
434
479
 
@@ -1,6 +1,7 @@
1
1
  import { type ActionKind, type UpdateAction } from '../actions/index.js'
2
2
  import { ApiModel, type ApiModelSchema } from '../ApiModel.js'
3
- import { type RouteDefinition, type RuntimeApiModelSchema } from '../RuntimeApiModel.js'
3
+ import { ExposedEntity } from '../ExposedEntity.js'
4
+ import { RouteParam, type RouteDefinition, type RuntimeApiModelSchema } from '../RuntimeApiModel.js'
4
5
 
5
6
  /**
6
7
  * A class that takes the API model and generates a runtime-optimized API model.
@@ -34,27 +35,27 @@ export class RuntimeModelGenerator {
34
35
  for (const action of expose.actions) {
35
36
  switch (action.kind) {
36
37
  case 'list':
37
- if (colPath) this.addRoute('GET', colPath, expose.key, action.kind)
38
+ if (colPath) this.addRoute('GET', colPath, expose, action.kind)
38
39
  break
39
40
  case 'create':
40
- if (colPath) this.addRoute('POST', colPath, expose.key, action.kind)
41
+ if (colPath) this.addRoute('POST', colPath, expose, action.kind)
41
42
  break
42
43
  case 'search':
43
- if (colPath) this.addRoute('POST', `${colPath}/search`, expose.key, action.kind)
44
+ if (colPath) this.addRoute('POST', `${colPath}/search`, expose, action.kind)
44
45
  break
45
46
  case 'read':
46
- if (resPath) this.addRoute('GET', resPath, expose.key, action.kind)
47
+ if (resPath) this.addRoute('GET', resPath, expose, action.kind)
47
48
  break
48
49
  case 'update':
49
50
  if (resPath) {
50
51
  const updateAction = action as UpdateAction
51
52
  for (const method of updateAction.allowedMethods) {
52
- this.addRoute(method, resPath, expose.key, action.kind)
53
+ this.addRoute(method, resPath, expose, action.kind)
53
54
  }
54
55
  }
55
56
  break
56
57
  case 'delete':
57
- if (resPath) this.addRoute('DELETE', resPath, expose.key, action.kind)
58
+ if (resPath) this.addRoute('DELETE', resPath, expose, action.kind)
58
59
  break
59
60
  }
60
61
  }
@@ -66,14 +67,65 @@ export class RuntimeModelGenerator {
66
67
  }
67
68
  }
68
69
 
69
- private addRoute(method: string, path: string, exposedEntityKey: string, actionKind: ActionKind): void {
70
+ private addRoute(method: string, path: string, expose: ExposedEntity, actionKind: ActionKind): void {
70
71
  const upperMethod = method.toUpperCase()
71
72
  if (!this.#routingMap[upperMethod]) {
72
73
  this.#routingMap[upperMethod] = []
73
74
  }
75
+
76
+ const domainEntity = this.#apiModel.domain?.findEntity(expose.entity.key, expose.entity.domain)
77
+ if (!domainEntity) throw new Error(`Entity ${expose.key} not found, this should never happen`)
78
+
79
+ const params: RouteParam[] = []
80
+ let current: ExposedEntity | undefined
81
+ let previous: ExposedEntity | undefined = undefined
82
+ if (['list', 'create', 'search'].includes(actionKind)) {
83
+ // when starting with a collection, ignore self and start with parent (if any).
84
+ // A collection won't have any path params anyway, so we can skip it.
85
+ current = expose.parent ? this.#apiModel.exposes.get(expose.parent.key) : undefined
86
+ // setting the previous to the current so the associations are captured.
87
+ previous = expose
88
+ } else {
89
+ current = expose
90
+ }
91
+
92
+ while (current) {
93
+ const match = current.resourcePath.match(/\{([^}]+)\}/)
94
+ if (match) {
95
+ const entity = this.#apiModel.domain?.findEntity(current.entity.key, current.entity.domain)
96
+ if (!entity) throw new Error(`Entity ${current.entity.key} not found, this should never happen`)
97
+ const pkName = entity.primaryKey()?.info.name
98
+ if (!pkName) throw new Error(`Entity ${current.entity.key} has no primary key, this should never happen`)
99
+
100
+ let associationName: string | undefined
101
+ if (previous && previous.parent && previous.parent.key === current.key) {
102
+ for (const assoc of entity.withInheritedAssociations()) {
103
+ if (previous.parent?.association.key === assoc.key) {
104
+ associationName = assoc.info.name
105
+ break
106
+ }
107
+ }
108
+ }
109
+
110
+ params.push({
111
+ paramName: match[1],
112
+ exposedEntityKey: current.key,
113
+ exposedEntityName: entity.info.name as string,
114
+ propertyName: pkName,
115
+ ...(associationName ? { associationName } : {}),
116
+ })
117
+ }
118
+ previous = current
119
+ current = current.parent ? this.#apiModel.exposes.get(current.parent.key) : undefined
120
+ }
74
121
  this.#routingMap[upperMethod].push({
75
122
  path,
76
- lookup: { exposedEntityKey, actionKind },
123
+ lookup: {
124
+ exposedEntityKey: expose.key,
125
+ exposedEntityName: domainEntity.info.name as string,
126
+ actionKind,
127
+ params,
128
+ },
77
129
  })
78
130
  }
79
131
  }
@@ -0,0 +1,270 @@
1
+ import { DataDomain } from '../../../../src/modeling/DataDomain.js'
2
+ import type { DomainEntity } from '../../../../src/modeling/DomainEntity.js'
3
+ import type { DomainModel } from '../../../../src/modeling/DomainModel.js'
4
+ import { SemanticType } from '../../../../src/modeling/Semantics.js'
5
+
6
+ /**
7
+ * Creates the simplest data domain with a single entity user.
8
+ */
9
+ export function userOnlyDomain(): { domain: DataDomain; model: DomainModel; user: DomainEntity } {
10
+ const domain = new DataDomain({ info: { name: 'Testing Domain', version: '1.0.0' } })
11
+ const model = domain.addModel({ info: { name: 'Container model' } })
12
+ const user = model.addEntity({ info: { name: 'user' } })
13
+ user.addProperty({ info: { name: 'id' }, primary: true })
14
+ user.addProperty({ info: { name: 'username' }, semantics: [{ id: SemanticType.Username }] })
15
+ user.addProperty({ info: { name: 'password' }, semantics: [{ id: SemanticType.Password }] })
16
+ user.addProperty({ info: { name: 'email' }, semantics: [{ id: SemanticType.Email }] })
17
+ user.addProperty({
18
+ info: { name: 'role' },
19
+ semantics: [{ id: SemanticType.UserRole }],
20
+ schema: {
21
+ enum: ['admin', 'member', 'guest'],
22
+ },
23
+ })
24
+ return { domain, model, user }
25
+ }
26
+
27
+ /**
28
+ * Returns a domain that has 1:1 association between user and config.
29
+ */
30
+ export function singletonDomain(): {
31
+ domain: DataDomain
32
+ model: DomainModel
33
+ user: DomainEntity
34
+ config: DomainEntity
35
+ } {
36
+ const { domain, model, user } = userOnlyDomain()
37
+ const config = model.addEntity({ info: { name: 'config' } })
38
+ config.addProperty({ info: { name: 'id' }, primary: true })
39
+ // user has 1 config
40
+ user.addAssociation({
41
+ info: { name: 'config' },
42
+ targets: [{ key: config.key }],
43
+ multiple: false,
44
+ required: false,
45
+ })
46
+ config.addAssociation({
47
+ info: { name: 'owner' },
48
+ targets: [{ key: user.key }],
49
+ multiple: false,
50
+ required: true,
51
+ semantics: [{ id: SemanticType.ResourceOwnerIdentifier }],
52
+ })
53
+ return { domain, model, user, config }
54
+ }
55
+
56
+ /**
57
+ * Returns a blog domain with four entities: user, blog, post, comment.
58
+ */
59
+ export function blogDomain(): {
60
+ domain: DataDomain
61
+ user: DomainEntity
62
+ blog: DomainEntity
63
+ post: DomainEntity
64
+ comment: DomainEntity
65
+ } {
66
+ const domain = new DataDomain({ info: { version: '1.0.0' } })
67
+ const model = domain.addModel({ info: { name: 'Container Model' } })
68
+
69
+ const user = model.addEntity({ info: { name: 'user' }, semantics: [{ id: SemanticType.User }] })
70
+ user.addProperty({
71
+ info: { name: 'id' },
72
+ type: 'string',
73
+ primary: true,
74
+ required: true,
75
+ schema: { defaultValue: { type: 'function', value: 'random' } },
76
+ })
77
+ user.addProperty({
78
+ info: { name: 'name' },
79
+ type: 'string',
80
+ required: true,
81
+ semantics: [{ id: SemanticType.Username }],
82
+ })
83
+ user.addProperty({
84
+ info: { name: 'password' },
85
+ type: 'string',
86
+ required: true,
87
+ semantics: [{ id: SemanticType.Password }],
88
+ })
89
+ user.addProperty({
90
+ info: { name: 'role' },
91
+ type: 'string',
92
+ required: true,
93
+ semantics: [{ id: SemanticType.UserRole }],
94
+ schema: {
95
+ defaultValue: { type: 'literal', value: 'user' },
96
+ enum: ['user', 'admin', 'moderator'],
97
+ },
98
+ })
99
+
100
+ const blog = model.addEntity({ info: { name: 'blog' } })
101
+ blog.addProperty({
102
+ info: { name: 'id' },
103
+ type: 'string',
104
+ primary: true,
105
+ required: true,
106
+ schema: { defaultValue: { type: 'function', value: 'random' } },
107
+ })
108
+ blog.addProperty({
109
+ info: { name: 'title' },
110
+ type: 'string',
111
+ required: true,
112
+ semantics: [{ id: SemanticType.Title }],
113
+ })
114
+ blog.addProperty({
115
+ info: { name: 'content' },
116
+ type: 'string',
117
+ required: true,
118
+ semantics: [{ id: SemanticType.Description }, { id: SemanticType.Markdown }],
119
+ })
120
+ blog.addProperty({
121
+ info: { name: 'status' },
122
+ type: 'string',
123
+ required: true,
124
+ semantics: [{ id: SemanticType.Status }],
125
+ schema: {
126
+ defaultValue: { type: 'literal', value: 'draft' },
127
+ enum: ['draft', 'published'],
128
+ },
129
+ })
130
+ blog.addProperty({
131
+ info: { name: 'created_at' },
132
+ type: 'datetime',
133
+ readOnly: true,
134
+ schema: { defaultValue: { type: 'function', value: 'now' } },
135
+ })
136
+ blog.addProperty({
137
+ info: { name: 'updated_at' },
138
+ type: 'datetime',
139
+ readOnly: true,
140
+ schema: { defaultValue: { type: 'function', value: 'now' } },
141
+ })
142
+ blog.addAssociation({
143
+ info: { name: 'author' },
144
+ targets: [{ key: user.key }],
145
+ multiple: false,
146
+ required: true,
147
+ semantics: [{ id: SemanticType.ResourceOwnerIdentifier }],
148
+ })
149
+ user.addAssociation({
150
+ info: { name: 'blogs' },
151
+ targets: [{ key: blog.key }],
152
+ multiple: true,
153
+ required: false,
154
+ onDelete: 'cascade',
155
+ })
156
+
157
+ const post = model.addEntity({ info: { name: 'posts' } })
158
+ post.addProperty({
159
+ info: { name: 'id' },
160
+ type: 'string',
161
+ primary: true,
162
+ required: true,
163
+ schema: { defaultValue: { type: 'function', value: 'random' } },
164
+ })
165
+ post.addProperty({
166
+ info: { name: 'title' },
167
+ type: 'string',
168
+ required: true,
169
+ semantics: [{ id: SemanticType.Title }],
170
+ })
171
+ post.addProperty({
172
+ info: { name: 'content' },
173
+ type: 'string',
174
+ required: true,
175
+ semantics: [{ id: SemanticType.Description }, { id: SemanticType.Markdown }],
176
+ })
177
+ post.addProperty({
178
+ info: { name: 'status' },
179
+ type: 'string',
180
+ required: true,
181
+ semantics: [{ id: SemanticType.Status }],
182
+ schema: {
183
+ defaultValue: { type: 'literal', value: 'draft' },
184
+ enum: ['draft', 'published'],
185
+ },
186
+ })
187
+ post.addProperty({
188
+ info: { name: 'created_at' },
189
+ type: 'datetime',
190
+ readOnly: true,
191
+ schema: { defaultValue: { type: 'function', value: 'now' } },
192
+ })
193
+ post.addProperty({
194
+ info: { name: 'updated_at' },
195
+ type: 'datetime',
196
+ readOnly: true,
197
+ schema: { defaultValue: { type: 'function', value: 'now' } },
198
+ })
199
+ post.addAssociation({
200
+ info: { name: 'author' },
201
+ targets: [{ key: user.key }],
202
+ multiple: false,
203
+ required: true,
204
+ semantics: [{ id: SemanticType.ResourceOwnerIdentifier }],
205
+ })
206
+ post.addAssociation({
207
+ info: { name: 'blog' },
208
+ targets: [{ key: blog.key }],
209
+ multiple: false,
210
+ required: true,
211
+ })
212
+ user.addAssociation({
213
+ info: { name: 'posts' },
214
+ targets: [{ key: post.key }],
215
+ multiple: true,
216
+ required: false,
217
+ onDelete: 'cascade',
218
+ })
219
+ blog.addAssociation({
220
+ info: { name: 'posts' },
221
+ targets: [{ key: post.key }],
222
+ multiple: true,
223
+ required: false,
224
+ onDelete: 'cascade',
225
+ })
226
+
227
+ const comment = model.addEntity({ info: { name: 'comment' } })
228
+ comment.addProperty({
229
+ info: { name: 'id' },
230
+ type: 'string',
231
+ primary: true,
232
+ required: true,
233
+ schema: { defaultValue: { type: 'function', value: 'random' } },
234
+ })
235
+ comment.addProperty({
236
+ info: { name: 'content' },
237
+ type: 'string',
238
+ required: true,
239
+ semantics: [{ id: SemanticType.Markdown }],
240
+ })
241
+ comment.addAssociation({
242
+ info: { name: 'author' },
243
+ targets: [{ key: user.key }],
244
+ multiple: false,
245
+ required: true,
246
+ semantics: [{ id: SemanticType.ResourceOwnerIdentifier }],
247
+ })
248
+ comment.addAssociation({
249
+ info: { name: 'post' },
250
+ targets: [{ key: post.key }],
251
+ multiple: false,
252
+ required: true,
253
+ })
254
+ user.addAssociation({
255
+ info: { name: 'comments' },
256
+ targets: [{ key: comment.key }],
257
+ multiple: true,
258
+ required: false,
259
+ onDelete: 'cascade',
260
+ })
261
+ post.addAssociation({
262
+ info: { name: 'comments' },
263
+ targets: [{ key: comment.key }],
264
+ multiple: true,
265
+ required: false,
266
+ onDelete: 'cascade',
267
+ })
268
+
269
+ return { domain, user, blog, post, comment }
270
+ }
@@ -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' } })
@@ -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 }) => {