@api-client/core 0.19.28 → 0.19.29
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/DomainEntity.d.ts +12 -0
- package/build/src/modeling/DomainEntity.d.ts.map +1 -1
- package/build/src/modeling/DomainEntity.js +32 -0
- package/build/src/modeling/DomainEntity.js.map +1 -1
- package/build/src/modeling/validation/api_model_rules.d.ts +1 -1
- package/build/src/modeling/validation/api_model_rules.d.ts.map +1 -1
- package/build/src/modeling/validation/api_model_rules.js +44 -42
- package/build/src/modeling/validation/api_model_rules.js.map +1 -1
- package/build/src/modeling/validation/semantic_validation.d.ts +1 -1
- package/build/src/modeling/validation/semantic_validation.d.ts.map +1 -1
- package/build/src/modeling/validation/semantic_validation.js +66 -6
- package/build/src/modeling/validation/semantic_validation.js.map +1 -1
- package/build/src/runtime/http-engine/CoreEngine.d.ts.map +1 -1
- package/build/src/runtime/http-engine/CoreEngine.js +1 -1
- package/build/src/runtime/http-engine/CoreEngine.js.map +1 -1
- package/build/src/runtime/http-engine/connections/DirectConnection.d.ts.map +1 -1
- package/build/src/runtime/http-engine/connections/DirectConnection.js +8 -2
- package/build/src/runtime/http-engine/connections/DirectConnection.js.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/modeling/DomainEntity.ts +33 -0
- package/src/modeling/validation/api_model_rules.ts +45 -42
- package/src/modeling/validation/api_model_validation_rules.md +10 -3
- package/src/modeling/validation/semantic_validation.ts +65 -6
- package/src/runtime/http-engine/CoreEngine.ts +1 -2
- package/src/runtime/http-engine/connections/DirectConnection.ts +8 -4
- package/tests/unit/modeling/domain_validation.spec.ts +6 -0
- package/tests/unit/modeling/validation/semantic_validation.spec.ts +4 -1
- package/tests/unit/runtime/http-engine/core_engine.spec.ts +10 -7
package/package.json
CHANGED
|
@@ -351,6 +351,39 @@ export class DomainEntity extends DomainElement {
|
|
|
351
351
|
}
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Collects a list of all properties that affect this entity,
|
|
356
|
+
* including all inherited from parents properties as well as
|
|
357
|
+
* this entity's properties.
|
|
358
|
+
*
|
|
359
|
+
* The resulting list already accounts for a property override.
|
|
360
|
+
* A property overrides another property if it is lower in the
|
|
361
|
+
* inheritance chain. As such, a property defined on this entity
|
|
362
|
+
* always overrides previously defined entities.
|
|
363
|
+
* @returns The iterator over the list of all properties.
|
|
364
|
+
*/
|
|
365
|
+
withInheritedProperties(): MapIterator<DomainProperty> {
|
|
366
|
+
const cache = new Map<string, DomainProperty>()
|
|
367
|
+
const parents = this.listAllParents('up')
|
|
368
|
+
for (const parent of parents) {
|
|
369
|
+
for (const prop of parent.properties) {
|
|
370
|
+
if (!prop.info.name) {
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
// We want to override the previously set property
|
|
374
|
+
// so the last definition wins.
|
|
375
|
+
cache.set(prop.info.name, prop)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (const prop of this.properties) {
|
|
379
|
+
if (!prop.info.name) {
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
cache.set(prop.info.name, prop)
|
|
383
|
+
}
|
|
384
|
+
return cache.values()
|
|
385
|
+
}
|
|
386
|
+
|
|
354
387
|
/**
|
|
355
388
|
* Checks if this entity has any properties.
|
|
356
389
|
*
|
|
@@ -454,16 +454,19 @@ export function validateAction(action: Action, parent: ExposedEntity, apiModelKe
|
|
|
454
454
|
return issues
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
-
export function validateExposedEntity(
|
|
457
|
+
export function validateExposedEntity(exposure: ExposedEntity, apiModel: ApiModel): ApiModelValidationItem[] {
|
|
458
458
|
const issues: ApiModelValidationItem[] = []
|
|
459
459
|
const context: ApiModelValidationContext = {
|
|
460
460
|
apiModelKey: apiModel.key,
|
|
461
461
|
kind: ExposedEntityKind,
|
|
462
|
-
key:
|
|
462
|
+
key: exposure.key,
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
+
const entity = exposure.entity ? apiModel.domain?.findEntity(exposure.entity.key, exposure.entity.domain) : undefined
|
|
466
|
+
const entityName = entity?.info.getLabel() || entity?.key || 'unknown'
|
|
467
|
+
|
|
465
468
|
// Valid Entity Reference
|
|
466
|
-
if (!
|
|
469
|
+
if (!exposure.entity || !exposure.entity.key) {
|
|
467
470
|
issues.push({
|
|
468
471
|
code: createCode('EXPOSURE', 'MISSING_ENTITY_REF'),
|
|
469
472
|
message: 'This exposed endpoint does not point to a specific data model.',
|
|
@@ -471,7 +474,7 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
471
474
|
severity: 'error',
|
|
472
475
|
context: { ...context, property: 'entity' },
|
|
473
476
|
})
|
|
474
|
-
} else if (
|
|
477
|
+
} else if (!entity) {
|
|
475
478
|
issues.push({
|
|
476
479
|
code: createCode('EXPOSURE', 'INVALID_ENTITY_REF'),
|
|
477
480
|
message: 'This endpoint points to a data model that no longer exists.',
|
|
@@ -482,21 +485,21 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
482
485
|
}
|
|
483
486
|
|
|
484
487
|
// Path Integrity
|
|
485
|
-
if (
|
|
486
|
-
if (!
|
|
488
|
+
if (exposure.hasCollection) {
|
|
489
|
+
if (!exposure.collectionPath) {
|
|
487
490
|
issues.push({
|
|
488
491
|
code: createCode('EXPOSURE', 'MISSING_COLLECTION_PATH'),
|
|
489
|
-
message:
|
|
492
|
+
message: `[${entityName}]: When an endpoint exposes a collection, it must define what its base URL path is.`,
|
|
490
493
|
suggestion: 'Add a path (e.g., "/items").',
|
|
491
494
|
severity: 'error',
|
|
492
495
|
context: { ...context, property: 'collectionPath' },
|
|
493
496
|
})
|
|
494
497
|
} else {
|
|
495
|
-
const parts =
|
|
496
|
-
if (parts.length !== 1 || !
|
|
498
|
+
const parts = exposure.collectionPath.split('/').filter(Boolean)
|
|
499
|
+
if (parts.length !== 1 || !exposure.collectionPath.startsWith('/')) {
|
|
497
500
|
issues.push({
|
|
498
501
|
code: createCode('EXPOSURE', 'INVALID_COLLECTION_PATH'),
|
|
499
|
-
message:
|
|
502
|
+
message: `[${entityName}]: The collection URL should start with "/" and have no extra slashes.`,
|
|
500
503
|
suggestion: `Ensure the path looks like exactly "/${parts[0] || 'subresource'}".`,
|
|
501
504
|
severity: 'error',
|
|
502
505
|
context: { ...context, property: 'collectionPath' },
|
|
@@ -504,41 +507,41 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
504
507
|
}
|
|
505
508
|
}
|
|
506
509
|
|
|
507
|
-
if (!
|
|
510
|
+
if (!exposure.resourcePath) {
|
|
508
511
|
issues.push({
|
|
509
512
|
code: createCode('EXPOSURE', 'MISSING_RESOURCE_PATH'),
|
|
510
|
-
message:
|
|
513
|
+
message: `[${entityName}]: You need an identifier to locate single items.`,
|
|
511
514
|
suggestion: 'Specify the identification parameter format, like "{id}".',
|
|
512
515
|
severity: 'error',
|
|
513
516
|
context: { ...context, property: 'resourcePath' },
|
|
514
517
|
})
|
|
515
|
-
} else if (
|
|
516
|
-
const colRegex = new RegExp(`^${
|
|
517
|
-
if (!colRegex.test(
|
|
518
|
+
} else if (exposure.collectionPath) {
|
|
519
|
+
const colRegex = new RegExp(`^${exposure.collectionPath}/\\{[a-zA-Z0-9_]+\\}$`)
|
|
520
|
+
if (!colRegex.test(exposure.resourcePath)) {
|
|
518
521
|
issues.push({
|
|
519
522
|
code: createCode('EXPOSURE', 'INVALID_RESOURCE_PATH_FORMAT'),
|
|
520
|
-
message:
|
|
521
|
-
suggestion: `Make sure the item path is formatted exactly like "${
|
|
523
|
+
message: `[${entityName}]: The single item route should match exactly your collection path plus one identifier variable.`,
|
|
524
|
+
suggestion: `Make sure the item path is formatted exactly like "${exposure.collectionPath}/{id}".`,
|
|
522
525
|
severity: 'error',
|
|
523
526
|
context: { ...context, property: 'resourcePath' },
|
|
524
527
|
})
|
|
525
528
|
}
|
|
526
529
|
}
|
|
527
530
|
} else {
|
|
528
|
-
if (!
|
|
531
|
+
if (!exposure.resourcePath) {
|
|
529
532
|
issues.push({
|
|
530
533
|
code: createCode('EXPOSURE', 'MISSING_RESOURCE_PATH'),
|
|
531
|
-
message:
|
|
534
|
+
message: `[${entityName}]: Exposed entity representing a single item must declare their URL route.`,
|
|
532
535
|
suggestion: 'Set the URL path (such as "/profile").',
|
|
533
536
|
severity: 'error',
|
|
534
537
|
context: { ...context, property: 'resourcePath' },
|
|
535
538
|
})
|
|
536
539
|
} else {
|
|
537
|
-
const parts =
|
|
538
|
-
if (parts.length !== 1 || !
|
|
540
|
+
const parts = exposure.resourcePath.split('/').filter(Boolean)
|
|
541
|
+
if (parts.length !== 1 || !exposure.resourcePath.startsWith('/')) {
|
|
539
542
|
issues.push({
|
|
540
543
|
code: createCode('EXPOSURE', 'INVALID_RESOURCE_PATH_FORMAT'),
|
|
541
|
-
message:
|
|
544
|
+
message: `[${entityName}]: The URL route ${exposure.resourcePath} must only contain one exact part or level.`,
|
|
542
545
|
suggestion: 'Simplify the endpoint URL to a single segment.',
|
|
543
546
|
severity: 'error',
|
|
544
547
|
context: { ...context, property: 'resourcePath' },
|
|
@@ -548,13 +551,13 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
548
551
|
}
|
|
549
552
|
|
|
550
553
|
// Path Collisions
|
|
551
|
-
if (
|
|
552
|
-
if (
|
|
553
|
-
const collectionCollision = apiModel.findCollectionPathCollision(
|
|
554
|
+
if (exposure.isRoot) {
|
|
555
|
+
if (exposure.collectionPath) {
|
|
556
|
+
const collectionCollision = apiModel.findCollectionPathCollision(exposure.collectionPath, exposure.key)
|
|
554
557
|
if (collectionCollision) {
|
|
555
558
|
issues.push({
|
|
556
559
|
code: createCode('EXPOSURE', 'ROOT_COLLECTION_COLLISION'),
|
|
557
|
-
message: `The route "${
|
|
560
|
+
message: `[${entityName}]: The route "${exposure.collectionPath}" is already used by another view.`,
|
|
558
561
|
suggestion: 'Give this resource a different path to avoid conflicts.',
|
|
559
562
|
severity: 'error',
|
|
560
563
|
context: { ...context, property: 'collectionPath' },
|
|
@@ -562,12 +565,12 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
562
565
|
}
|
|
563
566
|
}
|
|
564
567
|
|
|
565
|
-
if (
|
|
566
|
-
const resourceCollision = apiModel.findResourcePathCollision(
|
|
568
|
+
if (exposure.resourcePath) {
|
|
569
|
+
const resourceCollision = apiModel.findResourcePathCollision(exposure.resourcePath, exposure.key)
|
|
567
570
|
if (resourceCollision) {
|
|
568
571
|
issues.push({
|
|
569
572
|
code: createCode('EXPOSURE', 'ROOT_RESOURCE_COLLISION'),
|
|
570
|
-
message: `The route "${
|
|
573
|
+
message: `[${entityName}]: The route "${exposure.resourcePath}" is already used by another one.`,
|
|
571
574
|
suggestion: 'Give this single-item resource a different path to avoid conflicts.',
|
|
572
575
|
severity: 'error',
|
|
573
576
|
context: { ...context, property: 'resourcePath' },
|
|
@@ -577,10 +580,10 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
577
580
|
}
|
|
578
581
|
|
|
579
582
|
// Minimum Actions
|
|
580
|
-
if (!
|
|
583
|
+
if (!exposure.actions || exposure.actions.length === 0) {
|
|
581
584
|
issues.push({
|
|
582
585
|
code: createCode('EXPOSURE', 'MISSING_ACTIONS'),
|
|
583
|
-
message:
|
|
586
|
+
message: `[${entityName}]: This exposed entity does not let users do anything.`,
|
|
584
587
|
suggestion: 'Enable at least one operation like Create, Read, or Update.',
|
|
585
588
|
severity: 'error',
|
|
586
589
|
context: { ...context, property: 'actions' },
|
|
@@ -588,8 +591,8 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
588
591
|
} else {
|
|
589
592
|
let hasSearch = false
|
|
590
593
|
let hasList = false
|
|
591
|
-
for (const action of
|
|
592
|
-
issues.push(...validateAction(action,
|
|
594
|
+
for (const action of exposure.actions) {
|
|
595
|
+
issues.push(...validateAction(action, exposure, apiModel.key))
|
|
593
596
|
|
|
594
597
|
if (SearchAction.isSearchAction(action)) hasSearch = true
|
|
595
598
|
if (ListAction.isListAction(action)) hasList = true
|
|
@@ -598,9 +601,9 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
598
601
|
// For a rule to exist, it might be on the action, the exposure, any parent, or the apiModel.
|
|
599
602
|
let hasAuth = false
|
|
600
603
|
if (action.accessRule && action.accessRule.length > 0) hasAuth = true
|
|
601
|
-
if (!hasAuth &&
|
|
604
|
+
if (!hasAuth && exposure.accessRule && exposure.accessRule.length > 0) hasAuth = true
|
|
602
605
|
|
|
603
|
-
let curr =
|
|
606
|
+
let curr = exposure.parent?.key
|
|
604
607
|
while (curr && !hasAuth) {
|
|
605
608
|
const p = apiModel.exposes.get(curr)
|
|
606
609
|
if (p && p.accessRule && p.accessRule.length > 0) {
|
|
@@ -615,21 +618,21 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
615
618
|
if (!hasAuth) {
|
|
616
619
|
issues.push({
|
|
617
620
|
code: createCode('ACTION', 'MISSING_ACCESS_RULES'),
|
|
618
|
-
message: `The ${action.kind} action has no security rules attached, making it entirely inaccessible or open.`,
|
|
621
|
+
message: `[${entityName}]: The ${action.kind} action has no security rules attached, making it entirely inaccessible or open.`,
|
|
619
622
|
suggestion: 'Allow specific user roles to access this operation.',
|
|
620
623
|
severity: 'error',
|
|
621
624
|
// using action.kind as the key context equivalent
|
|
622
|
-
context: { ...context, kind: 'Action', key: action.kind, parentExposedEntityKey:
|
|
625
|
+
context: { ...context, kind: 'Action', key: action.kind, parentExposedEntityKey: exposure.key },
|
|
623
626
|
})
|
|
624
627
|
}
|
|
625
628
|
}
|
|
626
629
|
|
|
627
630
|
if (hasList || hasSearch) {
|
|
628
|
-
const contract =
|
|
631
|
+
const contract = exposure.paginationContract
|
|
629
632
|
if (!contract) {
|
|
630
633
|
issues.push({
|
|
631
634
|
code: createCode('EXPOSURE', 'MISSING_PAGINATION_CONTRACT'),
|
|
632
|
-
message:
|
|
635
|
+
message: `[${entityName}]: The List or Search action needs a pagination contract.`,
|
|
633
636
|
suggestion: 'Add a pagination contract to the exposed entity.',
|
|
634
637
|
severity: 'error',
|
|
635
638
|
context,
|
|
@@ -639,7 +642,7 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
639
642
|
if (!contract.filterableFields || contract.filterableFields.length === 0) {
|
|
640
643
|
issues.push({
|
|
641
644
|
code: createCode('EXPOSURE', 'LIST_MISSING_FILTERS'),
|
|
642
|
-
message:
|
|
645
|
+
message: `[${entityName}]: Listing all elements without filters could be overwhelming for large tables.`,
|
|
643
646
|
suggestion: 'Select a few important fields to allow filtering.',
|
|
644
647
|
severity: 'warning',
|
|
645
648
|
context,
|
|
@@ -648,7 +651,7 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
648
651
|
if (!contract.sortableFields || contract.sortableFields.length === 0) {
|
|
649
652
|
issues.push({
|
|
650
653
|
code: createCode('EXPOSURE', 'LIST_MISSING_SORTING'),
|
|
651
|
-
message:
|
|
654
|
+
message: `[${entityName}]: Listing all elements without sorting could be overwhelming for large tables.`,
|
|
652
655
|
suggestion: 'Select a few important fields to allow sorting.',
|
|
653
656
|
severity: 'warning',
|
|
654
657
|
context,
|
|
@@ -667,7 +670,7 @@ export function validateExposedEntity(entity: ExposedEntity, apiModel: ApiModel)
|
|
|
667
670
|
if (!all.length) {
|
|
668
671
|
issues.push({
|
|
669
672
|
code: createCode('EXPOSURE', 'SEARCH_MISSING_FIELDS'),
|
|
670
|
-
message:
|
|
673
|
+
message: `[${entityName}]: Search action needs to know which text fields to look in.`,
|
|
671
674
|
suggestion: 'Select a few important fields to allow searching or full-text search.',
|
|
672
675
|
severity: 'warning',
|
|
673
676
|
context,
|
|
@@ -3,21 +3,25 @@
|
|
|
3
3
|
This document outlines the validation rules for the `ApiModel` schema. These rules ensure that an API model is structurally sound, secure, and ready for use or publication.
|
|
4
4
|
|
|
5
5
|
Validations are evaluated with one of three severity levels:
|
|
6
|
+
|
|
6
7
|
- **Error**: A blocker that makes the model invalid and must be resolved (e.g., missing required configurations or broken references).
|
|
7
8
|
- **Warning**: A non-blocker, typically indicating missing optional but recommended properties, or non-optimal configurations.
|
|
8
9
|
- **Info**: Informational messages about the model's status or suggestions for improvement.
|
|
9
10
|
|
|
10
11
|
## 1. Core Properties
|
|
12
|
+
|
|
11
13
|
- **`kind`** [Error]: Must be exactly the value defined by `ApiModelKind`.
|
|
12
14
|
- **`key`** [Error]: Must be a defined, non-empty string.
|
|
13
15
|
- **`info.name`** [Error]: The `info` property must be a valid structure with at least a `name` defined.
|
|
14
16
|
- **`info.description`** [Warning]: It is highly recommended to provide a description for the API model.
|
|
15
17
|
|
|
16
18
|
## 2. Domain Dependency
|
|
19
|
+
|
|
17
20
|
- **Domain Attachment** [Error]: The API model must have an attached `DataDomain`.
|
|
18
21
|
- **Domain Versioning** [Error]: The attached Data Domain must have a `version` defined (`domain.info.version`).
|
|
19
22
|
|
|
20
23
|
## 3. Security & Access Control
|
|
24
|
+
|
|
21
25
|
- **Authentication** [Error]: The `authentication` configuration is required.
|
|
22
26
|
- If the strategy is `UsernamePassword`, the `passwordKey` must be defined.
|
|
23
27
|
- **Authorization** [Error]: The `authorization` configuration is required.
|
|
@@ -28,9 +32,10 @@ Validations are evaluated with one of three severity levels:
|
|
|
28
32
|
- **RBAC Role Property**: If the authorization strategy is `RBAC`, the `session.properties` must also map the role property into the session payload.
|
|
29
33
|
- **Transports**: At least one session transport (such as `cookie` or `jwt`) must be configured and marked as `enabled`.
|
|
30
34
|
- **User Entity** [Error]: A designated `user` entity reference is required. This reference must point to a Data Entity that is appropriately annotated with the `User` semantic.
|
|
31
|
-
- **Action Access Rules** [Error]: Each configured operation (Action) must have at least one access rule defined that applies to it. This can be inherited from the API Model level, the Exposed Entity level, any parent Exposed Entity, or explicitly defined on the Action itself.
|
|
35
|
+
- **Action Access Rules** [Error]: Each configured operation (Action) must have at least one access rule defined that applies to it. This can be inherited from the API Model level, the Exposed Entity level, any parent Exposed Entity, or explicitly defined on the Action itself.
|
|
32
36
|
|
|
33
37
|
## 4. Exposures
|
|
38
|
+
|
|
34
39
|
- **Path Integrity** [Error]:
|
|
35
40
|
- If an exposure `hasCollection` is true, it MUST have a `collectionPath` defined.
|
|
36
41
|
- The `collectionPath` (if present) must contain exactly one segment starting with `/`.
|
|
@@ -40,18 +45,20 @@ Validations are evaluated with one of three severity levels:
|
|
|
40
45
|
- **Exposures Exist** [Warning]: If no entities are exposed, the API model will not generate any endpoints.
|
|
41
46
|
|
|
42
47
|
## 5. API Actions
|
|
48
|
+
|
|
43
49
|
- **Minimum Actions** [Error]: Each exposed entity must have at least one action added to it in the `actions` array.
|
|
44
50
|
- **Rate Limiting** [Warning]: It is recommended to set up rate limiting for exposed entities and individual actions to protect the API.
|
|
45
|
-
- **List Action** [Error/Warning]:
|
|
51
|
+
- **List Action** [Error/Warning]:
|
|
46
52
|
- **Pagination** [Error]: The `List` action strictly requires a pagination definition (`pagination.kind` must be defined as `offset` or `cursor`).
|
|
47
53
|
- **Filtering/Sorting** [Warning]: It is recommended to define `filterableFields` and `sortableFields`, but it is up to the user.
|
|
48
54
|
- **Search Action** [Error]: The `Search` action requires the user to define at least one indexable field in the `fields` array to perform the search on.
|
|
49
|
-
- **Delete Action** [Error/Info]:
|
|
55
|
+
- **Delete Action** [Error/Info]:
|
|
50
56
|
- **Strategy** [Error]: The `Delete` action must define a deletion strategy (`soft` or `hard`).
|
|
51
57
|
- **Hard Delete Warning** [Info]: When the `hard` delete strategy is selected, print an info message that there will be no way to restore data.
|
|
52
58
|
- **Update Action** [Error]: The `Update` action must have at least one update method chosen in the `allowedMethods` array (e.g., `PUT` or `PATCH`).
|
|
53
59
|
|
|
54
60
|
## 6. Metadata
|
|
61
|
+
|
|
55
62
|
- **Contact Email** [Error]: If `contact.email` is provided, it MUST be in the format of a valid email address.
|
|
56
63
|
- **Contact URL** [Error]: If `contact.url` is provided, it MUST be in the format of a valid URL.
|
|
57
64
|
- **License URL** [Error]: If `license.url` is provided, it MUST be in the format of a valid URL.
|
|
@@ -37,30 +37,89 @@ export class SemanticValidation {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* Validates if there is
|
|
40
|
+
* Validates if there is exactly one entity with the User semantic.
|
|
41
41
|
* This is a recommended semantic for authentication purposes.
|
|
42
42
|
*/
|
|
43
43
|
private validateUserEntity(): DomainValidationSchema[] {
|
|
44
44
|
const results: DomainValidationSchema[] = []
|
|
45
|
-
let
|
|
45
|
+
let userEntitiesCount = 0
|
|
46
46
|
|
|
47
47
|
for (const entity of this.domain.listEntities()) {
|
|
48
48
|
if (entity.hasSemantic(SemanticType.User)) {
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
userEntitiesCount++
|
|
50
|
+
let hasUsername = false
|
|
51
|
+
let hasPassword = false
|
|
52
|
+
let hasRole = false
|
|
53
|
+
for (const property of entity.withInheritedProperties()) {
|
|
54
|
+
if (property.hasSemantic(SemanticType.Username)) {
|
|
55
|
+
hasUsername = true
|
|
56
|
+
}
|
|
57
|
+
if (property.hasSemantic(SemanticType.Password)) {
|
|
58
|
+
hasPassword = true
|
|
59
|
+
}
|
|
60
|
+
if (property.hasSemantic(SemanticType.UserRole)) {
|
|
61
|
+
hasRole = true
|
|
62
|
+
}
|
|
63
|
+
if (hasUsername && hasPassword && hasRole) {
|
|
64
|
+
break
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!hasUsername) {
|
|
68
|
+
results.push({
|
|
69
|
+
field: 'semantics',
|
|
70
|
+
rule: 'has_username',
|
|
71
|
+
message: 'No username property on the User entity found.',
|
|
72
|
+
help: 'It is recommended to set the "username" property semantic for APIs supporting authentication.',
|
|
73
|
+
severity: 'warning',
|
|
74
|
+
key: 'user_semantic',
|
|
75
|
+
kind: DomainEntityKind,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
if (!hasPassword) {
|
|
79
|
+
results.push({
|
|
80
|
+
field: 'semantics',
|
|
81
|
+
rule: 'has_password',
|
|
82
|
+
message: 'No password property on the User entity found.',
|
|
83
|
+
help: 'It is recommended to set the "password" property semantic for APIs supporting authentication.',
|
|
84
|
+
severity: 'warning',
|
|
85
|
+
key: 'user_semantic',
|
|
86
|
+
kind: DomainEntityKind,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
if (!hasRole) {
|
|
90
|
+
results.push({
|
|
91
|
+
field: 'semantics',
|
|
92
|
+
rule: 'has_user_role',
|
|
93
|
+
message: 'No role property on the User entity found.',
|
|
94
|
+
help: 'It is recommended to set the "User role" property semantic for APIs supporting authentication.',
|
|
95
|
+
severity: 'warning',
|
|
96
|
+
key: 'user_semantic',
|
|
97
|
+
kind: DomainEntityKind,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
51
100
|
}
|
|
52
101
|
}
|
|
53
102
|
|
|
54
|
-
if (
|
|
103
|
+
if (userEntitiesCount === 0) {
|
|
55
104
|
results.push({
|
|
56
105
|
field: 'semantics',
|
|
57
106
|
rule: 'recommended',
|
|
58
107
|
message: 'No entity with User taxonomy found in the domain.',
|
|
59
|
-
help: 'It is recommended to have
|
|
108
|
+
help: 'It is recommended to have an entity with the User taxonomy for authentication purposes.',
|
|
60
109
|
severity: 'warning',
|
|
61
110
|
key: 'user_semantic',
|
|
62
111
|
kind: DomainEntityKind,
|
|
63
112
|
})
|
|
113
|
+
} else if (userEntitiesCount > 1) {
|
|
114
|
+
results.push({
|
|
115
|
+
field: 'semantics',
|
|
116
|
+
rule: 'one_user',
|
|
117
|
+
message: 'There is more than one entity with User taxonomy in the domain.',
|
|
118
|
+
help: 'There can be only one user in the system. Remove the additional User taxonomies.',
|
|
119
|
+
severity: 'error',
|
|
120
|
+
key: 'user_semantic',
|
|
121
|
+
kind: DomainEntityKind,
|
|
122
|
+
})
|
|
64
123
|
}
|
|
65
124
|
|
|
66
125
|
return results
|
|
@@ -417,10 +417,9 @@ export class CoreEngine extends EventEmitter {
|
|
|
417
417
|
const port = getPort(this.uri.port, this.uri.protocol)
|
|
418
418
|
const host = this.uri.hostname
|
|
419
419
|
const protocol = this.uri.protocol
|
|
420
|
-
|
|
421
420
|
const socket = await this.connectionManager.createConnection(host, port, protocol, this.opts)
|
|
422
421
|
const { timeout } = this
|
|
423
|
-
if (timeout > 0) {
|
|
422
|
+
if (typeof timeout === 'number' && timeout > 0) {
|
|
424
423
|
socket.setTimeout(timeout)
|
|
425
424
|
}
|
|
426
425
|
this.socket = socket
|
|
@@ -30,8 +30,7 @@ export class DirectConnection {
|
|
|
30
30
|
} else {
|
|
31
31
|
socket = await this.createTcpConnection(host, port, logger, stats)
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
if (timeout && timeout > 0) {
|
|
33
|
+
if (typeof timeout === 'number' && timeout > 0) {
|
|
35
34
|
socket.setTimeout(timeout)
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -54,8 +53,12 @@ export class DirectConnection {
|
|
|
54
53
|
if (isIp) {
|
|
55
54
|
stats.lookupTime = Date.now()
|
|
56
55
|
}
|
|
57
|
-
|
|
58
|
-
const
|
|
56
|
+
const { timeout } = this.options
|
|
57
|
+
const opts: net.NetConnectOpts = { host, port }
|
|
58
|
+
if (typeof timeout === 'number' && timeout > 0) {
|
|
59
|
+
opts.timeout = timeout
|
|
60
|
+
}
|
|
61
|
+
const client = net.createConnection(opts, () => {
|
|
59
62
|
stats.connectedTime = Date.now()
|
|
60
63
|
logger.debug('HTTP connection established.')
|
|
61
64
|
resolve(client)
|
|
@@ -68,6 +71,7 @@ export class DirectConnection {
|
|
|
68
71
|
})
|
|
69
72
|
}
|
|
70
73
|
client.once('error', (err: Error) => reject(err))
|
|
74
|
+
client.once('', (err: Error) => reject(err))
|
|
71
75
|
})
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -73,14 +73,20 @@ test.group('DomainImpactAnalysis.validate()', (group) => {
|
|
|
73
73
|
const p3e1 = entity1.addProperty({ type: 'datetime', info: { name: 'p3e1' } })
|
|
74
74
|
const p4e1 = entity1.addProperty({ type: 'datetime', info: { name: 'p4e1' } })
|
|
75
75
|
const p5e1 = entity1.addProperty({ type: 'boolean', info: { name: 'p5e1' } })
|
|
76
|
+
const p6e1 = entity1.addProperty({ type: 'string', info: { name: 'role_property' } })
|
|
77
|
+
const p7e1 = entity1.addProperty({ type: 'string', info: { name: 'pass_property' } })
|
|
78
|
+
const p8e1 = entity1.addProperty({ type: 'string', info: { name: 'uname_property' } })
|
|
76
79
|
|
|
77
80
|
entity1.addSemantic({ id: SemanticType.User })
|
|
81
|
+
p8e1.addSemantic({ id: SemanticType.Username })
|
|
82
|
+
p7e1.addSemantic({ id: SemanticType.Password })
|
|
78
83
|
p3e2.addSemantic({ id: SemanticType.CreatedTimestamp })
|
|
79
84
|
p5e2.addSemantic({ id: SemanticType.UpdatedTimestamp })
|
|
80
85
|
p6e2.addSemantic({ id: SemanticType.DeletedTimestamp })
|
|
81
86
|
p3e1.addSemantic({ id: SemanticType.CreatedTimestamp })
|
|
82
87
|
p4e1.addSemantic({ id: SemanticType.UpdatedTimestamp })
|
|
83
88
|
p5e1.addSemantic({ id: SemanticType.DeletedFlag })
|
|
89
|
+
p6e1.addSemantic({ id: SemanticType.UserRole })
|
|
84
90
|
|
|
85
91
|
const report = analysis.validate()
|
|
86
92
|
assert.lengthOf(report, 0)
|
|
@@ -22,10 +22,13 @@ test.group('SemanticValidation', (group) => {
|
|
|
22
22
|
assert.equal(results[0].message, 'No entity with User taxonomy found in the domain.')
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
test('validate() should not return a warning when a User entity is found', ({ assert }) => {
|
|
25
|
+
test('validate() should not return a warning when a User entity is found with semantic properties', ({ assert }) => {
|
|
26
26
|
const model = domain.addModel({ key: 'model' })
|
|
27
27
|
const entity = model.addEntity({ key: 'user', info: { name: 'User' } })
|
|
28
28
|
entity.addSemantic({ id: SemanticType.User })
|
|
29
|
+
entity.addProperty({ type: 'string', info: { name: 'username' }, semantics: [{ id: SemanticType.Username }] })
|
|
30
|
+
entity.addProperty({ type: 'string', info: { name: 'password' }, semantics: [{ id: SemanticType.Password }] })
|
|
31
|
+
entity.addProperty({ type: 'string', info: { name: 'role' }, semantics: [{ id: SemanticType.UserRole }] })
|
|
29
32
|
const results = validation.validate()
|
|
30
33
|
assert.lengthOf(results, 3) // Only info for timestamps (2) and soft delete
|
|
31
34
|
assert.notEqual(results[0].message, 'No entity with User taxonomy found in the domain.')
|
|
@@ -268,12 +268,13 @@ test.group('CoreEngine - Error Handling Integration', () => {
|
|
|
268
268
|
|
|
269
269
|
test('handles connection errors with proper error types', async ({ assert }) => {
|
|
270
270
|
const request: IHttpRequest = {
|
|
271
|
-
|
|
271
|
+
// Use a local closed port so the failure is deterministic and does not depend on DNS behavior.
|
|
272
|
+
url: 'http://127.0.0.1:1/api',
|
|
272
273
|
method: 'GET',
|
|
273
274
|
}
|
|
274
275
|
const opts: HttpEngineOptions = {
|
|
275
276
|
logger,
|
|
276
|
-
timeout:
|
|
277
|
+
timeout: 300,
|
|
277
278
|
}
|
|
278
279
|
const engine = new CoreEngine(request, opts)
|
|
279
280
|
|
|
@@ -283,10 +284,12 @@ test.group('CoreEngine - Error Handling Integration', () => {
|
|
|
283
284
|
} catch (error: unknown) {
|
|
284
285
|
assert.isTrue(error instanceof Error)
|
|
285
286
|
if (error instanceof Error) {
|
|
286
|
-
assert.
|
|
287
|
+
assert.match(error.message, /connect|econnrefused|refused/i)
|
|
287
288
|
}
|
|
288
289
|
}
|
|
289
|
-
})
|
|
290
|
+
})
|
|
291
|
+
.tags(['@core-engine2', '@error-handling', '@connection'])
|
|
292
|
+
.timeout(5000)
|
|
290
293
|
|
|
291
294
|
test('handles unsupported payload types with proper error types', async ({ assert, httpConfig }) => {
|
|
292
295
|
const request: IHttpRequest = {
|
|
@@ -502,9 +505,9 @@ test.group('CoreEngine - Component Isolation Tests', () => {
|
|
|
502
505
|
|
|
503
506
|
const authManager = new AuthManager({
|
|
504
507
|
logger,
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
+
proxyCredentials: {
|
|
509
|
+
proxyUsername: 'testuser',
|
|
510
|
+
proxyPassword: 'testpass',
|
|
508
511
|
},
|
|
509
512
|
})
|
|
510
513
|
|