@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.
Files changed (29) hide show
  1. package/build/src/modeling/DomainEntity.d.ts +12 -0
  2. package/build/src/modeling/DomainEntity.d.ts.map +1 -1
  3. package/build/src/modeling/DomainEntity.js +32 -0
  4. package/build/src/modeling/DomainEntity.js.map +1 -1
  5. package/build/src/modeling/validation/api_model_rules.d.ts +1 -1
  6. package/build/src/modeling/validation/api_model_rules.d.ts.map +1 -1
  7. package/build/src/modeling/validation/api_model_rules.js +44 -42
  8. package/build/src/modeling/validation/api_model_rules.js.map +1 -1
  9. package/build/src/modeling/validation/semantic_validation.d.ts +1 -1
  10. package/build/src/modeling/validation/semantic_validation.d.ts.map +1 -1
  11. package/build/src/modeling/validation/semantic_validation.js +66 -6
  12. package/build/src/modeling/validation/semantic_validation.js.map +1 -1
  13. package/build/src/runtime/http-engine/CoreEngine.d.ts.map +1 -1
  14. package/build/src/runtime/http-engine/CoreEngine.js +1 -1
  15. package/build/src/runtime/http-engine/CoreEngine.js.map +1 -1
  16. package/build/src/runtime/http-engine/connections/DirectConnection.d.ts.map +1 -1
  17. package/build/src/runtime/http-engine/connections/DirectConnection.js +8 -2
  18. package/build/src/runtime/http-engine/connections/DirectConnection.js.map +1 -1
  19. package/build/tsconfig.tsbuildinfo +1 -1
  20. package/package.json +1 -1
  21. package/src/modeling/DomainEntity.ts +33 -0
  22. package/src/modeling/validation/api_model_rules.ts +45 -42
  23. package/src/modeling/validation/api_model_validation_rules.md +10 -3
  24. package/src/modeling/validation/semantic_validation.ts +65 -6
  25. package/src/runtime/http-engine/CoreEngine.ts +1 -2
  26. package/src/runtime/http-engine/connections/DirectConnection.ts +8 -4
  27. package/tests/unit/modeling/domain_validation.spec.ts +6 -0
  28. package/tests/unit/modeling/validation/semantic_validation.spec.ts +4 -1
  29. package/tests/unit/runtime/http-engine/core_engine.spec.ts +10 -7
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.19.28",
4
+ "version": "0.19.29",
5
5
  "license": "UNLICENSED",
6
6
  "exports": {
7
7
  "./browser.js": {
@@ -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(entity: ExposedEntity, apiModel: ApiModel): ApiModelValidationItem[] {
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: entity.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 (!entity.entity || !entity.entity.key) {
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 (apiModel.domain && !apiModel.domain.findEntity(entity.entity.key, entity.entity.domain)) {
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 (entity.hasCollection) {
486
- if (!entity.collectionPath) {
488
+ if (exposure.hasCollection) {
489
+ if (!exposure.collectionPath) {
487
490
  issues.push({
488
491
  code: createCode('EXPOSURE', 'MISSING_COLLECTION_PATH'),
489
- message: 'When an endpoint exposes a collection, it must define what its base URL path is.',
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 = entity.collectionPath.split('/').filter(Boolean)
496
- if (parts.length !== 1 || !entity.collectionPath.startsWith('/')) {
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: 'The collection URL should start with "/" and have no extra slashes.',
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 (!entity.resourcePath) {
510
+ if (!exposure.resourcePath) {
508
511
  issues.push({
509
512
  code: createCode('EXPOSURE', 'MISSING_RESOURCE_PATH'),
510
- message: 'You need an identifier to locate single items.',
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 (entity.collectionPath) {
516
- const colRegex = new RegExp(`^${entity.collectionPath}/\\{[a-zA-Z0-9_]+\\}$`)
517
- if (!colRegex.test(entity.resourcePath)) {
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: 'The single item route should match exactly your collection path plus one identifier variable.',
521
- suggestion: `Make sure the item path is formatted exactly like "${entity.collectionPath}/{id}".`,
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 (!entity.resourcePath) {
531
+ if (!exposure.resourcePath) {
529
532
  issues.push({
530
533
  code: createCode('EXPOSURE', 'MISSING_RESOURCE_PATH'),
531
- message: 'Endpoints representing a single item must declare their URL route.',
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 = entity.resourcePath.split('/').filter(Boolean)
538
- if (parts.length !== 1 || !entity.resourcePath.startsWith('/')) {
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: 'The URL route must only contain one exact part or level.',
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 (entity.isRoot) {
552
- if (entity.collectionPath) {
553
- const collectionCollision = apiModel.findCollectionPathCollision(entity.collectionPath, entity.key)
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 "${entity.collectionPath}" is already used by another view.`,
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 (entity.resourcePath) {
566
- const resourceCollision = apiModel.findResourcePathCollision(entity.resourcePath, entity.key)
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 "${entity.resourcePath}" is already used by another view.`,
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 (!entity.actions || entity.actions.length === 0) {
583
+ if (!exposure.actions || exposure.actions.length === 0) {
581
584
  issues.push({
582
585
  code: createCode('EXPOSURE', 'MISSING_ACTIONS'),
583
- message: 'This exposed view does not let users do anything.',
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 entity.actions) {
592
- issues.push(...validateAction(action, entity, apiModel.key))
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 && entity.accessRule && entity.accessRule.length > 0) hasAuth = true
604
+ if (!hasAuth && exposure.accessRule && exposure.accessRule.length > 0) hasAuth = true
602
605
 
603
- let curr = entity.parent?.key
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: entity.key },
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 = entity.paginationContract
631
+ const contract = exposure.paginationContract
629
632
  if (!contract) {
630
633
  issues.push({
631
634
  code: createCode('EXPOSURE', 'MISSING_PAGINATION_CONTRACT'),
632
- message: 'The List or Search action needs a pagination contract.',
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: 'Listing all elements without filters could be overwhelming for large tables.',
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: 'Listing all elements without sorting could be overwhelming for large tables.',
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: 'Search action needs to know which fields to look in.',
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 at least one entity with the User semantic.
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 hasUserEntity = false
45
+ let userEntitiesCount = 0
46
46
 
47
47
  for (const entity of this.domain.listEntities()) {
48
48
  if (entity.hasSemantic(SemanticType.User)) {
49
- hasUserEntity = true
50
- break
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 (!hasUserEntity) {
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 at least one entity with the User taxonomy for authentication purposes.',
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 client = net.createConnection(port, host, () => {
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
- url: 'http://invalid-hostname-that-does-not-exist-12345.com/api',
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: 5000,
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.include(error.message, 'connect')
287
+ assert.match(error.message, /connect|econnrefused|refused/i)
287
288
  }
288
289
  }
289
- }).tags(['@core-engine2', '@error-handling', '@connection'])
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
- credentials: {
506
- username: 'testuser',
507
- password: 'testpass',
508
+ proxyCredentials: {
509
+ proxyUsername: 'testuser',
510
+ proxyPassword: 'testpass',
508
511
  },
509
512
  })
510
513