d365fo-client 0.3.0__py3-none-any.whl → 0.3.2__py3-none-any.whl
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.
- d365fo_client/cli.py +135 -0
- d365fo_client/client.py +371 -19
- d365fo_client/crud.py +33 -10
- d365fo_client/main.py +58 -0
- d365fo_client/mcp/auth_server/auth/providers/apikey.py +83 -0
- d365fo_client/mcp/auth_server/auth/providers/azure.py +91 -23
- d365fo_client/mcp/fastmcp_main.py +92 -43
- d365fo_client/mcp/fastmcp_utils.py +4 -0
- d365fo_client/mcp/mixins/base_tools_mixin.py +13 -12
- d365fo_client/mcp/mixins/crud_tools_mixin.py +181 -40
- d365fo_client/mcp/server.py +10 -1
- d365fo_client/mcp/tools/__init__.py +2 -0
- d365fo_client/mcp/tools/json_service_tools.py +326 -0
- d365fo_client/models.py +45 -0
- d365fo_client/odata_serializer.py +300 -0
- d365fo_client/query.py +30 -20
- d365fo_client/settings.py +14 -2
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/METADATA +114 -3
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/RECORD +23 -20
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/WHEEL +0 -0
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.3.0.dist-info → d365fo_client-0.3.2.dist-info}/top_level.txt +0 -0
d365fo_client/cli.py
CHANGED
@@ -99,6 +99,7 @@ class CLIManager:
|
|
99
99
|
"metadata": self._handle_metadata_commands,
|
100
100
|
"entity": self._handle_entity_commands,
|
101
101
|
"action": self._handle_action_commands,
|
102
|
+
"service": self._handle_service_commands,
|
102
103
|
}
|
103
104
|
|
104
105
|
command = getattr(args, "command", None)
|
@@ -694,6 +695,140 @@ class CLIManager:
|
|
694
695
|
print(format_error_message(f"Error setting default profile: {e}"))
|
695
696
|
return 1
|
696
697
|
|
698
|
+
async def _handle_service_commands(self, args: argparse.Namespace) -> int:
|
699
|
+
"""Handle JSON service commands."""
|
700
|
+
subcommand = getattr(args, "service_subcommand", None)
|
701
|
+
|
702
|
+
if subcommand == "call":
|
703
|
+
return await self._handle_service_call(args)
|
704
|
+
elif subcommand == "sql-diagnostic":
|
705
|
+
return await self._handle_service_sql_diagnostic(args)
|
706
|
+
else:
|
707
|
+
print(format_error_message(f"Unknown service subcommand: {subcommand}"))
|
708
|
+
return 1
|
709
|
+
|
710
|
+
async def _handle_service_call(self, args: argparse.Namespace) -> int:
|
711
|
+
"""Handle generic JSON service call command."""
|
712
|
+
try:
|
713
|
+
service_group = getattr(args, "service_group", "")
|
714
|
+
service_name = getattr(args, "service_name", "")
|
715
|
+
operation_name = getattr(args, "operation_name", "")
|
716
|
+
|
717
|
+
# Parse parameters from JSON string if provided
|
718
|
+
parameters = None
|
719
|
+
parameters_str = getattr(args, "parameters", None)
|
720
|
+
if parameters_str:
|
721
|
+
try:
|
722
|
+
parameters = json.loads(parameters_str)
|
723
|
+
except json.JSONDecodeError as e:
|
724
|
+
print(format_error_message(f"Invalid JSON in parameters: {e}"))
|
725
|
+
return 1
|
726
|
+
|
727
|
+
# Call the service
|
728
|
+
response = await self.client.post_json_service(
|
729
|
+
service_group=service_group,
|
730
|
+
service_name=service_name,
|
731
|
+
operation_name=operation_name,
|
732
|
+
parameters=parameters,
|
733
|
+
)
|
734
|
+
|
735
|
+
# Format and display response
|
736
|
+
if response.success:
|
737
|
+
result = {
|
738
|
+
"success": True,
|
739
|
+
"statusCode": response.status_code,
|
740
|
+
"data": response.data,
|
741
|
+
"serviceGroup": service_group,
|
742
|
+
"serviceName": service_name,
|
743
|
+
"operationName": operation_name,
|
744
|
+
}
|
745
|
+
output = self.output_formatter.format_output(result)
|
746
|
+
print(output)
|
747
|
+
return 0
|
748
|
+
else:
|
749
|
+
error_result = {
|
750
|
+
"success": False,
|
751
|
+
"statusCode": response.status_code,
|
752
|
+
"error": response.error_message,
|
753
|
+
"serviceGroup": service_group,
|
754
|
+
"serviceName": service_name,
|
755
|
+
"operationName": operation_name,
|
756
|
+
}
|
757
|
+
output = self.output_formatter.format_output(error_result)
|
758
|
+
print(output)
|
759
|
+
return 1
|
760
|
+
|
761
|
+
except Exception as e:
|
762
|
+
print(format_error_message(f"Error calling service: {e}"))
|
763
|
+
return 1
|
764
|
+
|
765
|
+
async def _handle_service_sql_diagnostic(self, args: argparse.Namespace) -> int:
|
766
|
+
"""Handle SQL diagnostic service call command."""
|
767
|
+
try:
|
768
|
+
operation = getattr(args, "operation", "")
|
769
|
+
|
770
|
+
# Prepare parameters based on operation
|
771
|
+
parameters = {}
|
772
|
+
|
773
|
+
if operation == "GetAxSqlResourceStats":
|
774
|
+
since_minutes = getattr(args, "since_minutes", 10)
|
775
|
+
start_time = getattr(args, "start_time", None)
|
776
|
+
end_time = getattr(args, "end_time", None)
|
777
|
+
|
778
|
+
if start_time and end_time:
|
779
|
+
parameters = {
|
780
|
+
"start": start_time,
|
781
|
+
"end": end_time,
|
782
|
+
}
|
783
|
+
else:
|
784
|
+
# Use since_minutes to calculate start/end
|
785
|
+
from datetime import datetime, timezone, timedelta
|
786
|
+
end = datetime.now(timezone.utc)
|
787
|
+
start = end - timedelta(minutes=since_minutes)
|
788
|
+
parameters = {
|
789
|
+
"start": start.isoformat(),
|
790
|
+
"end": end.isoformat(),
|
791
|
+
}
|
792
|
+
|
793
|
+
# Call the SQL diagnostic service
|
794
|
+
response = await self.client.post_json_service(
|
795
|
+
service_group="SysSqlDiagnosticService",
|
796
|
+
service_name="SysSqlDiagnosticServiceOperations",
|
797
|
+
operation_name=operation,
|
798
|
+
parameters=parameters if parameters else None,
|
799
|
+
)
|
800
|
+
|
801
|
+
# Format and display response
|
802
|
+
if response.success:
|
803
|
+
result = {
|
804
|
+
"success": True,
|
805
|
+
"statusCode": response.status_code,
|
806
|
+
"operation": operation,
|
807
|
+
"data": response.data,
|
808
|
+
}
|
809
|
+
|
810
|
+
# Add summary information
|
811
|
+
if isinstance(response.data, list):
|
812
|
+
result["recordCount"] = len(response.data)
|
813
|
+
|
814
|
+
output = self.output_formatter.format_output(result)
|
815
|
+
print(output)
|
816
|
+
return 0
|
817
|
+
else:
|
818
|
+
error_result = {
|
819
|
+
"success": False,
|
820
|
+
"statusCode": response.status_code,
|
821
|
+
"operation": operation,
|
822
|
+
"error": response.error_message,
|
823
|
+
}
|
824
|
+
output = self.output_formatter.format_output(error_result)
|
825
|
+
print(output)
|
826
|
+
return 1
|
827
|
+
|
828
|
+
except Exception as e:
|
829
|
+
print(format_error_message(f"Error calling SQL diagnostic service: {e}"))
|
830
|
+
return 1
|
831
|
+
|
697
832
|
def _handle_error(self, error: Exception, verbose: bool = False) -> None:
|
698
833
|
"""Handle and display errors consistently.
|
699
834
|
|
d365fo_client/client.py
CHANGED
@@ -19,6 +19,8 @@ from .models import (
|
|
19
19
|
DataEntityInfo,
|
20
20
|
EnumerationInfo,
|
21
21
|
FOClientConfig,
|
22
|
+
JsonServiceRequest,
|
23
|
+
JsonServiceResponse,
|
22
24
|
PublicEntityInfo,
|
23
25
|
QueryOptions,
|
24
26
|
)
|
@@ -531,36 +533,58 @@ class FOClient:
|
|
531
533
|
# CRUD Operations
|
532
534
|
|
533
535
|
async def get_entities(
|
534
|
-
self, entity_name: str, options: Optional[QueryOptions] = None
|
536
|
+
self, entity_name: str, options: Optional[QueryOptions] = None, skip_validation: bool = False
|
535
537
|
) -> Dict[str, Any]:
|
536
538
|
"""Get entities with OData query options
|
537
539
|
|
538
540
|
Args:
|
539
541
|
entity_name: Name of the entity set
|
540
542
|
options: OData query options
|
543
|
+
skip_validation: Skip schema validation for performance
|
541
544
|
|
542
545
|
Returns:
|
543
546
|
Response containing entities
|
544
547
|
"""
|
545
|
-
|
548
|
+
entity_schema = None
|
549
|
+
if not skip_validation:
|
550
|
+
entity_schema = await self.get_public_entity_schema_by_entityset(entity_name)
|
551
|
+
if not entity_schema:
|
552
|
+
raise FOClientError(
|
553
|
+
f"Entity '{entity_name}' not found or not accessible for OData operations"
|
554
|
+
)
|
555
|
+
|
556
|
+
return await self.crud_ops.get_entities(entity_name, options, entity_schema)
|
546
557
|
|
547
558
|
async def get_entity(
|
548
559
|
self,
|
549
560
|
entity_name: str,
|
550
561
|
key: Union[str, Dict[str, Any]],
|
551
562
|
options: Optional[QueryOptions] = None,
|
563
|
+
skip_validation: bool = False,
|
552
564
|
) -> Dict[str, Any]:
|
553
|
-
"""Get single entity by key
|
565
|
+
"""Get single entity by key with schema validation
|
554
566
|
|
555
567
|
Args:
|
556
|
-
entity_name: Name of the entity set
|
568
|
+
entity_name: Name of the entity set (entityset/collection name)
|
557
569
|
key: Entity key value (string for simple keys, dict for composite keys)
|
558
570
|
options: OData query options
|
571
|
+
skip_validation: Skip schema validation for performance (batch operations)
|
559
572
|
|
560
573
|
Returns:
|
561
574
|
Entity data
|
575
|
+
|
576
|
+
Raises:
|
577
|
+
FOClientError: If entity not found or not accessible
|
562
578
|
"""
|
563
|
-
|
579
|
+
entity_schema = None
|
580
|
+
if not skip_validation:
|
581
|
+
entity_schema = await self.get_public_entity_schema_by_entityset(entity_name)
|
582
|
+
if not entity_schema:
|
583
|
+
raise FOClientError(
|
584
|
+
f"Entity '{entity_name}' not found or not accessible for OData operations"
|
585
|
+
)
|
586
|
+
|
587
|
+
return await self.crud_ops.get_entity(entity_name, key, options, entity_schema)
|
564
588
|
|
565
589
|
async def get_entity_by_key(
|
566
590
|
self,
|
@@ -568,6 +592,7 @@ class FOClient:
|
|
568
592
|
key: Union[str, Dict[str, Any]],
|
569
593
|
select: Optional[List[str]] = None,
|
570
594
|
expand: Optional[List[str]] = None,
|
595
|
+
skip_validation: bool = False,
|
571
596
|
) -> Optional[Dict[str, Any]]:
|
572
597
|
"""Get single entity by key with optional field selection and expansion
|
573
598
|
|
@@ -576,6 +601,7 @@ class FOClient:
|
|
576
601
|
key: Entity key value (string for simple keys, dict for composite keys)
|
577
602
|
select: Optional list of fields to select
|
578
603
|
expand: Optional list of navigation properties to expand
|
604
|
+
skip_validation: Skip schema validation for performance
|
579
605
|
|
580
606
|
Returns:
|
581
607
|
Entity data or None if not found
|
@@ -584,7 +610,7 @@ class FOClient:
|
|
584
610
|
options = (
|
585
611
|
QueryOptions(select=select, expand=expand) if select or expand else None
|
586
612
|
)
|
587
|
-
return await self.
|
613
|
+
return await self.get_entity(entity_name, key, options, skip_validation)
|
588
614
|
except Exception as e:
|
589
615
|
# If the entity is not found, return None instead of raising exception
|
590
616
|
if "404" in str(e):
|
@@ -592,18 +618,34 @@ class FOClient:
|
|
592
618
|
raise
|
593
619
|
|
594
620
|
async def create_entity(
|
595
|
-
self, entity_name: str, data: Dict[str, Any]
|
621
|
+
self, entity_name: str, data: Dict[str, Any], skip_validation: bool = False
|
596
622
|
) -> Dict[str, Any]:
|
597
|
-
"""Create new entity
|
623
|
+
"""Create new entity with schema validation
|
598
624
|
|
599
625
|
Args:
|
600
626
|
entity_name: Name of the entity set
|
601
627
|
data: Entity data to create
|
628
|
+
skip_validation: Skip schema validation for performance (batch operations)
|
602
629
|
|
603
630
|
Returns:
|
604
631
|
Created entity data
|
632
|
+
|
633
|
+
Raises:
|
634
|
+
FOClientError: If entity not found, read-only, or validation fails
|
605
635
|
"""
|
606
|
-
|
636
|
+
entity_schema = None
|
637
|
+
if not skip_validation:
|
638
|
+
entity_schema = await self.get_public_entity_schema_by_entityset(entity_name)
|
639
|
+
if not entity_schema:
|
640
|
+
raise FOClientError(
|
641
|
+
f"Entity '{entity_name}' not found or not accessible for OData operations"
|
642
|
+
)
|
643
|
+
if entity_schema.is_read_only:
|
644
|
+
raise FOClientError(
|
645
|
+
f"Entity '{entity_name}' is read-only and cannot accept create operations"
|
646
|
+
)
|
647
|
+
|
648
|
+
return await self.crud_ops.create_entity(entity_name, data, entity_schema)
|
607
649
|
|
608
650
|
async def update_entity(
|
609
651
|
self,
|
@@ -611,33 +653,66 @@ class FOClient:
|
|
611
653
|
key: Union[str, Dict[str, Any]],
|
612
654
|
data: Dict[str, Any],
|
613
655
|
method: str = "PATCH",
|
656
|
+
skip_validation: bool = False,
|
614
657
|
) -> Dict[str, Any]:
|
615
|
-
"""Update existing entity
|
658
|
+
"""Update existing entity with schema validation
|
616
659
|
|
617
660
|
Args:
|
618
661
|
entity_name: Name of the entity set
|
619
662
|
key: Entity key value (string for simple keys, dict for composite keys)
|
620
663
|
data: Updated entity data
|
621
664
|
method: HTTP method (PATCH or PUT)
|
665
|
+
skip_validation: Skip schema validation for performance (batch operations)
|
622
666
|
|
623
667
|
Returns:
|
624
668
|
Updated entity data
|
669
|
+
|
670
|
+
Raises:
|
671
|
+
FOClientError: If entity not found, read-only, or validation fails
|
625
672
|
"""
|
626
|
-
|
673
|
+
entity_schema = None
|
674
|
+
if not skip_validation:
|
675
|
+
entity_schema = await self.get_public_entity_schema_by_entityset(entity_name)
|
676
|
+
if not entity_schema:
|
677
|
+
raise FOClientError(
|
678
|
+
f"Entity '{entity_name}' not found or not accessible for OData operations"
|
679
|
+
)
|
680
|
+
if entity_schema.is_read_only:
|
681
|
+
raise FOClientError(
|
682
|
+
f"Entity '{entity_name}' is read-only and cannot accept update operations"
|
683
|
+
)
|
684
|
+
|
685
|
+
return await self.crud_ops.update_entity(entity_name, key, data, method, entity_schema)
|
627
686
|
|
628
687
|
async def delete_entity(
|
629
|
-
self, entity_name: str, key: Union[str, Dict[str, Any]]
|
688
|
+
self, entity_name: str, key: Union[str, Dict[str, Any]], skip_validation: bool = False
|
630
689
|
) -> bool:
|
631
|
-
"""Delete entity
|
690
|
+
"""Delete entity with schema validation
|
632
691
|
|
633
692
|
Args:
|
634
693
|
entity_name: Name of the entity set
|
635
694
|
key: Entity key value (string for simple keys, dict for composite keys)
|
695
|
+
skip_validation: Skip schema validation for performance (batch operations)
|
636
696
|
|
637
697
|
Returns:
|
638
698
|
True if successful
|
699
|
+
|
700
|
+
Raises:
|
701
|
+
FOClientError: If entity not found, read-only, or validation fails
|
639
702
|
"""
|
640
|
-
|
703
|
+
entity_schema = None
|
704
|
+
if not skip_validation:
|
705
|
+
entity_schema = await self.get_public_entity_schema_by_entityset(entity_name)
|
706
|
+
if not entity_schema:
|
707
|
+
raise FOClientError(
|
708
|
+
f"Entity '{entity_name}' not found or not accessible for OData operations"
|
709
|
+
)
|
710
|
+
if entity_schema.is_read_only:
|
711
|
+
raise FOClientError(
|
712
|
+
f"Entity '{entity_name}' is read-only and cannot accept delete operations"
|
713
|
+
)
|
714
|
+
|
715
|
+
return await self.crud_ops.delete_entity(entity_name, key, entity_schema)
|
641
716
|
|
642
717
|
async def call_action(
|
643
718
|
self,
|
@@ -645,6 +720,7 @@ class FOClient:
|
|
645
720
|
parameters: Optional[Dict[str, Any]] = None,
|
646
721
|
entity_name: Optional[str] = None,
|
647
722
|
entity_key: Optional[Union[str, Dict[str, Any]]] = None,
|
723
|
+
skip_validation: bool = False,
|
648
724
|
) -> Any:
|
649
725
|
"""Call OData action method
|
650
726
|
|
@@ -653,12 +729,21 @@ class FOClient:
|
|
653
729
|
parameters: Action parameters
|
654
730
|
entity_name: Entity name for bound actions
|
655
731
|
entity_key: Entity key for bound actions (string for simple keys, dict for composite keys)
|
732
|
+
skip_validation: Skip schema validation for performance
|
656
733
|
|
657
734
|
Returns:
|
658
735
|
Action result
|
659
736
|
"""
|
737
|
+
entity_schema = None
|
738
|
+
if not skip_validation and entity_name:
|
739
|
+
entity_schema = await self.get_public_entity_schema_by_entityset(entity_name)
|
740
|
+
if not entity_schema:
|
741
|
+
raise FOClientError(
|
742
|
+
f"Entity '{entity_name}' not found or not accessible for OData operations"
|
743
|
+
)
|
744
|
+
|
660
745
|
return await self.crud_ops.call_action(
|
661
|
-
action_name, parameters, entity_name, entity_key
|
746
|
+
action_name, parameters, entity_name, entity_key, entity_schema
|
662
747
|
)
|
663
748
|
|
664
749
|
# Label Operations
|
@@ -1244,17 +1329,284 @@ class FOClient:
|
|
1244
1329
|
|
1245
1330
|
# Application Version Operations
|
1246
1331
|
|
1247
|
-
async def
|
1248
|
-
|
1332
|
+
async def post_json_service(
|
1333
|
+
self,
|
1334
|
+
service_group: str,
|
1335
|
+
service_name: str,
|
1336
|
+
operation_name: str,
|
1337
|
+
parameters: Optional[Dict[str, Any]] = None,
|
1338
|
+
) -> JsonServiceResponse:
|
1339
|
+
"""Call D365 F&O JSON service endpoint using POST method
|
1340
|
+
|
1341
|
+
This method provides a generic way to call D365 F&O JSON services that use the
|
1342
|
+
/api/services/{ServiceGroup}/{ServiceName}/{OperationName} endpoint pattern.
|
1343
|
+
|
1344
|
+
Args:
|
1345
|
+
service_group: Service group name (e.g., "SysSqlDiagnosticService")
|
1346
|
+
service_name: Service name (e.g., "SysSqlDiagnosticServiceOperations")
|
1347
|
+
operation_name: Operation name (e.g., "GetAxSqlExecuting")
|
1348
|
+
parameters: Optional parameters to send in the POST body
|
1349
|
+
|
1350
|
+
Returns:
|
1351
|
+
JsonServiceResponse containing the result data and metadata
|
1352
|
+
|
1353
|
+
Raises:
|
1354
|
+
FOClientError: If the service call fails
|
1355
|
+
|
1356
|
+
Example:
|
1357
|
+
# Call a service without parameters
|
1358
|
+
response = await client.post_json_service(
|
1359
|
+
"SysSqlDiagnosticService",
|
1360
|
+
"SysSqlDiagnosticServiceOperations",
|
1361
|
+
"GetAxSqlExecuting"
|
1362
|
+
)
|
1363
|
+
|
1364
|
+
# Call a service with parameters
|
1365
|
+
response = await client.post_json_service(
|
1366
|
+
"SysSqlDiagnosticService",
|
1367
|
+
"SysSqlDiagnosticServiceOperations",
|
1368
|
+
"GetAxSqlResourceStats",
|
1369
|
+
{
|
1370
|
+
"start": "2023-01-01T00:00:00Z",
|
1371
|
+
"end": "2023-01-02T00:00:00Z"
|
1372
|
+
}
|
1373
|
+
)
|
1374
|
+
"""
|
1375
|
+
try:
|
1376
|
+
# Create service request
|
1377
|
+
request = JsonServiceRequest(
|
1378
|
+
service_group=service_group,
|
1379
|
+
service_name=service_name,
|
1380
|
+
operation_name=operation_name,
|
1381
|
+
parameters=parameters,
|
1382
|
+
)
|
1383
|
+
|
1384
|
+
# Get the endpoint path
|
1385
|
+
endpoint_path = request.get_endpoint_path()
|
1386
|
+
url = f"{self.config.base_url.rstrip('/')}{endpoint_path}"
|
1387
|
+
|
1388
|
+
# Get session and make request
|
1389
|
+
session = await self.session_manager.get_session()
|
1390
|
+
|
1391
|
+
# Prepare headers
|
1392
|
+
headers = {"Content-Type": "application/json"}
|
1393
|
+
|
1394
|
+
# Prepare request body
|
1395
|
+
body = parameters or {}
|
1396
|
+
|
1397
|
+
async with session.post(url, json=body, headers=headers) as response:
|
1398
|
+
status_code = response.status
|
1399
|
+
|
1400
|
+
# Handle success cases
|
1401
|
+
if status_code in [200, 201]:
|
1402
|
+
try:
|
1403
|
+
content_type = response.headers.get("content-type", "")
|
1404
|
+
if "application/json" in content_type:
|
1405
|
+
data = await response.json()
|
1406
|
+
else:
|
1407
|
+
data = await response.text()
|
1408
|
+
|
1409
|
+
return JsonServiceResponse(
|
1410
|
+
success=True,
|
1411
|
+
data=data,
|
1412
|
+
status_code=status_code,
|
1413
|
+
)
|
1414
|
+
except Exception as parse_error:
|
1415
|
+
# If we can't parse the response, still return success with raw text
|
1416
|
+
text_data = await response.text()
|
1417
|
+
return JsonServiceResponse(
|
1418
|
+
success=True,
|
1419
|
+
data=text_data,
|
1420
|
+
status_code=status_code,
|
1421
|
+
error_message=f"Response parsing warning: {parse_error}",
|
1422
|
+
)
|
1423
|
+
|
1424
|
+
# Handle error cases
|
1425
|
+
else:
|
1426
|
+
error_text = await response.text()
|
1427
|
+
return JsonServiceResponse(
|
1428
|
+
success=False,
|
1429
|
+
data=None,
|
1430
|
+
status_code=status_code,
|
1431
|
+
error_message=f"HTTP {status_code}: {error_text}",
|
1432
|
+
)
|
1433
|
+
|
1434
|
+
except Exception as e:
|
1435
|
+
# Handle network errors and other exceptions
|
1436
|
+
return JsonServiceResponse(
|
1437
|
+
success=False,
|
1438
|
+
data=None,
|
1439
|
+
status_code=0,
|
1440
|
+
error_message=f"Request failed: {e}",
|
1441
|
+
)
|
1442
|
+
|
1443
|
+
async def call_json_service(
|
1444
|
+
self,
|
1445
|
+
request: JsonServiceRequest,
|
1446
|
+
) -> JsonServiceResponse:
|
1447
|
+
"""Call D365 F&O JSON service using a JsonServiceRequest object
|
1448
|
+
|
1449
|
+
This is an alternative interface to post_json_service that accepts a request object.
|
1450
|
+
|
1451
|
+
Args:
|
1452
|
+
request: JsonServiceRequest containing service details and parameters
|
1453
|
+
|
1454
|
+
Returns:
|
1455
|
+
JsonServiceResponse containing the result data and metadata
|
1456
|
+
|
1457
|
+
Example:
|
1458
|
+
request = JsonServiceRequest(
|
1459
|
+
service_group="SysSqlDiagnosticService",
|
1460
|
+
service_name="SysSqlDiagnosticServiceOperations",
|
1461
|
+
operation_name="GetAxSqlExecuting"
|
1462
|
+
)
|
1463
|
+
response = await client.call_json_service(request)
|
1464
|
+
"""
|
1465
|
+
return await self.post_json_service(
|
1466
|
+
request.service_group,
|
1467
|
+
request.service_name,
|
1468
|
+
request.operation_name,
|
1469
|
+
request.parameters,
|
1470
|
+
)
|
1471
|
+
|
1472
|
+
async def get_public_entity_schema_by_entityset(
|
1473
|
+
self,
|
1474
|
+
entityset_name: str,
|
1475
|
+
use_cache_first: Optional[bool] = True
|
1476
|
+
) -> Optional[PublicEntityInfo]:
|
1477
|
+
"""Get public entity schema by entityset name (public collection name).
|
1478
|
+
|
1479
|
+
This method resolves the entityset name to the actual public entity name
|
1480
|
+
and retrieves the full schema with cache-first optimization.
|
1481
|
+
|
1482
|
+
Args:
|
1483
|
+
entityset_name: Public collection name or entity set name
|
1484
|
+
(e.g., "CustomersV3", "SalesOrders", "DataManagementEntities")
|
1485
|
+
use_cache_first: Use metadata cache before F&O API (default: True)
|
1486
|
+
|
1487
|
+
Returns:
|
1488
|
+
PublicEntityInfo with full schema, or None if entity not found
|
1489
|
+
|
1490
|
+
Resolution Logic:
|
1491
|
+
1. Try direct lookup in public entities (entityset_name == entity name)
|
1492
|
+
2. Search data entities for public_collection_name match
|
1493
|
+
3. Resolve to public_entity_name and fetch schema
|
1494
|
+
4. Use cache-first pattern for all lookups
|
1495
|
+
"""
|
1496
|
+
|
1497
|
+
async def cache_lookup():
|
1498
|
+
if not self.metadata_cache:
|
1499
|
+
return None
|
1500
|
+
|
1501
|
+
# Try direct public entity lookup first (entityset_name might be the entity name)
|
1502
|
+
entity_schema = await self.metadata_cache.get_public_entity_schema(entityset_name)
|
1503
|
+
if entity_schema:
|
1504
|
+
self.logger.debug(f"Found entity schema directly for '{entityset_name}'")
|
1505
|
+
return entity_schema
|
1506
|
+
|
1507
|
+
# Try resolving via data entity metadata
|
1508
|
+
# Search for entities matching the entityset name
|
1509
|
+
data_entities = await self.metadata_cache.get_data_entities(
|
1510
|
+
name_pattern=entityset_name
|
1511
|
+
)
|
1512
|
+
|
1513
|
+
for data_entity in data_entities:
|
1514
|
+
# Check if public_collection_name matches
|
1515
|
+
if data_entity.public_collection_name == entityset_name:
|
1516
|
+
self.logger.debug(
|
1517
|
+
f"Resolved '{entityset_name}' to entity '{data_entity.public_entity_name or data_entity.name}'"
|
1518
|
+
)
|
1519
|
+
# Found match - get schema by public_entity_name
|
1520
|
+
return await self.metadata_cache.get_public_entity_schema(
|
1521
|
+
data_entity.public_entity_name or data_entity.name
|
1522
|
+
)
|
1523
|
+
|
1524
|
+
# Also check if entity name matches (for direct name usage)
|
1525
|
+
if data_entity.name == entityset_name:
|
1526
|
+
self.logger.debug(f"Found entity by name '{entityset_name}'")
|
1527
|
+
return await self.metadata_cache.get_public_entity_schema(
|
1528
|
+
data_entity.public_entity_name or data_entity.name
|
1529
|
+
)
|
1530
|
+
|
1531
|
+
# Check if public_entity_name matches
|
1532
|
+
if data_entity.public_entity_name == entityset_name:
|
1533
|
+
self.logger.debug(f"Found entity by public_entity_name '{entityset_name}'")
|
1534
|
+
return await self.metadata_cache.get_public_entity_schema(entityset_name)
|
1535
|
+
|
1536
|
+
return None
|
1537
|
+
|
1538
|
+
async def fallback_lookup():
|
1539
|
+
# Try direct public entity lookup first
|
1540
|
+
try:
|
1541
|
+
entity_schema = await self.metadata_api_ops.get_public_entity_info(
|
1542
|
+
entityset_name, resolve_labels=False
|
1543
|
+
)
|
1544
|
+
if entity_schema:
|
1545
|
+
self.logger.debug(f"Found entity schema via API for '{entityset_name}'")
|
1546
|
+
return entity_schema
|
1547
|
+
except Exception as e:
|
1548
|
+
self.logger.debug(f"Direct API lookup failed for '{entityset_name}': {e}")
|
1549
|
+
|
1550
|
+
# Search data entities to find the mapping
|
1551
|
+
try:
|
1552
|
+
data_entities = await self.metadata_api_ops.search_data_entities("")
|
1553
|
+
|
1554
|
+
for data_entity in data_entities:
|
1555
|
+
# Check if public_collection_name matches
|
1556
|
+
if data_entity.public_collection_name == entityset_name:
|
1557
|
+
self.logger.debug(
|
1558
|
+
f"Resolved '{entityset_name}' to entity '{data_entity.public_entity_name or data_entity.name}' via API"
|
1559
|
+
)
|
1560
|
+
# Found match - get schema by public_entity_name
|
1561
|
+
return await self.metadata_api_ops.get_public_entity_info(
|
1562
|
+
data_entity.public_entity_name or data_entity.name,
|
1563
|
+
resolve_labels=False
|
1564
|
+
)
|
1565
|
+
|
1566
|
+
# Also check if entity name matches
|
1567
|
+
if data_entity.name == entityset_name:
|
1568
|
+
return await self.metadata_api_ops.get_public_entity_info(
|
1569
|
+
data_entity.public_entity_name or data_entity.name,
|
1570
|
+
resolve_labels=False
|
1571
|
+
)
|
1572
|
+
|
1573
|
+
# Check if public_entity_name matches
|
1574
|
+
if data_entity.public_entity_name == entityset_name:
|
1575
|
+
return await self.metadata_api_ops.get_public_entity_info(
|
1576
|
+
entityset_name,
|
1577
|
+
resolve_labels=False
|
1578
|
+
)
|
1579
|
+
except Exception as e:
|
1580
|
+
self.logger.debug(f"Data entity search failed for '{entityset_name}': {e}")
|
1581
|
+
|
1582
|
+
return None
|
1583
|
+
|
1584
|
+
return await self._get_from_cache_first(
|
1585
|
+
cache_lookup,
|
1586
|
+
fallback_lookup,
|
1587
|
+
use_cache_first=use_cache_first,
|
1588
|
+
)
|
1589
|
+
|
1590
|
+
async def get_entity_schema(
|
1591
|
+
self,
|
1592
|
+
entity_name: str,
|
1593
|
+
use_cache_first: Optional[bool] = True
|
1594
|
+
) -> Optional[PublicEntityInfo]:
|
1595
|
+
"""Get entity schema with cache-first optimization.
|
1596
|
+
|
1597
|
+
This is an enhanced version that uses the cache-first pattern for better performance.
|
1249
1598
|
|
1250
1599
|
Args:
|
1251
1600
|
entity_name: Name of the public entity
|
1601
|
+
use_cache_first: Use metadata cache before F&O API (default: True)
|
1252
1602
|
|
1253
1603
|
Returns:
|
1254
1604
|
PublicEntityInfo object with schema details or None if not found
|
1255
1605
|
"""
|
1256
|
-
return await self.
|
1257
|
-
entity_name,
|
1606
|
+
return await self.get_public_entity_info(
|
1607
|
+
entity_name,
|
1608
|
+
resolve_labels=False,
|
1609
|
+
use_cache_first=use_cache_first
|
1258
1610
|
)
|
1259
1611
|
|
1260
1612
|
async def get_application_version(self) -> str:
|