carestack 0.1.0__tar.gz
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.
- carestack-0.1.0/PKG-INFO +28 -0
- carestack-0.1.0/carestack/__init__.py +0 -0
- carestack-0.1.0/carestack/ai/__init__.py +0 -0
- carestack-0.1.0/carestack/ai/ai_dto.py +8 -0
- carestack-0.1.0/carestack/ai/ai_service.py +92 -0
- carestack-0.1.0/carestack/ai/ai_utils.py +135 -0
- carestack-0.1.0/carestack/base/__init__.py +0 -0
- carestack-0.1.0/carestack/base/base_service.py +298 -0
- carestack-0.1.0/carestack/base/base_types.py +15 -0
- carestack-0.1.0/carestack/base/errors.py +29 -0
- carestack-0.1.0/carestack/common/__init__.py +0 -0
- carestack-0.1.0/carestack/common/enums.py +870 -0
- carestack-0.1.0/carestack/common/error_validation.py +50 -0
- carestack-0.1.0/carestack/document_linking/__init__.py +0 -0
- carestack-0.1.0/carestack/document_linking/document_linking.py +411 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/__init__.py +0 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/intermediate_dto/__init__.py +0 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/intermediate_dto/op_consultation_dto.py +219 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/intermediate_dto/sections_dto.py +204 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/intermediate_dto.py +0 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/source_dto/__init__.py +0 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/source_dto/op_consultation_dto.py +203 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/__init__.py +0 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/advisory_note_dto.py +19 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/allergy_intolerance_dto.py +42 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/appointment_dto.py +49 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/care_plan_dto.py +40 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/condition_dto.py +19 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/create_care_context_dto.py +49 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/diagnostic_report_dto.py +37 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/diagnostic_report_imaging_dto.py +25 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/diagnostic_report_lab_dto.py +15 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/diagnostic_report_record_dto.py +88 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/discharge_summary_record_dto.py +167 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/document_reference_dto.py +16 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/dosage_instruction_dto.py +17 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/follow_up_dto.py +26 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/generic_dto.py +16 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/health_document_linking_dto.py +112 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/health_document_record_dto.py +20 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/health_information_dto.py +84 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/immunization_dto.py +18 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/immunization_recommendation_dto.py +12 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/immunization_record_dto.py +81 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/link_care_context_dto.py +35 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/medication_dto.py +16 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/medication_history_dto.py +28 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/medication_request_dto.py +43 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/medication_statement_dto.py +16 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/observation_dto.py +33 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/op_consult_record_dto.py +108 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/prescription_record_dto.py +39 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/procedure_dto.py +36 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/recommendation_dto.py +19 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/reference_range_dto.py +33 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/service_request_dto.py +17 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/snomed_code_dto.py +31 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/update_visit_records_dto.py +54 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/value_quantity_dto.py +10 -0
- carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/wellness_record_dto.py +37 -0
- carestack-0.1.0/carestack/document_linking/schema.py +109 -0
- carestack-0.1.0/carestack/document_linking/transformer/__init__.py +0 -0
- carestack-0.1.0/carestack/document_linking/transformer/op_consultation.py +441 -0
- carestack-0.1.0/carestack/document_linking/transformer/tranformer_factory.py +41 -0
- carestack-0.1.0/carestack/document_linking/transformer/transformer.py +845 -0
- carestack-0.1.0/carestack/document_linking/utilities.py +5 -0
- carestack-0.1.0/carestack/organization/__init__.py +0 -0
- carestack-0.1.0/carestack/organization/organization_dto.py +649 -0
- carestack-0.1.0/carestack/organization/organization_service.py +499 -0
- carestack-0.1.0/carestack/organization/organization_service_test.py +865 -0
- carestack-0.1.0/carestack/patient/__init__.py +0 -0
- carestack-0.1.0/carestack/patient/patient_dto.py +646 -0
- carestack-0.1.0/carestack/patient/patient_service.py +325 -0
- carestack-0.1.0/carestack/patient/patient_service_test.py +804 -0
- carestack-0.1.0/carestack/practitioner/__init__.py +0 -0
- carestack-0.1.0/carestack/practitioner/hpr_registration/__init__.py +0 -0
- carestack-0.1.0/carestack/practitioner/hpr_registration/hpr_dto.py +234 -0
- carestack-0.1.0/carestack/practitioner/hpr_registration/hpr_service.py +255 -0
- carestack-0.1.0/carestack/practitioner/practitioner_dto.py +477 -0
- carestack-0.1.0/carestack/practitioner/practitioner_service.py +303 -0
- carestack-0.1.0/carestack/practitioner/practitioner_service_test.py +573 -0
- carestack-0.1.0/carestack.egg-info/PKG-INFO +28 -0
- carestack-0.1.0/carestack.egg-info/SOURCES.txt +88 -0
- carestack-0.1.0/carestack.egg-info/dependency_links.txt +1 -0
- carestack-0.1.0/carestack.egg-info/requires.txt +13 -0
- carestack-0.1.0/carestack.egg-info/top_level.txt +1 -0
- carestack-0.1.0/pyproject.toml +57 -0
- carestack-0.1.0/setup.cfg +8 -0
- carestack-0.1.0/tests/test_hpr_service.py +480 -0
carestack-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: carestack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK for EHR Services
|
|
5
|
+
Author-email: achalahealth <venkatesh.dakarapu@achalasolutions.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/achalahealth/ehrsdk
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: httpx>=0.25.0
|
|
18
|
+
Requires-Dist: pydantic>=2.0.0
|
|
19
|
+
Requires-Dist: pydantic[email]>=2.0.0
|
|
20
|
+
Requires-Dist: typing_extensions
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
22
|
+
Requires-Dist: cryptography>=3.0
|
|
23
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
27
|
+
Requires-Dist: setuptools>=64; extra == "dev"
|
|
28
|
+
Requires-Dist: wheel; extra == "dev"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Type, Optional, List, Dict, TypeVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ValidationError
|
|
5
|
+
|
|
6
|
+
from carestack.base.base_service import BaseService, GetJsonFromTextResponse
|
|
7
|
+
from carestack.base.base_types import ClientConfig
|
|
8
|
+
from carestack.base.errors import EhrApiError
|
|
9
|
+
from .ai_dto import ProcessDSDto
|
|
10
|
+
from .ai_utils import AiUtilities
|
|
11
|
+
|
|
12
|
+
_DTO_T = TypeVar("_DTO_T", bound=BaseModel)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AiService(BaseService):
|
|
16
|
+
"""
|
|
17
|
+
Service for AI-related operations, such as generating discharge summaries.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: ClientConfig):
|
|
21
|
+
super().__init__(config)
|
|
22
|
+
self.logger = logging.getLogger(__name__)
|
|
23
|
+
self.utilities = AiUtilities()
|
|
24
|
+
|
|
25
|
+
async def _validate_data(
|
|
26
|
+
self, dto_type: Type[_DTO_T], request_data: Dict[str, Any]
|
|
27
|
+
) -> _DTO_T:
|
|
28
|
+
"""
|
|
29
|
+
Validates dictionary data against a Pydantic model and returns the validated instance.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
validated_instance: _DTO_T = dto_type(**request_data)
|
|
33
|
+
return validated_instance
|
|
34
|
+
except ValidationError as err:
|
|
35
|
+
self.logger.error(
|
|
36
|
+
f"Pydantic validation failed: {err.errors()}", exc_info=True
|
|
37
|
+
)
|
|
38
|
+
raise EhrApiError(f"Validation failed: {err.errors()}", 400) from err
|
|
39
|
+
|
|
40
|
+
async def generate_discharge_summary(self, process_ds_data: Dict[str, Any]) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Generates a discharge summary based on the provided data.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
process_ds_data: A dictionary containing data conforming to ProcessDSDto.
|
|
46
|
+
Expected keys: 'files' (List[Any]), 'public_key' (Optional[str]).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A string representing the generated discharge summary.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
EhrApiError: If validation fails, the API call returns an error, or an unexpected error occurs.
|
|
53
|
+
"""
|
|
54
|
+
self.logger.info(
|
|
55
|
+
f"Starting generation of discharge summary with data: {process_ds_data}"
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
process_ds_dto: ProcessDSDto = await self._validate_data(
|
|
59
|
+
ProcessDSDto, process_ds_data
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
encrypted_data = await self.utilities.encryption(process_ds_dto.files)
|
|
63
|
+
|
|
64
|
+
payload = {
|
|
65
|
+
"caseType": process_ds_dto.case_type,
|
|
66
|
+
"encryptedData": encrypted_data,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
api_response: GetJsonFromTextResponse = await self.post(
|
|
70
|
+
"/demo/generate-discharge-summary",
|
|
71
|
+
payload,
|
|
72
|
+
response_model=GetJsonFromTextResponse,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return api_response.response
|
|
76
|
+
|
|
77
|
+
except EhrApiError as e:
|
|
78
|
+
self.logger.error(
|
|
79
|
+
f"EHR API Error during discharge summary generation: {e.message}",
|
|
80
|
+
exc_info=True,
|
|
81
|
+
)
|
|
82
|
+
raise
|
|
83
|
+
except Exception as error:
|
|
84
|
+
error_message = str(error)
|
|
85
|
+
self.logger.error(
|
|
86
|
+
f"Unexpected error in generate_discharge_summary: {error_message}",
|
|
87
|
+
exc_info=True,
|
|
88
|
+
)
|
|
89
|
+
raise EhrApiError(
|
|
90
|
+
f"An unexpected error occurred while generating discharge summary: {error_message}",
|
|
91
|
+
500,
|
|
92
|
+
) from error
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import List
|
|
3
|
+
import json
|
|
4
|
+
from cryptography import x509
|
|
5
|
+
from cryptography.hazmat.primitives import serialization
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
7
|
+
from cryptography.hazmat.backends import default_backend
|
|
8
|
+
from jose import jwe, jwk
|
|
9
|
+
from jose.constants import ALGORITHMS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AiUtilities:
|
|
13
|
+
"""
|
|
14
|
+
A utility class for AI service specific operations like encryption/decryption.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
async def load_public_key_from_x509_certificate(self, certificate_pem: str) -> dict:
|
|
21
|
+
"""
|
|
22
|
+
Loads an RSA public key from a PEM-encoded X.509 certificate and returns it as a JWK.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
cert = x509.load_pem_x509_certificate(
|
|
26
|
+
certificate_pem.encode("utf-8"), default_backend()
|
|
27
|
+
)
|
|
28
|
+
public_key = cert.public_key()
|
|
29
|
+
|
|
30
|
+
if not isinstance(public_key, rsa.RSAPublicKey):
|
|
31
|
+
raise ValueError("The certificate does not contain an RSA public key.")
|
|
32
|
+
|
|
33
|
+
public_pem = public_key.public_bytes(
|
|
34
|
+
encoding=serialization.Encoding.PEM,
|
|
35
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
36
|
+
)
|
|
37
|
+
jwk_object = jwk.construct(
|
|
38
|
+
public_pem.decode("utf-8"), # public_pem is bytes, decode to string
|
|
39
|
+
algorithm=ALGORITHMS.RSA_OAEP_256, # Specify the algorithm context
|
|
40
|
+
)
|
|
41
|
+
jwk_dict = jwk_object.to_dict()
|
|
42
|
+
return jwk_dict
|
|
43
|
+
except Exception as e:
|
|
44
|
+
self.logger.error(
|
|
45
|
+
f"Error loading public key from certificate: {e}", exc_info=True
|
|
46
|
+
)
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
f"Failed to load public key from certificate: {e}"
|
|
49
|
+
) from e
|
|
50
|
+
|
|
51
|
+
async def encryption(self, files: List[str]) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Encrypts a list of file identifiers using JWE with an RSA public key.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
certificate_pem = (
|
|
57
|
+
"-----BEGIN CERTIFICATE-----\r\n"
|
|
58
|
+
"MIID6TCCAtECFCuFhek8z9Xvm+QpVXdvsVrC+/qSMA0GCSqGSIb3DQEBCwUAMIGw\r\n"
|
|
59
|
+
"MQswCQYDVQQGEwJJTjESMBAGA1UECAwJVGVsYW5nYW5hMRIwEAYDVQQHDAlIeWRl\r\n"
|
|
60
|
+
"cmFiYWQxJzAlBgNVBAoMHkFjaGFsYSBIZWFsdGggU2VydmljZXMgUHZ0IEx0ZDER\r\n"
|
|
61
|
+
"MA8GA1UECwwIU29mdHdhcmUxDzANBgNVBAMMBkFjaGFsYTEsMCoGCSqGSIb3DQEJ\r\n"
|
|
62
|
+
"ARYdamFnYW4udHVtdWxhQGFjaGFsYWhlYWx0aC5jb20wHhcNMjQxMTEzMTUzNzU0\r\n"
|
|
63
|
+
"WhcNMjQxMjEzMTUzNzU0WjCBsDELMAkGA1UEBhMCSU4xEjAQBgNVBAgMCVRlbGFu\r\n"
|
|
64
|
+
"Z2FuYTESMBAGA1UEBwwJSHlkZXJhYmFkMScwJQYDVQQKDB5BY2hhbGEgSGVhbHRo\r\n"
|
|
65
|
+
"IFNlcnZpY2VzIFB2dCBMdGQxETAPBgNVBAsMCFNvZnR3YXJlMQ8wDQYDVQQDDAZB\r\n"
|
|
66
|
+
"Y2hhbGExLDAqBgkqhkiG9w0BCQEWHWphZ2FuLnR1bXVsYUBhY2hhbGFoZWFsdGgu\r\n"
|
|
67
|
+
"Y29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAty+ydbqnP0gBp39y\r\n"
|
|
68
|
+
"xaFpdLHB9e+wQipf0j+IWW7dlKH1kDwdWLLzdb384vGTB6+Z31144xy+aku0I0e6\r\n"
|
|
69
|
+
"dCubNxKQFj3YFRZBgvlQo/YLRDulHkuJ35CzLdGTLk69Mmn0UAiz+ivaapfmqol+\r\n"
|
|
70
|
+
"U/51l1k7HLWGHAOeVHYLGUcxQTYaYzPecNRH7yn/OTweOZ6vzrQD4g2qzHmzYScP\r\n"
|
|
71
|
+
"+tiOsTg6Ri6bG72084eqXs5bUixClmsYpDq6Eoq5n9uPj+Q5tt98S3JSx1sGpRg2\r\n"
|
|
72
|
+
"KmdbV/xoQ39zMWSkIT8O2tuf5KdfLRvWtm+Q7Af1aQG2U4QedubS/rTCnjIq2AL9\r\n"
|
|
73
|
+
"eAWluQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQA+GdQdGxGDzeAUkfy1iNWx2Wtr\r\n"
|
|
74
|
+
"oMGqpGSgeg5J8dzWcXdwH2Avxh7I9C4yletnuFeKQlCK5GHPvJ2GQCjQ7LEb01BU\r\n"
|
|
75
|
+
"NSJILwfCFMMkjw5COXVXAhp3fr894816YhGW/3+3L3TasKuEG96+bke4rN0yD0v+\r\n"
|
|
76
|
+
"braTqG+fY+hEwwls59jPBrhx97PDoI4RzKPbquAOCcxadJ3gelX0JibOmo9MZLtn\r\n"
|
|
77
|
+
"FhLPTZf8wWJLTdiUiuuiI2YS9/CyN3+pPzznMOEfPDK+593slPwCimubtYb+o/UT\r\n"
|
|
78
|
+
"88tKxqbPGNMWL4CUX9xPTLft8oBkC1OA8oF6kIV0LlJ6LarfhmrWg5BzQqKF\r\n"
|
|
79
|
+
"-----END CERTIFICATE-----\r\n"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Load the public key as a JWK from the certificate
|
|
83
|
+
public_jwk = await self.load_public_key_from_x509_certificate(
|
|
84
|
+
certificate_pem
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
payload_bytes = json.dumps({"files": files}).encode("utf-8")
|
|
88
|
+
|
|
89
|
+
encrypted_payload = jwe.encrypt(
|
|
90
|
+
plaintext=payload_bytes,
|
|
91
|
+
key=public_jwk, # Use the JWK dictionary
|
|
92
|
+
algorithm=ALGORITHMS.RSA_OAEP_256,
|
|
93
|
+
encryption=ALGORITHMS.A256GCM,
|
|
94
|
+
# 'zip', 'cty', 'kid' could be passed here if needed and supported
|
|
95
|
+
)
|
|
96
|
+
self.logger.debug("Encryption successful.")
|
|
97
|
+
return encrypted_payload.decode("utf-8")
|
|
98
|
+
|
|
99
|
+
except Exception as error:
|
|
100
|
+
self.logger.error(f"Failed to encrypt data: {error}", exc_info=True)
|
|
101
|
+
raise RuntimeError(f"Failed to encrypt data: {error}") from error
|
|
102
|
+
|
|
103
|
+
# async def decrypt_payload(self, encrypted_data: str, private_key_pem: str) -> Any:
|
|
104
|
+
# """
|
|
105
|
+
# Decrypts JWE encrypted data using a PEM-encoded RSA private key.
|
|
106
|
+
# """
|
|
107
|
+
# try:
|
|
108
|
+
# private_key = serialization.load_pem_private_key(
|
|
109
|
+
# private_key_pem.encode("utf-8"),
|
|
110
|
+
# password=None,
|
|
111
|
+
# backend=default_backend(),
|
|
112
|
+
# )
|
|
113
|
+
# if not isinstance(private_key, rsa.RSAPrivateKey):
|
|
114
|
+
# raise ValueError("The provided key is not a valid RSA private key.")
|
|
115
|
+
|
|
116
|
+
# # Construct a JWK from the private key for python-jose
|
|
117
|
+
# # While python-jose might sometimes work with cryptography objects directly
|
|
118
|
+
# # for decryption, using jwk.construct is more robust.
|
|
119
|
+
# private_key_pem_bytes = private_key.private_bytes(
|
|
120
|
+
# encoding=serialization.Encoding.PEM,
|
|
121
|
+
# format=serialization.PrivateFormat.PKCS8,
|
|
122
|
+
# encryption_algorithm=serialization.NoEncryption(),
|
|
123
|
+
# )
|
|
124
|
+
# key_jwk = jwk.construct(
|
|
125
|
+
# private_key_pem_bytes.decode("utf-8"),
|
|
126
|
+
# algorithm=ALGORITHMS.RSA_OAEP_256,
|
|
127
|
+
# )
|
|
128
|
+
# decrypted_payload_bytes = jwe.decrypt(
|
|
129
|
+
# encrypted_data.encode("utf-8"), key_jwk
|
|
130
|
+
# )
|
|
131
|
+
# self.logger.debug("Decryption successful.")
|
|
132
|
+
# return json.loads(decrypted_payload_bytes.decode("utf-8"))
|
|
133
|
+
# except Exception as error:
|
|
134
|
+
# self.logger.error(f"Failed to decrypt data: {error}", exc_info=True)
|
|
135
|
+
# raise RuntimeError(f"Failed to decrypt data: {error}") from error
|
|
File without changes
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""This module provides functionalities related to BaseService."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from abc import ABC
|
|
6
|
+
import os
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Any, Optional, Type, TypeVar
|
|
9
|
+
import httpx
|
|
10
|
+
import os
|
|
11
|
+
import httpx
|
|
12
|
+
from abc import ABC
|
|
13
|
+
from typing import Optional, Dict
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, RootModel
|
|
16
|
+
from carestack.base.base_types import ClientConfig
|
|
17
|
+
from carestack.base.errors import EhrApiError, ValidationError
|
|
18
|
+
from carestack.document_linking.encounter_dto.target_dto.update_visit_records_dto import (
|
|
19
|
+
UpdateVisitRecordsResponse,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GetJsonFromTextResponse(BaseModel):
|
|
24
|
+
response: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
ResT = TypeVar("ResT", bound=BaseModel) # Response type
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BaseService(ABC):
|
|
33
|
+
"""Base Service for handling API requests with error management and resource handling."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
config: "ClientConfig",
|
|
38
|
+
timeout: Optional[int] = 6000000,
|
|
39
|
+
headers: Optional[Dict[str, str]] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Initializes the BaseService with API configuration.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
config (ClientConfig): Configuration containing API URL and authentication details.
|
|
46
|
+
timeout (Optional[int]): Timeout for requests (default: 6000000 milliseconds).
|
|
47
|
+
headers (Optional[Dict[str, str]]): Additional headers to include in the request.
|
|
48
|
+
"""
|
|
49
|
+
self.config = config
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
|
|
52
|
+
# Use values directly from the ClientConfig object
|
|
53
|
+
if not self.config.api_key:
|
|
54
|
+
raise ValueError("API_KEY is missing in ClientConfig.")
|
|
55
|
+
if not self.config.api_url:
|
|
56
|
+
raise ValueError("API_URL is missing in ClientConfig.")
|
|
57
|
+
if (
|
|
58
|
+
not self.config.hprid_auth
|
|
59
|
+
): # Assuming hprid_auth is a required part of ClientConfig
|
|
60
|
+
raise ValueError("x_hpr_id (hprid_auth) is missing in ClientConfig.")
|
|
61
|
+
|
|
62
|
+
# Default headers
|
|
63
|
+
default_headers = {
|
|
64
|
+
"Authorization": f"Bearer {self.config.api_key}",
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"x-hprid-auth": self.config.hprid_auth,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Merge user-provided headers, allowing overrides
|
|
70
|
+
self.headers = {**default_headers, **(headers or {})}
|
|
71
|
+
self.client = httpx.AsyncClient(
|
|
72
|
+
base_url=self.config.api_url,
|
|
73
|
+
headers=self.headers,
|
|
74
|
+
timeout=self.timeout,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_config(
|
|
79
|
+
cls,
|
|
80
|
+
config: ClientConfig,
|
|
81
|
+
timeout: Optional[int] = 10,
|
|
82
|
+
) -> "BaseService":
|
|
83
|
+
"""Factory method to create a BaseService instance from configuration."""
|
|
84
|
+
return cls(config, timeout)
|
|
85
|
+
|
|
86
|
+
async def __handle_error(self, response: httpx.Response) -> None:
|
|
87
|
+
"""Handles API errors by raising EhrApiError with meaningful messages."""
|
|
88
|
+
if response.status_code >= 400:
|
|
89
|
+
try:
|
|
90
|
+
if response.text:
|
|
91
|
+
try:
|
|
92
|
+
error_data = response.json()
|
|
93
|
+
error_message = error_data.get(
|
|
94
|
+
"message",
|
|
95
|
+
f"""Unknown error. Response: {
|
|
96
|
+
response.text}""",
|
|
97
|
+
)
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
error_message = f"""Invalid JSON response: {
|
|
100
|
+
response.text}"""
|
|
101
|
+
else:
|
|
102
|
+
error_message = f"""Empty response body with status code {
|
|
103
|
+
response.status_code}"""
|
|
104
|
+
logger.exception(
|
|
105
|
+
"API Error: Status Code: %s, Message: %s",
|
|
106
|
+
response.status_code,
|
|
107
|
+
error_message,
|
|
108
|
+
)
|
|
109
|
+
raise EhrApiError(error_message, response.status_code)
|
|
110
|
+
except json.JSONDecodeError as e:
|
|
111
|
+
logger.exception(
|
|
112
|
+
"API Error: Status Code: %s, Raw Response: %s",
|
|
113
|
+
response.status_code,
|
|
114
|
+
response.text,
|
|
115
|
+
)
|
|
116
|
+
raise EhrApiError(
|
|
117
|
+
f"An unexpected error occurred: {e}", response.status_code
|
|
118
|
+
) from e
|
|
119
|
+
|
|
120
|
+
async def __make_request(
|
|
121
|
+
self,
|
|
122
|
+
method: str,
|
|
123
|
+
endpoint: str,
|
|
124
|
+
response_model: Optional[Type[ResT]],
|
|
125
|
+
data: Optional[dict[str, Any]] = None,
|
|
126
|
+
params: Optional[dict[str, Any]] = None,
|
|
127
|
+
) -> ResT:
|
|
128
|
+
"""
|
|
129
|
+
Makes an API request with dynamic HTTP method.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
method (str): HTTP method (GET, POST, PUT, DELETE).
|
|
133
|
+
endpoint (str): API endpoint.
|
|
134
|
+
data (Optional[dict[str, Any]]): JSON payload for POST/PUT requests.
|
|
135
|
+
params (Optional[dict[str, Any]]): Query parameters for GET requests.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
dict[str, Any]: JSON response from the API.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
EhrApiError: If the API request fails.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
response_text: Optional[str] = None
|
|
145
|
+
if data:
|
|
146
|
+
# Be careful logging sensitive data in production
|
|
147
|
+
logger.debug(f"Request data: {data}")
|
|
148
|
+
try:
|
|
149
|
+
logger.info(f"Making request: {method} {endpoint}")
|
|
150
|
+
if data:
|
|
151
|
+
logger.debug(f"Request data: {data}") # Consider masking sensitive data
|
|
152
|
+
|
|
153
|
+
response = await self.client.request(
|
|
154
|
+
method, endpoint, json=data, params=params
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Read the response body as text FIRST
|
|
158
|
+
response_text_bytes = await response.aread()
|
|
159
|
+
response_text = response_text_bytes.decode("utf-8", errors="replace")
|
|
160
|
+
|
|
161
|
+
logger.info(
|
|
162
|
+
f"Received response status: {response.status_code} for {method} {endpoint}"
|
|
163
|
+
)
|
|
164
|
+
logger.debug(f"Raw response text: {response_text}")
|
|
165
|
+
|
|
166
|
+
await self.__handle_error(response)
|
|
167
|
+
response_data = response.json()
|
|
168
|
+
|
|
169
|
+
if response_model is GetJsonFromTextResponse:
|
|
170
|
+
logger.debug("Handling GetJsonFromTextResponse from raw string")
|
|
171
|
+
# Directly instantiate with the raw string response
|
|
172
|
+
try:
|
|
173
|
+
# Pydantic will validate if 'response_text' is a string
|
|
174
|
+
return GetJsonFromTextResponse(response=response_text) # type: ignore
|
|
175
|
+
except ValidationError as e:
|
|
176
|
+
logger.error(
|
|
177
|
+
f"Pydantic validation failed for GetJsonFromTextResponse: {e}. Raw text: {response_text}"
|
|
178
|
+
)
|
|
179
|
+
raise EhrApiError(
|
|
180
|
+
f"Validation failed for GetJsonFromTextResponse: {e}",
|
|
181
|
+
response.status_code,
|
|
182
|
+
) from e
|
|
183
|
+
|
|
184
|
+
# --- If not GetJsonFromTextResponse, THEN try parsing as JSON ---
|
|
185
|
+
try:
|
|
186
|
+
# Handle empty body explicitly after error check and GetJsonFromTextResponse check
|
|
187
|
+
if not response_text.strip():
|
|
188
|
+
logger.warning(
|
|
189
|
+
f"Empty response body received for {method} {endpoint} (expected JSON)"
|
|
190
|
+
)
|
|
191
|
+
# Raising an error is usually appropriate if JSON was expected
|
|
192
|
+
raise ValueError(
|
|
193
|
+
"Received empty response body when expecting JSON data."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
response_data = json.loads(response_text)
|
|
197
|
+
logger.debug(f"Parsed response data type: {type(response_data)}")
|
|
198
|
+
|
|
199
|
+
except json.JSONDecodeError as json_err:
|
|
200
|
+
# This error occurs if response_text is not valid JSON
|
|
201
|
+
error_msg = f"Failed to decode JSON response for {method} {endpoint}. Status: {response.status_code}. Error: {json_err}. Response text: {response_text}"
|
|
202
|
+
logger.error(error_msg)
|
|
203
|
+
raise EhrApiError(error_msg, response.status_code) from json_err
|
|
204
|
+
except ValueError as val_err: # Catch the empty body error
|
|
205
|
+
logger.error(
|
|
206
|
+
f"ValueError after parsing attempt: {val_err}. Raw text: {response_text}"
|
|
207
|
+
)
|
|
208
|
+
raise EhrApiError(
|
|
209
|
+
f"Data processing error: {val_err}", response.status_code
|
|
210
|
+
) from val_err
|
|
211
|
+
|
|
212
|
+
if response_model is UpdateVisitRecordsResponse and isinstance(
|
|
213
|
+
response_data, bool
|
|
214
|
+
):
|
|
215
|
+
# Manually create the response object
|
|
216
|
+
# Cast is safe here because we checked the type and model
|
|
217
|
+
return UpdateVisitRecordsResponse(success=response_data) # type: ignore
|
|
218
|
+
|
|
219
|
+
# Handle RootModel
|
|
220
|
+
elif response_model and issubclass(response_model, RootModel):
|
|
221
|
+
# For RootModel, pass the data directly
|
|
222
|
+
return response_model(response_data)
|
|
223
|
+
|
|
224
|
+
elif response_model and isinstance(response_data, dict):
|
|
225
|
+
# For standard BaseModel, unpack the dictionary
|
|
226
|
+
return response_model(**response_data)
|
|
227
|
+
else:
|
|
228
|
+
|
|
229
|
+
# Handle unexpected case: Model is not RootModel, but data isn't a dict
|
|
230
|
+
error_msg = (
|
|
231
|
+
f"Cannot instantiate {response_model.__name__ if response_model else 'unknown'}: "
|
|
232
|
+
f"Expected a dictionary response for this model, but received type {type(response_data)}"
|
|
233
|
+
)
|
|
234
|
+
logger.error(error_msg)
|
|
235
|
+
raise TypeError(error_msg)
|
|
236
|
+
|
|
237
|
+
except EhrApiError as e:
|
|
238
|
+
raise e
|
|
239
|
+
# except json.JSONDecodeError as e:
|
|
240
|
+
# raise EhrApiError(f"{response.text}", response.status_code) from e
|
|
241
|
+
# except Exception as e:
|
|
242
|
+
# logger.exception("Unexpected error during %s request: %s", method, e)
|
|
243
|
+
# raise EhrApiError(
|
|
244
|
+
# f"Unexpected error during {method} request: {e}", response.status_code
|
|
245
|
+
# ) from e
|
|
246
|
+
|
|
247
|
+
async def get(
|
|
248
|
+
self,
|
|
249
|
+
endpoint: str,
|
|
250
|
+
response_model: Type[ResT],
|
|
251
|
+
query_params: Optional[dict[str, Any]] = None,
|
|
252
|
+
) -> ResT:
|
|
253
|
+
"""Makes a GET request."""
|
|
254
|
+
response_data = await self.__make_request(
|
|
255
|
+
"GET", endpoint, response_model=response_model, params=query_params
|
|
256
|
+
)
|
|
257
|
+
return response_data
|
|
258
|
+
|
|
259
|
+
async def post(
|
|
260
|
+
self, endpoint: str, data: dict[str, Any], response_model: Type[ResT]
|
|
261
|
+
) -> ResT:
|
|
262
|
+
response_data: ResT = await self.__make_request(
|
|
263
|
+
"POST", endpoint, response_model=response_model, data=data
|
|
264
|
+
)
|
|
265
|
+
return response_data
|
|
266
|
+
|
|
267
|
+
async def put(
|
|
268
|
+
self, endpoint: str, data: dict[str, Any], response_model: Type[ResT]
|
|
269
|
+
) -> ResT:
|
|
270
|
+
"""Makes a PUT request."""
|
|
271
|
+
return await self.__make_request(
|
|
272
|
+
"PUT", endpoint, response_model=response_model, data=data
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def delete(
|
|
276
|
+
self, endpoint: str, response_model: Optional[Type[ResT]] = None
|
|
277
|
+
) -> Optional[ResT]:
|
|
278
|
+
"""Makes a DELETE request."""
|
|
279
|
+
return await self.__make_request(
|
|
280
|
+
"DELETE", endpoint, response_model=response_model
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
async def close(self) -> None:
|
|
284
|
+
"""Closes the HTTP client session."""
|
|
285
|
+
await self.client.aclose()
|
|
286
|
+
|
|
287
|
+
async def __aenter__(self) -> "BaseService":
|
|
288
|
+
"""Enables async context manager support."""
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
async def __aexit__(
|
|
292
|
+
self,
|
|
293
|
+
exc_type: Optional[type],
|
|
294
|
+
exc_val: Optional[BaseException],
|
|
295
|
+
exc_tb: Optional[TracebackType],
|
|
296
|
+
) -> None:
|
|
297
|
+
"""Closes session on exit."""
|
|
298
|
+
await self.close()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ClientConfig:
|
|
5
|
+
def __init__(self, api_key: str, api_url: str, x_hpr_id) -> None:
|
|
6
|
+
self.api_key = api_key
|
|
7
|
+
self.api_url = api_url
|
|
8
|
+
self.hprid_auth = x_hpr_id
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiResponse:
|
|
12
|
+
def __init__(self, data: Any, status: int, message: str) -> None:
|
|
13
|
+
self.data = data
|
|
14
|
+
self.status = status
|
|
15
|
+
self.message = message
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EhrApiError(Exception):
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
message: str,
|
|
8
|
+
status_code: Optional[int] = None,
|
|
9
|
+
code: Optional[str] = None,
|
|
10
|
+
) -> None:
|
|
11
|
+
self.message = message
|
|
12
|
+
self.code = code
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
super().__init__(self.message)
|
|
15
|
+
|
|
16
|
+
def __str__(self):
|
|
17
|
+
return f"{self.message}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthenticationError(EhrApiError):
|
|
21
|
+
def __init__(self, message: str = "Authentication failed") -> None:
|
|
22
|
+
super().__init__(message, 401)
|
|
23
|
+
self.name = "AuthenticationError"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ValidationError(EhrApiError):
|
|
27
|
+
def __init__(self, message: str) -> None:
|
|
28
|
+
super().__init__(message, 400)
|
|
29
|
+
self.name = "ValidationError"
|
|
File without changes
|