@api-client/core 0.18.32 → 0.18.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/src/modeling/ApiModel.d.ts +1 -1
- package/build/src/modeling/ApiModel.d.ts.map +1 -1
- package/build/src/modeling/ApiModel.js +13 -6
- package/build/src/modeling/ApiModel.js.map +1 -1
- package/build/src/modeling/ExposedEntity.d.ts +26 -16
- package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
- package/build/src/modeling/ExposedEntity.js +339 -275
- package/build/src/modeling/ExposedEntity.js.map +1 -1
- package/build/src/modeling/types.d.ts +11 -4
- package/build/src/modeling/types.d.ts.map +1 -1
- package/build/src/modeling/types.js.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/modeling/ApiModel.ts +5 -5
- package/src/modeling/ExposedEntity.ts +61 -47
- package/src/modeling/types.ts +11 -4
- package/tests/unit/modeling/api_model.spec.ts +4 -4
- package/tests/unit/modeling/api_model_expose_entity.spec.ts +2 -3
- package/tests/unit/modeling/api_model_remove_entity.spec.ts +1 -2
- package/tests/unit/modeling/exposed_entity.spec.ts +82 -27
package/package.json
CHANGED
package/src/modeling/ApiModel.ts
CHANGED
|
@@ -170,7 +170,7 @@ export class ApiModel extends DependentModel {
|
|
|
170
170
|
* The specific subset of Data Entities to be exposed by this API.
|
|
171
171
|
* These are the entities that are included in the data domain schema.
|
|
172
172
|
*/
|
|
173
|
-
exposes: ExposedEntity[]
|
|
173
|
+
@observed({ deep: true }) accessor exposes: ExposedEntity[]
|
|
174
174
|
/**
|
|
175
175
|
* Optional array of access rules that define the access control policies
|
|
176
176
|
* for the API. These rules are used to enforce security and permissions
|
|
@@ -411,8 +411,8 @@ export class ApiModel extends DependentModel {
|
|
|
411
411
|
entity: { ...entity },
|
|
412
412
|
actions: [],
|
|
413
413
|
isRoot: true,
|
|
414
|
-
relativeCollectionPath,
|
|
415
|
-
relativeResourcePath,
|
|
414
|
+
collectionPath: relativeCollectionPath,
|
|
415
|
+
resourcePath: relativeResourcePath,
|
|
416
416
|
hasCollection: true,
|
|
417
417
|
}
|
|
418
418
|
if (options) {
|
|
@@ -498,8 +498,8 @@ export class ApiModel extends DependentModel {
|
|
|
498
498
|
entity: { ...target },
|
|
499
499
|
actions: [],
|
|
500
500
|
isRoot: false,
|
|
501
|
-
relativeCollectionPath,
|
|
502
|
-
relativeResourcePath,
|
|
501
|
+
collectionPath: relativeCollectionPath,
|
|
502
|
+
resourcePath: relativeResourcePath,
|
|
503
503
|
hasCollection: isCollection,
|
|
504
504
|
parent: {
|
|
505
505
|
key: parentKey,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observed } from '../decorators/observed.js'
|
|
1
2
|
import { ExposedEntityKind } from '../models/kinds.js'
|
|
2
3
|
import { nanoid } from '../nanoid.js'
|
|
3
4
|
import type { ApiModel } from './ApiModel.js'
|
|
@@ -32,61 +33,71 @@ export class ExposedEntity extends EventTarget {
|
|
|
32
33
|
/**
|
|
33
34
|
* A pointer to a Data Entity from the Data Domain.
|
|
34
35
|
*/
|
|
35
|
-
entity: AssociationTarget
|
|
36
|
+
@observed() accessor entity: AssociationTarget
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* Indicates whether this exposure has a collection endpoint.
|
|
39
40
|
* A collection endpoint is optional for nested exposures where the association is 1:1
|
|
40
41
|
* and the schema is embedded directly under the parent resource.
|
|
42
|
+
*
|
|
43
|
+
* Note that this property is not observed for changes as it is immutable after creation.
|
|
41
44
|
*/
|
|
42
45
|
hasCollection: boolean
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
|
-
*
|
|
48
|
+
* Path to the collection endpoint for this exposure.
|
|
46
49
|
* Starts with '/'. Not set for 1:1 nested exposures where collection does not exist.
|
|
47
50
|
*/
|
|
48
|
-
|
|
51
|
+
@observed() accessor collectionPath: string | undefined
|
|
49
52
|
|
|
50
53
|
/**
|
|
51
|
-
*
|
|
54
|
+
* Path to the resource endpoint for this exposure.
|
|
52
55
|
* Starts with '/'. For 1:1 nested exposures the resource path typically does not include an id segment.
|
|
53
56
|
*/
|
|
54
|
-
|
|
57
|
+
@observed() accessor resourcePath: string
|
|
55
58
|
|
|
56
59
|
/**
|
|
57
60
|
* Whether this exposure is a root exposure (top-level collection).
|
|
58
61
|
* If this is set then the `parent` reference must be populated.
|
|
62
|
+
*
|
|
63
|
+
* Note that this property is not observed for changes as it is immutable after creation.
|
|
59
64
|
*/
|
|
60
65
|
isRoot?: boolean
|
|
61
66
|
|
|
62
67
|
/**
|
|
63
68
|
* Parent reference when this exposure was created via following an association.
|
|
69
|
+
*
|
|
70
|
+
* Note that this property is not observed for changes as it is immutable after creation.
|
|
64
71
|
*/
|
|
65
72
|
parent?: ExposeParentRef
|
|
66
73
|
|
|
67
74
|
/**
|
|
68
75
|
* Expose-time config used to create this exposure (persisted for auditing/UI).
|
|
69
76
|
* This is only populated for the root exposure. All children exposures inherit this config.
|
|
77
|
+
*
|
|
78
|
+
* Note that this property is not observed for changes as it is immutable after creation.
|
|
70
79
|
*/
|
|
71
80
|
exposeOptions?: ExposeOptions
|
|
72
81
|
|
|
73
82
|
/**
|
|
74
83
|
* The list of enabled API actions for this exposure (List/Read/Create/etc.)
|
|
75
84
|
*/
|
|
76
|
-
actions: ApiAction[]
|
|
85
|
+
@observed({ deep: true }) accessor actions: ApiAction[]
|
|
77
86
|
|
|
78
87
|
/**
|
|
79
88
|
* Optional array of access rules that define the access control policies for this exposure.
|
|
80
89
|
*/
|
|
81
|
-
accessRule
|
|
90
|
+
@observed({ deep: true }) accessor accessRule: AccessRule[] | undefined
|
|
82
91
|
|
|
83
92
|
/**
|
|
84
93
|
* Optional configuration for rate limiting for this exposure.
|
|
85
94
|
*/
|
|
86
|
-
rateLimiting
|
|
95
|
+
@observed({ deep: true }) accessor rateLimiting: RateLimitingConfiguration | undefined
|
|
87
96
|
|
|
88
97
|
/**
|
|
89
98
|
* When true, generation for this exposure hit configured limits
|
|
99
|
+
*
|
|
100
|
+
* Note that this property is not observed for changes as it is immutable after creation.
|
|
90
101
|
*/
|
|
91
102
|
truncated?: boolean
|
|
92
103
|
|
|
@@ -97,6 +108,11 @@ export class ExposedEntity extends EventTarget {
|
|
|
97
108
|
* the current notification is sent.
|
|
98
109
|
*/
|
|
99
110
|
#notifying = false
|
|
111
|
+
/**
|
|
112
|
+
* When the initializing flag is set to true,
|
|
113
|
+
* the domain is not notified of changes.
|
|
114
|
+
*/
|
|
115
|
+
#initializing = true
|
|
100
116
|
/**
|
|
101
117
|
* A reference to the parent API Model instance.
|
|
102
118
|
*/
|
|
@@ -106,8 +122,8 @@ export class ExposedEntity extends EventTarget {
|
|
|
106
122
|
const {
|
|
107
123
|
key = nanoid(),
|
|
108
124
|
entity = { key: '' },
|
|
109
|
-
|
|
110
|
-
|
|
125
|
+
collectionPath,
|
|
126
|
+
resourcePath = '/',
|
|
111
127
|
hasCollection = true,
|
|
112
128
|
isRoot,
|
|
113
129
|
parent,
|
|
@@ -122,11 +138,11 @@ export class ExposedEntity extends EventTarget {
|
|
|
122
138
|
key,
|
|
123
139
|
entity: { ...entity },
|
|
124
140
|
hasCollection,
|
|
125
|
-
|
|
141
|
+
resourcePath,
|
|
126
142
|
actions: actions.map((a) => ({ ...a })),
|
|
127
143
|
}
|
|
128
|
-
if (
|
|
129
|
-
result.
|
|
144
|
+
if (collectionPath !== undefined) {
|
|
145
|
+
result.collectionPath = collectionPath
|
|
130
146
|
}
|
|
131
147
|
if (isRoot !== undefined) {
|
|
132
148
|
result.isRoot = isRoot
|
|
@@ -157,8 +173,8 @@ export class ExposedEntity extends EventTarget {
|
|
|
157
173
|
this.key = init.key
|
|
158
174
|
this.entity = init.entity
|
|
159
175
|
this.hasCollection = init.hasCollection
|
|
160
|
-
this.
|
|
161
|
-
this.
|
|
176
|
+
this.collectionPath = init.collectionPath
|
|
177
|
+
this.resourcePath = init.resourcePath
|
|
162
178
|
this.isRoot = init.isRoot
|
|
163
179
|
this.parent = init.parent
|
|
164
180
|
this.exposeOptions = init.exposeOptions
|
|
@@ -166,10 +182,11 @@ export class ExposedEntity extends EventTarget {
|
|
|
166
182
|
this.accessRule = init.accessRule
|
|
167
183
|
this.rateLimiting = init.rateLimiting
|
|
168
184
|
this.truncated = init.truncated
|
|
185
|
+
this.#initializing = false
|
|
169
186
|
}
|
|
170
187
|
|
|
171
188
|
notifyChange() {
|
|
172
|
-
if (this.#notifying) {
|
|
189
|
+
if (this.#notifying || this.#initializing) {
|
|
173
190
|
return
|
|
174
191
|
}
|
|
175
192
|
this.#notifying = true
|
|
@@ -185,12 +202,12 @@ export class ExposedEntity extends EventTarget {
|
|
|
185
202
|
kind: this.kind,
|
|
186
203
|
key: this.key,
|
|
187
204
|
entity: { ...this.entity },
|
|
188
|
-
|
|
205
|
+
resourcePath: this.resourcePath,
|
|
189
206
|
actions: this.actions.map((a) => ({ ...a })),
|
|
190
207
|
hasCollection: this.hasCollection,
|
|
191
208
|
}
|
|
192
|
-
if (this.
|
|
193
|
-
result.
|
|
209
|
+
if (this.collectionPath !== undefined) {
|
|
210
|
+
result.collectionPath = this.collectionPath
|
|
194
211
|
}
|
|
195
212
|
if (this.isRoot !== undefined) {
|
|
196
213
|
result.isRoot = this.isRoot
|
|
@@ -214,15 +231,15 @@ export class ExposedEntity extends EventTarget {
|
|
|
214
231
|
}
|
|
215
232
|
|
|
216
233
|
/**
|
|
217
|
-
* Sets a new
|
|
234
|
+
* Sets a new collection path for this exposed entity.
|
|
218
235
|
*
|
|
219
236
|
* It:
|
|
220
|
-
* - updates the
|
|
237
|
+
* - updates the collectionPath property
|
|
221
238
|
* - updates the absoluteCollectionPath property accordingly
|
|
222
|
-
* - updates the
|
|
239
|
+
* - updates the resourcePath accordingly.
|
|
223
240
|
* @param path The new path to set.
|
|
224
241
|
*/
|
|
225
|
-
|
|
242
|
+
setCollectionPath(path: string) {
|
|
226
243
|
if (!this.hasCollection) {
|
|
227
244
|
throw new Error(`Cannot set collection path on an exposure that does not have a collection`)
|
|
228
245
|
}
|
|
@@ -235,22 +252,21 @@ export class ExposedEntity extends EventTarget {
|
|
|
235
252
|
const normalizedCollection = `/${segments[0]}`
|
|
236
253
|
// Preserve current parameter name if present, otherwise default to {id}
|
|
237
254
|
let param = '{id}'
|
|
238
|
-
if (this.
|
|
239
|
-
const curSegments = this.
|
|
255
|
+
if (this.resourcePath) {
|
|
256
|
+
const curSegments = this.resourcePath.split('/').filter(Boolean)
|
|
240
257
|
const maybeParam = curSegments[1]
|
|
241
258
|
if (maybeParam && /^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(maybeParam)) {
|
|
242
259
|
param = maybeParam
|
|
243
260
|
}
|
|
244
261
|
}
|
|
245
262
|
const nextResource = `${normalizedCollection}/${param}`
|
|
246
|
-
|
|
247
|
-
this.
|
|
248
|
-
|
|
249
|
-
if (changed) this.notifyChange()
|
|
263
|
+
this.collectionPath = normalizedCollection
|
|
264
|
+
this.resourcePath = nextResource
|
|
265
|
+
// rely on ApiModel.exposes deep observation to notify on property sets
|
|
250
266
|
}
|
|
251
267
|
|
|
252
268
|
/**
|
|
253
|
-
* Sets a new
|
|
269
|
+
* Sets a new resource path for this exposed entity.
|
|
254
270
|
*
|
|
255
271
|
* Rules:
|
|
256
272
|
* - Must start with '/'.
|
|
@@ -258,17 +274,17 @@ export class ExposedEntity extends EventTarget {
|
|
|
258
274
|
* parameter segment (e.g. `/products/{productId}`) and only the parameter name may vary.
|
|
259
275
|
* - If this exposure does NOT have a collection, the path can be any two segments (e.g. `/profile/{id}` or `/a/b`).
|
|
260
276
|
*/
|
|
261
|
-
|
|
277
|
+
setResourcePath(path: string) {
|
|
262
278
|
const cleaned = ensureLeadingSlash(path)
|
|
263
279
|
const segments = cleaned.split('/').filter(Boolean)
|
|
264
280
|
|
|
265
281
|
if (this.hasCollection) {
|
|
266
|
-
if (!this.
|
|
282
|
+
if (!this.collectionPath) {
|
|
267
283
|
throw new Error('Cannot set resource path: missing collection path for this exposure')
|
|
268
284
|
}
|
|
269
|
-
const colSegments = this.
|
|
285
|
+
const colSegments = this.collectionPath.split('/').filter(Boolean)
|
|
270
286
|
if (colSegments.length !== 1) {
|
|
271
|
-
throw new Error(`Invalid stored collection path "${this.
|
|
287
|
+
throw new Error(`Invalid stored collection path "${this.collectionPath}"`)
|
|
272
288
|
}
|
|
273
289
|
if (segments.length !== 2) {
|
|
274
290
|
throw new Error(`Resource path must be exactly two segments (collection + parameter). Received: "${cleaned}"`)
|
|
@@ -281,9 +297,8 @@ export class ExposedEntity extends EventTarget {
|
|
|
281
297
|
if (!/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(s2)) {
|
|
282
298
|
throw new Error(`The second segment must be a parameter in braces, e.g. {id}. Received: "${s2}"`)
|
|
283
299
|
}
|
|
284
|
-
if (this.
|
|
285
|
-
this.
|
|
286
|
-
this.notifyChange()
|
|
300
|
+
if (this.resourcePath !== cleaned) {
|
|
301
|
+
this.resourcePath = `/${s1}/${s2}`
|
|
287
302
|
}
|
|
288
303
|
return
|
|
289
304
|
}
|
|
@@ -294,9 +309,8 @@ export class ExposedEntity extends EventTarget {
|
|
|
294
309
|
`Resource path must contain exactly two segments when no collection is present. Received: "${cleaned}"`
|
|
295
310
|
)
|
|
296
311
|
}
|
|
297
|
-
if (this.
|
|
298
|
-
this.
|
|
299
|
-
this.notifyChange()
|
|
312
|
+
if (this.resourcePath !== cleaned) {
|
|
313
|
+
this.resourcePath = `/${segments[0]}/${segments[1]}`
|
|
300
314
|
}
|
|
301
315
|
}
|
|
302
316
|
|
|
@@ -304,16 +318,16 @@ export class ExposedEntity extends EventTarget {
|
|
|
304
318
|
* Computes the absolute path for this exposure's resource endpoint by
|
|
305
319
|
* walking up the exposure tree using `parent.key` until reaching a root exposure.
|
|
306
320
|
* The absolute path is composed by concatenating each ancestor's resource path
|
|
307
|
-
* with this exposure's
|
|
321
|
+
* with this exposure's resource path.
|
|
308
322
|
*/
|
|
309
323
|
getAbsoluteResourcePath(): string {
|
|
310
|
-
let absolute = ensureLeadingSlash(this.
|
|
324
|
+
let absolute = ensureLeadingSlash(this.resourcePath)
|
|
311
325
|
// Traverse parents, always joining with the parent's resource path
|
|
312
326
|
let parentKey = this.parent?.key
|
|
313
327
|
while (parentKey) {
|
|
314
328
|
const parent = this.api.exposes.find((e) => e.key === parentKey)
|
|
315
329
|
if (!parent) break
|
|
316
|
-
const parentResource = ensureLeadingSlash(parent.
|
|
330
|
+
const parentResource = ensureLeadingSlash(parent.resourcePath)
|
|
317
331
|
absolute = joinPaths(parentResource, absolute)
|
|
318
332
|
parentKey = parent.parent?.key
|
|
319
333
|
}
|
|
@@ -324,18 +338,18 @@ export class ExposedEntity extends EventTarget {
|
|
|
324
338
|
* Computes the absolute path for this exposure's collection endpoint (if any)
|
|
325
339
|
* by walking up the exposure tree using `parent.key` until reaching a root exposure.
|
|
326
340
|
* The absolute path is composed by concatenating each ancestor's resource path
|
|
327
|
-
* with this exposure's
|
|
341
|
+
* with this exposure's collection path.
|
|
328
342
|
* Returns undefined if this exposure has no collection.
|
|
329
343
|
*/
|
|
330
344
|
getAbsoluteCollectionPath(): string | undefined {
|
|
331
|
-
if (!this.hasCollection || !this.
|
|
332
|
-
let absolute = ensureLeadingSlash(this.
|
|
345
|
+
if (!this.hasCollection || !this.collectionPath) return undefined
|
|
346
|
+
let absolute = ensureLeadingSlash(this.collectionPath)
|
|
333
347
|
// Traverse parents, always joining with the parent's resource path
|
|
334
348
|
let parentKey = this.parent?.key
|
|
335
349
|
while (parentKey) {
|
|
336
350
|
const parent = this.api.exposes.find((e) => e.key === parentKey)
|
|
337
351
|
if (!parent) break
|
|
338
|
-
const parentResource = ensureLeadingSlash(parent.
|
|
352
|
+
const parentResource = ensureLeadingSlash(parent.resourcePath)
|
|
339
353
|
absolute = joinPaths(parentResource, absolute)
|
|
340
354
|
parentKey = parent.parent?.key
|
|
341
355
|
}
|
package/src/modeling/types.ts
CHANGED
|
@@ -457,16 +457,16 @@ export interface ExposedEntitySchema {
|
|
|
457
457
|
*/
|
|
458
458
|
hasCollection: boolean
|
|
459
459
|
/**
|
|
460
|
-
*
|
|
460
|
+
* A path to the collection endpoint for this exposure.
|
|
461
461
|
* Starts with '/'. Not set for 1:1 nested exposures where collection does not exist.
|
|
462
462
|
*/
|
|
463
|
-
|
|
463
|
+
collectionPath?: string
|
|
464
464
|
|
|
465
465
|
/**
|
|
466
|
-
*
|
|
466
|
+
* A path to the resource endpoint for this exposure.
|
|
467
467
|
* Starts with '/'. For 1:1 nested exposures the resource path typically does not include an id segment.
|
|
468
468
|
*/
|
|
469
|
-
|
|
469
|
+
resourcePath: string
|
|
470
470
|
|
|
471
471
|
/**
|
|
472
472
|
* Whether this exposure is a root exposure (top-level collection).
|
|
@@ -674,6 +674,13 @@ export interface DeleteAction extends Action {
|
|
|
674
674
|
* @default 'soft'
|
|
675
675
|
*/
|
|
676
676
|
strategy?: 'soft' | 'hard'
|
|
677
|
+
/**
|
|
678
|
+
* The data retention period (in days) for soft-deleted resources.
|
|
679
|
+
* This defines how long the data should be kept before it is permanently deleted.
|
|
680
|
+
*
|
|
681
|
+
* @default 30
|
|
682
|
+
*/
|
|
683
|
+
retentionPeriod?: number
|
|
677
684
|
}
|
|
678
685
|
|
|
679
686
|
/**
|
|
@@ -42,7 +42,7 @@ test.group('ApiModel.createSchema()', () => {
|
|
|
42
42
|
actions: [],
|
|
43
43
|
hasCollection: true,
|
|
44
44
|
kind: ExposedEntityKind,
|
|
45
|
-
|
|
45
|
+
resourcePath: '/',
|
|
46
46
|
entity: { key: 'entity1' },
|
|
47
47
|
},
|
|
48
48
|
],
|
|
@@ -116,7 +116,7 @@ test.group('ApiModel.constructor()', () => {
|
|
|
116
116
|
{
|
|
117
117
|
key: 'entity1',
|
|
118
118
|
actions: [],
|
|
119
|
-
|
|
119
|
+
resourcePath: '/',
|
|
120
120
|
entity: { key: 'entity1' },
|
|
121
121
|
hasCollection: true,
|
|
122
122
|
kind: ExposedEntityKind,
|
|
@@ -205,7 +205,7 @@ test.group('ApiModel.toJSON()', () => {
|
|
|
205
205
|
{
|
|
206
206
|
key: 'entity1',
|
|
207
207
|
actions: [],
|
|
208
|
-
|
|
208
|
+
resourcePath: '/',
|
|
209
209
|
entity: { key: 'entity1' },
|
|
210
210
|
hasCollection: true,
|
|
211
211
|
kind: ExposedEntityKind,
|
|
@@ -251,7 +251,7 @@ test.group('ApiModel.getExposedEntity()', () => {
|
|
|
251
251
|
actions: [],
|
|
252
252
|
hasCollection: true,
|
|
253
253
|
kind: ExposedEntityKind,
|
|
254
|
-
|
|
254
|
+
resourcePath: '/',
|
|
255
255
|
entity: { key: entityKey },
|
|
256
256
|
}
|
|
257
257
|
model.exposes.push(new ExposedEntity(model, exposed))
|
|
@@ -15,7 +15,6 @@ test.group('ApiModel.exposeEntity()', () => {
|
|
|
15
15
|
assert.typeOf(exposedEntity.key, 'string')
|
|
16
16
|
assert.deepEqual(exposedEntity.entity, { key: e1.key })
|
|
17
17
|
assert.deepEqual(exposedEntity.actions, [])
|
|
18
|
-
assert.includeDeepMembers(model.exposes, [exposedEntity])
|
|
19
18
|
}).tags(['@modeling', '@api'])
|
|
20
19
|
|
|
21
20
|
test('returns an existing entity if already exposed', ({ assert }) => {
|
|
@@ -28,7 +27,7 @@ test.group('ApiModel.exposeEntity()', () => {
|
|
|
28
27
|
const initialExposedEntity = model.exposeEntity({ key: e1.key })
|
|
29
28
|
const retrievedExposedEntity = model.exposeEntity({ key: e1.key })
|
|
30
29
|
|
|
31
|
-
assert.
|
|
30
|
+
assert.deepEqual(retrievedExposedEntity.toJSON(), initialExposedEntity.toJSON())
|
|
32
31
|
assert.lengthOf(model.exposes, 1)
|
|
33
32
|
}).tags(['@modeling', '@api'])
|
|
34
33
|
|
|
@@ -81,7 +80,7 @@ test.group('ApiModel.exposeEntity()', () => {
|
|
|
81
80
|
const nestedB = model.exposes.find((e) => !e.isRoot && e.entity.key === eB.key)
|
|
82
81
|
assert.isDefined(nestedB)
|
|
83
82
|
assert.deepEqual(nestedB?.parent?.key, exposedA.key)
|
|
84
|
-
assert.strictEqual(nestedB?.
|
|
83
|
+
assert.strictEqual(nestedB?.collectionPath, '/entitybs')
|
|
85
84
|
})
|
|
86
85
|
|
|
87
86
|
test('does not infinitely expose circular associations', ({ assert }) => {
|
|
@@ -45,10 +45,9 @@ test.group('ApiModel.removeEntity()', () => {
|
|
|
45
45
|
const model = new ApiModel()
|
|
46
46
|
model.attachDataDomain(domain)
|
|
47
47
|
model.exposeEntity({ key: e1.key })
|
|
48
|
-
const initialExposes = [...model.exposes]
|
|
49
48
|
|
|
50
49
|
model.removeEntity({ key: 'non-existing-entity' })
|
|
51
|
-
assert.
|
|
50
|
+
assert.lengthOf(model.exposes, 1, 'exposes count should remain unchanged')
|
|
52
51
|
}).tags(['@modeling', '@api'])
|
|
53
52
|
|
|
54
53
|
test('notifies change when an entity is removed', async ({ assert }) => {
|
|
@@ -1,80 +1,81 @@
|
|
|
1
1
|
import { test } from '@japa/runner'
|
|
2
|
-
import { ApiModel, ExposedEntity } from '../../../src/index.js'
|
|
2
|
+
import { ApiModel, ExposedEntity, type ExposedEntitySchema } from '../../../src/index.js'
|
|
3
|
+
import { ExposedEntityKind } from '../../../src/models/kinds.js'
|
|
3
4
|
|
|
4
5
|
test.group('ExposedEntity', () => {
|
|
5
|
-
test('
|
|
6
|
+
test('setCollectionPath normalizes and preserves resource param', ({ assert }) => {
|
|
6
7
|
const model = new ApiModel()
|
|
7
8
|
const ex = new ExposedEntity(model, {
|
|
8
9
|
hasCollection: true,
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
collectionPath: '/items',
|
|
11
|
+
resourcePath: '/items/{customId}',
|
|
11
12
|
})
|
|
12
13
|
|
|
13
|
-
ex.
|
|
14
|
+
ex.setCollectionPath('products')
|
|
14
15
|
|
|
15
|
-
assert.equal(ex.
|
|
16
|
-
assert.equal(ex.
|
|
16
|
+
assert.equal(ex.collectionPath, '/products')
|
|
17
|
+
assert.equal(ex.resourcePath, '/products/{customId}')
|
|
17
18
|
}).tags(['@modeling', '@exposed-entity'])
|
|
18
19
|
|
|
19
|
-
test('
|
|
20
|
+
test('setResourcePath with collection allows only parameter name change', ({ assert }) => {
|
|
20
21
|
const model = new ApiModel()
|
|
21
22
|
const ex = new ExposedEntity(model, {
|
|
22
23
|
hasCollection: true,
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
collectionPath: '/products',
|
|
25
|
+
resourcePath: '/products/{id}',
|
|
25
26
|
})
|
|
26
27
|
|
|
27
28
|
// valid: same collection segment, different param name
|
|
28
|
-
ex.
|
|
29
|
-
assert.equal(ex.
|
|
29
|
+
ex.setResourcePath('/products/{productId}')
|
|
30
|
+
assert.equal(ex.resourcePath, '/products/{productId}')
|
|
30
31
|
|
|
31
32
|
// invalid: different first segment
|
|
32
|
-
assert.throws(() => ex.
|
|
33
|
+
assert.throws(() => ex.setResourcePath('/wrong/{id}'))
|
|
33
34
|
|
|
34
35
|
// invalid: second segment not a parameter
|
|
35
|
-
assert.throws(() => ex.
|
|
36
|
+
assert.throws(() => ex.setResourcePath('/products/notParam'))
|
|
36
37
|
}).tags(['@modeling', '@exposed-entity'])
|
|
37
38
|
|
|
38
|
-
test('
|
|
39
|
+
test('setResourcePath without collection must have exactly two segments', ({ assert }) => {
|
|
39
40
|
const model = new ApiModel()
|
|
40
41
|
const ex = new ExposedEntity(model, {
|
|
41
42
|
hasCollection: false,
|
|
42
|
-
|
|
43
|
+
resourcePath: '/profile/{id}',
|
|
43
44
|
})
|
|
44
45
|
|
|
45
|
-
ex.
|
|
46
|
-
assert.equal(ex.
|
|
46
|
+
ex.setResourcePath('settings/secret')
|
|
47
|
+
assert.equal(ex.resourcePath, '/settings/secret')
|
|
47
48
|
|
|
48
|
-
assert.throws(() => ex.
|
|
49
|
+
assert.throws(() => ex.setResourcePath('onlyone'))
|
|
49
50
|
}).tags(['@modeling', '@exposed-entity'])
|
|
50
51
|
|
|
51
52
|
test('computes absolute resource and collection paths along parent chain', ({ assert }) => {
|
|
52
53
|
const model = new ApiModel()
|
|
53
54
|
|
|
54
55
|
// Build exposure schemas
|
|
55
|
-
const rootSchema = {
|
|
56
|
+
const rootSchema: Partial<ExposedEntitySchema> = {
|
|
56
57
|
key: 'root',
|
|
57
58
|
entity: { key: 'user' },
|
|
58
59
|
hasCollection: true,
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
collectionPath: '/users',
|
|
61
|
+
resourcePath: '/users/{userId}',
|
|
61
62
|
isRoot: true,
|
|
62
63
|
actions: [],
|
|
63
64
|
}
|
|
64
|
-
const childSchema = {
|
|
65
|
+
const childSchema: Partial<ExposedEntitySchema> = {
|
|
65
66
|
key: 'child',
|
|
66
67
|
entity: { key: 'post' },
|
|
67
68
|
hasCollection: true,
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
collectionPath: '/posts',
|
|
70
|
+
resourcePath: '/posts/{postId}',
|
|
70
71
|
parent: { key: 'root', association: { key: 'toPosts' } },
|
|
71
72
|
actions: [],
|
|
72
73
|
}
|
|
73
|
-
const grandSchema = {
|
|
74
|
+
const grandSchema: Partial<ExposedEntitySchema> = {
|
|
74
75
|
key: 'grand',
|
|
75
76
|
entity: { key: 'details' },
|
|
76
77
|
hasCollection: false,
|
|
77
|
-
|
|
78
|
+
resourcePath: '/details',
|
|
78
79
|
parent: { key: 'child', association: { key: 'toDetails' } },
|
|
79
80
|
actions: [],
|
|
80
81
|
}
|
|
@@ -97,4 +98,58 @@ test.group('ExposedEntity', () => {
|
|
|
97
98
|
assert.isUndefined(grandEx.getAbsoluteCollectionPath())
|
|
98
99
|
assert.equal(grandEx.getAbsoluteResourcePath(), '/users/{userId}/posts/{postId}/details')
|
|
99
100
|
}).tags(['@modeling', '@exposed-entity'])
|
|
101
|
+
|
|
102
|
+
test('ApiModel notifies when nested ExposedEntity collection path changes', async ({ assert }) => {
|
|
103
|
+
const model = new ApiModel({
|
|
104
|
+
exposes: [
|
|
105
|
+
{
|
|
106
|
+
kind: ExposedEntityKind,
|
|
107
|
+
key: 'e1',
|
|
108
|
+
entity: { key: 'e1' },
|
|
109
|
+
hasCollection: true,
|
|
110
|
+
collectionPath: '/things',
|
|
111
|
+
resourcePath: '/things/{id}',
|
|
112
|
+
isRoot: true,
|
|
113
|
+
actions: [],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
let notified = 0
|
|
119
|
+
model.addEventListener('change', () => {
|
|
120
|
+
notified += 1
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const ex = model.exposes[0]
|
|
124
|
+
ex.setCollectionPath('items')
|
|
125
|
+
await Promise.resolve() // allow ApiModel.notifyChange microtask to run
|
|
126
|
+
assert.isAtLeast(notified, 1)
|
|
127
|
+
}).tags(['@modeling', '@exposed-entity', '@observed'])
|
|
128
|
+
|
|
129
|
+
test('ApiModel notifies when nested ExposedEntity resource path changes', async ({ assert }) => {
|
|
130
|
+
const model = new ApiModel({
|
|
131
|
+
exposes: [
|
|
132
|
+
{
|
|
133
|
+
kind: ExposedEntityKind,
|
|
134
|
+
key: 'e1',
|
|
135
|
+
entity: { key: 'e1' },
|
|
136
|
+
hasCollection: true,
|
|
137
|
+
collectionPath: '/products',
|
|
138
|
+
resourcePath: '/products/{id}',
|
|
139
|
+
isRoot: true,
|
|
140
|
+
actions: [],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
let notified = 0
|
|
146
|
+
model.addEventListener('change', () => {
|
|
147
|
+
notified += 1
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const ex = model.exposes[0]
|
|
151
|
+
ex.setResourcePath('/products/{productId}')
|
|
152
|
+
await Promise.resolve()
|
|
153
|
+
assert.isAtLeast(notified, 1)
|
|
154
|
+
}).tags(['@modeling', '@exposed-entity', '@observed'])
|
|
100
155
|
})
|