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.
Files changed (89) hide show
  1. carestack-0.1.0/PKG-INFO +28 -0
  2. carestack-0.1.0/carestack/__init__.py +0 -0
  3. carestack-0.1.0/carestack/ai/__init__.py +0 -0
  4. carestack-0.1.0/carestack/ai/ai_dto.py +8 -0
  5. carestack-0.1.0/carestack/ai/ai_service.py +92 -0
  6. carestack-0.1.0/carestack/ai/ai_utils.py +135 -0
  7. carestack-0.1.0/carestack/base/__init__.py +0 -0
  8. carestack-0.1.0/carestack/base/base_service.py +298 -0
  9. carestack-0.1.0/carestack/base/base_types.py +15 -0
  10. carestack-0.1.0/carestack/base/errors.py +29 -0
  11. carestack-0.1.0/carestack/common/__init__.py +0 -0
  12. carestack-0.1.0/carestack/common/enums.py +870 -0
  13. carestack-0.1.0/carestack/common/error_validation.py +50 -0
  14. carestack-0.1.0/carestack/document_linking/__init__.py +0 -0
  15. carestack-0.1.0/carestack/document_linking/document_linking.py +411 -0
  16. carestack-0.1.0/carestack/document_linking/encounter_dto/__init__.py +0 -0
  17. carestack-0.1.0/carestack/document_linking/encounter_dto/intermediate_dto/__init__.py +0 -0
  18. carestack-0.1.0/carestack/document_linking/encounter_dto/intermediate_dto/op_consultation_dto.py +219 -0
  19. carestack-0.1.0/carestack/document_linking/encounter_dto/intermediate_dto/sections_dto.py +204 -0
  20. carestack-0.1.0/carestack/document_linking/encounter_dto/intermediate_dto.py +0 -0
  21. carestack-0.1.0/carestack/document_linking/encounter_dto/source_dto/__init__.py +0 -0
  22. carestack-0.1.0/carestack/document_linking/encounter_dto/source_dto/op_consultation_dto.py +203 -0
  23. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/__init__.py +0 -0
  24. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/advisory_note_dto.py +19 -0
  25. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/allergy_intolerance_dto.py +42 -0
  26. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/appointment_dto.py +49 -0
  27. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/care_plan_dto.py +40 -0
  28. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/condition_dto.py +19 -0
  29. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/create_care_context_dto.py +49 -0
  30. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/diagnostic_report_dto.py +37 -0
  31. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/diagnostic_report_imaging_dto.py +25 -0
  32. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/diagnostic_report_lab_dto.py +15 -0
  33. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/diagnostic_report_record_dto.py +88 -0
  34. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/discharge_summary_record_dto.py +167 -0
  35. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/document_reference_dto.py +16 -0
  36. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/dosage_instruction_dto.py +17 -0
  37. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/follow_up_dto.py +26 -0
  38. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/generic_dto.py +16 -0
  39. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/health_document_linking_dto.py +112 -0
  40. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/health_document_record_dto.py +20 -0
  41. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/health_information_dto.py +84 -0
  42. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/immunization_dto.py +18 -0
  43. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/immunization_recommendation_dto.py +12 -0
  44. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/immunization_record_dto.py +81 -0
  45. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/link_care_context_dto.py +35 -0
  46. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/medication_dto.py +16 -0
  47. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/medication_history_dto.py +28 -0
  48. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/medication_request_dto.py +43 -0
  49. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/medication_statement_dto.py +16 -0
  50. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/observation_dto.py +33 -0
  51. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/op_consult_record_dto.py +108 -0
  52. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/prescription_record_dto.py +39 -0
  53. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/procedure_dto.py +36 -0
  54. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/recommendation_dto.py +19 -0
  55. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/reference_range_dto.py +33 -0
  56. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/service_request_dto.py +17 -0
  57. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/snomed_code_dto.py +31 -0
  58. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/update_visit_records_dto.py +54 -0
  59. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/value_quantity_dto.py +10 -0
  60. carestack-0.1.0/carestack/document_linking/encounter_dto/target_dto/wellness_record_dto.py +37 -0
  61. carestack-0.1.0/carestack/document_linking/schema.py +109 -0
  62. carestack-0.1.0/carestack/document_linking/transformer/__init__.py +0 -0
  63. carestack-0.1.0/carestack/document_linking/transformer/op_consultation.py +441 -0
  64. carestack-0.1.0/carestack/document_linking/transformer/tranformer_factory.py +41 -0
  65. carestack-0.1.0/carestack/document_linking/transformer/transformer.py +845 -0
  66. carestack-0.1.0/carestack/document_linking/utilities.py +5 -0
  67. carestack-0.1.0/carestack/organization/__init__.py +0 -0
  68. carestack-0.1.0/carestack/organization/organization_dto.py +649 -0
  69. carestack-0.1.0/carestack/organization/organization_service.py +499 -0
  70. carestack-0.1.0/carestack/organization/organization_service_test.py +865 -0
  71. carestack-0.1.0/carestack/patient/__init__.py +0 -0
  72. carestack-0.1.0/carestack/patient/patient_dto.py +646 -0
  73. carestack-0.1.0/carestack/patient/patient_service.py +325 -0
  74. carestack-0.1.0/carestack/patient/patient_service_test.py +804 -0
  75. carestack-0.1.0/carestack/practitioner/__init__.py +0 -0
  76. carestack-0.1.0/carestack/practitioner/hpr_registration/__init__.py +0 -0
  77. carestack-0.1.0/carestack/practitioner/hpr_registration/hpr_dto.py +234 -0
  78. carestack-0.1.0/carestack/practitioner/hpr_registration/hpr_service.py +255 -0
  79. carestack-0.1.0/carestack/practitioner/practitioner_dto.py +477 -0
  80. carestack-0.1.0/carestack/practitioner/practitioner_service.py +303 -0
  81. carestack-0.1.0/carestack/practitioner/practitioner_service_test.py +573 -0
  82. carestack-0.1.0/carestack.egg-info/PKG-INFO +28 -0
  83. carestack-0.1.0/carestack.egg-info/SOURCES.txt +88 -0
  84. carestack-0.1.0/carestack.egg-info/dependency_links.txt +1 -0
  85. carestack-0.1.0/carestack.egg-info/requires.txt +13 -0
  86. carestack-0.1.0/carestack.egg-info/top_level.txt +1 -0
  87. carestack-0.1.0/pyproject.toml +57 -0
  88. carestack-0.1.0/setup.cfg +8 -0
  89. carestack-0.1.0/tests/test_hpr_service.py +480 -0
@@ -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,8 @@
1
+ from typing import Optional, List, Any
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class ProcessDSDto(BaseModel):
6
+ case_type: str
7
+ files: List[str]
8
+ public_key: Optional[str] = None
@@ -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