@api-client/core 0.19.22 → 0.19.23
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.map +1 -1
- package/build/src/modeling/ApiModel.js +37 -13
- package/build/src/modeling/ApiModel.js.map +1 -1
- package/build/src/modeling/ExposedEntity.d.ts.map +1 -1
- package/build/src/modeling/ExposedEntity.js +54 -15
- package/build/src/modeling/ExposedEntity.js.map +1 -1
- package/build/src/modeling/actions/Action.js +2 -2
- package/build/src/modeling/actions/Action.js.map +1 -1
- package/build/src/modeling/rules/AccessRule.d.ts +5 -1
- package/build/src/modeling/rules/AccessRule.d.ts.map +1 -1
- package/build/src/modeling/rules/AccessRule.js +4 -1
- package/build/src/modeling/rules/AccessRule.js.map +1 -1
- package/build/src/modeling/rules/AllowAuthenticated.d.ts +4 -1
- package/build/src/modeling/rules/AllowAuthenticated.d.ts.map +1 -1
- package/build/src/modeling/rules/AllowAuthenticated.js +2 -2
- package/build/src/modeling/rules/AllowAuthenticated.js.map +1 -1
- package/build/src/modeling/rules/AllowPublic.d.ts +4 -1
- package/build/src/modeling/rules/AllowPublic.d.ts.map +1 -1
- package/build/src/modeling/rules/AllowPublic.js +2 -2
- package/build/src/modeling/rules/AllowPublic.js.map +1 -1
- package/build/src/modeling/rules/MatchEmailDomain.d.ts +4 -1
- package/build/src/modeling/rules/MatchEmailDomain.d.ts.map +1 -1
- package/build/src/modeling/rules/MatchEmailDomain.js +2 -2
- package/build/src/modeling/rules/MatchEmailDomain.js.map +1 -1
- package/build/src/modeling/rules/MatchResourceOwner.d.ts +4 -1
- package/build/src/modeling/rules/MatchResourceOwner.d.ts.map +1 -1
- package/build/src/modeling/rules/MatchResourceOwner.js +2 -2
- package/build/src/modeling/rules/MatchResourceOwner.js.map +1 -1
- package/build/src/modeling/rules/MatchUserProperty.d.ts +4 -1
- package/build/src/modeling/rules/MatchUserProperty.d.ts.map +1 -1
- package/build/src/modeling/rules/MatchUserProperty.js +2 -2
- package/build/src/modeling/rules/MatchUserProperty.js.map +1 -1
- package/build/src/modeling/rules/MatchUserRole.d.ts +4 -1
- package/build/src/modeling/rules/MatchUserRole.d.ts.map +1 -1
- package/build/src/modeling/rules/MatchUserRole.js +2 -2
- package/build/src/modeling/rules/MatchUserRole.js.map +1 -1
- package/build/src/modeling/rules/index.d.ts +4 -1
- package/build/src/modeling/rules/index.d.ts.map +1 -1
- package/build/src/modeling/rules/index.js +7 -7
- package/build/src/modeling/rules/index.js.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/modeling/ApiModel.ts +37 -13
- package/src/modeling/ExposedEntity.ts +62 -16
- package/src/modeling/actions/Action.ts +2 -2
- package/src/modeling/rules/AccessRule.ts +8 -1
- package/src/modeling/rules/AllowAuthenticated.ts +5 -2
- package/src/modeling/rules/AllowPublic.ts +5 -2
- package/src/modeling/rules/MatchEmailDomain.ts +5 -2
- package/src/modeling/rules/MatchResourceOwner.ts +5 -2
- package/src/modeling/rules/MatchUserProperty.ts +5 -2
- package/src/modeling/rules/MatchUserRole.ts +5 -2
- package/tests/unit/modeling/actions/Action.spec.ts +13 -10
- package/tests/unit/modeling/actions/CreateAction.spec.ts +7 -6
- package/tests/unit/modeling/actions/DeleteAction.spec.ts +7 -6
- package/tests/unit/modeling/actions/ListAction.spec.ts +5 -4
- package/tests/unit/modeling/actions/ReadAction.spec.ts +9 -8
- package/tests/unit/modeling/actions/SearchAction.spec.ts +5 -4
- package/tests/unit/modeling/actions/UpdateAction.spec.ts +7 -6
- package/tests/unit/modeling/actions/helpers.ts +7 -0
- package/tests/unit/modeling/api_model.spec.ts +3 -1
- package/tests/unit/modeling/api_model_expose_entity.spec.ts +5 -17
- package/tests/unit/modeling/exposed_entity.spec.ts +6 -2
- package/tests/unit/modeling/exposed_entity_actions.spec.ts +0 -4
- package/tests/unit/modeling/rules/AccessRule.spec.ts +6 -5
- package/tests/unit/modeling/rules/AllowAuthenticated.spec.ts +4 -3
- package/tests/unit/modeling/rules/AllowPublic.spec.ts +4 -3
- package/tests/unit/modeling/rules/MatchEmailDomain.spec.ts +6 -5
- package/tests/unit/modeling/rules/MatchResourceOwner.spec.ts +7 -6
- package/tests/unit/modeling/rules/MatchUserProperty.spec.ts +6 -5
- package/tests/unit/modeling/rules/MatchUserRole.spec.ts +6 -5
- package/tests/unit/modeling/rules/restoring_rules.spec.ts +19 -21
package/package.json
CHANGED
package/src/modeling/ApiModel.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { ExposedEntity } from './ExposedEntity.js'
|
|
|
22
22
|
import { AccessRule, AccessRuleSchema } from './rules/AccessRule.js'
|
|
23
23
|
import { RateLimitingConfiguration, RateLimitingConfigurationSchema } from './rules/RateLimitingConfiguration.js'
|
|
24
24
|
import { restoreAccessRule } from './rules/index.js'
|
|
25
|
+
import { Exception } from '../exceptions/exception.js'
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Contact information for the exposed API.
|
|
@@ -180,7 +181,7 @@ export class ApiModel extends DependentModel {
|
|
|
180
181
|
*
|
|
181
182
|
* The `key` is the key of the exposed entity. Using a Map to allow for quick lookups.
|
|
182
183
|
*/
|
|
183
|
-
@observed(
|
|
184
|
+
@observed() accessor exposes: Map<string, ExposedEntity>
|
|
184
185
|
/**
|
|
185
186
|
* Optional array of access rules that define the access control policies
|
|
186
187
|
* for the API. These rules are used to enforce security and permissions
|
|
@@ -189,7 +190,7 @@ export class ApiModel extends DependentModel {
|
|
|
189
190
|
* These rules apply to all exposed entities and actions. An API action
|
|
190
191
|
* can declare its own access rules, which will override these.
|
|
191
192
|
*/
|
|
192
|
-
@observed(
|
|
193
|
+
@observed() accessor accessRule: AccessRule[]
|
|
193
194
|
/**
|
|
194
195
|
* Optional configuration for API-wide rate limiting and throttling.
|
|
195
196
|
* Defines rules to protect the API from overuse.
|
|
@@ -294,7 +295,10 @@ export class ApiModel extends DependentModel {
|
|
|
294
295
|
} else if (typeof domain === 'object' && domain.kind === DataDomainKind) {
|
|
295
296
|
instances.push(new DataDomain(domain))
|
|
296
297
|
} else if (domain) {
|
|
297
|
-
throw new
|
|
298
|
+
throw new Exception(`Invalid domain provided. Expected a DataDomain instance or schema.`, {
|
|
299
|
+
code: 'E_DOMAIN_INVALID',
|
|
300
|
+
help: 'It looks like the provided data domain is not a valid instance or domain schema.',
|
|
301
|
+
})
|
|
298
302
|
}
|
|
299
303
|
// Note that since we're using the `DependentModel` class, but the API Model can have only one dependency,
|
|
300
304
|
// we keep the reference to the data domain under the `dependencyList` property.
|
|
@@ -302,7 +306,11 @@ export class ApiModel extends DependentModel {
|
|
|
302
306
|
// process when the API model is loaded from the API.
|
|
303
307
|
if (domain) {
|
|
304
308
|
if (!domain.info.version) {
|
|
305
|
-
throw new
|
|
309
|
+
throw new Exception(`Domain ${domain.key} must have a version.`, {
|
|
310
|
+
code: 'E_DOMAIN_NO_VERSION',
|
|
311
|
+
// eslint-disable-next-line max-len
|
|
312
|
+
help: 'Before attaching the data domain, give it a version name. Only versioned domains can be attached to an API model.',
|
|
313
|
+
})
|
|
306
314
|
}
|
|
307
315
|
init.dependencyList = [{ key: domain.key, version: domain.info.version }]
|
|
308
316
|
}
|
|
@@ -327,7 +335,7 @@ export class ApiModel extends DependentModel {
|
|
|
327
335
|
this.exposes = new Map()
|
|
328
336
|
}
|
|
329
337
|
if (Array.isArray(init.accessRule)) {
|
|
330
|
-
this.accessRule = init.accessRule.map((rule) => restoreAccessRule(rule))
|
|
338
|
+
this.accessRule = init.accessRule.map((rule) => restoreAccessRule(this, rule))
|
|
331
339
|
} else {
|
|
332
340
|
this.accessRule = []
|
|
333
341
|
}
|
|
@@ -422,7 +430,10 @@ export class ApiModel extends DependentModel {
|
|
|
422
430
|
exposeEntity(entity: AssociationTarget, options?: ExposeOptions): ExposedEntity {
|
|
423
431
|
const domain = this.domain
|
|
424
432
|
if (!domain) {
|
|
425
|
-
throw new
|
|
433
|
+
throw new Exception(`No domain attached to API model`, {
|
|
434
|
+
code: 'E_NO_DOMAIN',
|
|
435
|
+
help: 'Attach a data domain to the API model before exposing entities.',
|
|
436
|
+
})
|
|
426
437
|
}
|
|
427
438
|
// checks whether the entity is already exposed as a root exposure.
|
|
428
439
|
let existing: ExposedEntity | undefined
|
|
@@ -433,13 +444,17 @@ export class ApiModel extends DependentModel {
|
|
|
433
444
|
}
|
|
434
445
|
}
|
|
435
446
|
if (existing) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
447
|
+
throw new Exception(`Entity ${entity.key} is already exposed.`, {
|
|
448
|
+
code: 'E_ENTITY_ALREADY_EXPOSED',
|
|
449
|
+
help: 'Do not try to expose already exposed entity.',
|
|
450
|
+
})
|
|
439
451
|
}
|
|
440
452
|
const domainEntity = domain.findEntity(entity.key, entity.domain)
|
|
441
453
|
if (!domainEntity) {
|
|
442
|
-
throw new
|
|
454
|
+
throw new Exception(`Entity not found in domain: ${entity.key}`, {
|
|
455
|
+
code: 'E_NO_ENTITY',
|
|
456
|
+
help: 'Make sure the entity you are trying to expose exists in the data domain.',
|
|
457
|
+
})
|
|
443
458
|
}
|
|
444
459
|
const name = domainEntity.info.name || ''
|
|
445
460
|
const segment = pluralize(name.toLocaleLowerCase())
|
|
@@ -491,7 +506,9 @@ export class ApiModel extends DependentModel {
|
|
|
491
506
|
private followEntityAssociations(parentExposure: ExposedEntitySchema, options: ExposeOptions): void {
|
|
492
507
|
const domain = this.domain
|
|
493
508
|
if (!domain) {
|
|
494
|
-
|
|
509
|
+
throw new Exception(`No domain attached to API model`, {
|
|
510
|
+
code: 'E_NO_DOMAIN',
|
|
511
|
+
})
|
|
495
512
|
}
|
|
496
513
|
const maxDepth = options.maxDepth ?? 6
|
|
497
514
|
const follow = (currentEntity: AssociationTarget, parentKey: string, depth: number, currentPath: string[]) => {
|
|
@@ -609,7 +626,10 @@ export class ApiModel extends DependentModel {
|
|
|
609
626
|
*/
|
|
610
627
|
removeExposedEntity(key: string): void {
|
|
611
628
|
if (!this.exposes.has(key)) {
|
|
612
|
-
throw new
|
|
629
|
+
throw new Exception(`Exposed entity with key "${key}" not found.`, {
|
|
630
|
+
code: 'E_NO_EXPOSURE',
|
|
631
|
+
help: 'The exposed entity you are trying to remove does not exist.',
|
|
632
|
+
})
|
|
613
633
|
}
|
|
614
634
|
this.removeWithChildren(key)
|
|
615
635
|
}
|
|
@@ -667,7 +687,11 @@ export class ApiModel extends DependentModel {
|
|
|
667
687
|
*/
|
|
668
688
|
attachDataDomain(domain: DataDomain): void {
|
|
669
689
|
if (!domain.info.version) {
|
|
670
|
-
throw new
|
|
690
|
+
throw new Exception(`Cannot attach DataDomain without a version. Please set the version in the domain info.`, {
|
|
691
|
+
code: 'E_DOMAIN_NO_VERSION',
|
|
692
|
+
// eslint-disable-next-line max-len
|
|
693
|
+
help: 'Before attaching the data domain, give it a version name. Only versioned domains can be attached to an API model.',
|
|
694
|
+
})
|
|
671
695
|
}
|
|
672
696
|
this.cleanForDomainChange()
|
|
673
697
|
this.dependencies.set(domain.key, domain)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { observed, toRaw } from '../decorators/observed.js'
|
|
2
|
+
import { Exception } from '../exceptions/exception.js'
|
|
2
3
|
import { ExposedEntityKind } from '../models/kinds.js'
|
|
3
4
|
import { nanoid } from '../nanoid.js'
|
|
4
5
|
import { Action } from './actions/Action.js'
|
|
@@ -94,12 +95,12 @@ export class ExposedEntity extends EventTarget {
|
|
|
94
95
|
/**
|
|
95
96
|
* The list of enabled API actions for this exposure (List/Read/Create/etc.)
|
|
96
97
|
*/
|
|
97
|
-
@observed(
|
|
98
|
+
@observed() accessor actions: Action[]
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
101
|
* Optional array of access rules that define the access control policies for this exposure.
|
|
101
102
|
*/
|
|
102
|
-
@observed(
|
|
103
|
+
@observed() accessor accessRule: AccessRule[] | undefined
|
|
103
104
|
|
|
104
105
|
/**
|
|
105
106
|
* Optional configuration for rate limiting for this exposure.
|
|
@@ -203,7 +204,7 @@ export class ExposedEntity extends EventTarget {
|
|
|
203
204
|
this.parent = init.parent
|
|
204
205
|
this.exposeOptions = init.exposeOptions
|
|
205
206
|
this.actions = init.actions ? init.actions.map((a) => restoreAction(this, a)) : []
|
|
206
|
-
this.accessRule = init.accessRule ? init.accessRule.map((ar) => restoreAccessRule(ar)) : []
|
|
207
|
+
this.accessRule = init.accessRule ? init.accessRule.map((ar) => restoreAccessRule(this, ar)) : []
|
|
207
208
|
if (init.rateLimiting) {
|
|
208
209
|
this.rateLimiting = new RateLimitingConfiguration(init.rateLimiting)
|
|
209
210
|
}
|
|
@@ -223,6 +224,7 @@ export class ExposedEntity extends EventTarget {
|
|
|
223
224
|
this.#notifying = false
|
|
224
225
|
const event = new Event('change')
|
|
225
226
|
this.dispatchEvent(event)
|
|
227
|
+
this.api.notifyChange()
|
|
226
228
|
})
|
|
227
229
|
}
|
|
228
230
|
|
|
@@ -273,13 +275,19 @@ export class ExposedEntity extends EventTarget {
|
|
|
273
275
|
*/
|
|
274
276
|
setCollectionPath(path: string) {
|
|
275
277
|
if (!this.hasCollection) {
|
|
276
|
-
throw new
|
|
278
|
+
throw new Exception(`Cannot set collection path on an exposure that does not have a collection`, {
|
|
279
|
+
code: 'E_PATH_MISSING',
|
|
280
|
+
help: 'The exposed entity you are trying to set a collection path for does not have a collection.',
|
|
281
|
+
})
|
|
277
282
|
}
|
|
278
283
|
const cleaned = ensureLeadingSlash(path)
|
|
279
284
|
// Ensure exactly one non-empty segment
|
|
280
285
|
const segments = cleaned.split('/').filter(Boolean)
|
|
281
286
|
if (segments.length !== 1) {
|
|
282
|
-
throw new
|
|
287
|
+
throw new Exception(`Collection path must contain exactly one segment. Received: "${path}"`, {
|
|
288
|
+
code: 'E_PATH_SEGMENT_SIZE',
|
|
289
|
+
help: 'The set path must have a single path segment (e.g., `/products`)',
|
|
290
|
+
})
|
|
283
291
|
}
|
|
284
292
|
const normalizedCollection = `/${segments[0]}`
|
|
285
293
|
|
|
@@ -287,7 +295,10 @@ export class ExposedEntity extends EventTarget {
|
|
|
287
295
|
if (this.isRoot) {
|
|
288
296
|
const collision = this.api.findCollectionPathCollision(normalizedCollection, this.key)
|
|
289
297
|
if (collision) {
|
|
290
|
-
throw new
|
|
298
|
+
throw new Exception(`Collection path "${normalizedCollection}" is already in use by another root entity.`, {
|
|
299
|
+
code: 'E_PATH_INUSE',
|
|
300
|
+
help: 'The set path is already in use by another root entity.',
|
|
301
|
+
})
|
|
291
302
|
}
|
|
292
303
|
}
|
|
293
304
|
|
|
@@ -321,22 +332,44 @@ export class ExposedEntity extends EventTarget {
|
|
|
321
332
|
|
|
322
333
|
if (this.hasCollection) {
|
|
323
334
|
if (!this.collectionPath) {
|
|
324
|
-
throw
|
|
335
|
+
// Why do we throw this error? We should just create it from the resource path...
|
|
336
|
+
throw new Exception('Cannot set resource path: missing collection path for this exposure', {
|
|
337
|
+
code: 'E_PATH_MISSING',
|
|
338
|
+
help: 'Set the collection path on the exposed entity first.',
|
|
339
|
+
})
|
|
325
340
|
}
|
|
326
341
|
const colSegments = this.collectionPath.split('/').filter(Boolean)
|
|
327
342
|
if (colSegments.length !== 1) {
|
|
328
|
-
throw new
|
|
343
|
+
throw new Exception(`Invalid stored collection path "${this.collectionPath}"`, {
|
|
344
|
+
code: 'E_PATH_SEGMENT_SIZE',
|
|
345
|
+
help: 'The set collection path must have a single path segment (e.g., `/products`)',
|
|
346
|
+
})
|
|
329
347
|
}
|
|
330
348
|
if (segments.length !== 2) {
|
|
331
|
-
throw new
|
|
349
|
+
throw new Exception(
|
|
350
|
+
`Resource path must be exactly two segments (collection + parameter). Received: "${cleaned}"`,
|
|
351
|
+
{
|
|
352
|
+
code: 'E_PATH_SEGMENT_SIZE',
|
|
353
|
+
help: 'The set resource path must have exactly two segments (collection + parameter).',
|
|
354
|
+
}
|
|
355
|
+
)
|
|
332
356
|
}
|
|
333
357
|
const [s1, s2] = segments
|
|
334
358
|
if (s1 !== colSegments[0]) {
|
|
335
|
-
throw new
|
|
359
|
+
throw new Exception(
|
|
360
|
+
`Resource path must start with the collection segment "${colSegments[0]}". Received: "${s1}"`,
|
|
361
|
+
{
|
|
362
|
+
code: 'E_PATH_MISMATCH',
|
|
363
|
+
help: 'Set the resource path to the same value as the collection path + the parameter value.',
|
|
364
|
+
}
|
|
365
|
+
)
|
|
336
366
|
}
|
|
337
367
|
// s2 must be a parameter segment {name}
|
|
338
368
|
if (!/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(s2)) {
|
|
339
|
-
throw new
|
|
369
|
+
throw new Exception(`The second segment must be a parameter in braces, e.g. {id}. Received: "${s2}"`, {
|
|
370
|
+
code: 'E_PARAMETER_INVALID',
|
|
371
|
+
help: 'Use the braces to surround the parameter name, e.g., {productId}.',
|
|
372
|
+
})
|
|
340
373
|
}
|
|
341
374
|
if (this.resourcePath !== cleaned) {
|
|
342
375
|
this.resourcePath = `/${s1}/${s2}`
|
|
@@ -346,15 +379,22 @@ export class ExposedEntity extends EventTarget {
|
|
|
346
379
|
|
|
347
380
|
// No collection: allow exactly one segment
|
|
348
381
|
if (segments.length !== 1) {
|
|
349
|
-
throw new
|
|
350
|
-
`Resource path must contain exactly one segment when no collection is present. Received: "${cleaned}"
|
|
382
|
+
throw new Exception(
|
|
383
|
+
`Resource path must contain exactly one segment when no collection is present. Received: "${cleaned}"`,
|
|
384
|
+
{
|
|
385
|
+
code: 'E_PATH_SEGMENT_SIZE',
|
|
386
|
+
help: 'The set resource path must have exactly one segment.',
|
|
387
|
+
}
|
|
351
388
|
)
|
|
352
389
|
}
|
|
353
390
|
// Check for collision if this is a root entity (singleton case)
|
|
354
391
|
if (this.isRoot) {
|
|
355
392
|
const collision = this.api.findResourcePathCollision(cleaned, this.key)
|
|
356
393
|
if (collision) {
|
|
357
|
-
throw new
|
|
394
|
+
throw new Exception(`Resource path "${cleaned}" is already in use by another root entity.`, {
|
|
395
|
+
code: 'E_PATH_INUSE',
|
|
396
|
+
help: 'The set path is already in use by another root entity.',
|
|
397
|
+
})
|
|
358
398
|
}
|
|
359
399
|
}
|
|
360
400
|
|
|
@@ -468,7 +508,10 @@ export class ExposedEntity extends EventTarget {
|
|
|
468
508
|
*/
|
|
469
509
|
addAction(schema: ApiActionSchema): Action {
|
|
470
510
|
if (this.actions.some((action) => action.kind === schema.kind)) {
|
|
471
|
-
throw new
|
|
511
|
+
throw new Exception(`Action of kind "${schema.kind}" already exists for this exposure`, {
|
|
512
|
+
code: 'E_ACTION_USED',
|
|
513
|
+
help: "There's no need to add an API action again.",
|
|
514
|
+
})
|
|
472
515
|
}
|
|
473
516
|
const action = restoreAction(this, schema)
|
|
474
517
|
this.actions.push(action)
|
|
@@ -486,7 +529,10 @@ export class ExposedEntity extends EventTarget {
|
|
|
486
529
|
createPaginationContract(): void {
|
|
487
530
|
const entity = this.api.domain?.findEntity(this.entity.key, this.entity.domain)
|
|
488
531
|
if (!entity) {
|
|
489
|
-
throw new
|
|
532
|
+
throw new Exception(`Entity "${this.entity.key}" not found"`, {
|
|
533
|
+
code: 'E_ENTITY_NOT_FOUND',
|
|
534
|
+
help: 'The set entity does not exist.',
|
|
535
|
+
})
|
|
490
536
|
}
|
|
491
537
|
if (!this.paginationContract) {
|
|
492
538
|
this.paginationContract = {
|
|
@@ -46,13 +46,13 @@ export class Action extends EventTarget implements ActionSchema {
|
|
|
46
46
|
super()
|
|
47
47
|
this.parent = parent
|
|
48
48
|
this.kind = state.kind || ''
|
|
49
|
-
this.accessRule = state.accessRule ? state.accessRule.map((rule) => restoreAccessRule(rule)) : []
|
|
49
|
+
this.accessRule = state.accessRule ? state.accessRule.map((rule) => restoreAccessRule(this, rule)) : []
|
|
50
50
|
this.rateLimiting = state.rateLimiting ? new RateLimitingConfiguration(state.rateLimiting) : undefined
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
notifyChange() {
|
|
54
54
|
this.dispatchEvent(new Event('change'))
|
|
55
|
-
|
|
55
|
+
this.parent.notifyChange()
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
toJSON(): ActionSchema {
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type { Action } from '../actions/Action.js'
|
|
2
|
+
import type { ApiModel } from '../ApiModel.js'
|
|
3
|
+
import type { ExposedEntity } from '../ExposedEntity.js'
|
|
4
|
+
|
|
1
5
|
export interface AccessRuleSchema {
|
|
2
6
|
/**
|
|
3
7
|
* The unique identifier for the access rule.
|
|
@@ -11,9 +15,11 @@ export interface AccessRuleSchema {
|
|
|
11
15
|
*/
|
|
12
16
|
export class AccessRule extends EventTarget implements AccessRuleSchema {
|
|
13
17
|
readonly type: string
|
|
18
|
+
readonly parent: ExposedEntity | ApiModel | Action
|
|
14
19
|
|
|
15
|
-
constructor(state: Partial<AccessRuleSchema> = {}) {
|
|
20
|
+
constructor(parent: ExposedEntity | ApiModel | Action, state: Partial<AccessRuleSchema> = {}) {
|
|
16
21
|
super()
|
|
22
|
+
this.parent = parent
|
|
17
23
|
this.type = state.type ?? ''
|
|
18
24
|
}
|
|
19
25
|
|
|
@@ -25,5 +31,6 @@ export class AccessRule extends EventTarget implements AccessRuleSchema {
|
|
|
25
31
|
|
|
26
32
|
notifyChange() {
|
|
27
33
|
this.dispatchEvent(new Event('change'))
|
|
34
|
+
this.parent.notifyChange()
|
|
28
35
|
}
|
|
29
36
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { Action } from '../actions/Action.js'
|
|
2
|
+
import type { ApiModel } from '../ApiModel.js'
|
|
3
|
+
import type { ExposedEntity } from '../ExposedEntity.js'
|
|
1
4
|
import { AccessRule, type AccessRuleSchema } from './AccessRule.js'
|
|
2
5
|
|
|
3
6
|
/**
|
|
@@ -17,8 +20,8 @@ export interface AllowAuthenticatedAccessRuleSchema extends AccessRuleSchema {
|
|
|
17
20
|
export class AllowAuthenticatedAccessRule extends AccessRule implements AllowAuthenticatedAccessRuleSchema {
|
|
18
21
|
override readonly type: 'allowAuthenticated'
|
|
19
22
|
|
|
20
|
-
constructor(state: Partial<AllowAuthenticatedAccessRuleSchema> = {}) {
|
|
21
|
-
super(state)
|
|
23
|
+
constructor(parent: ExposedEntity | ApiModel | Action, state: Partial<AllowAuthenticatedAccessRuleSchema> = {}) {
|
|
24
|
+
super(parent, state)
|
|
22
25
|
this.type = 'allowAuthenticated'
|
|
23
26
|
}
|
|
24
27
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { Action } from '../actions/Action.js'
|
|
2
|
+
import type { ApiModel } from '../ApiModel.js'
|
|
3
|
+
import type { ExposedEntity } from '../ExposedEntity.js'
|
|
1
4
|
import { AccessRule, type AccessRuleSchema } from './AccessRule.js'
|
|
2
5
|
|
|
3
6
|
/**
|
|
@@ -17,8 +20,8 @@ export interface AllowPublicAccessRuleSchema extends AccessRuleSchema {
|
|
|
17
20
|
export class AllowPublicAccessRule extends AccessRule implements AllowPublicAccessRuleSchema {
|
|
18
21
|
override readonly type: 'allowPublic'
|
|
19
22
|
|
|
20
|
-
constructor(state: Partial<AllowPublicAccessRuleSchema> = {}) {
|
|
21
|
-
super(state)
|
|
23
|
+
constructor(parent: ExposedEntity | ApiModel | Action, state: Partial<AllowPublicAccessRuleSchema> = {}) {
|
|
24
|
+
super(parent, state)
|
|
22
25
|
this.type = 'allowPublic'
|
|
23
26
|
}
|
|
24
27
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { Action } from '../actions/Action.js'
|
|
2
|
+
import type { ApiModel } from '../ApiModel.js'
|
|
3
|
+
import type { ExposedEntity } from '../ExposedEntity.js'
|
|
1
4
|
import { AccessRule, type AccessRuleSchema } from './AccessRule.js'
|
|
2
5
|
import { observed, toRaw } from '../../decorators/observed.js'
|
|
3
6
|
|
|
@@ -24,8 +27,8 @@ export class MatchEmailDomainAccessRule extends AccessRule implements MatchEmail
|
|
|
24
27
|
|
|
25
28
|
@observed({ deep: true }) accessor domains: string[]
|
|
26
29
|
|
|
27
|
-
constructor(state: Partial<MatchEmailDomainAccessRuleSchema> = {}) {
|
|
28
|
-
super(state)
|
|
30
|
+
constructor(parent: ExposedEntity | ApiModel | Action, state: Partial<MatchEmailDomainAccessRuleSchema> = {}) {
|
|
31
|
+
super(parent, state)
|
|
29
32
|
this.type = 'matchEmailDomain'
|
|
30
33
|
this.domains = state.domains ? [...state.domains] : []
|
|
31
34
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { Action } from '../actions/Action.js'
|
|
2
|
+
import type { ApiModel } from '../ApiModel.js'
|
|
3
|
+
import type { ExposedEntity } from '../ExposedEntity.js'
|
|
1
4
|
import { AccessRule, type AccessRuleSchema } from './AccessRule.js'
|
|
2
5
|
import { observed } from '../../decorators/observed.js'
|
|
3
6
|
|
|
@@ -38,8 +41,8 @@ export class MatchResourceOwnerAccessRule extends AccessRule implements MatchRes
|
|
|
38
41
|
@observed() accessor property: string | undefined
|
|
39
42
|
@observed() accessor target: 'property' | 'user-entity'
|
|
40
43
|
|
|
41
|
-
constructor(state: Partial<MatchResourceOwnerAccessRuleSchema> = {}) {
|
|
42
|
-
super(state)
|
|
44
|
+
constructor(parent: ExposedEntity | ApiModel | Action, state: Partial<MatchResourceOwnerAccessRuleSchema> = {}) {
|
|
45
|
+
super(parent, state)
|
|
43
46
|
this.type = 'matchResourceOwner'
|
|
44
47
|
this.property = state.property
|
|
45
48
|
this.target = state.target ?? 'property'
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { Action } from '../actions/Action.js'
|
|
2
|
+
import type { ApiModel } from '../ApiModel.js'
|
|
3
|
+
import type { ExposedEntity } from '../ExposedEntity.js'
|
|
1
4
|
import { AccessRule, type AccessRuleSchema } from './AccessRule.js'
|
|
2
5
|
import { observed } from '../../decorators/observed.js'
|
|
3
6
|
|
|
@@ -27,8 +30,8 @@ export class MatchUserPropertyAccessRule extends AccessRule implements MatchUser
|
|
|
27
30
|
@observed() accessor property: string
|
|
28
31
|
@observed() accessor value: string
|
|
29
32
|
|
|
30
|
-
constructor(state: Partial<MatchUserPropertyAccessRuleSchema> = {}) {
|
|
31
|
-
super(state)
|
|
33
|
+
constructor(parent: ExposedEntity | ApiModel | Action, state: Partial<MatchUserPropertyAccessRuleSchema> = {}) {
|
|
34
|
+
super(parent, state)
|
|
32
35
|
this.type = 'matchUserProperty'
|
|
33
36
|
this.property = state.property ?? ''
|
|
34
37
|
this.value = state.value ?? ''
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { Action } from '../actions/Action.js'
|
|
2
|
+
import type { ApiModel } from '../ApiModel.js'
|
|
3
|
+
import type { ExposedEntity } from '../ExposedEntity.js'
|
|
1
4
|
import { AccessRule, type AccessRuleSchema } from './AccessRule.js'
|
|
2
5
|
import { observed, toRaw } from '../../decorators/observed.js'
|
|
3
6
|
|
|
@@ -28,8 +31,8 @@ export class MatchUserRoleAccessRule extends AccessRule implements MatchUserRole
|
|
|
28
31
|
|
|
29
32
|
@observed({ deep: true }) accessor role: string[]
|
|
30
33
|
|
|
31
|
-
constructor(state: Partial<MatchUserRoleAccessRuleSchema> = {}) {
|
|
32
|
-
super(state)
|
|
34
|
+
constructor(parent: ExposedEntity | ApiModel | Action, state: Partial<MatchUserRoleAccessRuleSchema> = {}) {
|
|
35
|
+
super(parent, state)
|
|
33
36
|
this.type = 'matchUserRole'
|
|
34
37
|
this.role = state.role ? [...state.role] : []
|
|
35
38
|
}
|
|
@@ -2,10 +2,11 @@ import { test } from '@japa/runner'
|
|
|
2
2
|
import { Action, AccessRule, RateLimitingConfiguration } from '../../../../src/modeling/index.js'
|
|
3
3
|
import { type ActionSchema } from '../../../../src/modeling/actions/Action.js'
|
|
4
4
|
import { type ExposedEntity } from '../../../../src/modeling/ExposedEntity.js'
|
|
5
|
+
import { mockExposedEntity } from './helpers.js'
|
|
5
6
|
|
|
6
7
|
test.group('Action', () => {
|
|
7
8
|
test('initializes with default values', ({ assert }) => {
|
|
8
|
-
const action = new Action(
|
|
9
|
+
const action = new Action(mockExposedEntity())
|
|
9
10
|
assert.equal(action.kind, '')
|
|
10
11
|
assert.deepEqual(action.accessRule, [])
|
|
11
12
|
assert.isUndefined(action.rateLimiting)
|
|
@@ -17,7 +18,7 @@ test.group('Action', () => {
|
|
|
17
18
|
accessRule: [{ type: 'allowPublic' }],
|
|
18
19
|
rateLimiting: { rules: [] },
|
|
19
20
|
}
|
|
20
|
-
const action = new Action(
|
|
21
|
+
const action = new Action(mockExposedEntity(), schema)
|
|
21
22
|
assert.equal(action.kind, 'read')
|
|
22
23
|
assert.lengthOf(action.accessRule, 1)
|
|
23
24
|
assert.instanceOf(action.accessRule[0], AccessRule)
|
|
@@ -26,7 +27,7 @@ test.group('Action', () => {
|
|
|
26
27
|
}).tags(['@modeling', '@action'])
|
|
27
28
|
|
|
28
29
|
test('serializes to JSON', ({ assert }) => {
|
|
29
|
-
const action = new Action(
|
|
30
|
+
const action = new Action(mockExposedEntity(), {
|
|
30
31
|
kind: 'write',
|
|
31
32
|
accessRule: [{ type: 'allowPublic' }],
|
|
32
33
|
rateLimiting: { rules: [] },
|
|
@@ -39,7 +40,7 @@ test.group('Action', () => {
|
|
|
39
40
|
}).tags(['@modeling', '@action'])
|
|
40
41
|
|
|
41
42
|
test('notifies change when kind changes', async ({ assert }) => {
|
|
42
|
-
const action = new Action(
|
|
43
|
+
const action = new Action(mockExposedEntity(), { kind: 'read' })
|
|
43
44
|
let notified = false
|
|
44
45
|
action.addEventListener('change', () => {
|
|
45
46
|
notified = true
|
|
@@ -53,19 +54,19 @@ test.group('Action', () => {
|
|
|
53
54
|
}).tags(['@modeling', '@action', '@observed'])
|
|
54
55
|
|
|
55
56
|
test('notifies change when accessRule array is replaced', async ({ assert }) => {
|
|
56
|
-
const action = new Action(
|
|
57
|
+
const action = new Action(mockExposedEntity())
|
|
57
58
|
let notified = false
|
|
58
59
|
action.addEventListener('change', () => {
|
|
59
60
|
notified = true
|
|
60
61
|
})
|
|
61
62
|
|
|
62
|
-
action.accessRule = [new AccessRule({ type: 'allowPublic' })]
|
|
63
|
+
action.accessRule = [new AccessRule(mockExposedEntity(), { type: 'allowPublic' })]
|
|
63
64
|
await Promise.resolve()
|
|
64
65
|
assert.isTrue(notified)
|
|
65
66
|
}).tags(['@modeling', '@action', '@observed'])
|
|
66
67
|
|
|
67
68
|
test('notifies change when rateLimiting is replaced', async ({ assert }) => {
|
|
68
|
-
const action = new Action(
|
|
69
|
+
const action = new Action(mockExposedEntity())
|
|
69
70
|
let notified = false
|
|
70
71
|
action.addEventListener('change', () => {
|
|
71
72
|
notified = true
|
|
@@ -78,7 +79,7 @@ test.group('Action', () => {
|
|
|
78
79
|
|
|
79
80
|
test('constructor copies accessRule array (immutability)', ({ assert }) => {
|
|
80
81
|
const rules = [{ type: 'allowPublic' }]
|
|
81
|
-
const action = new Action(
|
|
82
|
+
const action = new Action(mockExposedEntity(), { kind: 'read', accessRule: rules })
|
|
82
83
|
|
|
83
84
|
// Modify original array
|
|
84
85
|
rules.push({ type: 'other' })
|
|
@@ -89,7 +90,7 @@ test.group('Action', () => {
|
|
|
89
90
|
}).tags(['@modeling', '@action', '@immutability'])
|
|
90
91
|
|
|
91
92
|
test('toJSON returns safe copy', ({ assert }) => {
|
|
92
|
-
const action = new Action(
|
|
93
|
+
const action = new Action(mockExposedEntity(), {
|
|
93
94
|
kind: 'read',
|
|
94
95
|
accessRule: [{ type: 'allowPublic' }],
|
|
95
96
|
rateLimiting: { rules: [] },
|
|
@@ -110,7 +111,8 @@ test.group('Action', () => {
|
|
|
110
111
|
|
|
111
112
|
test('getAllRules() aggregates rules from action and parent', ({ assert }) => {
|
|
112
113
|
const parent = {
|
|
113
|
-
getAllRules: () => [new AccessRule({ type: 'allowPublic' })],
|
|
114
|
+
getAllRules: () => [new AccessRule(mockExposedEntity(), { type: 'allowPublic' })],
|
|
115
|
+
notifyChange: () => {},
|
|
114
116
|
} as unknown as ExposedEntity
|
|
115
117
|
|
|
116
118
|
const action = new Action(parent, {
|
|
@@ -127,6 +129,7 @@ test.group('Action', () => {
|
|
|
127
129
|
const parentLimiter = new RateLimitingConfiguration({ rules: [{ rate: 10, interval: 'second' }] })
|
|
128
130
|
const parent = {
|
|
129
131
|
getAllRateLimiters: () => [parentLimiter],
|
|
132
|
+
notifyChange: () => {},
|
|
130
133
|
} as unknown as ExposedEntity
|
|
131
134
|
|
|
132
135
|
const action = new Action(parent, {
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { test } from '@japa/runner'
|
|
2
2
|
import { CreateAction } from '../../../../src/modeling/actions/CreateAction.js'
|
|
3
3
|
import { AccessRule } from '../../../../src/modeling/rules/index.js'
|
|
4
|
+
import { mockExposedEntity } from './helpers.js'
|
|
4
5
|
|
|
5
6
|
test.group('CreateAction', () => {
|
|
6
7
|
test('initializes with default values', ({ assert }) => {
|
|
7
|
-
const action = new CreateAction(
|
|
8
|
+
const action = new CreateAction(mockExposedEntity())
|
|
8
9
|
assert.equal(action.kind, 'create')
|
|
9
10
|
assert.isEmpty(action.accessRule) // Inherited from Action
|
|
10
11
|
}).tags(['@modeling', '@action', '@create-action'])
|
|
11
12
|
|
|
12
13
|
test('initializes with inherited values', ({ assert }) => {
|
|
13
|
-
const action = new CreateAction(
|
|
14
|
+
const action = new CreateAction(mockExposedEntity(), {
|
|
14
15
|
accessRule: [{ type: 'allowPublic' }],
|
|
15
16
|
})
|
|
16
17
|
|
|
@@ -22,7 +23,7 @@ test.group('CreateAction', () => {
|
|
|
22
23
|
test('constructor copies arrays (immutability)', ({ assert }) => {
|
|
23
24
|
const rules = [{ type: 'allowPublic' }]
|
|
24
25
|
|
|
25
|
-
const action = new CreateAction(
|
|
26
|
+
const action = new CreateAction(mockExposedEntity(), {
|
|
26
27
|
accessRule: rules,
|
|
27
28
|
})
|
|
28
29
|
|
|
@@ -35,7 +36,7 @@ test.group('CreateAction', () => {
|
|
|
35
36
|
}).tags(['@modeling', '@action', '@create-action', '@immutability'])
|
|
36
37
|
|
|
37
38
|
test('toJSON returns valid schema', ({ assert }) => {
|
|
38
|
-
const action = new CreateAction(
|
|
39
|
+
const action = new CreateAction(mockExposedEntity(), {
|
|
39
40
|
accessRule: [{ type: 'allowPublic' }],
|
|
40
41
|
})
|
|
41
42
|
|
|
@@ -51,14 +52,14 @@ test.group('CreateAction', () => {
|
|
|
51
52
|
}).tags(['@modeling', '@action', '@create-action', '@serialization'])
|
|
52
53
|
|
|
53
54
|
test('notifies change when inherited property changes', async ({ assert }) => {
|
|
54
|
-
const action = new CreateAction(
|
|
55
|
+
const action = new CreateAction(mockExposedEntity())
|
|
55
56
|
let notified = false
|
|
56
57
|
action.addEventListener('change', () => {
|
|
57
58
|
notified = true
|
|
58
59
|
})
|
|
59
60
|
|
|
60
61
|
// Modify inherited property
|
|
61
|
-
action.accessRule = [new AccessRule({ type: 'allowPublic' })]
|
|
62
|
+
action.accessRule = [new AccessRule(mockExposedEntity(), { type: 'allowPublic' })]
|
|
62
63
|
await Promise.resolve()
|
|
63
64
|
assert.isTrue(notified)
|
|
64
65
|
}).tags(['@modeling', '@action', '@create-action', '@observed'])
|