diff --git a/README.md b/README.md
index 086daa0f..0cc76743 100644
--- a/README.md
+++ b/README.md
@@ -22,13 +22,14 @@ First time here? Check out our [Docs](https://dotimplement.github.io/HealthChain
## Features
-- [x] π **Gateway**: Connect to multiple EHR systems with [unified API](https://dotimplement.github.io/HealthChain/reference/gateway/gateway/) supporting FHIR, CDS Hooks, and SOAP/CDA protocols
+- [x] π **Gateway**: Connect to multiple EHR systems with [unified API](https://dotimplement.github.io/HealthChain/reference/gateway/gateway/) supporting FHIR, CDS Hooks, and SOAP/CDA protocols (sync / async support)
- [x] π₯ **Pipelines**: Build FHIR-native ML workflows or use [pre-built ones](https://dotimplement.github.io/HealthChain/reference/pipeline/pipeline/#prebuilt) for your healthcare NLP and AI tasks
- [x] π **InteropEngine**: Convert between FHIR, CDA, and HL7v2 with a [template-based engine](https://dotimplement.github.io/HealthChain/reference/interop/interop/)
- [x] π Type-safe healthcare data with full type hints and Pydantic validation for [FHIR resources](https://dotimplement.github.io/HealthChain/reference/utilities/fhir_helpers/)
-- [x] β‘ Event-driven architecture with real-time event handling and [audit trails](https://dotimplement.github.io/HealthChain/reference/gateway/events/) built-in
+- [x] β‘ Built-in event-driven logging and operation tracking for [audit trails](https://dotimplement.github.io/HealthChain/reference/gateway/events/)
- [x] π Deploy production-ready applications with [HealthChainAPI](https://dotimplement.github.io/HealthChain/reference/gateway/api/) and FastAPI integration
- [x] π§ͺ Generate [synthetic healthcare data](https://dotimplement.github.io/HealthChain/reference/utilities/data_generator/) and [sandbox testing](https://dotimplement.github.io/HealthChain/reference/sandbox/sandbox/) utilities
+- [x] π₯οΈ Bootstrap configurations with CLI tools
## Why use HealthChain?
- **EHR integrations are manual and time-consuming** - **HealthChainAPI** abstracts away complexities so you can focus on AI development, not learning FHIR APIs, CDS Hooks, and authentication schemes.
@@ -86,6 +87,11 @@ app.register_service(notes)
# /fhir/* - Patient data, observations, etc.
# /cds/* - Real-time clinical alerts
# /soap/* - Clinical document processing
+
+# Deploy with uvicorn
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, port=8888)
```
### FHIR Operations with AI Enhancement
@@ -99,23 +105,27 @@ gateway.add_source("epic", "fhir://fhir.epic.com/r4?...")
# Add AI transformations to FHIR data
@gateway.transform(Patient)
-async def enhance_patient(id: str, source: str = None) -> Patient:
- async with gateway.modify(Patient, id, source) as patient:
- # Get lab results and process with AI
- lab_results = await gateway.search(
- Observation,
- {"patient": id, "category": "laboratory"},
- source
- )
- insights = nlp_pipeline.process(patient, lab_results)
-
- # Add AI summary to patient record
- patient.extension = patient.extension or []
- patient.extension.append({
- "url": "http://healthchain.org/fhir/summary",
- "valueString": insights.summary
- })
- return patient
+def enhance_patient(id: str, source: str = None) -> Patient:
+ patient = gateway.read(Patient, id, source)
+
+ # Get lab results and process with AI
+ lab_results = gateway.search(
+ Observation,
+ {"patient": id, "category": "laboratory"},
+ source
+ )
+ insights = nlp_pipeline.process(patient, lab_results)
+
+ # Add AI summary to patient record
+ patient.extension = patient.extension or []
+ patient.extension.append({
+ "url": "http://healthchain.org/fhir/summary",
+ "valueString": insights.summary
+ })
+
+ # Update the patient record
+ gateway.update(patient, source)
+ return patient
# Automatically available at: GET /fhir/transform/Patient/123?source=epic
```
diff --git a/docs/quickstart.md b/docs/quickstart.md
index d588b655..7f96c355 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -8,12 +8,13 @@ HealthChain provides three core tools for healthcare AI integration: **Gateway**
### HealthChainAPI Gateway π
-The HealthChainAPI provides a unified interface for connecting your AI models to multiple healthcare systems through a single API. Handle FHIR, CDS Hooks, and SOAP/CDA protocols with OAuth2 authentication and connection pooling.
+The HealthChainAPI provides a unified interface for connecting your AI models to multiple healthcare systems through a single API. Handle FHIR, CDS Hooks, and SOAP/CDA protocols with OAuth2 authentication.
[(Full Documentation on Gateway)](./reference/gateway/gateway.md)
```python
from healthchain.gateway import HealthChainAPI, FHIRGateway
+from fhir.resources.patient import Patient
# Create your healthcare application
app = HealthChainAPI(title="My Healthcare AI App")
@@ -25,11 +26,12 @@ fhir.add_source("medplum", "fhir://api.medplum.com/fhir/R4/?client_id=...")
# Add AI transformations to FHIR data
@fhir.transform(Patient)
-async def enhance_patient(id: str, source: str = None) -> Patient:
- async with fhir.modify(Patient, id, source) as patient:
- # Your AI logic here
- patient.active = True
- return patient
+def enhance_patient(id: str, source: str = None) -> Patient:
+ patient = fhir.read(Patient, id, source)
+ # Your AI logic here
+ patient.active = True
+ fhir.update(patient, source)
+ return patient
# Register and run
app.register_gateway(fhir)
@@ -154,10 +156,6 @@ The HealthChain Interoperability module provides tools for converting between di
[(Full Documentation on Interoperability Engine)](./reference/interop/interop.md)
-
-**Choose your setup based on your needs:**
-
-β
**Default configs** - For basic testing and prototyping only:
```python
from healthchain.interop import create_interop, FormatType
@@ -175,32 +173,6 @@ fhir_resources = engine.to_fhir(cda_xml, src_format=FormatType.CDA)
cda_document = engine.from_fhir(fhir_resources, dest_format=FormatType.CDA)
```
-> β οΈ **Default configs are limited** - Only supports problems, medications, and notes. No allergies, custom mappings, or organization-specific templates.
-
-π οΈ **Custom configs** - **Required for real-world use**:
-```bash
-# Create editable configuration templates
-healthchain init-configs ./my_configs
-```
-
-```python
-# Use your customized configs
-engine = create_interop(config_dir="./my_configs")
-
-# Now you can customize:
-# β’ Add experimental features (allergies, procedures)
-# β’ Modify terminology mappings (SNOMED, LOINC codes)
-# β’ Customize templates for your organization's CDA format
-# β’ Configure validation rules and environments
-```
-
-**When you need custom configs:**
-- π₯ **Production healthcare applications**
-- π§ **Organization-specific CDA templates**
-- π§ͺ **Experimental features** (allergies, procedures)
-- πΊοΈ **Custom terminology mappings**
-- π‘οΈ **Specific validation requirements**
-
## Utilities βοΈ
diff --git a/docs/reference/gateway/fhir_gateway.md b/docs/reference/gateway/fhir_gateway.md
index 007882b0..32f81e10 100644
--- a/docs/reference/gateway/fhir_gateway.md
+++ b/docs/reference/gateway/fhir_gateway.md
@@ -1,15 +1,29 @@
# FHIR Gateway
-The `FHIRGateway` provides a unified **asynchronous** interface for connecting to multiple FHIR servers with automatic authentication, connection pooling, error handling, and simplified CRUD operations. It handles the complexity of managing multiple FHIR clients and provides a consistent API across different healthcare systems.
+The FHIR Gateway provides a unified interface for connecting to multiple FHIR servers with automatic authentication, connection pooling, error handling, and simplified CRUD operations. It comes in two variants:
+
+- **`FHIRGateway`** - Synchronous FHIR client (`httpx.Client`)
+- **`AsyncFHIRGateway`** - Asynchronous FHIR client (`httpx.AsyncClient`)
+
+Both handle the complexity of managing multiple FHIR clients and provide a consistent API across different healthcare systems.
+
+
+!!! info "Sync vs Async: When to Choose What"
+ **Choose sync for:** Getting started, interaction with legacy systems, simpler debugging - safe for most use cases
+
+ **Choose async for:** High-throughput scenarios, concurrent requests, modern applications
+
+ **Key difference:** Async version includes connection pooling and the `modify()` context manager for automatic resource saving.
## Basic Usage
+### Synchronous Gateway
+
```python
from healthchain.gateway import FHIRGateway
from fhir.resources.patient import Patient
-# Create gateway
gateway = FHIRGateway()
# Connect to FHIR server
@@ -18,12 +32,41 @@ gateway.add_source(
"fhir://fhir.example.com/api/FHIR/R4/?client_id=your_app&client_secret=secret&token_url=https://fhir.example.com/oauth2/token"
)
-async with gateway:
+with gateway:
# FHIR operations
- patient = await gateway.read(Patient, "123", "my_fhir_server")
+ patient = gateway.read(Patient, "123", "my_fhir_server")
print(f"Patient: {patient.name[0].family}")
```
+### Asynchronous Gateway
+
+```python
+import asyncio
+
+from healthchain.gateway import AsyncFHIRGateway
+from fhir.resources.patient import Patient
+
+gateway = AsyncFHIRGateway()
+
+# Connect to FHIR server
+gateway.add_source(
+ "my_fhir_server",
+ "fhir://fhir.example.com/api/FHIR/R4/?client_id=your_app&client_secret=secret&token_url=https://fhir.example.com/oauth2/token"
+)
+
+async with gateway:
+ # Fetch multiple resources concurrently
+ tasks = [
+ gateway.read(Patient, "123", "my_fhir_server"),
+ gateway.read(Patient, "456", "my_fhir_server"),
+ gateway.read(Patient, "789", "my_fhir_server")
+ ]
+ patients = await asyncio.gather(*tasks)
+
+ for patient in patients:
+ print(f"Patient: {patient.name[0].family}")
+```
+
## Adding Sources π₯
@@ -115,7 +158,7 @@ patient = Patient(
birthDate="1990-01-01"
)
-created_patient = await gateway.create(resource=patient, source="medplum")
+created_patient = gateway.create(resource=patient, source="medplum")
print(f"Created patient with ID: {created_patient.id}")
```
@@ -125,29 +168,40 @@ print(f"Created patient with ID: {created_patient.id}")
from fhir.resources.patient import Patient
# Read a specific patient (Derrick Lin, Epic Sandbox)
-patient = await gateway.read(
+patient = gateway.read(
resource_type=Patient,
fhir_id="eq081-VQEgP8drUUqCWzHfw3",
source="epic"
- )
+)
```
### Update Resources
-```python
-from fhir.resources.patient import Patient
+=== "Sync"
+ ```python
+ from fhir.resources.patient import Patient
-# Read, modify, and update
-patient = await gateway.read(Patient, "123", "medplum")
-patient.name[0].family = "Johnson"
-updated_patient = await gateway.update(patient, "medplum")
+ # Read, modify, and update (sync)
+ patient = gateway.read(Patient, "123", "medplum")
+ patient.name[0].family = "Johnson"
+ updated_patient = gateway.update(patient, "medplum")
+ ```
-# Using context manager
-async with gateway.modify(Patient, "123", "medplum") as patient:
- patient.active = True
- patient.name[0].given = ["Jane"]
- # Automatic save on exit
-```
+=== "Async"
+ ```python
+ from fhir.resources.patient import Patient
+
+ # Read, modify, and update (async)
+ patient = await gateway.read(Patient, "123", "medplum")
+ patient.name[0].family = "Johnson"
+ updated_patient = await gateway.update(patient, "medplum")
+
+ # Using async context manager - automatically saves on exit
+ async with gateway.modify(Patient, "123", "medplum") as patient:
+ patient.active = True
+ patient.name[0].given = ["Jane"]
+ # Automatic save on exit
+ ```
### Delete Resources
@@ -155,7 +209,7 @@ async with gateway.modify(Patient, "123", "medplum") as patient:
from fhir.resources.patient import Patient
# Delete a patient
-success = await gateway.delete(Patient, "123", "medplum")
+success = gateway.delete(Patient, "123", "medplum")
if success:
print("Patient deleted successfully")
```
@@ -170,7 +224,7 @@ from fhir.resources.bundle import Bundle
# Search by name
search_params = {"family": "Smith", "given": "John"}
-results: Bundle = await gateway.search(Patient, search_params, "epic")
+results: Bundle = gateway.search(Patient, search_params, "epic")
for entry in results.entry:
patient = entry.resource
@@ -191,7 +245,7 @@ search_params = {
"_sort": "family"
}
-results = await gateway.search(Patient, search_params, "epic")
+results = gateway.search(Patient, search_params, "epic")
print(f"Found {len(results.entry)} patients")
```
@@ -199,17 +253,20 @@ print(f"Found {len(results.entry)} patients")
Transform handlers allow you to create custom API endpoints that process and enhance FHIR resources with additional logic, AI insights, or data transformations before returning them to clients. These handlers run before the response is sent, enabling real-time data enrichment and processing.
-```python
-from fhir.resources.patient import Patient
-from fhir.resources.observation import Observation
+=== "Sync"
+ ```python
+ from fhir.resources.patient import Patient
+ from fhir.resources.observation import Observation
-@fhir_gateway.transform(Patient)
-async def get_enhanced_patient_summary(id: str, source: str = None) -> Patient:
- """Create enhanced patient summary with AI insights"""
+ @fhir_gateway.transform(Patient)
+ def get_enhanced_patient_summary(id: str, source: str = None) -> Patient:
+ """Create enhanced patient summary with AI insights"""
- async with fhir_gateway.modify(Patient, id, source=source) as patient:
- # Get lab results and process with AI
- lab_results = await fhir_gateway.search(
+ # Read the patient
+ patient = fhir_gateway.read(Patient, id, source)
+
+ # Get lab results
+ lab_results = fhir_gateway.search(
resource_type=Observation,
search_params={"patient": id, "category": "laboratory"},
source=source
@@ -223,44 +280,109 @@ async def get_enhanced_patient_summary(id: str, source: str = None) -> Patient:
"valueString": insights.summary
})
+ # Update the patient
+ fhir_gateway.update(patient, source)
+
return patient
-# The handler is automatically called via HTTP endpoint:
-# GET /fhir/transform/Patient/123?source=epic
-```
+ # The handler is automatically called via HTTP endpoint:
+ # GET /fhir/transform/Patient/123?source=epic
+ ```
+
+=== "Async"
+ ```python
+ from fhir.resources.patient import Patient
+ from fhir.resources.observation import Observation
+
+ @fhir_gateway.transform(Patient)
+ async def get_enhanced_patient_summary(id: str, source: str = None) -> Patient:
+
+ # Use the context manager to modify the patient
+ async with fhir_gateway.modify(Patient, id, source=source) as patient:
+ # Get lab results
+ lab_results = await fhir_gateway.search(
+ resource_type=Observation,
+ search_params={"patient": id, "category": "laboratory"},
+ source=source
+ )
+ insights = nlp_pipeline.process(patient, lab_results)
-## Aggregate Handlers π
+ # Add AI summary
+ patient.extension = patient.extension or []
+ patient.extension.append({
+ "url": "http://healthchain.org/fhir/summary",
+ "valueString": insights.summary
+ })
-Aggregate handlers allow you to combine data from multiple FHIR sources into a single resource. This is useful for creating unified views across different EHR systems or consolidating patient data from various healthcare providers.
+ return patient
+ # The handler is automatically called via HTTP endpoint:
+ # GET /fhir/transform/Patient/123?source=epic
+ ```
-```python
-from fhir.resources.observation import Observation
-from fhir.resources.bundle import Bundle
-@gateway.aggregate(Observation)
-async def aggregate_vitals(patient_id: str, sources: List[str] = None) -> Bundle:
- """Aggregate vital signs from multiple sources."""
- sources = sources or ["epic", "cerner"]
- all_observations = []
-
- for source in sources:
- try:
- results = await gateway.search(
- Observation,
- {"patient": patient_id, "category": "vital-signs"},
- source
- )
- processed_observations = process_observations(results)
- all_observations.append(processed_observations)
- except Exception as e:
- print(f"Could not get vitals from {source}: {e}")
+## Aggregate Handlers π
- return Bundle(type="searchset", entry=[{"resource": obs} for obs in all_observations])
+Aggregate handlers allow you to combine data from multiple FHIR sources into a single resource. This is useful for creating unified views across different EHR systems or consolidating patient data from various healthcare providers.
-# The handler is automatically called via HTTP endpoint:
-# GET /fhir/aggregate/Observation?patient_id=123&sources=epic&sources=cerner
-```
+
+=== "Sync"
+ ```python
+ from fhir.resources.observation import Observation
+ from fhir.resources.bundle import Bundle
+
+ @gateway.aggregate(Observation)
+ def aggregate_vitals(patient_id: str, sources: list = None) -> Bundle:
+ """Aggregate vital signs from multiple sources."""
+ sources = sources or ["epic", "cerner"]
+ all_observations = []
+
+ for source in sources:
+ try:
+ results = gateway.search(
+ Observation,
+ {"patient": patient_id, "category": "vital-signs"},
+ source
+ )
+ processed_observations = process_observations(results)
+ all_observations.append(processed_observations)
+ except Exception as e:
+ print(f"Could not get vitals from {source}: {e}")
+
+ return Bundle(type="searchset", entry=[{"resource": obs} for obs in all_observations])
+
+ # The handler is automatically called via HTTP endpoint:
+ # GET /fhir/aggregate/Observation?patient_id=123&sources=epic&sources=cerner
+ ```
+
+=== "Async"
+ ```python
+ from fhir.resources.observation import Observation
+ from fhir.resources.bundle import Bundle
+
+ @gateway.aggregate(Observation)
+ async def aggregate_vitals(patient_id: str, sources: list = None) -> Bundle:
+ """Aggregate vital signs from multiple sources."""
+ sources = sources or ["epic", "cerner"]
+ all_observations = []
+
+ for source in sources:
+ try:
+ results = await gateway.search(
+ Observation,
+ {"patient": patient_id, "category": "vital-signs"},
+ source
+ )
+ processed_observations = process_observations(results)
+ all_observations.append(processed_observations)
+ except Exception as e:
+ print(f"Could not get vitals from {source}: {e}")
+
+ return Bundle(type="searchset", entry=[{"resource": obs} for obs in all_observations])
+
+ # The handler is automatically called via HTTP endpoint:
+ # GET /fhir/aggregate/Observation?patient_id=123&sources=epic&sources=cerner
+ ```
## Server Capabilities
@@ -268,16 +390,16 @@ async def aggregate_vitals(patient_id: str, sources: List[str] = None) -> Bundle
- **GET** `/fhir/status` - Returns Gateway status and connection health
-## Connection Pool Management
+## Connection Pool Management (Async Only)
-When you add a connection to a FHIR server, the gateway will automatically add it to a connection pool to manage connections to FHIR servers.
+When you add a connection to a FHIR server, the async gateway will automatically add it to a connection pool to manage connections to FHIR servers.
### Pool Configuration
```python
# Create gateway with optimized connection settings
-gateway = FHIRGateway(
+gateway = AsyncFHIRGateway(
max_connections=100, # Total connections across all sources
max_keepalive_connections=20, # Keep-alive connections per source
keepalive_expiry=30.0, # Keep connections alive for 30 seconds
diff --git a/docs/reference/gateway/gateway.md b/docs/reference/gateway/gateway.md
index 6fc16773..388efe92 100644
--- a/docs/reference/gateway/gateway.md
+++ b/docs/reference/gateway/gateway.md
@@ -1,6 +1,8 @@
# Gateway
-The HealthChain Gateway module provides a secure, asynchronous integration layer for connecting your NLP/ML pipelines with multiple healthcare systems. It provides a unified interface for connecting to FHIR servers, CDS Hooks, and SOAP/CDA services and is designed to be used in conjunction with the [HealthChainAPI](api.md) to create a complete healthcare integration platform.
+The HealthChain Gateway module provides a secure integration layer for connecting your NLP/ML pipelines with multiple healthcare systems.
+
+It provides a unified interface for connecting to FHIR servers, CDS Hooks, and SOAP/CDA services and is designed to be used in conjunction with the [HealthChainAPI](api.md) to create a complete healthcare integration platform.
## Features π
@@ -19,7 +21,7 @@ The Gateway handles the complex parts of healthcare integration:
| Component | Description | Use Case |
|-----------|-------------|----------|
| [**HealthChainAPI**](api.md) | FastAPI app with gateway and service registration | Main app that coordinates everything |
-| [**FHIRGateway**](fhir_gateway.md) | FHIR client with connection pooling and authentication| Reading/writing patient data from EHRs (Epic, Cerner, etc.) or application FHIR servers (Medplum, Hapi etc.) |
+| [**FHIRGateway**](fhir_gateway.md) | Sync and async FHIR client with connection pooling and authentication| Reading/writing patient data from EHRs (Epic, Cerner, etc.) or application FHIR servers (Medplum, Hapi etc.) |
| [**CDSHooksService**](cdshooks.md) | Clinical Decision Support hooks service | Real-time alerts and recommendations |
| [**NoteReaderService**](soap_cda.md) | SOAP/CDA document processing service | Processing clinical documents and notes |
| [**Event System**](events.md) | Event-driven integration | Audit trails, workflow automation |
@@ -28,38 +30,59 @@ The Gateway handles the complex parts of healthcare integration:
## Basic Usage
-```python
-from healthchain.gateway import HealthChainAPI, FHIRGateway
-from fhir.resources.patient import Patient
-
-# Create the application
-app = HealthChainAPI()
+=== "Sync"
+ ```python
+ from healthchain.gateway import HealthChainAPI, FHIRGateway
+ from fhir.resources.patient import Patient
-# Create and configure a FHIR gateway
-fhir = FHIRGateway()
+ # Create the application
+ app = HealthChainAPI()
-# Connect to your FHIR APIs
-fhir.add_source("epic", "fhir://epic.org/api/FHIR/R4?client_id=...")
-fhir.add_source("medplum", "fhir://api.medplum.com/fhir/R4/?client_id=...")
+ # Synchronous FHIR gateway
+ fhir = FHIRGateway()
+ fhir.add_source("epic", "fhir://epic.org/api/FHIR/R4?client_id=...")
-# Add AI enhancements to patient data
-@fhir.transform(Patient)
-async def enhance_patient(id: str, source: str = None) -> Patient:
- async with fhir.modify(Patient, id, source) as patient:
+ @fhir.transform(Patient)
+ def enhance_patient(id: str, source: str = None) -> Patient:
+ patient = fhir.read(Patient, id, source)
patient.active = True # Your custom logic here
+ fhir.update(patient, source)
return patient
-# Register and run
-app.register_gateway(fhir)
+ app.register_gateway(fhir)
-if __name__ == "__main__":
- import uvicorn
- uvicorn.run(app)
+ if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app)
+ # Default: http://127.0.0.1:8000/
+ ```
+=== "Async"
+ ```python
+ from healthchain.gateway import HealthChainAPI, AsyncFHIRGateway
+ from fhir.resources.patient import Patient
-# Default: http://127.0.0.1:8000/
-```
+ # Create the application
+ app = HealthChainAPI()
+
+ # Asynchronous FHIR gateway
+ async_fhir = AsyncFHIRGateway()
+ async_fhir.add_source("medplum", "fhir://api.medplum.com/fhir/R4/?client_id=...")
+
+ @async_fhir.transform(Patient)
+ async def enhance_patient_async(id: str, source: str = None) -> Patient:
+ # modify is a context manager that allows you to modify the patient resource
+ async with async_fhir.modify(Patient, id, source) as patient:
+ patient.active = True # Your custom logic here
+ return patient
+
+ app.register_gateway(async_fhir)
+
+ if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app)
+ ```
-You can also register multiple services of different protocols!
+You can also register multiple services of different protocols:
```python
from healthchain.gateway import (
@@ -100,6 +123,6 @@ app.register_service(notes)
| Protocol | Implementation | Features |
|----------|---------------|----------|
-| **FHIR API** | `FHIRGateway` | FHIR-instance level CRUD operations - [read](https://hl7.org/fhir/http.html#read), [create](https://hl7.org/fhir/http.html#create), [update](https://hl7.org/fhir/http.html#update), [delete](https://hl7.org/fhir/http.html#delete), [search](https://hl7.org/fhir/http.html#search), register `transform` and `aggregate` handlers, connection pooling and authentication management |
+| **FHIR API** | `FHIRGateway`
`AsyncFHIRGateway` | FHIR-instance level CRUD operations - [read](https://hl7.org/fhir/http.html#read), [create](https://hl7.org/fhir/http.html#create), [update](https://hl7.org/fhir/http.html#update), [delete](https://hl7.org/fhir/http.html#delete), [search](https://hl7.org/fhir/http.html#search), register `transform` and `aggregate` handlers, connection pooling and authentication management |
| **CDS Hooks** | `CDSHooksService` | Hook Registration, Service Discovery |
| **SOAP/CDA** | `NoteReaderService` | Method Registration (`ProcessDocument`), SOAP Service Discovery (WSDL)|
diff --git a/docs/reference/interop/experimental.md b/docs/reference/interop/experimental.md
index 2e0a3b16..aa42180e 100644
--- a/docs/reference/interop/experimental.md
+++ b/docs/reference/interop/experimental.md
@@ -6,9 +6,9 @@ This page tracks templates that are under development or have known issues. Use
| Template Type | Status | Known Issues | Location |
|---------------|--------|--------------|----------|
-| **Problems** | β
**Stable** | None | Bundled in default configs |
-| **Medications** | β
**Stable** | None | Bundled in default configs |
-| **Notes** | β
**Stable** | None | Bundled in default configs |
+| **Problems** | π’ **Basic Stable** | None, but not fully tested for edge cases | Bundled in default configs |
+| **Medications** | π’ **Basic Stable** | None, but not fully tested for edge cases | Bundled in default configs |
+| **Notes** | π’ **Basic Stable** | None, but not fully tested for edge cases, html tags are parsed as text | Bundled in default configs |
| **Allergies** | β οΈ **Experimental** | Clinical status parsing bugs, round-trip issues | `dev-templates/allergies/` |
## Using Experimental Templates
diff --git a/docs/reference/interop/interop.md b/docs/reference/interop/interop.md
index 03af0478..1c073e38 100644
--- a/docs/reference/interop/interop.md
+++ b/docs/reference/interop/interop.md
@@ -41,7 +41,7 @@ The interoperability module is built around a central `InteropEngine` that coord
## Basic Usage
-FHIR serves as the de facto data standard in HealthChain (and in the world of healthcare more broadly, ideally), therefore everything converts to and from FHIR resources.
+FHIR serves as the de facto modern data standard in HealthChain and in the world of healthcare more broadly, therefore everything converts to and from FHIR resources.
The main conversion methods are (hold on to your hats):
@@ -63,6 +63,27 @@ fhir_resources = engine.to_fhir(cda_xml, src_format="cda")
# Convert FHIR resources back to CDA
cda_document = engine.from_fhir(fhir_resources, dest_format="cda")
```
+### Custom Configs
+
+The default templates that come with the package are limited to problems, medications, and notes and are meant for basic testing and prototyping. Use the `healthchain init-configs` command to create editable configuration templates:
+
+```bash
+# Create editable configuration templates
+healthchain init-configs ./my_configs
+```
+
+Then use the `config_dir` parameter to specify the path to your custom configs:
+
+```python
+# Use your customized configs
+engine = create_interop(config_dir="./my_configs")
+
+# Now you can customize:
+# β’ Add experimental features (allergies, procedures)
+# β’ Modify terminology mappings (SNOMED, LOINC codes)
+# β’ Customize templates for your organization's CDA format
+# β’ Configure validation rules and environments
+```
## Customization Points
diff --git a/healthchain/gateway/README.md b/healthchain/gateway/README.md
index ac670c0f..df9748ab 100644
--- a/healthchain/gateway/README.md
+++ b/healthchain/gateway/README.md
@@ -23,7 +23,8 @@ from healthchain.gateway import (
app = HealthChainAPI()
# Create gateways for different protocols
-fhir = FHIRGateway(base_url="https://fhir.example.com/r4")
+fhir = FHIRGateway()
+fhir.add_source("main", "fhir://fhir.example.com/r4?client_id=...")
cds = CDSHooksService()
soap = NoteReaderService()
@@ -52,24 +53,26 @@ app.register_gateway(soap)
- `EventCapability`: A component that provides event dispatching
- `HealthChainAPI`: FastAPI wrapper for healthcare gateway registration
- Concrete gateway implementations:
- - `FHIRGateway`: FHIR REST API protocol
+ - `FHIRGateway`: Synchronous FHIR REST API protocol
+ - `AsyncFHIRGateway`: Asynchronous FHIR REST API protocol
- `CDSHooksService`: CDS Hooks protocol
- `NoteReaderService`: SOAP/CDA protocol
## Quick Start
```python
-from healthchain.gateway import create_app, FHIRGateway
+from healthchain.gateway import HealthChainAPI, FHIRGateway
from fhir.resources.patient import Patient
# Create the app
-app = create_app()
+app = HealthChainAPI()
# Create and register a FHIR gateway
fhir = FHIRGateway()
+fhir.add_source("main", "fhir://fhir.example.com/r4?client_id=...")
@fhir.read(Patient)
-def read_patient(patient):
+async def read_patient(patient):
# Custom logic for processing a patient
return patient
@@ -81,35 +84,38 @@ if __name__ == "__main__":
uvicorn.run(app)
```
-## Type Safety with Protocols
+## Dependency Injection
-The gateway module uses Python's Protocol typing for robust interface definitions:
+The gateway module provides dependency injection for accessing registered gateways and services:
```python
+from healthchain.gateway.api.dependencies import get_gateway_by_name
+from fastapi import Depends
+
# Register gateways with explicit types
app.register_gateway(fhir) # Implements FHIRGateway
app.register_gateway(cds) # Implements CDSHooksService
app.register_gateway(soap) # Implements NoteReaderService
-# Get typed gateway dependencies in API routes
+# Get gateway dependencies in API routes
@app.get("/api/patient/{id}")
async def get_patient(
id: str,
- fhir: FHIRGatewayProtocol = Depends(get_typed_gateway("FHIRGateway", FHIRGatewayProtocol))
+ fhir=Depends(get_gateway_by_name("fhir"))
):
- # Type-safe access to FHIR methods
+ # Access to FHIR methods through dependency injection
return await fhir.read("Patient", id)
```
This approach provides:
-- Enhanced type checking and IDE auto-completion
-- Clear interface definition for gateway implementations
-- Runtime type safety with detailed error messages
-- Better testability through protocol-based mocking
+- Dependency injection for clean separation of concerns
+- Easy access to registered gateways and services
+- Runtime validation with detailed error messages
+- Better testability through dependency override
## Context Managers
-Context managers are a powerful tool for managing resource lifecycles in a safe and predictable way. They are particularly useful for:
+Context managers are available in the `AsyncFHIRGateway` and are a powerful tool for managing resource lifecycles in a safe and predictable way. They are particularly useful for:
- Standalone CRUD operations
- Creating new resources
diff --git a/healthchain/gateway/__init__.py b/healthchain/gateway/__init__.py
index 62f2aa57..6580e1a5 100644
--- a/healthchain/gateway/__init__.py
+++ b/healthchain/gateway/__init__.py
@@ -15,12 +15,13 @@
)
# Core Components
-from healthchain.gateway.core.base import BaseGateway, BaseProtocolHandler
-from healthchain.gateway.core.fhirgateway import FHIRGateway
+from healthchain.gateway.base import BaseGateway, BaseProtocolHandler
+from healthchain.gateway.fhir.sync import FHIRGateway
+from healthchain.gateway.fhir.aio import AsyncFHIRGateway
# Protocol Handlers
-from healthchain.gateway.protocols.cdshooks import CDSHooksService
-from healthchain.gateway.protocols.notereader import NoteReaderService
+from healthchain.gateway.cds import CDSHooksService
+from healthchain.gateway.soap.notereader import NoteReaderService
# Event System
from healthchain.gateway.events.dispatcher import (
@@ -30,8 +31,9 @@
)
# Client Utilities
-from healthchain.gateway.clients.fhir import AsyncFHIRClient
-from healthchain.gateway.clients.pool import FHIRClientPool
+from healthchain.gateway.clients.pool import ClientPool
+from healthchain.gateway.clients.fhir.aio import AsyncFHIRClient
+from healthchain.gateway.clients.fhir.sync import FHIRClient
__all__ = [
# API
@@ -44,6 +46,7 @@
"BaseGateway",
"BaseProtocolHandler",
"FHIRGateway",
+ "AsyncFHIRGateway",
# Protocols
"CDSHooksService",
"NoteReaderService",
@@ -53,5 +56,6 @@
"EHREventType",
# Clients
"AsyncFHIRClient",
- "FHIRClientPool",
+ "FHIRClient",
+ "ClientPool",
]
diff --git a/healthchain/gateway/api/app.py b/healthchain/gateway/api/app.py
index 4b27583d..0c67430b 100644
--- a/healthchain/gateway/api/app.py
+++ b/healthchain/gateway/api/app.py
@@ -18,7 +18,7 @@
from typing import Dict, Optional, Type, Union
-from healthchain.gateway.core.base import BaseGateway, BaseProtocolHandler
+from healthchain.gateway.base import BaseGateway, BaseProtocolHandler
from healthchain.gateway.events.dispatcher import EventDispatcher
from healthchain.gateway.api.dependencies import get_app
diff --git a/healthchain/gateway/core/base.py b/healthchain/gateway/base.py
similarity index 99%
rename from healthchain/gateway/core/base.py
rename to healthchain/gateway/base.py
index be77b1e8..2bf262d2 100644
--- a/healthchain/gateway/core/base.py
+++ b/healthchain/gateway/base.py
@@ -15,8 +15,10 @@
from healthchain.gateway.api.protocols import EventDispatcherProtocol
+
logger = logging.getLogger(__name__)
+
# Type variables for self-referencing return types and generic gateways
G = TypeVar("G", bound="BaseGateway")
P = TypeVar("P", bound="BaseProtocolHandler")
@@ -336,7 +338,7 @@ def __init__(
self.return_errors = self.config.return_errors or options.get(
"return_errors", False
)
- self.events = EventCapability()
+ self.events = EventCapability() if self.use_events else None
def get_gateway_status(self) -> Dict[str, Any]:
"""
diff --git a/healthchain/gateway/protocols/cdshooks.py b/healthchain/gateway/cds/__init__.py
similarity index 97%
rename from healthchain/gateway/protocols/cdshooks.py
rename to healthchain/gateway/cds/__init__.py
index 328d608f..19004736 100644
--- a/healthchain/gateway/protocols/cdshooks.py
+++ b/healthchain/gateway/cds/__init__.py
@@ -1,32 +1,25 @@
-"""
-CDS Hooks protocol integration for HealthChain Gateway.
-
-This module implements the CDS Hooks standard for clinical decision support
-integration with EHR systems.
-"""
-
import logging
from typing import Any, Callable, Dict, List, Optional, TypeVar, Union
+
from fastapi import APIRouter, Body, Depends
from pydantic import BaseModel
-from healthchain.gateway.core.base import BaseProtocolHandler
-from healthchain.gateway.events.cdshooks import create_cds_hook_event
+from healthchain.gateway.base import BaseProtocolHandler
+from healthchain.gateway.cds.events import create_cds_hook_event
from healthchain.gateway.events.dispatcher import EventDispatcher
from healthchain.models.requests.cdsrequest import CDSRequest
from healthchain.models.responses.cdsdiscovery import CDSService, CDSServiceInformation
from healthchain.models.responses.cdsresponse import CDSResponse
from healthchain.sandbox.workflows import UseCaseMapping
-logger = logging.getLogger(__name__)
+logger = logging.getLogger(__name__)
# Type variable for self-referencing return types
T = TypeVar("T", bound="CDSHooksService")
-# Configuration options for CDS Hooks service
class CDSHooksConfig(BaseModel):
"""Configuration options for CDS Hooks service"""
diff --git a/healthchain/gateway/events/cdshooks.py b/healthchain/gateway/cds/events.py
similarity index 100%
rename from healthchain/gateway/events/cdshooks.py
rename to healthchain/gateway/cds/events.py
diff --git a/healthchain/gateway/clients/__init__.py b/healthchain/gateway/clients/__init__.py
index f1d7ace3..88e15401 100644
--- a/healthchain/gateway/clients/__init__.py
+++ b/healthchain/gateway/clients/__init__.py
@@ -1,13 +1,27 @@
-from .fhir import FHIRServerInterface, AsyncFHIRClient, create_fhir_client
-from .auth import OAuth2TokenManager, FHIRAuthConfig, parse_fhir_auth_connection_string
-from .pool import FHIRClientPool
+from .fhir.base import (
+ FHIRAuthConfig,
+ FHIRClientError,
+ FHIRServerInterface,
+ parse_fhir_auth_connection_string,
+)
+from .auth import (
+ OAuth2TokenManager,
+ AsyncOAuth2TokenManager,
+)
+from .fhir.aio import AsyncFHIRClient, create_async_fhir_client
+from .fhir.sync import FHIRClient, create_fhir_client
+from .pool import ClientPool
__all__ = [
- "FHIRServerInterface",
"AsyncFHIRClient",
- "create_fhir_client",
- "OAuth2TokenManager",
+ "AsyncOAuth2TokenManager",
"FHIRAuthConfig",
+ "FHIRClient",
+ "FHIRClientError",
+ "ClientPool",
+ "FHIRServerInterface",
+ "OAuth2TokenManager",
+ "create_fhir_client",
+ "create_async_fhir_client",
"parse_fhir_auth_connection_string",
- "FHIRClientPool",
]
diff --git a/healthchain/gateway/clients/auth.py b/healthchain/gateway/clients/auth.py
index ba0eae5f..6daf8189 100644
--- a/healthchain/gateway/clients/auth.py
+++ b/healthchain/gateway/clients/auth.py
@@ -9,6 +9,7 @@
import os
import uuid
import asyncio
+import threading
import httpx
from typing import Dict, Optional, Any
@@ -97,7 +98,7 @@ def is_expired(self, buffer_seconds: int = 300) -> bool:
class OAuth2TokenManager:
"""
- Manages OAuth2.0 tokens with automatic refresh for FHIR clients.
+ OAuth2.0 token manager for FHIR clients.
Supports client credentials flow commonly used in healthcare integrations.
"""
@@ -113,32 +114,24 @@ def __init__(self, config: OAuth2Config, refresh_buffer_seconds: int = 300):
self.config = config
self.refresh_buffer_seconds = refresh_buffer_seconds
self._token: Optional[TokenInfo] = None
- self._refresh_lock: Optional[asyncio.Lock] = None
+ self._refresh_lock = threading.Lock()
- def _get_refresh_lock(self) -> asyncio.Lock:
- """Get or create the refresh lock when an event loop is running."""
- if self._refresh_lock is None:
- # Only create the lock when we have a running event loop
- # This ensures Python 3.9 compatibility
- self._refresh_lock = asyncio.Lock()
- return self._refresh_lock
-
- async def get_access_token(self) -> str:
+ def get_access_token(self) -> str:
"""
Get a valid access token, refreshing if necessary.
Returns:
Valid Bearer access token
"""
- async with self._get_refresh_lock():
+ with self._refresh_lock:
if self._token is None or self._token.is_expired(
self.refresh_buffer_seconds
):
- await self._refresh_token()
+ self._refresh_token()
return self._token.access_token
- async def _refresh_token(self):
+ def _refresh_token(self):
"""Refresh the access token using client credentials flow."""
logger.debug(f"Refreshing token from {self.config.token_url}")
@@ -166,9 +159,9 @@ async def _refresh_token(self):
token_data["audience"] = self.config.audience
# Make token request
- async with httpx.AsyncClient() as client:
+ with httpx.Client() as client:
try:
- response = await client.post(
+ response = client.post(
self.config.token_url,
data=token_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
@@ -232,234 +225,138 @@ def _create_jwt_assertion(self) -> str:
return signed_jwt
-class FHIRAuthConfig(BaseModel):
- """Configuration for FHIR server authentication."""
-
- # OAuth2 settings
- client_id: str
- client_secret: Optional[str] = None # Client secret string for standard flow
- client_secret_path: Optional[str] = (
- None # Path to private key file for JWT assertion
- )
- token_url: str
- scope: Optional[str] = "system/*.read system/*.write"
- audience: Optional[str] = None
- use_jwt_assertion: bool = False # Use JWT client assertion (Epic/SMART style)
-
- # Connection settings
- base_url: str
- timeout: int = 30
- verify_ssl: bool = True
-
- def model_post_init(self, __context) -> None:
- """Validate that exactly one of client_secret or client_secret_path is provided."""
- if not self.client_secret and not self.client_secret_path:
- raise ValueError(
- "Either client_secret or client_secret_path must be provided"
- )
-
- if self.client_secret and self.client_secret_path:
- raise ValueError("Cannot provide both client_secret and client_secret_path")
-
- if self.use_jwt_assertion and not self.client_secret_path:
- raise ValueError(
- "use_jwt_assertion=True requires client_secret_path to be set"
- )
-
- if not self.use_jwt_assertion and self.client_secret_path:
- raise ValueError(
- "client_secret_path can only be used with use_jwt_assertion=True"
- )
+class AsyncOAuth2TokenManager:
+ """
+ Manages OAuth2.0 tokens with automatic refresh for async FHIR clients.
- def to_oauth2_config(self) -> OAuth2Config:
- """Convert to OAuth2Config for token manager."""
- return OAuth2Config(
- client_id=self.client_id,
- client_secret=self.client_secret,
- client_secret_path=self.client_secret_path,
- token_url=self.token_url,
- scope=self.scope,
- audience=self.audience,
- use_jwt_assertion=self.use_jwt_assertion,
- )
+ Supports client credentials flow commonly used in healthcare integrations.
+ """
- @classmethod
- def from_env(cls, env_prefix: str) -> "FHIRAuthConfig":
+ def __init__(self, config: OAuth2Config, refresh_buffer_seconds: int = 300):
"""
- Create FHIRAuthConfig from environment variables.
+ Initialize OAuth2 token manager.
Args:
- env_prefix: Environment variable prefix (e.g., "EPIC")
-
- Expected environment variables:
- {env_prefix}_CLIENT_ID
- {env_prefix}_CLIENT_SECRET (or {env_prefix}_CLIENT_SECRET_PATH)
- {env_prefix}_TOKEN_URL
- {env_prefix}_BASE_URL
- {env_prefix}_SCOPE (optional)
- {env_prefix}_AUDIENCE (optional)
- {env_prefix}_TIMEOUT (optional, default: 30)
- {env_prefix}_VERIFY_SSL (optional, default: true)
- {env_prefix}_USE_JWT_ASSERTION (optional, default: false)
-
- Returns:
- FHIRAuthConfig instance
-
- Example:
- # Set environment variables:
- # EPIC_CLIENT_ID=app123
- # EPIC_CLIENT_SECRET=secret456
- # EPIC_TOKEN_URL=https://epic.com/oauth2/token
- # EPIC_BASE_URL=https://epic.com/api/FHIR/R4
-
- config = FHIRAuthConfig.from_env("EPIC")
+ config: OAuth2 configuration
+ refresh_buffer_seconds: Refresh token this many seconds before expiry
"""
- import os
-
- # Read required environment variables
- client_id = os.getenv(f"{env_prefix}_CLIENT_ID")
- client_secret = os.getenv(f"{env_prefix}_CLIENT_SECRET")
- client_secret_path = os.getenv(f"{env_prefix}_CLIENT_SECRET_PATH")
- token_url = os.getenv(f"{env_prefix}_TOKEN_URL")
- base_url = os.getenv(f"{env_prefix}_BASE_URL")
-
- if not all([client_id, token_url, base_url]):
- missing = [
- var
- for var, val in [
- (f"{env_prefix}_CLIENT_ID", client_id),
- (f"{env_prefix}_TOKEN_URL", token_url),
- (f"{env_prefix}_BASE_URL", base_url),
- ]
- if not val
- ]
- raise ValueError(f"Missing required environment variables: {missing}")
-
- # Read optional environment variables
- scope = os.getenv(f"{env_prefix}_SCOPE", "system/*.read system/*.write")
- audience = os.getenv(f"{env_prefix}_AUDIENCE")
- timeout = int(os.getenv(f"{env_prefix}_TIMEOUT", "30"))
- verify_ssl = os.getenv(f"{env_prefix}_VERIFY_SSL", "true").lower() == "true"
- use_jwt_assertion = (
- os.getenv(f"{env_prefix}_USE_JWT_ASSERTION", "false").lower() == "true"
- )
+ self.config = config
+ self.refresh_buffer_seconds = refresh_buffer_seconds
+ self._token: Optional[TokenInfo] = None
+ self._refresh_lock: Optional[asyncio.Lock] = None
- return cls(
- client_id=client_id,
- client_secret=client_secret,
- client_secret_path=client_secret_path,
- token_url=token_url,
- base_url=base_url,
- scope=scope,
- audience=audience,
- timeout=timeout,
- verify_ssl=verify_ssl,
- use_jwt_assertion=use_jwt_assertion,
- )
+ def _get_refresh_lock(self) -> asyncio.Lock:
+ """Get or create the refresh lock when an event loop is running."""
+ if self._refresh_lock is None:
+ # Only create the lock when we have a running event loop
+ # This ensures Python 3.9 compatibility
+ self._refresh_lock = asyncio.Lock()
+ return self._refresh_lock
- def to_connection_string(self) -> str:
+ async def get_access_token(self) -> str:
"""
- Convert FHIRAuthConfig to connection string format.
+ Get a valid access token, refreshing if necessary.
Returns:
- Connection string in fhir:// format
-
- Example:
- config = FHIRAuthConfig(...)
- connection_string = config.to_connection_string()
- # Returns: "fhir://hostname/path?client_id=...&token_url=..."
+ Valid Bearer access token
"""
- # Extract hostname and path from base_url
- import urllib.parse
+ async with self._get_refresh_lock():
+ if self._token is None or self._token.is_expired(
+ self.refresh_buffer_seconds
+ ):
+ await self._refresh_token()
- parsed_base = urllib.parse.urlparse(self.base_url)
+ return self._token.access_token
- # Build query parameters
- params = {
- "client_id": self.client_id,
- "token_url": self.token_url,
- }
+ async def _refresh_token(self):
+ """Refresh the access token using client credentials flow."""
+ logger.debug(f"Refreshing token from {self.config.token_url}")
- # Add secret (either client_secret or client_secret_path)
- if self.client_secret:
- params["client_secret"] = self.client_secret
- elif self.client_secret_path:
- params["client_secret_path"] = self.client_secret_path
-
- # Add optional parameters
- if self.scope:
- params["scope"] = self.scope
- if self.audience:
- params["audience"] = self.audience
- if self.timeout != 30:
- params["timeout"] = str(self.timeout)
- if not self.verify_ssl:
- params["verify_ssl"] = "false"
- if self.use_jwt_assertion:
- params["use_jwt_assertion"] = "true"
-
- # Build connection string
- query_string = urllib.parse.urlencode(params)
- return f"fhir://{parsed_base.netloc}{parsed_base.path}?{query_string}"
-
-
-def parse_fhir_auth_connection_string(connection_string: str) -> FHIRAuthConfig:
- """
- Parse a FHIR connection string into authentication configuration.
+ # Check if client_secret is a private key path or JWT assertion is enabled
+ if self.config.use_jwt_assertion or self.config.client_secret_path:
+ # Use JWT client assertion flow (Epic/SMART on FHIR style)
+ jwt_assertion = self._create_jwt_assertion()
+ token_data = {
+ "grant_type": "client_credentials",
+ "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+ "client_assertion": jwt_assertion,
+ }
+ else:
+ # Standard client credentials flow
+ token_data = {
+ "grant_type": "client_credentials",
+ "client_id": self.config.client_id,
+ "client_secret": self.config.secret_value,
+ }
- Format: fhir://hostname:port/path?client_id=xxx&client_secret=xxx&token_url=xxx&scope=xxx
- Or for JWT: fhir://hostname:port/path?client_id=xxx&client_secret_path=xxx&token_url=xxx&use_jwt_assertion=true
+ if self.config.scope:
+ token_data["scope"] = self.config.scope
- Args:
- connection_string: FHIR connection string with OAuth2 credentials
+ if self.config.audience:
+ token_data["audience"] = self.config.audience
+
+ # Make token request
+ async with httpx.AsyncClient() as client:
+ try:
+ response = await client.post(
+ self.config.token_url,
+ data=token_data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ timeout=30,
+ )
+ response.raise_for_status()
- Returns:
- FHIRAuthConfig with parsed settings
+ response_data = response.json()
+ self._token = TokenInfo.from_response(response_data)
- Raises:
- ValueError: If connection string is invalid or missing required parameters
- """
- import urllib.parse
+ logger.debug(
+ f"Token refreshed successfully, expires at {self._token.expires_at}"
+ )
- if not connection_string.startswith("fhir://"):
- raise ValueError("Connection string must start with fhir://")
+ except httpx.HTTPStatusError as e:
+ logger.error(
+ f"Token refresh failed: {e.response.status_code} {e.response.text}"
+ )
+ raise Exception(f"Failed to refresh token: {e.response.status_code}")
+ except Exception as e:
+ logger.error(f"Token refresh error: {str(e)}")
+ raise
- parsed = urllib.parse.urlparse(connection_string)
- params = dict(urllib.parse.parse_qsl(parsed.query))
+ def invalidate_token(self):
+ """Invalidate the current token to force refresh on next request."""
+ self._token = None
- # Validate required parameters
- required_params = ["client_id", "token_url"]
- missing_params = [param for param in required_params if param not in params]
+ def _create_jwt_assertion(self) -> str:
+ """Create JWT client assertion for SMART on FHIR authentication."""
+ from jwt import JWT, jwk_from_pem
- if missing_params:
- raise ValueError(f"Missing required parameters: {missing_params}")
+ # Generate unique JTI
+ jti = str(uuid.uuid4())
- # Check that exactly one of client_secret or client_secret_path is provided
- has_secret = "client_secret" in params
- has_secret_path = "client_secret_path" in params
+ # Load private key (client_secret should be path to private key for JWT assertion)
+ try:
+ with open(self.config.client_secret_path, "rb") as f:
+ private_key_data = f.read()
+ key = jwk_from_pem(private_key_data)
+ except Exception as e:
+ raise Exception(
+ f"Failed to load private key from {os.path.basename(self.config.client_secret_path)}: {e}"
+ )
- if not has_secret and not has_secret_path:
- raise ValueError(
- "Either 'client_secret' or 'client_secret_path' parameter must be provided"
- )
+ # Create JWT claims matching the script
+ now = datetime.now(timezone.utc)
+ claims = {
+ "iss": self.config.client_id, # Issuer (client ID)
+ "sub": self.config.client_id, # Subject (client ID)
+ "aud": self.config.token_url, # Audience (token endpoint)
+ "jti": jti, # Unique token identifier
+ "iat": int(now.timestamp()), # Issued at
+ "exp": int(
+ (now + timedelta(minutes=5)).timestamp()
+ ), # Expires in 5 minutes
+ }
- if has_secret and has_secret_path:
- raise ValueError(
- "Cannot provide both 'client_secret' and 'client_secret_path' parameters"
- )
+ # Create and sign JWT
+ signed_jwt = JWT().encode(claims, key, alg="RS384")
- # Build base URL
- base_url = f"https://{parsed.netloc}{parsed.path}"
-
- return FHIRAuthConfig(
- client_id=params["client_id"],
- client_secret=params.get("client_secret"),
- client_secret_path=params.get("client_secret_path"),
- token_url=params["token_url"],
- scope=params.get("scope", "system/*.read system/*.write"),
- audience=params.get("audience"),
- base_url=base_url,
- timeout=int(params.get("timeout", 30)),
- verify_ssl=params.get("verify_ssl", "true").lower() == "true",
- use_jwt_assertion=params.get("use_jwt_assertion", "false").lower() == "true",
- )
+ return signed_jwt
diff --git a/healthchain/gateway/clients/fhir/__init__.py b/healthchain/gateway/clients/fhir/__init__.py
new file mode 100644
index 00000000..ad3eb071
--- /dev/null
+++ b/healthchain/gateway/clients/fhir/__init__.py
@@ -0,0 +1,7 @@
+from .aio.client import AsyncFHIRClient
+from .sync.client import FHIRClient
+
+__all__ = [
+ "AsyncFHIRClient",
+ "FHIRClient",
+]
diff --git a/healthchain/gateway/clients/fhir/aio/__init__.py b/healthchain/gateway/clients/fhir/aio/__init__.py
new file mode 100644
index 00000000..67be1b41
--- /dev/null
+++ b/healthchain/gateway/clients/fhir/aio/__init__.py
@@ -0,0 +1,3 @@
+from .client import AsyncFHIRClient, create_async_fhir_client
+
+__all__ = ["AsyncFHIRClient", "create_async_fhir_client"]
diff --git a/healthchain/gateway/clients/fhir.py b/healthchain/gateway/clients/fhir/aio/client.py
similarity index 58%
rename from healthchain/gateway/clients/fhir.py
rename to healthchain/gateway/clients/fhir/aio/client.py
index 2ca6513d..f190c370 100644
--- a/healthchain/gateway/clients/fhir.py
+++ b/healthchain/gateway/clients/fhir/aio/client.py
@@ -1,111 +1,19 @@
-"""
-FHIR client interfaces and implementations.
-
-This module provides standardized interfaces for different FHIR client libraries.
-"""
-
import logging
-import json
import httpx
-from abc import ABC, abstractmethod
-from typing import Dict, Any, Optional, Union, Type
-from urllib.parse import urljoin, urlencode
-from functools import lru_cache
+from typing import Any, Dict, Type, Union
-from fhir.resources.resource import Resource
from fhir.resources.bundle import Bundle
from fhir.resources.capabilitystatement import CapabilityStatement
+from fhir.resources.resource import Resource
-from healthchain.gateway.clients.auth import OAuth2TokenManager, FHIRAuthConfig
+from healthchain.gateway.clients.auth import AsyncOAuth2TokenManager
+from healthchain.gateway.clients.fhir.base import FHIRAuthConfig, FHIRServerInterface
logger = logging.getLogger(__name__)
-def create_fhir_client(
- auth_config: FHIRAuthConfig,
- limits: httpx.Limits = None,
- **additional_params,
-) -> "FHIRServerInterface":
- """
- Factory function to create and configure a FHIR server interface using OAuth2.0
-
- Args:
- auth_config: OAuth2.0 authentication configuration
- limits: httpx connection limits for pooling
- **additional_params: Additional parameters for the client
-
- Returns:
- A configured FHIRServerInterface implementation
- """
- logger.debug(f"Creating FHIR server with OAuth2.0 for {auth_config.base_url}")
- return AsyncFHIRClient(auth_config=auth_config, limits=limits, **additional_params)
-
-
-class FHIRClientError(Exception):
- """Base exception for FHIR client errors."""
-
- def __init__(
- self, message: str, status_code: int = None, response_data: dict = None
- ):
- self.status_code = status_code
- self.response_data = response_data
- super().__init__(message)
-
-
-class FHIRServerInterface(ABC):
- """
- Interface for FHIR servers.
-
- Provides a standardized interface for interacting with FHIR servers
- using different client libraries.
- """
-
- @abstractmethod
- async def read(
- self, resource_type: Union[str, Type[Resource]], resource_id: str
- ) -> Resource:
- """Read a specific resource by ID."""
- pass
-
- @abstractmethod
- async def create(self, resource: Resource) -> Resource:
- """Create a new resource."""
- pass
-
- @abstractmethod
- async def update(self, resource: Resource) -> Resource:
- """Update an existing resource."""
- pass
-
- @abstractmethod
- async def delete(
- self, resource_type: Union[str, Type[Resource]], resource_id: str
- ) -> bool:
- """Delete a resource."""
- pass
-
- @abstractmethod
- async def search(
- self,
- resource_type: Union[str, Type[Resource]],
- params: Optional[Dict[str, Any]] = None,
- ) -> Bundle:
- """Search for resources."""
- pass
-
- @abstractmethod
- async def transaction(self, bundle: Bundle) -> Bundle:
- """Execute a transaction bundle."""
- pass
-
- @abstractmethod
- async def capabilities(self) -> CapabilityStatement:
- """Get the capabilities of the FHIR server."""
- pass
-
-
class AsyncFHIRClient(FHIRServerInterface):
"""
Async FHIR client optimized for HealthChain gateway use cases.
@@ -129,16 +37,8 @@ def __init__(
limits: httpx connection limits for pooling
**kwargs: Additional parameters passed to httpx.AsyncClient
"""
- self.base_url = auth_config.base_url.rstrip("/") + "/"
- self.timeout = auth_config.timeout
- self.verify_ssl = auth_config.verify_ssl
- self.token_manager = OAuth2TokenManager(auth_config.to_oauth2_config())
-
- # Setup base headers
- self.base_headers = {
- "Accept": "application/fhir+json",
- "Content-Type": "application/fhir+json",
- }
+ super().__init__(auth_config)
+ self.token_manager = AsyncOAuth2TokenManager(auth_config.to_oauth2_config())
# Create httpx client with connection pooling and additional kwargs
client_kwargs = {"timeout": self.timeout, "verify": self.verify_ssl}
@@ -169,66 +69,6 @@ async def _get_headers(self) -> Dict[str, str]:
headers["Authorization"] = f"Bearer {token}"
return headers
- def _build_url(self, path: str, params: Dict[str, Any] = None) -> str:
- """Build a complete URL with optional query parameters."""
- url = urljoin(self.base_url, path)
- if params:
- # Filter out None values and convert to strings
- clean_params = {k: str(v) for k, v in params.items() if v is not None}
- if clean_params:
- url += "?" + urlencode(clean_params)
- return url
-
- def _handle_response(self, response: httpx.Response) -> dict:
- """Handle HTTP response and convert to dict."""
- try:
- data = response.json()
- except json.JSONDecodeError:
- raise FHIRClientError(
- f"Invalid JSON response: {response.text}",
- status_code=response.status_code,
- )
-
- if not response.is_success:
- error_msg = f"FHIR request failed: {response.status_code}"
- if isinstance(data, dict) and "issue" in data:
- # FHIR OperationOutcome format
- issues = data.get("issue", [])
- if issues:
- error_msg += f" - {issues[0].get('diagnostics', 'Unknown error')}"
-
- raise FHIRClientError(
- error_msg, status_code=response.status_code, response_data=data
- )
-
- return data
-
- @lru_cache(maxsize=128)
- def _resolve_resource_type(
- self, resource_type: Union[str, Type[Resource]]
- ) -> tuple[str, Type[Resource]]:
- """
- Resolve FHIR resource type to string name and class. Cached with LRU.
-
- Args:
- resource_type: FHIR resource type or class
-
- Returns:
- Tuple of (type_name: str, resource_class: Type[Resource])
- """
- if hasattr(resource_type, "__name__"):
- # It's already a class
- type_name = resource_type.__name__
- resource_class = resource_type
- else:
- # It's a string, need to dynamically import
- type_name = str(resource_type)
- module_name = f"fhir.resources.{type_name.lower()}"
- module = __import__(module_name, fromlist=[type_name])
- resource_class = getattr(module, type_name)
-
- return type_name, resource_class
-
async def capabilities(self) -> CapabilityStatement:
"""
Fetch the server's CapabilityStatement.
@@ -387,3 +227,24 @@ async def transaction(self, bundle: Bundle) -> Bundle:
data = self._handle_response(response)
return Bundle(**data)
+
+
+def create_async_fhir_client(
+ auth_config: FHIRAuthConfig,
+ limits: httpx.Limits = None,
+ **additional_params,
+) -> AsyncFHIRClient:
+ """
+ Factory function to create and configure an async FHIR server interface using OAuth2.0
+
+ Args:
+ auth_config: OAuth2.0 authentication configuration
+ limits: httpx connection limits for pooling
+ **additional_params: Additional parameters for the client
+
+ Returns:
+ A configured async AsyncFHIRClient implementation
+ """
+ logger.debug(f"Creating async FHIR server with OAuth2.0 for {auth_config.base_url}")
+
+ return AsyncFHIRClient(auth_config=auth_config, limits=limits, **additional_params)
diff --git a/healthchain/gateway/clients/fhir/aio/connection.py b/healthchain/gateway/clients/fhir/aio/connection.py
new file mode 100644
index 00000000..53e4a04f
--- /dev/null
+++ b/healthchain/gateway/clients/fhir/aio/connection.py
@@ -0,0 +1,116 @@
+import httpx
+
+from typing import Dict
+
+from healthchain.gateway.clients.fhir.base import FHIRServerInterface
+from healthchain.gateway.clients.pool import ClientPool
+from healthchain.gateway.clients.fhir.sync.connection import FHIRConnectionManager
+
+
+class AsyncFHIRConnectionManager(FHIRConnectionManager):
+ """
+ Async FHIR connection manager with connection pooling.
+
+ Handles connection strings, source configuration, and provides
+ pooled async FHIR clients for efficient resource management.
+ """
+
+ def __init__(
+ self,
+ max_connections: int = 100,
+ max_keepalive_connections: int = 20,
+ keepalive_expiry: float = 5.0,
+ ):
+ """
+ Initialize the async connection manager.
+
+ Args:
+ max_connections: Maximum total HTTP connections across all sources
+ max_keepalive_connections: Maximum keep-alive connections per source
+ keepalive_expiry: How long to keep connections alive (seconds)
+ """
+ super().__init__()
+
+ # Create httpx-based client pool
+ self.client_pool = ClientPool(
+ max_connections=max_connections,
+ max_keepalive_connections=max_keepalive_connections,
+ keepalive_expiry=keepalive_expiry,
+ )
+
+ async def close(self):
+ """Close all connections and clean up resources."""
+ await self.client_pool.close_all()
+
+ def get_status(self) -> Dict[str, any]:
+ """
+ Get the current status of the async connection manager.
+
+ Returns:
+ Dict containing status information including pool stats.
+ """
+ status = {
+ "client_type": "async",
+ "pooling_enabled": True,
+ "sources": {
+ "count": len(self.sources),
+ "configured": list(self.sources.keys()),
+ "connection_strings": {
+ name: f"fhir://{name}/*" for name in self.sources.keys()
+ },
+ },
+ "pool_stats": self.client_pool.get_pool_stats(),
+ }
+ return status
+
+ async def get_client(self, source: str = None) -> FHIRServerInterface:
+ """
+ Get an async FHIR client for the specified source.
+
+ Args:
+ source: Source name to get client for (uses first available if None)
+
+ Returns:
+ FHIRServerInterface: An async FHIR client with pooled connections
+
+ Raises:
+ ValueError: If source is unknown or no connection string found
+ """
+ source_name = source or next(iter(self.sources.keys()))
+ if source_name not in self.sources:
+ raise ValueError(f"Unknown source: {source_name}")
+
+ if source_name not in self._connection_strings:
+ raise ValueError(f"No connection string found for source: {source_name}")
+
+ connection_string = self._connection_strings[source_name]
+
+ return await self.client_pool.get_client(
+ connection_string, self._create_server_from_connection_string
+ )
+
+ def _create_server_from_connection_string(
+ self, connection_string: str, limits: httpx.Limits = None
+ ) -> FHIRServerInterface:
+ """
+ Create an async FHIR server instance from a connection string with connection pooling.
+
+ This is used by the client pool to create new server instances.
+
+ Args:
+ connection_string: FHIR connection string
+ limits: httpx connection limits for pooling
+
+ Returns:
+ FHIRServerInterface: A new async FHIR server instance with pooled connections
+ """
+ from healthchain.gateway.clients.fhir.aio.client import create_async_fhir_client
+ from healthchain.gateway.clients.fhir.base import (
+ parse_fhir_auth_connection_string,
+ )
+
+ # Parse connection string as OAuth2.0 configuration
+ auth_config = parse_fhir_auth_connection_string(connection_string)
+
+ # Pass httpx limits for connection pooling
+ return create_async_fhir_client(auth_config=auth_config, limits=limits)
diff --git a/healthchain/gateway/clients/fhir/base.py b/healthchain/gateway/clients/fhir/base.py
new file mode 100644
index 00000000..45bd6c2f
--- /dev/null
+++ b/healthchain/gateway/clients/fhir/base.py
@@ -0,0 +1,386 @@
+import json
+import logging
+import httpx
+
+from abc import ABC, abstractmethod
+from functools import lru_cache
+from typing import Any, Dict, Optional, Type, Union
+from urllib.parse import urlencode, urljoin
+
+from fhir.resources.bundle import Bundle
+from fhir.resources.capabilitystatement import CapabilityStatement
+from fhir.resources.resource import Resource
+
+from healthchain.gateway.clients.auth import OAuth2Config
+from pydantic import BaseModel
+
+
+logger = logging.getLogger(__name__)
+
+
+class FHIRClientError(Exception):
+ """Base exception for FHIR client errors."""
+
+ def __init__(
+ self, message: str, status_code: int = None, response_data: dict = None
+ ):
+ self.status_code = status_code
+ self.response_data = response_data
+ super().__init__(message)
+
+
+class FHIRAuthConfig(BaseModel):
+ """Configuration for FHIR server authentication."""
+
+ # OAuth2 settings
+ client_id: str
+ client_secret: Optional[str] = None # Client secret string for standard flow
+ client_secret_path: Optional[str] = (
+ None # Path to private key file for JWT assertion
+ )
+ token_url: str
+ scope: Optional[str] = "system/*.read system/*.write"
+ audience: Optional[str] = None
+ use_jwt_assertion: bool = False # Use JWT client assertion (Epic/SMART style)
+
+ # Connection settings
+ base_url: str
+ timeout: int = 30
+ verify_ssl: bool = True
+
+ def model_post_init(self, __context) -> None:
+ """Validate that exactly one of client_secret or client_secret_path is provided."""
+ if not self.client_secret and not self.client_secret_path:
+ raise ValueError(
+ "Either client_secret or client_secret_path must be provided"
+ )
+
+ if self.client_secret and self.client_secret_path:
+ raise ValueError("Cannot provide both client_secret and client_secret_path")
+
+ if self.use_jwt_assertion and not self.client_secret_path:
+ raise ValueError(
+ "use_jwt_assertion=True requires client_secret_path to be set"
+ )
+
+ if not self.use_jwt_assertion and self.client_secret_path:
+ raise ValueError(
+ "client_secret_path can only be used with use_jwt_assertion=True"
+ )
+
+ def to_oauth2_config(self) -> OAuth2Config:
+ """Convert to OAuth2Config for token manager."""
+ return OAuth2Config(
+ client_id=self.client_id,
+ client_secret=self.client_secret,
+ client_secret_path=self.client_secret_path,
+ token_url=self.token_url,
+ scope=self.scope,
+ audience=self.audience,
+ use_jwt_assertion=self.use_jwt_assertion,
+ )
+
+ @classmethod
+ def from_env(cls, env_prefix: str) -> "FHIRAuthConfig":
+ """
+ Create FHIRAuthConfig from environment variables.
+
+ Args:
+ env_prefix: Environment variable prefix (e.g., "EPIC")
+
+ Expected environment variables:
+ {env_prefix}_CLIENT_ID
+ {env_prefix}_CLIENT_SECRET (or {env_prefix}_CLIENT_SECRET_PATH)
+ {env_prefix}_TOKEN_URL
+ {env_prefix}_BASE_URL
+ {env_prefix}_SCOPE (optional)
+ {env_prefix}_AUDIENCE (optional)
+ {env_prefix}_TIMEOUT (optional, default: 30)
+ {env_prefix}_VERIFY_SSL (optional, default: true)
+ {env_prefix}_USE_JWT_ASSERTION (optional, default: false)
+
+ Returns:
+ FHIRAuthConfig instance
+
+ Example:
+ # Set environment variables:
+ # EPIC_CLIENT_ID=app123
+ # EPIC_CLIENT_SECRET=secret456
+ # EPIC_TOKEN_URL=https://epic.com/oauth2/token
+ # EPIC_BASE_URL=https://epic.com/api/FHIR/R4
+
+ config = FHIRAuthConfig.from_env("EPIC")
+ """
+ import os
+
+ # Read required environment variables
+ client_id = os.getenv(f"{env_prefix}_CLIENT_ID")
+ client_secret = os.getenv(f"{env_prefix}_CLIENT_SECRET")
+ client_secret_path = os.getenv(f"{env_prefix}_CLIENT_SECRET_PATH")
+ token_url = os.getenv(f"{env_prefix}_TOKEN_URL")
+ base_url = os.getenv(f"{env_prefix}_BASE_URL")
+
+ if not all([client_id, token_url, base_url]):
+ missing = [
+ var
+ for var, val in [
+ (f"{env_prefix}_CLIENT_ID", client_id),
+ (f"{env_prefix}_TOKEN_URL", token_url),
+ (f"{env_prefix}_BASE_URL", base_url),
+ ]
+ if not val
+ ]
+ raise ValueError(f"Missing required environment variables: {missing}")
+
+ # Read optional environment variables
+ scope = os.getenv(f"{env_prefix}_SCOPE", "system/*.read system/*.write")
+ audience = os.getenv(f"{env_prefix}_AUDIENCE")
+ timeout = int(os.getenv(f"{env_prefix}_TIMEOUT", "30"))
+ verify_ssl = os.getenv(f"{env_prefix}_VERIFY_SSL", "true").lower() == "true"
+ use_jwt_assertion = (
+ os.getenv(f"{env_prefix}_USE_JWT_ASSERTION", "false").lower() == "true"
+ )
+
+ return cls(
+ client_id=client_id,
+ client_secret=client_secret,
+ client_secret_path=client_secret_path,
+ token_url=token_url,
+ base_url=base_url,
+ scope=scope,
+ audience=audience,
+ timeout=timeout,
+ verify_ssl=verify_ssl,
+ use_jwt_assertion=use_jwt_assertion,
+ )
+
+ def to_connection_string(self) -> str:
+ """
+ Convert FHIRAuthConfig to connection string format.
+
+ Returns:
+ Connection string in fhir:// format
+
+ Example:
+ config = FHIRAuthConfig(...)
+ connection_string = config.to_connection_string()
+ # Returns: "fhir://hostname/path?client_id=...&token_url=..."
+ """
+ # Extract hostname and path from base_url
+ import urllib.parse
+
+ parsed_base = urllib.parse.urlparse(self.base_url)
+
+ # Build query parameters
+ params = {
+ "client_id": self.client_id,
+ "token_url": self.token_url,
+ }
+
+ # Add secret (either client_secret or client_secret_path)
+ if self.client_secret:
+ params["client_secret"] = self.client_secret
+ elif self.client_secret_path:
+ params["client_secret_path"] = self.client_secret_path
+
+ # Add optional parameters
+ if self.scope:
+ params["scope"] = self.scope
+ if self.audience:
+ params["audience"] = self.audience
+ if self.timeout != 30:
+ params["timeout"] = str(self.timeout)
+ if not self.verify_ssl:
+ params["verify_ssl"] = "false"
+ if self.use_jwt_assertion:
+ params["use_jwt_assertion"] = "true"
+
+ # Build connection string
+ query_string = urllib.parse.urlencode(params)
+ return f"fhir://{parsed_base.netloc}{parsed_base.path}?{query_string}"
+
+
+class FHIRServerInterface(ABC):
+ """
+ Base FHIR client interface with common functionality.
+
+ Provides a standardized interface for interacting with FHIR servers
+ using different client libraries, with shared utility methods.
+ """
+
+ def __init__(self, auth_config: FHIRAuthConfig):
+ """Initialize common client properties."""
+ self.base_url = auth_config.base_url.rstrip("/") + "/"
+ self.timeout = auth_config.timeout
+ self.verify_ssl = auth_config.verify_ssl
+
+ # Setup base headers
+ self.base_headers = {
+ "Accept": "application/fhir+json",
+ "Content-Type": "application/fhir+json",
+ }
+
+ def _build_url(self, path: str, params: Dict[str, Any] = None) -> str:
+ """Build a complete URL with optional query parameters."""
+ url = urljoin(self.base_url, path)
+ if params:
+ # Filter out None values and convert to strings
+ clean_params = {k: str(v) for k, v in params.items() if v is not None}
+ if clean_params:
+ url += "?" + urlencode(clean_params)
+ return url
+
+ def _handle_response(self, response: httpx.Response) -> dict:
+ """Handle HTTP response and convert to dict."""
+ try:
+ data = response.json()
+ except json.JSONDecodeError:
+ raise FHIRClientError(
+ f"Invalid JSON response: {response.text}",
+ status_code=response.status_code,
+ )
+
+ if not response.is_success:
+ error_msg = f"FHIR request failed: {response.status_code}"
+ if isinstance(data, dict) and "issue" in data:
+ # FHIR OperationOutcome format
+ issues = data.get("issue", [])
+ if issues:
+ error_msg += f" - {issues[0].get('diagnostics', 'Unknown error')}"
+
+ raise FHIRClientError(
+ error_msg, status_code=response.status_code, response_data=data
+ )
+
+ return data
+
+ @lru_cache(maxsize=128)
+ def _resolve_resource_type(
+ self, resource_type: Union[str, Type[Resource]]
+ ) -> tuple[str, Type[Resource]]:
+ """
+ Resolve FHIR resource type to string name and class. Cached with LRU.
+
+ Args:
+ resource_type: FHIR resource type or class
+
+ Returns:
+ Tuple of (type_name: str, resource_class: Type[Resource])
+ """
+ if hasattr(resource_type, "__name__"):
+ # It's already a class
+ type_name = resource_type.__name__
+ resource_class = resource_type
+ else:
+ # It's a string, need to dynamically import
+ type_name = str(resource_type)
+ module_name = f"fhir.resources.{type_name.lower()}"
+ module = __import__(module_name, fromlist=[type_name])
+ resource_class = getattr(module, type_name)
+
+ return type_name, resource_class
+
+ @abstractmethod
+ def read(
+ self, resource_type: Union[str, Type[Resource]], resource_id: str
+ ) -> Resource:
+ """Read a specific resource by ID."""
+ pass
+
+ @abstractmethod
+ def create(self, resource: Resource) -> Resource:
+ """Create a new resource."""
+ pass
+
+ @abstractmethod
+ def update(self, resource: Resource) -> Resource:
+ """Update an existing resource."""
+ pass
+
+ @abstractmethod
+ def delete(
+ self, resource_type: Union[str, Type[Resource]], resource_id: str
+ ) -> bool:
+ """Delete a resource."""
+ pass
+
+ @abstractmethod
+ def search(
+ self,
+ resource_type: Union[str, Type[Resource]],
+ params: Optional[Dict[str, Any]] = None,
+ ) -> Bundle:
+ """Search for resources."""
+ pass
+
+ @abstractmethod
+ def transaction(self, bundle: Bundle) -> Bundle:
+ """Execute a transaction bundle."""
+ pass
+
+ @abstractmethod
+ def capabilities(self) -> CapabilityStatement:
+ """Get the capabilities of the FHIR server."""
+ pass
+
+
+def parse_fhir_auth_connection_string(connection_string: str) -> FHIRAuthConfig:
+ """
+ Parse a FHIR connection string into authentication configuration.
+
+ Format: fhir://hostname:port/path?client_id=xxx&client_secret=xxx&token_url=xxx&scope=xxx
+ Or for JWT: fhir://hostname:port/path?client_id=xxx&client_secret_path=xxx&token_url=xxx&use_jwt_assertion=true
+
+ Args:
+ connection_string: FHIR connection string with OAuth2 credentials
+
+ Returns:
+ FHIRAuthConfig with parsed settings
+
+ Raises:
+ ValueError: If connection string is invalid or missing required parameters
+ """
+ import urllib.parse
+
+ if not connection_string.startswith("fhir://"):
+ raise ValueError("Connection string must start with fhir://")
+
+ parsed = urllib.parse.urlparse(connection_string)
+ params = dict(urllib.parse.parse_qsl(parsed.query))
+
+ # Validate required parameters
+ required_params = ["client_id", "token_url"]
+ missing_params = [param for param in required_params if param not in params]
+
+ if missing_params:
+ raise ValueError(f"Missing required parameters: {missing_params}")
+
+ # Check that exactly one of client_secret or client_secret_path is provided
+ has_secret = "client_secret" in params
+ has_secret_path = "client_secret_path" in params
+
+ if not has_secret and not has_secret_path:
+ raise ValueError(
+ "Either 'client_secret' or 'client_secret_path' parameter must be provided"
+ )
+
+ if has_secret and has_secret_path:
+ raise ValueError(
+ "Cannot provide both 'client_secret' and 'client_secret_path' parameters"
+ )
+
+ # Build base URL
+ base_url = f"https://{parsed.netloc}{parsed.path}"
+
+ return FHIRAuthConfig(
+ client_id=params["client_id"],
+ client_secret=params.get("client_secret"),
+ client_secret_path=params.get("client_secret_path"),
+ token_url=params["token_url"],
+ scope=params.get("scope", "system/*.read system/*.write"),
+ audience=params.get("audience"),
+ base_url=base_url,
+ timeout=int(params.get("timeout", 30)),
+ verify_ssl=params.get("verify_ssl", "true").lower() == "true",
+ use_jwt_assertion=params.get("use_jwt_assertion", "false").lower() == "true",
+ )
diff --git a/healthchain/gateway/clients/fhir/sync/__init__.py b/healthchain/gateway/clients/fhir/sync/__init__.py
new file mode 100644
index 00000000..4c191307
--- /dev/null
+++ b/healthchain/gateway/clients/fhir/sync/__init__.py
@@ -0,0 +1,3 @@
+from .client import FHIRClient, create_fhir_client
+
+__all__ = ["FHIRClient", "create_fhir_client"]
diff --git a/healthchain/gateway/clients/fhir/sync/client.py b/healthchain/gateway/clients/fhir/sync/client.py
new file mode 100644
index 00000000..6b0ff16d
--- /dev/null
+++ b/healthchain/gateway/clients/fhir/sync/client.py
@@ -0,0 +1,248 @@
+import logging
+import httpx
+
+from typing import Any, Dict, Type, Union
+
+from fhir.resources.bundle import Bundle
+from fhir.resources.capabilitystatement import CapabilityStatement
+from fhir.resources.resource import Resource
+
+from healthchain.gateway.clients.auth import OAuth2TokenManager
+from healthchain.gateway.clients.fhir.base import FHIRAuthConfig, FHIRServerInterface
+
+
+logger = logging.getLogger(__name__)
+
+
+class FHIRClient(FHIRServerInterface):
+ """
+ Synchronous FHIR client optimized for HealthChain gateway use cases.
+
+ - Uses fhir.resources for validation
+ - Supports JWT Bearer token authentication
+ - Synchronous with httpx
+ """
+
+ def __init__(
+ self,
+ auth_config: FHIRAuthConfig,
+ limits: httpx.Limits = None,
+ **kwargs,
+ ):
+ """
+ Initialize the FHIR client with OAuth2.0 authentication.
+
+ Args:
+ auth_config: OAuth2.0 authentication configuration
+ limits: httpx connection limits for pooling
+ **kwargs: Additional parameters passed to httpx.Client
+ """
+ super().__init__(auth_config)
+ self.token_manager = OAuth2TokenManager(auth_config.to_oauth2_config())
+
+ # Create httpx client with connection pooling and additional kwargs
+ client_kwargs = {"timeout": self.timeout, "verify": self.verify_ssl}
+ if limits is not None:
+ client_kwargs["limits"] = limits
+
+ # Pass through additional kwargs to httpx.Client
+ client_kwargs.update(kwargs)
+
+ self.client = httpx.Client(**client_kwargs)
+
+ def __enter__(self):
+ """Context manager entry."""
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """Context manager exit."""
+ self.close()
+
+ def close(self):
+ """Close the HTTP client."""
+ self.client.close()
+
+ def _get_headers(self) -> Dict[str, str]:
+ """Get headers with fresh OAuth2.0 token."""
+ headers = self.base_headers.copy()
+ token = self.token_manager.get_access_token()
+ headers["Authorization"] = f"Bearer {token}"
+ return headers
+
+ def capabilities(self) -> CapabilityStatement:
+ """
+ Fetch the server's CapabilityStatement.
+
+ Returns:
+ CapabilityStatement resource
+ """
+ headers = self._get_headers()
+ response = self.client.get(self._build_url("metadata"), headers=headers)
+ data = self._handle_response(response)
+ return CapabilityStatement(**data)
+
+ def read(
+ self, resource_type: Union[str, Type[Resource]], resource_id: str
+ ) -> Resource:
+ """
+ Read a specific resource by ID.
+
+ Args:
+ resource_type: FHIR resource type or class
+ resource_id: Resource ID
+
+ Returns:
+ Resource instance
+ """
+ type_name, resource_class = self._resolve_resource_type(resource_type)
+ url = self._build_url(f"{type_name}/{resource_id}")
+ logger.debug(f"Sending GET request to {url}")
+
+ headers = self._get_headers()
+ response = self.client.get(url, headers=headers)
+ data = self._handle_response(response)
+
+ return resource_class(**data)
+
+ def search(
+ self, resource_type: Union[str, Type[Resource]], params: Dict[str, Any] = None
+ ) -> Bundle:
+ """
+ Search for resources.
+
+ Args:
+ resource_type: FHIR resource type or class
+ params: Search parameters
+
+ Returns:
+ Bundle containing search results
+ """
+ type_name, _ = self._resolve_resource_type(resource_type)
+ url = self._build_url(type_name, params)
+ logger.debug(f"Sending GET request to {url}")
+
+ headers = self._get_headers()
+ response = self.client.get(url, headers=headers)
+ data = self._handle_response(response)
+
+ return Bundle(**data)
+
+ def create(self, resource: Resource) -> Resource:
+ """
+ Create a new resource.
+
+ Args:
+ resource: Resource to create
+
+ Returns:
+ Created resource with server-assigned ID
+ """
+ type_name, resource_class = self._resolve_resource_type(
+ resource.__resource_type__
+ )
+ url = self._build_url(type_name)
+ logger.debug(f"Sending POST request to {url}")
+
+ headers = self._get_headers()
+ response = self.client.post(
+ url, content=resource.model_dump_json(), headers=headers
+ )
+ data = self._handle_response(response)
+
+ # Return the same resource type
+ return resource_class(**data)
+
+ def update(self, resource: Resource) -> Resource:
+ """
+ Update an existing resource.
+
+ Args:
+ resource: Resource to update (must have ID)
+
+ Returns:
+ Updated resource
+ """
+ if not resource.id:
+ raise ValueError("Resource must have an ID for update")
+
+ type_name, resource_class = self._resolve_resource_type(
+ resource.__resource_type__
+ )
+ url = self._build_url(f"{type_name}/{resource.id}")
+ logger.debug(f"Sending PUT request to {url}")
+
+ headers = self._get_headers()
+ response = self.client.put(
+ url, content=resource.model_dump_json(), headers=headers
+ )
+ data = self._handle_response(response)
+
+ # Return the same resource type
+ return resource_class(**data)
+
+ def delete(
+ self, resource_type: Union[str, Type[Resource]], resource_id: str
+ ) -> bool:
+ """
+ Delete a resource.
+
+ Args:
+ resource_type: FHIR resource type or class
+ resource_id: Resource ID to delete
+
+ Returns:
+ True if successful
+ """
+ type_name, _ = self._resolve_resource_type(resource_type)
+ url = self._build_url(f"{type_name}/{resource_id}")
+ logger.debug(f"Sending DELETE request to {url}")
+
+ headers = self._get_headers()
+ response = self.client.delete(url, headers=headers)
+
+ # Delete operations typically return 204 No Content
+ if response.status_code in (200, 204):
+ return True
+
+ self._handle_response(response) # This will raise an error
+ return False
+
+ def transaction(self, bundle: Bundle) -> Bundle:
+ """
+ Execute a transaction bundle.
+
+ Args:
+ bundle: Transaction bundle
+
+ Returns:
+ Response bundle
+ """
+ url = self._build_url("") # Base URL for transaction
+ logger.debug(f"Sending POST request to {url}")
+
+ headers = self._get_headers()
+ response = self.client.post(
+ url, content=bundle.model_dump_json(), headers=headers
+ )
+ data = self._handle_response(response)
+
+ return Bundle(**data)
+
+
+def create_fhir_client(
+ auth_config: FHIRAuthConfig,
+ **additional_params,
+) -> "FHIRClient":
+ """
+ Factory function to create and configure a sync FHIR server interface using OAuth2.0
+
+ Args:
+ auth_config: OAuth2.0 authentication configuration
+ **additional_params: Additional parameters for the client
+
+ Returns:
+ A configured sync FHIRClient implementation
+ """
+ logger.debug(f"Creating sync FHIR server with OAuth2.0 for {auth_config.base_url}")
+
+ return FHIRClient(auth_config=auth_config, **additional_params)
diff --git a/healthchain/gateway/core/connection.py b/healthchain/gateway/clients/fhir/sync/connection.py
similarity index 53%
rename from healthchain/gateway/core/connection.py
rename to healthchain/gateway/clients/fhir/sync/connection.py
index 009f18c1..eb70a4f6 100644
--- a/healthchain/gateway/core/connection.py
+++ b/healthchain/gateway/clients/fhir/sync/connection.py
@@ -1,19 +1,10 @@
-"""
-FHIR Connection Management for HealthChain Gateway.
-
-This module provides centralized connection management for FHIR sources,
-including connection string parsing, client pooling, and source configuration.
-"""
-
import logging
import urllib.parse
-from typing import Dict
-import httpx
+from typing import Dict
-from healthchain.gateway.clients.fhir import FHIRServerInterface
-from healthchain.gateway.clients.pool import FHIRClientPool
-from healthchain.gateway.core.errors import FHIRConnectionError
+from healthchain.gateway.clients.fhir.base import FHIRServerInterface
+from healthchain.gateway.fhir.errors import FHIRConnectionError
logger = logging.getLogger(__name__)
@@ -21,33 +12,14 @@
class FHIRConnectionManager:
"""
- Manages FHIR connections and client pooling.
+ Base FHIR connection manager for sync clients.
Handles connection strings, source configuration, and provides
- pooled FHIR clients for efficient resource management.
+ sync FHIR clients.
"""
- def __init__(
- self,
- max_connections: int = 100,
- max_keepalive_connections: int = 20,
- keepalive_expiry: float = 5.0,
- ):
- """
- Initialize the connection manager.
-
- Args:
- max_connections: Maximum total HTTP connections across all sources
- max_keepalive_connections: Maximum keep-alive connections per source
- keepalive_expiry: How long to keep connections alive (seconds)
- """
- # Create httpx-based client pool
- self.client_pool = FHIRClientPool(
- max_connections=max_connections,
- max_keepalive_connections=max_keepalive_connections,
- keepalive_expiry=keepalive_expiry,
- )
-
+ def __init__(self):
+ """Initialize the sync connection manager."""
# Store configuration
self.sources = {}
self._connection_strings = {}
@@ -96,39 +68,15 @@ def add_source(self, name: str, connection_string: str):
state="500",
)
- def _create_server_from_connection_string(
- self, connection_string: str, limits: httpx.Limits = None
- ) -> FHIRServerInterface:
- """
- Create a FHIR server instance from a connection string with connection pooling.
-
- This is used by the client pool to create new server instances.
-
- Args:
- connection_string: FHIR connection string
- limits: httpx connection limits for pooling
-
- Returns:
- FHIRServerInterface: A new FHIR server instance with pooled connections
- """
- from healthchain.gateway.clients import create_fhir_client
- from healthchain.gateway.clients.auth import parse_fhir_auth_connection_string
-
- # Parse connection string as OAuth2.0 configuration
- auth_config = parse_fhir_auth_connection_string(connection_string)
-
- # Pass httpx limits for connection pooling
- return create_fhir_client(auth_config=auth_config, limits=limits)
-
- async def get_client(self, source: str = None) -> FHIRServerInterface:
+ def get_client(self, source: str = None) -> FHIRServerInterface:
"""
- Get a FHIR client for the specified source.
+ Get a sync FHIR client for the specified source.
Args:
source: Source name to get client for (uses first available if None)
Returns:
- FHIRServerInterface: A FHIR client with pooled connections
+ FHIRServerInterface: A sync FHIR client
Raises:
ValueError: If source is unknown or no connection string found
@@ -142,21 +90,23 @@ async def get_client(self, source: str = None) -> FHIRServerInterface:
connection_string = self._connection_strings[source_name]
- return await self.client_pool.get_client(
- connection_string, self._create_server_from_connection_string
- )
+ return self._create_server_from_connection_string(connection_string)
- def get_pool_status(self) -> Dict[str, any]:
+ def get_status(self) -> Dict[str, any]:
"""
- Get the current status of the connection pool.
+ Get the current status of the sync connection manager.
Returns:
- Dict containing pool status information including:
- - max_connections: Maximum connections across all sources
- - sources: Dict of source names and their connection info
- - client_stats: Detailed httpx connection pool statistics
+ Dict containing status information for sync clients.
"""
- return self.client_pool.get_pool_stats()
+ return {
+ "client_type": "sync",
+ "pooling_enabled": False,
+ "sources": {
+ "count": len(self.sources),
+ "configured": list(self.sources.keys()),
+ },
+ }
def get_sources(self) -> Dict[str, any]:
"""
@@ -167,6 +117,24 @@ def get_sources(self) -> Dict[str, any]:
"""
return self.sources.copy()
- async def close(self):
- """Close all connections and clean up resources."""
- await self.client_pool.close_all()
+ def _create_server_from_connection_string(
+ self, connection_string: str
+ ) -> FHIRServerInterface:
+ """
+ Create a sync FHIR server instance from a connection string.
+
+ Args:
+ connection_string: FHIR connection string
+
+ Returns:
+ FHIRServerInterface: A new sync FHIR server instance
+ """
+ from healthchain.gateway.clients.fhir.sync.client import create_fhir_client
+ from healthchain.gateway.clients.fhir.base import (
+ parse_fhir_auth_connection_string,
+ )
+
+ # Parse connection string as OAuth2.0 configuration
+ auth_config = parse_fhir_auth_connection_string(connection_string)
+
+ return create_fhir_client(auth_config=auth_config)
diff --git a/healthchain/gateway/clients/pool.py b/healthchain/gateway/clients/pool.py
index ae9da57d..0c5514cf 100644
--- a/healthchain/gateway/clients/pool.py
+++ b/healthchain/gateway/clients/pool.py
@@ -1,13 +1,15 @@
import httpx
-from typing import Any, Callable, Dict
-from healthchain.gateway.clients import FHIRServerInterface
+from typing import Any, Callable, Dict, TypeVar, Generic
+# Generic client interface type
+ClientInterface = TypeVar("ClientInterface")
-class FHIRClientPool:
+
+class ClientPool(Generic[ClientInterface]):
"""
- Manages FHIR client instances with connection pooling using httpx.
- Handles connection lifecycle, timeouts, and resource cleanup.
+ Generic client pool for managing client instances with connection pooling using httpx.
+ Handles connection lifecycle, timeouts, and resource cleanup for any client type.
"""
def __init__(
@@ -17,14 +19,14 @@ def __init__(
keepalive_expiry: float = 5.0,
):
"""
- Initialize the FHIR client pool.
+ Initialize the client pool.
Args:
max_connections: Maximum number of total connections
max_keepalive_connections: Maximum number of keep-alive connections
keepalive_expiry: How long to keep connections alive (seconds)
"""
- self._clients: Dict[str, FHIRServerInterface] = {}
+ self._clients: Dict[str, ClientInterface] = {}
self._client_limits = httpx.Limits(
max_connections=max_connections,
max_keepalive_connections=max_keepalive_connections,
@@ -33,16 +35,16 @@ def __init__(
async def get_client(
self, connection_string: str, client_factory: Callable
- ) -> FHIRServerInterface:
+ ) -> ClientInterface:
"""
- Get a FHIR client for the given connection string.
+ Get a client for the given connection string.
Args:
- connection_string: FHIR connection string
+ connection_string: Connection string for the client
client_factory: Factory function to create new clients
Returns:
- FHIRServerInterface: A FHIR client with pooled connections
+ ClientInterface: A client with pooled connections
"""
if connection_string not in self._clients:
# Create new client with connection pooling
diff --git a/healthchain/gateway/core/__init__.py b/healthchain/gateway/core/__init__.py
deleted file mode 100644
index e8dab522..00000000
--- a/healthchain/gateway/core/__init__.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""
-Core components for the HealthChain Gateway module.
-
-This module contains the base abstractions and core components
-that define the gateway architecture.
-"""
-
-from .base import BaseGateway, GatewayConfig, EventCapability
-from .connection import FHIRConnectionManager
-from .errors import FHIRErrorHandler, FHIRConnectionError
-from .fhirgateway import FHIRGateway
-
-# Import these if available, but don't error if they're not
-try:
- __all__ = [
- "BaseGateway",
- "GatewayConfig",
- "EventCapability",
- "FHIRConnectionManager",
- "FHIRErrorHandler",
- "FHIRConnectionError",
- "FHIRGateway",
- "EHREvent",
- "SOAPEvent",
- "EHREventType",
- "RequestModel",
- "ResponseModel",
- ]
-except ImportError:
- __all__ = [
- "BaseGateway",
- "GatewayConfig",
- "EventCapability",
- "FHIRConnectionManager",
- "FHIRErrorHandler",
- "FHIRConnectionError",
- "FHIRGateway",
- ]
diff --git a/healthchain/gateway/core/fhirgateway.py b/healthchain/gateway/core/fhirgateway.py
deleted file mode 100644
index e951040b..00000000
--- a/healthchain/gateway/core/fhirgateway.py
+++ /dev/null
@@ -1,913 +0,0 @@
-"""
-FHIR Gateway for HealthChain.
-
-This module provides a specialized FHIR integration hub for data aggregation,
-transformation, and routing.
-"""
-
-import logging
-import inspect
-import warnings
-
-from contextlib import asynccontextmanager
-from typing import (
- Dict,
- List,
- Any,
- Callable,
- Optional,
- TypeVar,
- Type,
-)
-from fastapi import Depends, HTTPException, Query, Path
-from fastapi.responses import JSONResponse
-from datetime import datetime
-
-from fhir.resources.resource import Resource
-from fhir.resources.bundle import Bundle
-from fhir.resources.capabilitystatement import CapabilityStatement
-
-from healthchain.gateway.core.base import BaseGateway
-from healthchain.gateway.core.connection import FHIRConnectionManager
-from healthchain.gateway.core.errors import FHIRErrorHandler
-from healthchain.gateway.events.fhir import create_fhir_event
-from healthchain.gateway.clients.fhir import FHIRServerInterface
-
-
-logger = logging.getLogger(__name__)
-
-# Type variable for FHIR Resource
-T = TypeVar("T", bound=Resource)
-
-
-class FHIRResponse(JSONResponse):
- """
- Custom response class for FHIR resources.
-
- This sets the correct content-type header for FHIR resources.
- """
-
- media_type = "application/fhir+json"
-
-
-class FHIRGateway(BaseGateway):
- """
- FHIR Gateway for HealthChain.
-
- A specialized gateway for FHIR resource operations including:
- - Connection pooling and management
- - Resource transformation and aggregation
- - Event-driven processing
- - OAuth2 authentication support
-
- Example:
- ```python
- # Initialize with connection pooling
- async with FHIRGateway(max_connections=50) as gateway:
- # Add FHIR source
- gateway.add_source("epic", "fhir://epic.org/api/FHIR/R4?...")
-
- # Register transformation handler
- @gateway.transform(Patient)
- async def enhance_patient(id: str, source: str = None) -> Patient:
- async with gateway.modify(Patient, id, source) as patient:
- patient.active = True
- return patient
-
- # Use the gateway
- patient = await gateway.read(Patient, "123", "epic")
- ```
- """
-
- def __init__(
- self,
- sources: Dict[str, FHIRServerInterface] = None,
- prefix: str = "/fhir",
- tags: List[str] = ["FHIR"],
- use_events: bool = True,
- max_connections: int = 100,
- max_keepalive_connections: int = 20,
- keepalive_expiry: float = 5.0,
- **options,
- ):
- """
- Initialize the FHIR Gateway.
-
- Args:
- sources: Dictionary of named FHIR servers or connection strings
- prefix: URL prefix for API routes
- tags: OpenAPI tags
- use_events: Enable event-based processing
- max_connections: Maximum total HTTP connections across all sources
- max_keepalive_connections: Maximum keep-alive connections per source
- keepalive_expiry: How long to keep connections alive (seconds)
- **options: Additional options
- """
- # Initialize as BaseGateway (which includes APIRouter)
- super().__init__(use_events=use_events, prefix=prefix, tags=tags, **options)
-
- self.use_events = use_events
-
- # Create connection manager
- self.connection_manager = FHIRConnectionManager(
- max_connections=max_connections,
- max_keepalive_connections=max_keepalive_connections,
- keepalive_expiry=keepalive_expiry,
- )
-
- # Add sources if provided
- if sources:
- for name, source in sources.items():
- if isinstance(source, str):
- self.connection_manager.add_source(name, source)
- else:
- self.connection_manager.sources[name] = source
-
- # Handlers for resource operations
- self._resource_handlers: Dict[str, Dict[str, Callable]] = {}
-
- # Register base routes only (metadata endpoint)
- self._register_base_routes()
-
- def _get_gateway_dependency(self):
- """Create a dependency function that returns this gateway instance."""
-
- def get_self_gateway():
- return self
-
- return get_self_gateway
-
- def _get_resource_name(self, resource_type: Type[Resource]) -> str:
- """Extract resource name from resource type."""
- return resource_type.__resource_type__
-
- def _register_base_routes(self):
- """Register basic endpoints"""
- get_self_gateway = self._get_gateway_dependency()
-
- # FHIR Metadata endpoint - returns CapabilityStatement
- @self.get("/metadata", response_class=FHIRResponse)
- def capability_statement(
- fhir: "FHIRGateway" = Depends(get_self_gateway),
- ):
- """Return the FHIR capability statement for this gateway's services."""
- return fhir.build_capability_statement().model_dump()
-
- # Gateway status endpoint - returns operational metadata
- @self.get("/status", response_class=JSONResponse)
- def gateway_status(
- fhir: "FHIRGateway" = Depends(get_self_gateway),
- ):
- """Return operational status and metadata for this gateway."""
- return fhir.get_gateway_status()
-
- def build_capability_statement(self) -> CapabilityStatement:
- """
- Build a FHIR CapabilityStatement for this gateway's value-add services.
-
- Only includes resources and operations that this gateway provides through
- its transform/aggregate endpoints, not the underlying FHIR sources.
-
- Returns:
- CapabilityStatement: FHIR-compliant capability statement
- """
- # Build resource entries based on registered handlers
- resources = []
- for resource_type, operations in self._resource_handlers.items():
- interactions = []
-
- # Add supported interactions based on registered handlers
- for operation in operations:
- if operation == "transform":
- interactions.append(
- {"code": "read"}
- ) # Transform requires read access
- elif operation == "aggregate":
- interactions.append(
- {"code": "search-type"}
- ) # Aggregate is like search
-
- if interactions:
- # Extract the resource name from the resource type class
- resource_name = self._get_resource_name(resource_type)
- resources.append(
- {
- "type": resource_name,
- "interaction": interactions,
- "documentation": f"Gateway provides {', '.join(operations)} operations for {resource_name}",
- }
- )
-
- capability_data = {
- "resourceType": "CapabilityStatement",
- "status": "active",
- "date": datetime.now().strftime("%Y-%m-%d"),
- "publisher": "HealthChain",
- "kind": "instance",
- "software": {
- "name": "HealthChain FHIR Gateway",
- "version": "1.0.0", # TODO: Extract from package
- },
- "fhirVersion": "4.0.1",
- "format": ["application/fhir+json"],
- "rest": [
- {
- "mode": "server",
- "documentation": "HealthChain FHIR Gateway provides transformation and aggregation services",
- "resource": resources,
- }
- ]
- if resources
- else [],
- }
-
- return CapabilityStatement(**capability_data)
-
- @property
- def supported_resources(self) -> List[str]:
- """Get list of supported FHIR resource types."""
- return [
- self._get_resource_name(resource_type)
- for resource_type in self._resource_handlers.keys()
- ]
-
- def get_capabilities(self) -> List[str]:
- """
- Get list of supported FHIR operations and resources.
-
- Returns:
- List of capabilities this gateway supports
- """
- capabilities = []
- for resource_type, operations in self._resource_handlers.items():
- resource_name = self._get_resource_name(resource_type)
- for operation in operations:
- capabilities.append(f"{operation}:{resource_name}")
- return capabilities
-
- def get_gateway_status(self) -> Dict[str, Any]:
- """
- Get operational status and metadata for this gateway.
-
- This provides gateway-specific operational information.
-
- Returns:
- Dict containing gateway operational status and metadata
- """
- status = {
- "gateway_type": "FHIRGateway",
- "version": "1.0.0", # TODO: Extract from package
- "status": "active",
- "timestamp": datetime.now().isoformat() + "Z",
- "sources": {
- "count": len(self.connection_manager.sources),
- "names": list(self.connection_manager.sources.keys()),
- },
- "connection_pool": self.get_pool_status(),
- "supported_operations": {
- "resources": self.supported_resources,
- "operations": self.get_capabilities(),
- "endpoints": {
- "transform": len(
- [
- r
- for r, ops in self._resource_handlers.items()
- if "transform" in ops
- ]
- ),
- "aggregate": len(
- [
- r
- for r, ops in self._resource_handlers.items()
- if "aggregate" in ops
- ]
- ),
- },
- },
- "events": {
- "enabled": self.use_events,
- "dispatcher_configured": self.events.dispatcher is not None,
- },
- }
-
- return status
-
- def _register_resource_handler(
- self,
- resource_type: Type[Resource],
- operation: str,
- handler: Callable,
- ) -> None:
- """Register a custom handler for a resource operation."""
- self._validate_handler_annotations(resource_type, operation, handler)
-
- if resource_type not in self._resource_handlers:
- self._resource_handlers[resource_type] = {}
- self._resource_handlers[resource_type][operation] = handler
-
- resource_name = self._get_resource_name(resource_type)
- logger.debug(
- f"Registered {operation} handler for {resource_name}: {handler.__name__}"
- )
-
- self._register_operation_route(resource_type, operation)
-
- def _validate_handler_annotations(
- self,
- resource_type: Type[Resource],
- operation: str,
- handler: Callable,
- ) -> None:
- """Validate that handler annotations match the decorator resource type."""
- if operation != "transform":
- return
-
- try:
- sig = inspect.signature(handler)
- return_annotation = sig.return_annotation
-
- if return_annotation == inspect.Parameter.empty:
- warnings.warn(
- f"Handler {handler.__name__} missing return type annotation for {resource_type.__name__}"
- )
- return
-
- if return_annotation != resource_type:
- raise TypeError(
- f"Handler {handler.__name__} return type ({return_annotation}) "
- f"doesn't match decorator resource type ({resource_type})"
- )
-
- except Exception as e:
- if isinstance(e, TypeError):
- raise
- logger.warning(f"Could not validate handler annotations: {str(e)}")
-
- def _register_operation_route(
- self, resource_type: Type[Resource], operation: str
- ) -> None:
- """Register a route for a specific resource type and operation."""
- resource_name = self._get_resource_name(resource_type)
-
- if operation == "transform":
- path = f"/transform/{resource_name}/{{id}}"
- summary = f"Transform {resource_name}"
- description = (
- f"Transform a {resource_name} resource with registered handler"
- )
- elif operation == "aggregate":
- path = f"/aggregate/{resource_name}"
- summary = f"Aggregate {resource_name}"
- description = f"Aggregate {resource_name} resources from multiple sources"
- else:
- raise ValueError(f"Unsupported operation: {operation}")
-
- handler = self._create_route_handler(resource_type, operation)
-
- self.add_api_route(
- path=path,
- endpoint=handler,
- methods=["GET"],
- summary=summary,
- description=description,
- response_model_exclude_none=True,
- response_class=FHIRResponse,
- tags=self.tags,
- include_in_schema=True,
- )
- logger.debug(f"Registered {operation} endpoint: {self.prefix}{path}")
-
- def _create_route_handler(
- self, resource_type: Type[Resource], operation: str
- ) -> Callable:
- """Create a route handler for the given resource type and operation."""
- get_self_gateway = self._get_gateway_dependency()
-
- def _execute_handler(fhir: "FHIRGateway", *args) -> Any:
- """Common handler execution logic with error handling."""
- try:
- handler_func = fhir._resource_handlers[resource_type][operation]
- result = handler_func(*args)
- return result
- except Exception as e:
- logger.error(f"Error in {operation} handler: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
-
- if operation == "transform":
-
- async def handler(
- id: str = Path(..., description="Resource ID to transform"),
- source: Optional[str] = Query(
- None, description="Source system to retrieve the resource from"
- ),
- fhir: "FHIRGateway" = Depends(get_self_gateway),
- ):
- """Transform a resource with registered handler."""
- return _execute_handler(fhir, id, source)
-
- elif operation == "aggregate":
-
- async def handler(
- id: Optional[str] = Query(None, description="ID to aggregate data for"),
- sources: Optional[List[str]] = Query(
- None, description="List of source names to query"
- ),
- fhir: "FHIRGateway" = Depends(get_self_gateway),
- ):
- """Aggregate resources with registered handler."""
- return _execute_handler(fhir, id, sources)
-
- else:
- raise ValueError(f"Unsupported operation: {operation}")
-
- return handler
-
- def add_source(self, name: str, connection_string: str) -> None:
- """
- Add a FHIR data source using connection string with OAuth2.0 flow.
-
- Format: fhir://hostname:port/path?param1=value1¶m2=value2
-
- Examples:
- fhir://epic.org/api/FHIR/R4?client_id=my_app&client_secret=secret&token_url=https://epic.org/oauth2/token&scope=system/*.read
- fhir://cerner.org/r4?client_id=app_id&client_secret=app_secret&token_url=https://cerner.org/token&audience=https://cerner.org/fhir
- """
- return self.connection_manager.add_source(name, connection_string)
-
- async def get_client(self, source: str = None) -> FHIRServerInterface:
- """
- Get a FHIR client for the specified source.
-
- Args:
- source: Source name to get client for (uses first available if None)
-
- Returns:
- FHIRServerInterface: A FHIR client with pooled connections
-
- Raises:
- ValueError: If source is unknown or no connection string found
- """
- return await self.connection_manager.get_client(source)
-
- async def capabilities(self, source: str = None) -> CapabilityStatement:
- """
- Get the capabilities of the FHIR server.
-
- Args:
- source: Source name to get capabilities for (uses first available if None)
-
- Returns:
- CapabilityStatement: The capabilities of the FHIR server
-
- Raises:
- FHIRConnectionError: If connection fails
- """
- capabilities = await self._execute_with_client(
- "capabilities",
- source=source,
- resource_type=CapabilityStatement,
- )
-
- # Emit capabilities event
- self._emit_fhir_event("capabilities", "CapabilityStatement", None, capabilities)
- logger.debug("Retrieved server capabilities")
-
- return capabilities
-
- async def read(
- self,
- resource_type: Type[Resource],
- fhir_id: str,
- source: str = None,
- ) -> Resource:
- """
- Read a FHIR resource.
-
- Args:
- resource_type: The FHIR resource type class
- fhir_id: Resource ID to fetch
- source: Source name to fetch from (uses first available if None)
-
- Returns:
- The FHIR resource object
-
- Raises:
- ValueError: If resource not found or source invalid
- FHIRConnectionError: If connection fails
-
- Example:
- # Simple read-only access
- document = await fhir_gateway.read(DocumentReference, "123", "epic")
- summary = extract_summary(document.text)
- """
- resource = await self._execute_with_client(
- "read",
- source=source,
- resource_type=resource_type,
- resource_id=fhir_id,
- client_args=(resource_type, fhir_id),
- )
-
- if not resource:
- type_name = resource_type.__resource_type__
- raise ValueError(f"Resource {type_name}/{fhir_id} not found")
-
- # Emit read event
- type_name = resource.__resource_type__
- self._emit_fhir_event("read", type_name, fhir_id, resource)
- logger.debug(f"Retrieved {type_name}/{fhir_id} for read-only access")
-
- return resource
-
- async def search(
- self,
- resource_type: Type[Resource],
- params: Dict[str, Any] = None,
- source: str = None,
- ) -> Bundle:
- """
- Search for FHIR resources.
-
- Args:
- resource_type: The FHIR resource type class
- params: Search parameters (e.g., {"name": "Smith", "active": "true"})
- source: Source name to search in (uses first available if None)
-
- Returns:
- Bundle containing search results
-
- Raises:
- ValueError: If source is invalid
- FHIRConnectionError: If connection fails
-
- Example:
- # Search for patients by name
- bundle = await fhir_gateway.search(Patient, {"name": "Smith"}, "epic")
- for entry in bundle.entry or []:
- patient = entry.resource
- print(f"Found patient: {patient.name[0].family}")
- """
- bundle = await self._execute_with_client(
- "search",
- source=source,
- resource_type=resource_type,
- client_args=(resource_type,),
- client_kwargs={"params": params},
- )
-
- # Emit search event with result count
- type_name = resource_type.__resource_type__
- event_data = {
- "params": params,
- "result_count": len(bundle.entry) if bundle.entry else 0,
- }
- self._emit_fhir_event("search", type_name, None, event_data)
- logger.debug(
- f"Searched {type_name} with params {params}, found {len(bundle.entry) if bundle.entry else 0} results"
- )
-
- return bundle
-
- async def create(self, resource: Resource, source: str = None) -> Resource:
- """
- Create a new FHIR resource.
-
- Args:
- resource: The FHIR resource to create
- source: Source name to create in (uses first available if None)
-
- Returns:
- The created FHIR resource with server-assigned ID
-
- Raises:
- ValueError: If source is invalid
- FHIRConnectionError: If connection fails
-
- Example:
- # Create a new patient
- patient = Patient(name=[HumanName(family="Smith", given=["John"])])
- created = await fhir_gateway.create(patient, "epic")
- print(f"Created patient with ID: {created.id}")
- """
- created = await self._execute_with_client(
- "create",
- source=source,
- resource_type=resource.__class__,
- client_args=(resource,),
- )
-
- # Emit create event
- type_name = resource.__resource_type__
- self._emit_fhir_event("create", type_name, created.id, created)
- logger.debug(f"Created {type_name} resource with ID {created.id}")
-
- return created
-
- async def update(self, resource: Resource, source: str = None) -> Resource:
- """
- Update an existing FHIR resource.
-
- Args:
- resource: The FHIR resource to update (must have ID)
- source: Source name to update in (uses first available if None)
-
- Returns:
- The updated FHIR resource
-
- Raises:
- ValueError: If resource has no ID or source is invalid
- FHIRConnectionError: If connection fails
-
- Example:
- # Update a patient's name
- patient = await fhir_gateway.read(Patient, "123", "epic")
- patient.name[0].family = "Jones"
- updated = await fhir_gateway.update(patient, "epic")
- """
- if not resource.id:
- raise ValueError("Resource must have an ID for update")
-
- updated = await self._execute_with_client(
- "update",
- source=source,
- resource_type=resource.__class__,
- resource_id=resource.id,
- client_args=(resource,),
- )
-
- # Emit update event
- type_name = resource.__resource_type__
- self._emit_fhir_event("update", type_name, resource.id, updated)
- logger.debug(f"Updated {type_name} resource with ID {resource.id}")
-
- return updated
-
- async def delete(
- self, resource_type: Type[Resource], fhir_id: str, source: str = None
- ) -> bool:
- """
- Delete a FHIR resource.
-
- Args:
- resource_type: The FHIR resource type class
- fhir_id: Resource ID to delete
- source: Source name to delete from (uses first available if None)
-
- Returns:
- True if deletion was successful
-
- Raises:
- ValueError: If source is invalid
- FHIRConnectionError: If connection fails
-
- Example:
- # Delete a patient
- success = await fhir_gateway.delete(Patient, "123", "epic")
- if success:
- print("Patient deleted successfully")
- """
- success = await self._execute_with_client(
- "delete",
- source=source,
- resource_type=resource_type,
- resource_id=fhir_id,
- client_args=(resource_type, fhir_id),
- )
-
- if success:
- # Emit delete event
- type_name = resource_type.__resource_type__
- self._emit_fhir_event("delete", type_name, fhir_id, None)
- logger.debug(f"Deleted {type_name} resource with ID {fhir_id}")
-
- return success
-
- async def transaction(self, bundle: Bundle, source: str = None) -> Bundle:
- """
- Execute a FHIR transaction bundle.
-
- Args:
- bundle: The transaction bundle to execute
- source: Source name to execute in (uses first available if None)
-
- Returns:
- The response bundle with results
-
- Raises:
- ValueError: If source is invalid
- FHIRConnectionError: If connection fails
-
- Example:
- # Create a transaction bundle
- bundle = Bundle(type="transaction", entry=[
- BundleEntry(resource=patient1, request=BundleRequest(method="POST")),
- BundleEntry(resource=patient2, request=BundleRequest(method="POST"))
- ])
- result = await fhir_gateway.transaction(bundle, "epic")
- """
- result = await self._execute_with_client(
- "transaction",
- source=source,
- resource_type=Bundle,
- client_args=(bundle,),
- )
-
- # Emit transaction event with entry counts
- event_data = {
- "entry_count": len(bundle.entry) if bundle.entry else 0,
- "result_count": len(result.entry) if result.entry else 0,
- }
- self._emit_fhir_event("transaction", "Bundle", None, event_data)
- logger.debug(
- f"Executed transaction bundle with {len(bundle.entry) if bundle.entry else 0} entries"
- )
-
- return result
-
- @asynccontextmanager
- async def modify(
- self, resource_type: Type[Resource], fhir_id: str = None, source: str = None
- ):
- """
- Context manager for working with FHIR resources.
-
- Automatically handles fetching, updating, and error handling using connection pooling.
-
- Args:
- resource_type: The FHIR resource type class (e.g. Patient)
- fhir_id: Resource ID (if None, creates a new resource)
- source: Source name to use (uses first available if None)
-
- Yields:
- Resource: The FHIR resource object
-
- Raises:
- FHIRConnectionError: If connection fails
- ValueError: If resource type is invalid
- """
- client = await self.get_client(source)
- resource = None
- is_new = fhir_id is None
-
- # Get type name for error messages
- type_name = resource_type.__resource_type__
-
- try:
- if is_new:
- resource = resource_type()
- else:
- resource = await client.read(resource_type, fhir_id)
- logger.debug(f"Retrieved {type_name}/{fhir_id} in modify context")
- self._emit_fhir_event("read", type_name, fhir_id, resource)
-
- yield resource
-
- if is_new:
- updated_resource = await client.create(resource)
- else:
- updated_resource = await client.update(resource)
-
- resource.id = updated_resource.id
- for field_name, field_value in updated_resource.model_dump().items():
- if hasattr(resource, field_name):
- setattr(resource, field_name, field_value)
-
- operation = "create" if is_new else "update"
- self._emit_fhir_event(operation, type_name, resource.id, updated_resource)
- logger.debug(
- f"{'Created' if is_new else 'Updated'} {type_name} resource in modify context"
- )
-
- except Exception as e:
- operation = (
- "read"
- if not is_new and resource is None
- else "create"
- if is_new
- else "update"
- )
- FHIRErrorHandler.handle_fhir_error(e, type_name, fhir_id, operation)
-
- def aggregate(self, resource_type: Type[Resource]):
- """
- Decorator for custom aggregation functions.
-
- Args:
- resource_type: The FHIR resource type class that this handler aggregates
-
- Example:
- @fhir_gateway.aggregate(Patient)
- def aggregate_patients(id: str = None, sources: List[str] = None) -> List[Patient]:
- # Handler implementation
- pass
- """
-
- def decorator(handler: Callable):
- self._register_resource_handler(resource_type, "aggregate", handler)
- return handler
-
- return decorator
-
- def transform(self, resource_type: Type[Resource]):
- """
- Decorator for custom transformation functions.
-
- Args:
- resource_type: The FHIR resource type class that this handler transforms
-
- Example:
- @fhir_gateway.transform(DocumentReference)
- def transform_document(id: str, source: str = None) -> DocumentReference:
- # Handler implementation
- pass
- """
-
- def decorator(handler: Callable):
- self._register_resource_handler(resource_type, "transform", handler)
- return handler
-
- return decorator
-
- def _emit_fhir_event(
- self, operation: str, resource_type: str, resource_id: str, resource: Any = None
- ):
- """
- Emit an event for FHIR operations.
-
- Args:
- operation: The FHIR operation (read, search, create, update, delete)
- resource_type: The FHIR resource type
- resource_id: The resource ID
- resource: The resource object or data
- """
- self.events.emit_event(
- create_fhir_event,
- operation,
- resource_type,
- resource_id,
- resource,
- use_events=self.use_events,
- )
-
- def get_pool_status(self) -> Dict[str, Any]:
- """
- Get the current status of the connection pool.
-
- Returns:
- Dict containing pool status information including:
- - max_connections: Maximum connections across all sources
- - sources: Dict of source names and their connection info
- - client_stats: Detailed httpx connection pool statistics
- """
- return self.connection_manager.get_pool_status()
-
- async def close(self):
- """Close all connections and clean up resources."""
- await self.connection_manager.close()
-
- async def __aenter__(self):
- """Async context manager entry."""
- return self
-
- async def __aexit__(self, exc_type, exc_val, exc_tb):
- """Async context manager exit."""
- await self.close()
-
- async def _execute_with_client(
- self,
- operation: str,
- *, # Force keyword-only arguments
- source: str = None,
- resource_type: Type[Resource] = None,
- resource_id: str = None,
- client_args: tuple = (),
- client_kwargs: dict = None,
- ):
- """
- Execute a client operation with consistent error handling.
-
- Args:
- operation: Operation name (read, create, update, delete, etc.)
- source: Source name to use
- resource_type: Resource type for error handling
- resource_id: Resource ID for error handling (if applicable)
- client_args: Positional arguments to pass to the client method
- client_kwargs: Keyword arguments to pass to the client method
- """
- client = await self.get_client(source)
- client_kwargs = client_kwargs or {}
-
- try:
- result = await getattr(client, operation)(*client_args, **client_kwargs)
- return result
-
- except Exception as e:
- # Use existing error handler
- error_resource_type = resource_type or (
- client_args[0].__class__
- if client_args and hasattr(client_args[0], "__class__")
- else None
- )
- FHIRErrorHandler.handle_fhir_error(
- e, error_resource_type, resource_id, operation
- )
diff --git a/healthchain/gateway/events/__init__.py b/healthchain/gateway/events/__init__.py
index ba674dc0..e6987d90 100644
--- a/healthchain/gateway/events/__init__.py
+++ b/healthchain/gateway/events/__init__.py
@@ -2,7 +2,7 @@
Event handling system for the HealthChain Gateway.
This module provides event dispatching and handling functionality for
-asynchronous communication between healthcare systems.
+communication between healthcare systems.
"""
from .dispatcher import EventDispatcher, EHREvent, EHREventType
diff --git a/healthchain/gateway/fhir/__init__.py b/healthchain/gateway/fhir/__init__.py
new file mode 100644
index 00000000..ea6824f0
--- /dev/null
+++ b/healthchain/gateway/fhir/__init__.py
@@ -0,0 +1,12 @@
+from .base import BaseFHIRGateway
+from .aio import AsyncFHIRGateway
+from .sync import FHIRGateway
+from .errors import FHIRErrorHandler, FHIRConnectionError
+
+__all__ = [
+ "BaseFHIRGateway",
+ "AsyncFHIRGateway",
+ "FHIRGateway",
+ "FHIRErrorHandler",
+ "FHIRConnectionError",
+]
diff --git a/healthchain/gateway/fhir/aio.py b/healthchain/gateway/fhir/aio.py
new file mode 100644
index 00000000..ef150688
--- /dev/null
+++ b/healthchain/gateway/fhir/aio.py
@@ -0,0 +1,498 @@
+import logging
+
+from contextlib import asynccontextmanager
+from typing import Any, Dict, Type
+
+from fhir.resources.bundle import Bundle
+from fhir.resources.capabilitystatement import CapabilityStatement
+from fhir.resources.resource import Resource
+
+from healthchain.gateway.clients.fhir.base import FHIRServerInterface
+from healthchain.gateway.clients.fhir.aio.connection import AsyncFHIRConnectionManager
+from healthchain.gateway.fhir.errors import FHIRErrorHandler
+from healthchain.gateway.fhir.base import BaseFHIRGateway
+from healthchain.gateway.events.fhir import create_fhir_event
+
+
+logger = logging.getLogger(__name__)
+
+
+class AsyncFHIRGateway(BaseFHIRGateway):
+ """
+ Async FHIR Gateway for HealthChain.
+
+ A specialized async gateway for FHIR resource operations including:
+ - Connection pooling and management
+ - Resource transformation and aggregation
+ - Event-driven processing
+ - OAuth2 authentication support
+
+ Example:
+ ```python
+ # Initialize with connection pooling
+ async with AsyncFHIRGateway(max_connections=50) as gateway:
+ # Add FHIR source
+ gateway.add_source("epic", "fhir://epic.org/api/FHIR/R4?...")
+
+ # Use the gateway
+ patient = await gateway.read(Patient, "123", "epic")
+ ```
+ """
+
+ def __init__(
+ self,
+ max_connections: int = 100,
+ max_keepalive_connections: int = 20,
+ keepalive_expiry: float = 5.0,
+ **kwargs,
+ ):
+ """Initialize the Async FHIR Gateway."""
+ super().__init__(**kwargs)
+
+ # Initialize async connection manager with pooling
+ self.connection_manager = AsyncFHIRConnectionManager(
+ max_connections=max_connections,
+ max_keepalive_connections=max_keepalive_connections,
+ keepalive_expiry=keepalive_expiry,
+ )
+
+ # Initialize sources
+ self._initialize_connection_manager()
+
+ async def __aenter__(self):
+ """Async context manager entry."""
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ """Async context manager exit."""
+ await self.close()
+
+ async def close(self):
+ """Close all connections and clean up resources."""
+ await self.connection_manager.close()
+
+ async def get_client(self, source: str = None) -> FHIRServerInterface:
+ """
+ Get a FHIR client for the specified source.
+
+ Args:
+ source: Source name to get client for (uses first available if None)
+
+ Returns:
+ FHIRServerInterface: A FHIR client with pooled connections
+ """
+ return await self.connection_manager.get_client(source)
+
+ def get_pool_status(self) -> Dict[str, Any]:
+ """
+ Get the current status of the connection pool.
+
+ Returns:
+ Dict containing pool status information including:
+ - max_connections: Maximum connections across all sources
+ - sources: Dict of source names and their connection info
+ - client_stats: Detailed httpx connection pool statistics
+ """
+ return self.connection_manager.get_status()
+
+ async def capabilities(self, source: str = None) -> CapabilityStatement:
+ """
+ Get the capabilities of the FHIR server.
+
+ Args:
+ source: Source name to get capabilities for (uses first available if None)
+
+ Returns:
+ CapabilityStatement: The capabilities of the FHIR server
+
+ Raises:
+ FHIRConnectionError: If connection fails
+ """
+ capabilities = await self._execute_with_client(
+ "capabilities",
+ source=source,
+ resource_type=CapabilityStatement,
+ )
+
+ # Emit capabilities event
+ self._emit_fhir_event("capabilities", "CapabilityStatement", None, capabilities)
+ logger.debug("Retrieved server capabilities")
+
+ return capabilities
+
+ async def read(
+ self,
+ resource_type: Type[Resource],
+ fhir_id: str,
+ source: str = None,
+ ) -> Resource:
+ """
+ Read a FHIR resource.
+
+ Args:
+ resource_type: The FHIR resource type class
+ fhir_id: Resource ID to fetch
+ source: Source name to fetch from (uses first available if None)
+
+ Returns:
+ The FHIR resource object
+
+ Raises:
+ ValueError: If resource not found or source invalid
+ FHIRConnectionError: If connection fails
+
+ Example:
+ # Simple read-only access
+ document = await fhir_gateway.read(DocumentReference, "123", "epic")
+ summary = extract_summary(document.text)
+ """
+ resource = await self._execute_with_client(
+ "read",
+ source=source,
+ resource_type=resource_type,
+ resource_id=fhir_id,
+ client_args=(resource_type, fhir_id),
+ )
+
+ if not resource:
+ type_name = resource_type.__resource_type__
+ raise ValueError(f"Resource {type_name}/{fhir_id} not found")
+
+ # Emit read event
+ type_name = resource.__resource_type__
+ self._emit_fhir_event("read", type_name, fhir_id, resource)
+ logger.debug(f"Retrieved {type_name}/{fhir_id} for read-only access")
+
+ return resource
+
+ async def search(
+ self,
+ resource_type: Type[Resource],
+ params: Dict[str, Any] = None,
+ source: str = None,
+ ) -> Bundle:
+ """
+ Search for FHIR resources.
+
+ Args:
+ resource_type: The FHIR resource type class
+ params: Search parameters (e.g., {"name": "Smith", "active": "true"})
+ source: Source name to search in (uses first available if None)
+
+ Returns:
+ Bundle containing search results
+
+ Raises:
+ ValueError: If source is invalid
+ FHIRConnectionError: If connection fails
+
+ Example:
+ # Search for patients by name
+ bundle = await fhir_gateway.search(Patient, {"name": "Smith"}, "epic")
+ for entry in bundle.entry or []:
+ patient = entry.resource
+ print(f"Found patient: {patient.name[0].family}")
+ """
+ bundle = await self._execute_with_client(
+ "search",
+ source=source,
+ resource_type=resource_type,
+ client_args=(resource_type,),
+ client_kwargs={"params": params},
+ )
+
+ # Emit search event with result count
+ type_name = resource_type.__resource_type__
+ event_data = {
+ "params": params,
+ "result_count": len(bundle.entry) if bundle.entry else 0,
+ }
+ self._emit_fhir_event("search", type_name, None, event_data)
+ logger.debug(
+ f"Searched {type_name} with params {params}, found {len(bundle.entry) if bundle.entry else 0} results"
+ )
+
+ return bundle
+
+ async def create(self, resource: Resource, source: str = None) -> Resource:
+ """
+ Create a new FHIR resource.
+
+ Args:
+ resource: The FHIR resource to create
+ source: Source name to create in (uses first available if None)
+
+ Returns:
+ The created FHIR resource with server-assigned ID
+
+ Raises:
+ ValueError: If source is invalid
+ FHIRConnectionError: If connection fails
+
+ Example:
+ # Create a new patient
+ patient = Patient(name=[HumanName(family="Smith", given=["John"])])
+ created = await fhir_gateway.create(patient, "epic")
+ print(f"Created patient with ID: {created.id}")
+ """
+ created = await self._execute_with_client(
+ "create",
+ source=source,
+ resource_type=resource.__class__,
+ client_args=(resource,),
+ )
+
+ # Emit create event
+ type_name = resource.__resource_type__
+ self._emit_fhir_event("create", type_name, created.id, created)
+ logger.debug(f"Created {type_name} resource with ID {created.id}")
+
+ return created
+
+ async def update(self, resource: Resource, source: str = None) -> Resource:
+ """
+ Update an existing FHIR resource.
+
+ Args:
+ resource: The FHIR resource to update (must have ID)
+ source: Source name to update in (uses first available if None)
+
+ Returns:
+ The updated FHIR resource
+
+ Raises:
+ ValueError: If resource has no ID or source is invalid
+ FHIRConnectionError: If connection fails
+
+ Example:
+ # Update a patient's name
+ patient = await fhir_gateway.read(Patient, "123", "epic")
+ patient.name[0].family = "Jones"
+ updated = await fhir_gateway.update(patient, "epic")
+ """
+ if not resource.id:
+ raise ValueError("Resource must have an ID for update")
+
+ updated = await self._execute_with_client(
+ "update",
+ source=source,
+ resource_type=resource.__class__,
+ resource_id=resource.id,
+ client_args=(resource,),
+ )
+
+ # Emit update event
+ type_name = resource.__resource_type__
+ self._emit_fhir_event("update", type_name, resource.id, updated)
+ logger.debug(f"Updated {type_name} resource with ID {resource.id}")
+
+ return updated
+
+ async def delete(
+ self, resource_type: Type[Resource], fhir_id: str, source: str = None
+ ) -> bool:
+ """
+ Delete a FHIR resource.
+
+ Args:
+ resource_type: The FHIR resource type class
+ fhir_id: Resource ID to delete
+ source: Source name to delete from (uses first available if None)
+
+ Returns:
+ True if deletion was successful
+
+ Raises:
+ ValueError: If source is invalid
+ FHIRConnectionError: If connection fails
+
+ Example:
+ # Delete a patient
+ success = await fhir_gateway.delete(Patient, "123", "epic")
+ if success:
+ print("Patient deleted successfully")
+ """
+ success = await self._execute_with_client(
+ "delete",
+ source=source,
+ resource_type=resource_type,
+ resource_id=fhir_id,
+ client_args=(resource_type, fhir_id),
+ )
+
+ if success:
+ # Emit delete event
+ type_name = resource_type.__resource_type__
+ self._emit_fhir_event("delete", type_name, fhir_id, None)
+ logger.debug(f"Deleted {type_name} resource with ID {fhir_id}")
+
+ return success
+
+ async def transaction(self, bundle: Bundle, source: str = None) -> Bundle:
+ """
+ Execute a FHIR transaction bundle.
+
+ Args:
+ bundle: The transaction bundle to execute
+ source: Source name to execute in (uses first available if None)
+
+ Returns:
+ The response bundle with results
+
+ Raises:
+ ValueError: If source is invalid
+ FHIRConnectionError: If connection fails
+
+ Example:
+ # Create a transaction bundle
+ bundle = Bundle(type="transaction", entry=[
+ BundleEntry(resource=patient1, request=BundleRequest(method="POST")),
+ BundleEntry(resource=patient2, request=BundleRequest(method="POST"))
+ ])
+ result = await fhir_gateway.transaction(bundle, "epic")
+ """
+ result = await self._execute_with_client(
+ "transaction",
+ source=source,
+ resource_type=Bundle,
+ client_args=(bundle,),
+ )
+
+ # Emit transaction event with entry counts
+ event_data = {
+ "entry_count": len(bundle.entry) if bundle.entry else 0,
+ "result_count": len(result.entry) if result.entry else 0,
+ }
+ self._emit_fhir_event("transaction", "Bundle", None, event_data)
+ logger.debug(
+ f"Executed transaction bundle with {len(bundle.entry) if bundle.entry else 0} entries"
+ )
+
+ return result
+
+ @asynccontextmanager
+ async def modify(
+ self, resource_type: Type[Resource], fhir_id: str = None, source: str = None
+ ):
+ """
+ Context manager for working with FHIR resources.
+
+ Automatically handles fetching, updating, and error handling using connection pooling.
+
+ Args:
+ resource_type: The FHIR resource type class (e.g. Patient)
+ fhir_id: Resource ID (if None, creates a new resource)
+ source: Source name to use (uses first available if None)
+
+ Yields:
+ Resource: The FHIR resource object
+
+ Raises:
+ FHIRConnectionError: If connection fails
+ ValueError: If resource type is invalid
+ """
+ client = await self.get_client(source)
+ resource = None
+ is_new = fhir_id is None
+
+ # Get type name for error messages
+ type_name = resource_type.__resource_type__
+
+ try:
+ if is_new:
+ resource = resource_type()
+ else:
+ resource = await client.read(resource_type, fhir_id)
+ logger.debug(f"Retrieved {type_name}/{fhir_id} in modify context")
+ self._emit_fhir_event("read", type_name, fhir_id, resource)
+
+ yield resource
+
+ if is_new:
+ updated_resource = await client.create(resource)
+ else:
+ updated_resource = await client.update(resource)
+
+ resource.id = updated_resource.id
+ for field_name, field_value in updated_resource.model_dump().items():
+ if hasattr(resource, field_name):
+ setattr(resource, field_name, field_value)
+
+ operation = "create" if is_new else "update"
+ self._emit_fhir_event(operation, type_name, resource.id, updated_resource)
+ logger.debug(
+ f"{'Created' if is_new else 'Updated'} {type_name} resource in modify context"
+ )
+
+ except Exception as e:
+ operation = (
+ "read"
+ if not is_new and resource is None
+ else "create"
+ if is_new
+ else "update"
+ )
+ FHIRErrorHandler.handle_fhir_error(e, type_name, fhir_id, operation)
+
+ async def _execute_with_client(
+ self,
+ operation: str,
+ *, # Force keyword-only arguments
+ source: str = None,
+ resource_type: Type[Resource] = None,
+ resource_id: str = None,
+ client_args: tuple = (),
+ client_kwargs: dict = None,
+ ):
+ """
+ Execute a client operation with consistent error handling.
+
+ Args:
+ operation: Operation name (read, create, update, delete, etc.)
+ source: Source name to use
+ resource_type: Resource type for error handling
+ resource_id: Resource ID for error handling (if applicable)
+ client_args: Positional arguments to pass to the client method
+ client_kwargs: Keyword arguments to pass to the client method
+ """
+ client = await self.get_client(source)
+ client_kwargs = client_kwargs or {}
+
+ try:
+ result = await getattr(client, operation)(*client_args, **client_kwargs)
+ return result
+
+ except Exception as e:
+ # Use existing error handler
+ error_resource_type = resource_type or (
+ client_args[0].__class__
+ if client_args and hasattr(client_args[0], "__class__")
+ else None
+ )
+ FHIRErrorHandler.handle_fhir_error(
+ e, error_resource_type, resource_id, operation
+ )
+
+ def _emit_fhir_event(
+ self, operation: str, resource_type: str, resource_id: str, resource: Any = None
+ ):
+ """
+ Emit an event for FHIR operations.
+
+ Args:
+ operation: The FHIR operation (read, search, create, update, delete)
+ resource_type: The FHIR resource type
+ resource_id: The resource ID
+ resource: The resource object or data
+ """
+ if not self.use_events:
+ return
+
+ self.events.emit_event(
+ create_fhir_event,
+ operation,
+ resource_type,
+ resource_id,
+ resource,
+ use_events=self.use_events,
+ )
diff --git a/healthchain/gateway/fhir/base.py b/healthchain/gateway/fhir/base.py
new file mode 100644
index 00000000..ff397e63
--- /dev/null
+++ b/healthchain/gateway/fhir/base.py
@@ -0,0 +1,490 @@
+import logging
+import inspect
+import warnings
+
+from fastapi import Depends, HTTPException, Path, Query
+from datetime import datetime
+from typing import Any, Callable, Dict, List, Type, TypeVar, Optional
+from fastapi.responses import JSONResponse
+
+from fhir.resources.capabilitystatement import CapabilityStatement
+from fhir.resources.resource import Resource
+
+from healthchain.gateway.clients.fhir.base import FHIRServerInterface
+from healthchain.gateway.base import BaseGateway
+
+
+logger = logging.getLogger(__name__)
+
+
+# Type variable for FHIR Resource
+T = TypeVar("T", bound=Resource)
+
+
+class FHIRResponse(JSONResponse):
+ """
+ Custom response class for FHIR resources.
+
+ This sets the correct content-type header for FHIR resources.
+ """
+
+ media_type = "application/fhir+json"
+
+
+class BaseFHIRGateway(BaseGateway):
+ """
+ Base FHIR Gateway with shared functionality.
+
+ Contains all common FHIR gateway logic including routing, decorators,
+ capability statements, and resource management.
+
+ Subclasses implement sync vs async specific methods.
+ """
+
+ def __init__(
+ self,
+ sources: Dict[str, FHIRServerInterface] = None,
+ prefix: str = "/fhir",
+ tags: List[str] = ["FHIR"],
+ use_events: bool = False,
+ **options,
+ ):
+ """Initialize the Base FHIR Gateway."""
+ # Initialize as BaseGateway (which includes APIRouter)
+ super().__init__(use_events=use_events, prefix=prefix, tags=tags, **options)
+
+ self.use_events = use_events
+
+ # Initialize connection manager (subclasses will set appropriate type)
+ self.connection_manager = None
+
+ # Store sources for initialization
+ self._initial_sources = sources
+
+ # Handlers for resource operations
+ self._resource_handlers: Dict[str, Dict[str, Callable]] = {}
+
+ # Register base routes only (metadata endpoint)
+ self._register_base_routes()
+
+ def _initialize_connection_manager(self):
+ """Initialize connection manager with sources. Called by subclasses."""
+ # Add initial sources if provided
+ if self._initial_sources:
+ for name, source in self._initial_sources.items():
+ if isinstance(source, str):
+ self.connection_manager.add_source(name, source)
+ else:
+ self.connection_manager.sources[name] = source
+
+ def _get_gateway_dependency(self):
+ """Create a dependency function that returns this gateway instance."""
+
+ def get_self_gateway():
+ return self
+
+ return get_self_gateway
+
+ def _get_resource_name(self, resource_type: Type[Resource]) -> str:
+ """Extract resource name from resource type."""
+ return resource_type.__resource_type__
+
+ def _register_base_routes(self):
+ """Register basic endpoints"""
+ get_self_gateway = self._get_gateway_dependency()
+
+ # FHIR Metadata endpoint - returns CapabilityStatement
+ @self.get("/metadata", response_class=FHIRResponse)
+ def capability_statement(
+ fhir: "BaseFHIRGateway" = Depends(get_self_gateway),
+ ):
+ """Return the FHIR capability statement for this gateway's services.
+
+ Includes both custom transform/aggregate operations (via REST endpoints)
+ and standard FHIR CRUD operations (via Python gateway methods).
+ """
+ return fhir.build_capability_statement().model_dump()
+
+ # Gateway status endpoint - returns operational metadata
+ @self.get("/status", response_class=JSONResponse)
+ def gateway_status(
+ fhir: "BaseFHIRGateway" = Depends(get_self_gateway),
+ ):
+ """Return operational status and metadata for this gateway.
+
+ Includes information about supported FHIR CRUD operations,
+ custom transform/aggregate operations, and connected sources.
+ """
+ return fhir.get_gateway_status()
+
+ def build_capability_statement(self) -> CapabilityStatement:
+ """
+ Build a FHIR CapabilityStatement for this gateway's value-add services.
+
+ Returns detailed capability information including registered transform/aggregate
+ handlers, available FHIR sources, and supported operations.
+
+ Returns:
+ CapabilityStatement: FHIR-compliant capability statement for gateway operations
+ """
+ # Build resource entries based on registered handlers
+ resources = []
+ for resource_type, operations in self._resource_handlers.items():
+ interactions = []
+ operation_details = []
+
+ # Add supported interactions based on registered handlers
+ for operation in operations:
+ if operation == "transform":
+ interactions.append(
+ {
+ "code": "read",
+ "documentation": "Custom transformation via REST endpoint",
+ }
+ )
+ operation_details.append(
+ "Transform: Custom resource transformation"
+ )
+ elif operation == "aggregate":
+ interactions.append(
+ {
+ "code": "search-type",
+ "documentation": "Multi-source aggregation via REST endpoint",
+ }
+ )
+ operation_details.append("Aggregate: Multi-source data aggregation")
+
+ if interactions:
+ resource_name = self._get_resource_name(resource_type)
+ documentation = f"Gateway provides {', '.join(operation_details)} for {resource_name}"
+ resources.append(
+ {
+ "type": resource_name,
+ "interaction": interactions,
+ "documentation": documentation,
+ }
+ )
+
+ # Add available FHIR sources information
+ sources_info = []
+ if self.connection_manager and hasattr(self.connection_manager, "sources"):
+ for source_name in self.connection_manager.sources.keys():
+ sources_info.append(f"Source: {source_name}")
+
+ # Enhanced documentation with examples
+ sources_list = (
+ ", ".join(self.connection_manager.sources.keys())
+ if self.connection_manager and hasattr(self.connection_manager, "sources")
+ else "None configured"
+ )
+ rest_documentation = (
+ "HealthChain FHIR Gateway provides transformation and aggregation services. "
+ f"Gateway also provides standard FHIR CRUD operations (create, read, update, delete, search) to connected FHIR servers via Python API. "
+ f"Available sources: {sources_list}. "
+ "Use /status endpoint for operational details."
+ )
+
+ capability_data = {
+ "resourceType": "CapabilityStatement",
+ "status": "active",
+ "date": datetime.now().strftime("%Y-%m-%d"),
+ "publisher": "HealthChain",
+ "kind": "instance",
+ "software": {
+ "name": "HealthChain FHIR Gateway",
+ "version": "1.0.0", # TODO: Extract from package
+ },
+ "fhirVersion": "5.0.0",
+ "format": ["application/fhir+json"],
+ "rest": [
+ {
+ "mode": "server",
+ "documentation": rest_documentation,
+ "resource": resources,
+ }
+ ],
+ }
+
+ return CapabilityStatement(**capability_data)
+
+ @property
+ def supported_resources(self) -> List[str]:
+ """Get list of supported FHIR resource types."""
+ return [
+ self._get_resource_name(resource_type)
+ for resource_type in self._resource_handlers.keys()
+ ]
+
+ def get_capabilities(self) -> List[str]:
+ """
+ Get list of supported FHIR operations and resources.
+
+ Returns:
+ List of capabilities this gateway supports
+ """
+ capabilities = []
+ for resource_type, operations in self._resource_handlers.items():
+ resource_name = self._get_resource_name(resource_type)
+ for operation in operations:
+ capabilities.append(f"{operation}:{resource_name}")
+ return capabilities
+
+ def get_gateway_status(self) -> Dict[str, Any]:
+ """
+ Get operational status and metadata for this gateway.
+
+ Enhanced with detailed FHIR operation discovery information including
+ both standard CRUD operations and custom transform/aggregate operations.
+
+ Returns:
+ Dict containing gateway operational status and metadata
+ """
+ # Get available operations with examples
+ available_operations = {}
+ for resource_type, operations in self._resource_handlers.items():
+ resource_name = self._get_resource_name(resource_type)
+ operation_list = []
+
+ for operation in operations:
+ if operation == "transform":
+ operation_list.append(
+ {
+ "type": "transform",
+ "endpoint": f"/transform/{resource_name}/{{id}}",
+ "description": f"Transform {resource_name} with custom logic",
+ "method": "GET",
+ "parameters": ["id", "source (optional)"],
+ }
+ )
+ elif operation == "aggregate":
+ operation_list.append(
+ {
+ "type": "aggregate",
+ "endpoint": f"/aggregate/{resource_name}",
+ "description": f"Aggregate {resource_name} from multiple sources",
+ "method": "GET",
+ "parameters": ["id (optional)", "sources (optional)"],
+ }
+ )
+
+ if operation_list:
+ available_operations[resource_name] = operation_list
+
+ status = {
+ "gateway_type": self.__class__.__name__,
+ "version": "1.0.0", # TODO: Extract from package
+ "status": "active",
+ "timestamp": datetime.now().isoformat() + "Z",
+ "sources": {
+ "count": len(self.connection_manager.sources)
+ if self.connection_manager
+ and hasattr(self.connection_manager, "sources")
+ else 0,
+ "names": list(self.connection_manager.sources.keys())
+ if self.connection_manager
+ and hasattr(self.connection_manager, "sources")
+ else [],
+ },
+ "connection_pool": self.connection_manager.get_status()
+ if self.connection_manager
+ else {"status": "not_initialized"},
+ "supported_operations": available_operations,
+ "discovery_endpoints": {
+ "/metadata": "FHIR CapabilityStatement with supported operations",
+ "/status": "Gateway operational status and available operations",
+ "/docs": "OpenAPI documentation for all endpoints",
+ },
+ "events": {
+ "enabled": self.use_events,
+ "dispatcher_configured": hasattr(self, "events")
+ and self.events
+ and self.events.dispatcher is not None,
+ "event_type": "async"
+ if self.__class__.__name__ == "AsyncFHIRGateway"
+ else "logging_only",
+ },
+ }
+
+ return status
+
+ def _register_resource_handler(
+ self,
+ resource_type: Type[Resource],
+ operation: str,
+ handler: Callable,
+ ) -> None:
+ """Register a custom handler for a resource operation."""
+ self._validate_handler_annotations(resource_type, operation, handler)
+
+ if resource_type not in self._resource_handlers:
+ self._resource_handlers[resource_type] = {}
+ self._resource_handlers[resource_type][operation] = handler
+
+ resource_name = self._get_resource_name(resource_type)
+ logger.debug(
+ f"Registered {operation} handler for {resource_name}: {handler.__name__}"
+ )
+
+ self._register_operation_route(resource_type, operation)
+
+ def _validate_handler_annotations(
+ self,
+ resource_type: Type[Resource],
+ operation: str,
+ handler: Callable,
+ ) -> None:
+ """Validate that handler annotations match the decorator resource type."""
+ if operation != "transform":
+ return
+
+ try:
+ sig = inspect.signature(handler)
+ return_annotation = sig.return_annotation
+
+ if return_annotation == inspect.Parameter.empty:
+ warnings.warn(
+ f"Handler {handler.__name__} missing return type annotation for {resource_type.__name__}"
+ )
+ return
+
+ if return_annotation != resource_type:
+ raise TypeError(
+ f"Handler {handler.__name__} return type ({return_annotation}) "
+ f"doesn't match decorator resource type ({resource_type})"
+ )
+
+ except Exception as e:
+ if isinstance(e, TypeError):
+ raise
+ logger.warning(f"Could not validate handler annotations: {str(e)}")
+
+ def _register_operation_route(
+ self, resource_type: Type[Resource], operation: str
+ ) -> None:
+ """Register a route for a specific resource type and operation."""
+ resource_name = self._get_resource_name(resource_type)
+
+ if operation == "transform":
+ path = f"/transform/{resource_name}/{{id}}"
+ summary = f"Transform {resource_name}"
+ description = (
+ f"Transform a {resource_name} resource with registered handler"
+ )
+ elif operation == "aggregate":
+ path = f"/aggregate/{resource_name}"
+ summary = f"Aggregate {resource_name}"
+ description = f"Aggregate {resource_name} resources from multiple sources"
+ else:
+ raise ValueError(f"Unsupported operation: {operation}")
+
+ handler = self._create_route_handler(resource_type, operation)
+
+ self.add_api_route(
+ path=path,
+ endpoint=handler,
+ methods=["GET"],
+ summary=summary,
+ description=description,
+ response_model_exclude_none=True,
+ response_class=FHIRResponse,
+ tags=self.tags,
+ include_in_schema=True,
+ )
+ logger.debug(f"Registered {operation} endpoint: {self.prefix}{path}")
+
+ def _create_route_handler(
+ self, resource_type: Type[Resource], operation: str
+ ) -> Callable:
+ """Create a route handler for the given resource type and operation."""
+ get_self_gateway = self._get_gateway_dependency()
+
+ def _execute_handler(fhir: "BaseFHIRGateway", *args) -> Any:
+ """Common handler execution logic with error handling."""
+ try:
+ handler_func = fhir._resource_handlers[resource_type][operation]
+ result = handler_func(*args)
+ return result
+ except Exception as e:
+ logger.error(f"Error in {operation} handler: {str(e)}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ if operation == "transform":
+
+ async def handler(
+ id: str = Path(..., description="Resource ID to transform"),
+ source: Optional[str] = Query(
+ None, description="Source system to retrieve the resource from"
+ ),
+ fhir: "BaseFHIRGateway" = Depends(get_self_gateway),
+ ):
+ """Transform a resource with registered handler."""
+ return _execute_handler(fhir, id, source)
+
+ elif operation == "aggregate":
+
+ async def handler(
+ id: Optional[str] = Query(None, description="ID to aggregate data for"),
+ sources: Optional[List[str]] = Query(
+ None, description="List of source names to query"
+ ),
+ fhir: "BaseFHIRGateway" = Depends(get_self_gateway),
+ ):
+ """Aggregate resources with registered handler."""
+ return _execute_handler(fhir, id, sources)
+
+ else:
+ raise ValueError(f"Unsupported operation: {operation}")
+
+ return handler
+
+ def add_source(self, name: str, connection_string: str) -> None:
+ """
+ Add a FHIR data source using connection string with OAuth2.0 flow.
+
+ Format: fhir://hostname:port/path?param1=value1¶m2=value2
+
+ Examples:
+ fhir://epic.org/api/FHIR/R4?client_id=my_app&client_secret=secret&token_url=https://epic.org/oauth2/token&scope=system/*.read
+ fhir://cerner.org/r4?client_id=app_id&client_secret=app_secret&token_url=https://cerner.org/token&audience=https://cerner.org/fhir
+ """
+ return self.connection_manager.add_source(name, connection_string)
+
+ def aggregate(self, resource_type: Type[Resource]):
+ """
+ Decorator for custom aggregation functions.
+
+ Args:
+ resource_type: The FHIR resource type class that this handler aggregates
+
+ Example:
+ @fhir_gateway.aggregate(Patient)
+ def aggregate_patients(id: str = None, sources: List[str] = None) -> List[Patient]:
+ # Handler implementation
+ pass
+ """
+
+ def decorator(handler: Callable):
+ self._register_resource_handler(resource_type, "aggregate", handler)
+ return handler
+
+ return decorator
+
+ def transform(self, resource_type: Type[Resource]):
+ """
+ Decorator for custom transformation functions.
+
+ Args:
+ resource_type: The FHIR resource type class that this handler transforms
+
+ Example:
+ @fhir_gateway.transform(DocumentReference)
+ def transform_document(id: str, source: str = None) -> DocumentReference:
+ # Handler implementation
+ pass
+ """
+
+ def decorator(handler: Callable):
+ self._register_resource_handler(resource_type, "transform", handler)
+ return handler
+
+ return decorator
diff --git a/healthchain/gateway/core/errors.py b/healthchain/gateway/fhir/errors.py
similarity index 100%
rename from healthchain/gateway/core/errors.py
rename to healthchain/gateway/fhir/errors.py
diff --git a/healthchain/gateway/fhir/sync.py b/healthchain/gateway/fhir/sync.py
new file mode 100644
index 00000000..0c01d9d5
--- /dev/null
+++ b/healthchain/gateway/fhir/sync.py
@@ -0,0 +1,293 @@
+import logging
+
+from typing import Any, Dict, Type
+
+from fhir.resources.bundle import Bundle
+from fhir.resources.capabilitystatement import CapabilityStatement
+from fhir.resources.resource import Resource
+
+from healthchain.gateway.clients.fhir.base import FHIRServerInterface
+from healthchain.gateway.clients.fhir.sync.connection import FHIRConnectionManager
+from healthchain.gateway.fhir.base import BaseFHIRGateway
+from healthchain.gateway.fhir.errors import FHIRErrorHandler
+
+
+logger = logging.getLogger(__name__)
+
+
+class FHIRGateway(BaseFHIRGateway):
+ """
+ Sync FHIR Gateway for HealthChain.
+
+ A synchronous gateway for FHIR resource operations including:
+ - Resource transformation and aggregation
+ - Simple logging-based operation tracking
+ - OAuth2 authentication support
+
+ Example:
+ ```python
+ # Initialize sync gateway
+ gateway = FHIRGateway()
+ gateway.add_source("epic", "fhir://epic.org/api/FHIR/R4?...")
+
+ patient = gateway.read(Patient, "123", "epic")
+ ```
+ """
+
+ def __init__(self, **kwargs):
+ """Initialize the Sync FHIR Gateway."""
+ super().__init__(**kwargs)
+
+ # Initialize sync connection manager
+ self.connection_manager = FHIRConnectionManager()
+
+ # Initialize sources
+ self._initialize_connection_manager()
+
+ def get_client(self, source: str = None) -> FHIRServerInterface:
+ """
+ Get a sync FHIR client for the specified source.
+
+ Args:
+ source: Source name to get client for (uses first available if None)
+
+ Returns:
+ FHIRServerInterface: A sync FHIR client
+
+ Raises:
+ ValueError: If source is unknown or no sources configured
+ """
+ return self.connection_manager.get_client(source)
+
+ def _execute_with_client(
+ self,
+ operation: str,
+ *,
+ source: str = None,
+ resource_type: Type[Resource] = None,
+ resource_id: str = None,
+ client_args: tuple = (),
+ client_kwargs: dict = None,
+ ):
+ """
+ Execute a client operation with consistent error handling.
+
+ Args:
+ operation: Operation name (read, create, update, delete, etc.)
+ source: Source name to use
+ resource_type: Resource type for error handling
+ resource_id: Resource ID for error handling (if applicable)
+ client_args: Positional arguments to pass to the client method
+ client_kwargs: Keyword arguments to pass to the client method
+ """
+ client = self.get_client(source)
+ client_kwargs = client_kwargs or {}
+ try:
+ return getattr(client, operation)(*client_args, **client_kwargs)
+ except Exception as e:
+ FHIRErrorHandler.handle_fhir_error(e, resource_type, resource_id, operation)
+
+ def capabilities(self, source: str = None) -> CapabilityStatement:
+ """
+ Get the capabilities of a FHIR server.
+
+ Args:
+ source: Source name to get capabilities for (uses first available if None)
+
+ Returns:
+ CapabilityStatement: The capabilities of the FHIR server
+ """
+ capabilities = self._execute_with_client(
+ "capabilities",
+ source=source,
+ resource_type=CapabilityStatement,
+ )
+ logger.info("FHIR operation: capabilities on CapabilityStatement/None")
+ return capabilities
+
+ def transaction(self, bundle: Bundle, source: str = None) -> Bundle:
+ """
+ Execute a FHIR transaction bundle (sync version).
+
+ Args:
+ bundle: The transaction bundle to execute
+ source: Source name to execute in (uses first available if None)
+
+ Returns:
+ The response bundle with results
+
+ Example:
+ bundle = Bundle(type="transaction", entry=[...])
+ result = gateway.transaction(bundle, "epic")
+ """
+ result = self._execute_with_client(
+ "transaction",
+ source=source,
+ resource_type=Bundle,
+ client_args=(bundle,),
+ )
+ entry_count = len(bundle.entry) if bundle.entry else 0
+ result_count = len(result.entry) if result.entry else 0
+ logger.info(
+ f"FHIR operation: transaction on Bundle/None (entry_count={entry_count}, result_count={result_count})"
+ )
+
+ return result
+
+ def read(
+ self,
+ resource_type: Type[Resource],
+ fhir_id: str,
+ source: str = None,
+ ) -> Resource:
+ """
+ Read a FHIR resource (sync version).
+
+ Args:
+ resource_type: The FHIR resource type class
+ fhir_id: Resource ID to fetch
+ source: Source name to fetch from (uses first available if None)
+
+ Returns:
+ The FHIR resource object
+
+ Example:
+ patient = gateway.read(Patient, "123", "epic")
+ """
+ resource = self._execute_with_client(
+ "read",
+ source=source,
+ resource_type=resource_type,
+ resource_id=fhir_id,
+ client_args=(resource_type, fhir_id),
+ )
+ if not resource:
+ type_name = resource_type.__resource_type__
+ raise ValueError(f"Resource {type_name}/{fhir_id} not found")
+
+ type_name = resource.__resource_type__
+ logger.info(f"FHIR operation: read on {type_name}/{fhir_id}")
+
+ return resource
+
+ def create(self, resource: Resource, source: str = None) -> Resource:
+ """
+ Create a new FHIR resource (sync version).
+
+ Args:
+ resource: The FHIR resource to create
+ source: Source name to create in (uses first available if None)
+
+ Returns:
+ The created FHIR resource with server-assigned ID
+
+ Example:
+ patient = Patient(name=[HumanName(family="Smith", given=["John"])])
+ created = gateway.create(patient, "epic")
+ """
+ created = self._execute_with_client(
+ "create",
+ source=source,
+ resource_type=type(resource),
+ client_args=(resource,),
+ )
+ type_name = resource.__resource_type__
+ logger.info(f"FHIR operation: create on {type_name}/{created.id}")
+
+ return created
+
+ def update(self, resource: Resource, source: str = None) -> Resource:
+ """
+ Update an existing FHIR resource (sync version).
+
+ Args:
+ resource: The FHIR resource to update (must have ID)
+ source: Source name to update in (uses first available if None)
+
+ Returns:
+ The updated FHIR resource
+
+ Example:
+ patient = gateway.read(Patient, "123", "epic")
+ patient.name[0].family = "Jones"
+ updated = gateway.update(patient, "epic")
+ """
+ if not resource.id:
+ raise ValueError("Resource must have an ID for update")
+
+ updated = self._execute_with_client(
+ "update",
+ source=source,
+ resource_type=type(resource),
+ resource_id=resource.id,
+ client_args=(resource,),
+ )
+ type_name = resource.__resource_type__
+ logger.info(f"FHIR operation: update on {type_name}/{resource.id}")
+
+ return updated
+
+ def search(
+ self,
+ resource_type: Type[Resource],
+ params: Dict[str, Any] = None,
+ source: str = None,
+ ) -> Bundle:
+ """
+ Search for FHIR resources (sync version).
+
+ Args:
+ resource_type: The FHIR resource type class
+ params: Search parameters (e.g., {"name": "Smith", "active": "true"})
+ source: Source name to search in (uses first available if None)
+
+ Returns:
+ Bundle containing search results
+
+ Example:
+ bundle = gateway.search(Patient, {"name": "Smith"}, "epic")
+ """
+
+ bundle = self._execute_with_client(
+ "search",
+ source=source,
+ resource_type=resource_type,
+ client_args=(resource_type, params),
+ )
+ type_name = resource_type.__resource_type__
+ result_count = len(bundle.entry) if bundle.entry else 0
+ logger.info(
+ f"FHIR operation: search on {type_name} with params {params}, found {result_count} results"
+ )
+
+ return bundle
+
+ def delete(
+ self, resource_type: Type[Resource], fhir_id: str, source: str = None
+ ) -> bool:
+ """
+ Delete a FHIR resource (sync version).
+
+ Args:
+ resource_type: The FHIR resource type class
+ fhir_id: Resource ID to delete
+ source: Source name to delete from (uses first available if None)
+
+ Returns:
+ True if deletion was successful
+
+ Example:
+ success = gateway.delete(Patient, "123", "epic")
+ """
+ success = self._execute_with_client(
+ "delete",
+ source=source,
+ resource_type=resource_type,
+ resource_id=fhir_id,
+ client_args=(resource_type, fhir_id),
+ )
+ if success:
+ type_name = resource_type.__resource_type__
+ logger.info(f"FHIR operation: delete on {type_name}/{fhir_id}")
+
+ return success or True
diff --git a/healthchain/gateway/protocols/__init__.py b/healthchain/gateway/protocols/__init__.py
deleted file mode 100644
index b3e2c699..00000000
--- a/healthchain/gateway/protocols/__init__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""
-Protocol implementations for the HealthChain Gateway.
-
-This module contains protocol-specific gateway implementations that provide
-integration with various healthcare standards like FHIR, CDS Hooks, SOAP, etc.
-
-These gateways handle the details of each protocol while presenting a consistent
-interface for registration, event handling, and endpoint management.
-"""
-
-from .cdshooks import CDSHooksService
-from .notereader import NoteReaderService
-from .apiprotocol import ApiProtocol
-
-__all__ = [
- "CDSHooksService",
- "NoteReaderService",
- "ApiProtocol",
-]
diff --git a/healthchain/gateway/protocols/apiprotocol.py b/healthchain/gateway/protocols/apiprotocol.py
deleted file mode 100644
index 092265cf..00000000
--- a/healthchain/gateway/protocols/apiprotocol.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from enum import Enum
-
-
-class ApiProtocol(Enum):
- """
- Enum defining the supported API protocols.
-
- Available protocols:
- - soap: SOAP protocol
- - rest: REST protocol
- """
-
- soap = "SOAP"
- rest = "REST"
diff --git a/healthchain/gateway/soap/__init__.py b/healthchain/gateway/soap/__init__.py
new file mode 100644
index 00000000..8972b003
--- /dev/null
+++ b/healthchain/gateway/soap/__init__.py
@@ -0,0 +1,10 @@
+from .notereader import NoteReaderService
+from .utils.epiccds import CDSServices
+from .utils.model import ClientFault, ServerFault
+
+__all__ = [
+ "NoteReaderService",
+ "CDSServices",
+ "ClientFault",
+ "ServerFault",
+]
diff --git a/healthchain/gateway/events/notereader.py b/healthchain/gateway/soap/events.py
similarity index 100%
rename from healthchain/gateway/events/notereader.py
rename to healthchain/gateway/soap/events.py
diff --git a/healthchain/gateway/protocols/notereader.py b/healthchain/gateway/soap/notereader.py
similarity index 96%
rename from healthchain/gateway/protocols/notereader.py
rename to healthchain/gateway/soap/notereader.py
index bdfcceda..9bbd11ad 100644
--- a/healthchain/gateway/protocols/notereader.py
+++ b/healthchain/gateway/soap/notereader.py
@@ -14,12 +14,11 @@
from spyne.protocol.soap import Soap11
from spyne.server.wsgi import WsgiApplication
-from healthchain.gateway.core.base import BaseProtocolHandler
+from healthchain.gateway.base import BaseProtocolHandler
from healthchain.gateway.events.dispatcher import EventDispatcher
-from healthchain.gateway.events.notereader import create_notereader_event
-from healthchain.gateway.soap.epiccdsservice import CDSServices
-from healthchain.gateway.soap.model.epicclientfault import ClientFault
-from healthchain.gateway.soap.model.epicserverfault import ServerFault
+from healthchain.gateway.soap.events import create_notereader_event
+from healthchain.gateway.soap.utils.epiccds import CDSServices
+from healthchain.gateway.soap.utils.model import ClientFault, ServerFault
from healthchain.models.requests.cdarequest import CdaRequest
from healthchain.models.responses.cdaresponse import CdaResponse
diff --git a/healthchain/gateway/soap/epiccdsservice.py b/healthchain/gateway/soap/utils/epiccds.py
similarity index 97%
rename from healthchain/gateway/soap/epiccdsservice.py
rename to healthchain/gateway/soap/utils/epiccds.py
index 6a778c6b..9b64ef88 100644
--- a/healthchain/gateway/soap/epiccdsservice.py
+++ b/healthchain/gateway/soap/utils/epiccds.py
@@ -3,7 +3,7 @@
from spyne import rpc, ServiceBase, Unicode, ByteArray
from healthchain.models.requests.cdarequest import CdaRequest
-from .model import Response, ClientFault, ServerFault
+from healthchain.gateway.soap.utils.model import Response, ClientFault, ServerFault
log = logging.getLogger(__name__)
diff --git a/healthchain/gateway/soap/model/__init__.py b/healthchain/gateway/soap/utils/model/__init__.py
similarity index 100%
rename from healthchain/gateway/soap/model/__init__.py
rename to healthchain/gateway/soap/utils/model/__init__.py
diff --git a/healthchain/gateway/soap/model/epicclientfault.py b/healthchain/gateway/soap/utils/model/epicclientfault.py
similarity index 100%
rename from healthchain/gateway/soap/model/epicclientfault.py
rename to healthchain/gateway/soap/utils/model/epicclientfault.py
diff --git a/healthchain/gateway/soap/model/epicresponse.py b/healthchain/gateway/soap/utils/model/epicresponse.py
similarity index 100%
rename from healthchain/gateway/soap/model/epicresponse.py
rename to healthchain/gateway/soap/utils/model/epicresponse.py
diff --git a/healthchain/gateway/soap/model/epicserverfault.py b/healthchain/gateway/soap/utils/model/epicserverfault.py
similarity index 100%
rename from healthchain/gateway/soap/model/epicserverfault.py
rename to healthchain/gateway/soap/utils/model/epicserverfault.py
diff --git a/healthchain/gateway/soap/wsgi.py b/healthchain/gateway/soap/utils/wsgi.py
similarity index 89%
rename from healthchain/gateway/soap/wsgi.py
rename to healthchain/gateway/soap/utils/wsgi.py
index 108dae45..0cf45dc1 100644
--- a/healthchain/gateway/soap/wsgi.py
+++ b/healthchain/gateway/soap/utils/wsgi.py
@@ -4,8 +4,8 @@
from typing import Callable
-from healthchain.gateway.soap.epiccdsservice import CDSServices
-from healthchain.gateway.soap.model import ClientFault, ServerFault
+from healthchain.gateway.soap.utils.epiccds import CDSServices
+from healthchain.gateway.soap.utils.model import ClientFault, ServerFault
def start_wsgi(
diff --git a/healthchain/io/containers/document.py b/healthchain/io/containers/document.py
index c8978944..9bbf2690 100644
--- a/healthchain/io/containers/document.py
+++ b/healthchain/io/containers/document.py
@@ -23,6 +23,7 @@
create_single_codeable_concept,
read_content_attachment,
create_condition,
+ set_problem_list_item_category,
)
logger = logging.getLogger(__name__)
@@ -698,6 +699,7 @@ def update_problem_list_from_nlp(
display=ent.text,
system=coding_system,
)
+ set_problem_list_item_category(condition)
logger.debug(f"Adding condition from spaCy: {condition.model_dump()}")
new_conditions.append(condition)
@@ -721,6 +723,7 @@ def update_problem_list_from_nlp(
display=entity_text,
system=coding_system,
)
+ set_problem_list_item_category(condition)
logger.debug(
f"Adding condition from entities: {condition.model_dump()}"
)
diff --git a/healthchain/service/endpoints.py b/healthchain/service/endpoints.py
index 424c5986..795aa781 100644
--- a/healthchain/service/endpoints.py
+++ b/healthchain/service/endpoints.py
@@ -1,30 +1,19 @@
from enum import Enum
from pydantic import BaseModel, field_validator
from typing import Optional, Callable
-import warnings
-# Keep for backward compatibility but warn about new location
-try:
- from healthchain.gateway.protocols.apiprotocol import ApiProtocol
-except ImportError:
- # Fallback definition if the new location isn't available yet
- class ApiProtocol(Enum):
- """
- DEPRECATED: This enum has moved to healthchain.gateway.protocols.api_protocol
- """
+class ApiProtocol(Enum):
+ """
+ Enum defining the supported API protocols.
- soap = "SOAP"
- rest = "REST"
+ Available protocols:
+ - soap: SOAP protocol
+ - rest: REST protocol
+ """
- def __init__(self, *args, **kwargs):
- warnings.warn(
- "ApiProtocol has moved to healthchain.gateway.protocols.api_protocol. "
- "This location is deprecated and will be removed in a future version.",
- DeprecationWarning,
- stacklevel=2,
- )
- super().__init__(*args, **kwargs)
+ soap = "SOAP"
+ rest = "REST"
class Endpoint(BaseModel):
diff --git a/healthchain/service/service.py b/healthchain/service/service.py
index e2dab47a..3a7ca036 100644
--- a/healthchain/service/service.py
+++ b/healthchain/service/service.py
@@ -12,15 +12,8 @@
from contextlib import asynccontextmanager
from termcolor import colored
-from healthchain.gateway.soap.wsgi import start_wsgi
-
-# Use new location but maintain old import for backward compatibility
-try:
- from healthchain.gateway.protocols.apiprotocol import ApiProtocol
-except ImportError:
- from .endpoints import ApiProtocol
-
-from .endpoints import Endpoint
+from healthchain.gateway.soap.utils.wsgi import start_wsgi
+from healthchain.service.endpoints import ApiProtocol, Endpoint
log = logging.getLogger(__name__)
diff --git a/resources/uclh_cda.xml b/resources/uclh_cda.xml
index a4b037bf..d3b5e18d 100644
--- a/resources/uclh_cda.xml
+++ b/resources/uclh_cda.xml
@@ -1793,7 +1793,7 @@ Not responding to Antibiotics
-
@@ -1874,7 +1874,7 @@ Not responding to Antibiotics
-
@@ -1959,7 +1959,7 @@ Not responding to Antibiotics
-
@@ -2057,7 +2057,7 @@ Not responding to Antibiotics
-
diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py
new file mode 100644
index 00000000..ba8b08f2
--- /dev/null
+++ b/tests/gateway/conftest.py
@@ -0,0 +1,225 @@
+"""
+Shared test fixtures and data for gateway tests.
+This could be used to reduce duplication across test files.
+"""
+
+import pytest
+from unittest.mock import Mock
+from fhir.resources.patient import Patient
+from fhir.resources.bundle import Bundle
+from healthchain.gateway.clients.fhir.base import FHIRAuthConfig
+
+
+# =============================================================================
+# FHIR Test Data
+# =============================================================================
+
+
+@pytest.fixture
+def sample_patient():
+ """Standard test patient used across multiple test files."""
+ return Patient(
+ id="test-patient-123",
+ active=True,
+ name=[{"family": "TestFamily", "given": ["TestGiven"]}],
+ gender="unknown",
+ )
+
+
+@pytest.fixture
+def sample_patient_data():
+ """Raw patient data for JSON responses."""
+ return {
+ "resourceType": "Patient",
+ "id": "test-patient-123",
+ "active": True,
+ "name": [{"family": "TestFamily", "given": ["TestGiven"]}],
+ "gender": "unknown",
+ }
+
+
+@pytest.fixture
+def sample_bundle():
+ """Standard test bundle for transaction testing."""
+ return Bundle(
+ type="transaction",
+ entry=[
+ {
+ "resource": {
+ "resourceType": "Patient",
+ "id": "test-patient-123",
+ "active": True,
+ },
+ "request": {"method": "PUT", "url": "Patient/test-patient-123"},
+ }
+ ],
+ )
+
+
+# =============================================================================
+# Connection & Auth Test Data
+# =============================================================================
+
+
+@pytest.fixture
+def standard_auth_config():
+ """Standard FHIR auth config used across tests."""
+ return FHIRAuthConfig(
+ base_url="https://test.fhir.org/R4",
+ client_id="test_client",
+ client_secret="test_secret",
+ token_url="https://test.fhir.org/oauth/token",
+ scope="system/*.read",
+ )
+
+
+@pytest.fixture
+def standard_connection_string():
+ """Standard FHIR connection string for testing."""
+ return (
+ "fhir://test.fhir.org/R4?"
+ "client_id=test_client&"
+ "client_secret=test_secret&"
+ "token_url=https://test.fhir.org/oauth/token&"
+ "scope=system/*.read"
+ )
+
+
+@pytest.fixture
+def multi_source_connection_strings():
+ """Multiple connection strings for multi-source testing."""
+ return {
+ "source1": (
+ "fhir://source1.fhir.org/R4?"
+ "client_id=client1&client_secret=secret1&"
+ "token_url=https://source1.fhir.org/token"
+ ),
+ "source2": (
+ "fhir://source2.fhir.org/R4?"
+ "client_id=client2&client_secret=secret2&"
+ "token_url=https://source2.fhir.org/token"
+ ),
+ }
+
+
+# =============================================================================
+# Mock HTTP Responses
+# =============================================================================
+
+
+@pytest.fixture
+def mock_successful_patient_response():
+ """Mock HTTP response for successful patient read."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "resourceType": "Patient",
+ "id": "test-patient-123",
+ "active": True,
+ }
+ mock_response.raise_for_status.return_value = None
+ return mock_response
+
+
+@pytest.fixture
+def mock_token_response():
+ """Mock HTTP response for OAuth token endpoint."""
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "access_token": "test_access_token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ }
+ mock_response.raise_for_status.return_value = None
+ return mock_response
+
+
+@pytest.fixture
+def mock_token_data():
+ """Raw token data for JSON responses."""
+ return {
+ "access_token": "test_access_token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ }
+
+
+# =============================================================================
+# Common Test Utilities
+# =============================================================================
+
+
+def create_mock_client(async_mode=False):
+ """Create a standard mock FHIR client."""
+ mock_client = Mock()
+
+ if async_mode:
+ from unittest.mock import AsyncMock
+
+ mock_client.read = AsyncMock(return_value=Patient(id="test", active=True))
+ mock_client.create = AsyncMock(return_value=Patient(id="test", active=True))
+ mock_client.update = AsyncMock(return_value=Patient(id="test", active=True))
+ mock_client.delete = AsyncMock()
+ mock_client.search = AsyncMock(return_value=Bundle(type="searchset", entry=[]))
+ mock_client.close = AsyncMock()
+ else:
+ mock_client.read.return_value = Patient(id="test", active=True)
+ mock_client.create.return_value = Patient(id="test", active=True)
+ mock_client.update.return_value = Patient(id="test", active=True)
+ mock_client.delete.return_value = None
+ mock_client.search.return_value = Bundle(type="searchset", entry=[])
+ mock_client.close = Mock()
+
+ return mock_client
+
+
+def create_mock_connection_manager(async_mode=False):
+ """Create a standard mock connection manager."""
+ mock_manager = Mock()
+ mock_client = create_mock_client(async_mode)
+ mock_manager.get_client.return_value = mock_client
+ mock_manager.add_source = Mock()
+
+ if async_mode:
+ from unittest.mock import AsyncMock
+
+ mock_manager.close = AsyncMock()
+
+ return mock_manager
+
+
+# =============================================================================
+# Error Test Data
+# =============================================================================
+
+
+@pytest.fixture
+def invalid_connection_strings():
+ """Collection of invalid connection strings for error testing."""
+ return [
+ "invalid://not-fhir",
+ "fhir://", # Missing hostname
+ "fhir://example.com", # Missing required params
+ "not-a-url-at-all",
+ "http://wrong-scheme.com/fhir",
+ ]
+
+
+@pytest.fixture
+def http_error_scenarios():
+ """Common HTTP error scenarios for testing."""
+ import httpx
+
+ return {
+ "timeout": httpx.ConnectTimeout("Connection timed out"),
+ "404": httpx.HTTPStatusError(
+ "Not found", request=Mock(), response=Mock(status_code=404)
+ ),
+ "500": httpx.HTTPStatusError(
+ "Server error", request=Mock(), response=Mock(status_code=500)
+ ),
+ "401": httpx.HTTPStatusError(
+ "Unauthorized", request=Mock(), response=Mock(status_code=401)
+ ),
+ }
diff --git a/tests/gateway/test_api_app.py b/tests/gateway/test_api_app.py
index 06556f13..8ea593cf 100644
--- a/tests/gateway/test_api_app.py
+++ b/tests/gateway/test_api_app.py
@@ -13,7 +13,7 @@
get_all_gateways,
)
from healthchain.gateway.events.dispatcher import EventDispatcher
-from healthchain.gateway.core.base import BaseGateway
+from healthchain.gateway.base import BaseGateway
class MockGateway(BaseGateway):
diff --git a/tests/gateway/test_auth.py b/tests/gateway/test_auth.py
index 3532b646..280efe89 100644
--- a/tests/gateway/test_auth.py
+++ b/tests/gateway/test_auth.py
@@ -14,12 +14,8 @@
OAuth2Config,
TokenInfo,
OAuth2TokenManager,
- FHIRAuthConfig,
- parse_fhir_auth_connection_string,
)
-# Configure pytest-asyncio for async tests only (sync tests don't need the mark)
-
@pytest.fixture
def oauth2_config():
@@ -86,98 +82,8 @@ def temp_key_file():
os.unlink(temp_path)
-# Core Validation Tests
-@pytest.mark.parametrize(
- "config_args,expected_error",
- [
- # Missing both secrets
- (
- {"client_id": "test", "token_url": "https://example.com/token"},
- "Either client_secret or client_secret_path must be provided",
- ),
- # Both secrets provided
- (
- {
- "client_id": "test",
- "client_secret": "secret",
- "client_secret_path": "/path",
- "token_url": "https://example.com/token",
- },
- "Cannot provide both client_secret and client_secret_path",
- ),
- # JWT without path
- (
- {
- "client_id": "test",
- "client_secret": "secret",
- "token_url": "https://example.com/token",
- "use_jwt_assertion": True,
- },
- "use_jwt_assertion=True requires client_secret_path to be set",
- ),
- # Path without JWT
- (
- {
- "client_id": "test",
- "client_secret_path": "/path",
- "token_url": "https://example.com/token",
- "use_jwt_assertion": False,
- },
- "client_secret_path can only be used with use_jwt_assertion=True",
- ),
- ],
-)
-def test_oauth2_config_validation_rules(config_args, expected_error):
- """OAuth2Config enforces validation rules for secret configuration."""
- with pytest.raises(ValueError, match=expected_error):
- OAuth2Config(**config_args)
-
-
-def test_oauth2_config_secret_value_reads_from_file(temp_key_file):
- """OAuth2Config reads secret from file when client_secret_path is provided."""
- config = OAuth2Config(
- client_id="test_client",
- client_secret_path=temp_key_file,
- token_url="https://example.com/token",
- use_jwt_assertion=True,
- )
- secret_value = config.secret_value
- assert "BEGIN PRIVATE KEY" in secret_value
- assert "END PRIVATE KEY" in secret_value
-
-
-def test_oauth2_config_secret_value_handles_file_errors():
- """OAuth2Config raises clear error when file cannot be read."""
- config = OAuth2Config(
- client_id="test_client",
- client_secret_path="/nonexistent/file.pem",
- token_url="https://example.com/token",
- use_jwt_assertion=True,
- )
- with pytest.raises(ValueError, match="Failed to read secret from"):
- _ = config.secret_value
-
-
-# Token Management Core Tests
-def test_token_info_expiration_logic():
- """TokenInfo correctly calculates expiration with buffer."""
- # Test near-expiry with buffer
- near_expiry_token = TokenInfo(
- access_token="test_token",
- expires_in=240,
- expires_at=datetime.now() + timedelta(minutes=4),
- )
- assert near_expiry_token.is_expired(
- buffer_seconds=300
- ) # 5 min buffer, expires in 4
- assert not near_expiry_token.is_expired(
- buffer_seconds=120
- ) # 2 min buffer, expires in 4
-
-
-@pytest.mark.asyncio
-@patch("httpx.AsyncClient.post")
-async def test_oauth2_token_manager_standard_flow(
+@patch("httpx.Client.post")
+def test_oauth2_token_manager_standard_flow(
mock_post, token_manager, mock_token_response
):
"""OAuth2TokenManager performs standard client credentials flow correctly."""
@@ -187,7 +93,7 @@ async def test_oauth2_token_manager_standard_flow(
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
- token = await token_manager.get_access_token()
+ token = token_manager.get_access_token()
# Verify token returned
assert token == "test_access_token"
@@ -201,10 +107,9 @@ async def test_oauth2_token_manager_standard_flow(
assert "client_assertion" not in request_data
-@pytest.mark.asyncio
@patch("healthchain.gateway.clients.auth.OAuth2TokenManager._create_jwt_assertion")
-@patch("httpx.AsyncClient.post")
-async def test_oauth2_token_manager_jwt_flow(
+@patch("httpx.Client.post")
+def test_oauth2_token_manager_jwt_flow(
mock_post, mock_create_jwt, token_manager_jwt, mock_token_response
):
"""OAuth2TokenManager performs JWT assertion flow correctly."""
@@ -216,7 +121,7 @@ async def test_oauth2_token_manager_jwt_flow(
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
- token = await token_manager_jwt.get_access_token()
+ token = token_manager_jwt.get_access_token()
assert token == "test_access_token"
# Verify JWT-specific request data
@@ -231,9 +136,8 @@ async def test_oauth2_token_manager_jwt_flow(
assert "client_secret" not in request_data
-@pytest.mark.asyncio
-@patch("httpx.AsyncClient.post")
-async def test_oauth2_token_manager_caching_and_refresh(
+@patch("httpx.Client.post")
+def test_oauth2_token_manager_caching_and_refresh(
mock_post, token_manager, mock_token_response
):
"""OAuth2TokenManager caches valid tokens and refreshes expired ones."""
@@ -245,7 +149,7 @@ async def test_oauth2_token_manager_caching_and_refresh(
)
# Should use cached token
- token = await token_manager.get_access_token()
+ token = token_manager.get_access_token()
assert token == "cached_token"
mock_post.assert_not_called()
@@ -263,14 +167,13 @@ async def test_oauth2_token_manager_caching_and_refresh(
mock_post.return_value = mock_response
# Should refresh token
- token = await token_manager.get_access_token()
+ token = token_manager.get_access_token()
assert token == "test_access_token"
mock_post.assert_called_once()
-@pytest.mark.asyncio
-@patch("httpx.AsyncClient.post")
-async def test_oauth2_token_manager_error_handling(mock_post, token_manager):
+@patch("httpx.Client.post")
+def test_oauth2_token_manager_error_handling(mock_post, token_manager):
"""OAuth2TokenManager handles HTTP errors gracefully."""
from httpx import HTTPStatusError, Request
@@ -283,7 +186,7 @@ async def test_oauth2_token_manager_error_handling(mock_post, token_manager):
)
with pytest.raises(Exception, match="Failed to refresh token: 401"):
- await token_manager.get_access_token()
+ token_manager.get_access_token()
@patch("jwt.JWT.encode")
@@ -313,112 +216,134 @@ def test_oauth2_token_manager_jwt_assertion_creation(
assert "exp" in claims
-# FHIR Config Tests (Core Validation Only)
-def test_fhir_auth_config_validation_mirrors_oauth2_config():
- """FHIRAuthConfig enforces same validation rules as OAuth2Config."""
- # Should fail with same validation error
- with pytest.raises(
- ValueError, match="Either client_secret or client_secret_path must be provided"
- ):
- FHIRAuthConfig(
- client_id="test_client",
- token_url="https://example.com/token",
- base_url="https://example.com/fhir/R4",
+def test_oauth2_token_manager_thread_safety():
+ """OAuth2TokenManager handles concurrent token requests safely using threading.Lock."""
+ import time
+ from concurrent.futures import ThreadPoolExecutor
+
+ config = OAuth2Config(
+ client_id="test_client",
+ client_secret="test_secret",
+ token_url="https://test.fhir.org/oauth/token",
+ )
+
+ token_manager = OAuth2TokenManager(config)
+
+ # Mock the token refresh to track calls
+ refresh_calls = []
+ token_manager._refresh_token
+
+ def tracked_refresh():
+ refresh_calls.append(time.time())
+ # Simulate network delay
+ time.sleep(0.1)
+ # Create a mock token that won't expire during test
+ from datetime import datetime, timedelta
+
+ token_manager._token = TokenInfo(
+ access_token="test_token",
+ expires_in=3600, # Long expiry to avoid expiration during test
+ token_type="Bearer",
+ expires_at=datetime.now() + timedelta(seconds=3600),
)
+ token_manager._refresh_token = tracked_refresh
+
+ # Test concurrent access with multiple threads
+ def get_token():
+ return token_manager.get_access_token()
+
+ # Run multiple threads concurrently trying to get token
+ with ThreadPoolExecutor(max_workers=5) as executor:
+ futures = [executor.submit(get_token) for _ in range(5)]
+ tokens = [future.result() for future in futures]
+
+ # All threads should get the same token
+ assert all(token == "test_token" for token in tokens)
+
+ # Only one refresh should have occurred due to thread safety
+ assert len(refresh_calls) == 1, f"Expected 1 refresh call, got {len(refresh_calls)}"
-def test_fhir_auth_config_to_oauth2_config_conversion():
- """FHIRAuthConfig correctly converts to OAuth2Config preserving all auth settings."""
- fhir_config = FHIRAuthConfig(
+
+def test_oauth2_token_manager_uses_threading_lock():
+ """OAuth2TokenManager uses threading.Lock for synchronization."""
+ import threading
+
+ config = OAuth2Config(
client_id="test_client",
- client_secret_path="/path/to/private.pem",
- token_url="https://example.com/token",
- base_url="https://example.com/fhir/R4",
- use_jwt_assertion=True,
- scope="custom_scope",
- audience="custom_audience",
+ client_secret="test_secret",
+ token_url="https://test.fhir.org/oauth/token",
)
- oauth2_config = fhir_config.to_oauth2_config()
-
- # Verify auth-related fields are preserved
- assert oauth2_config.client_id == fhir_config.client_id
- assert oauth2_config.client_secret_path == fhir_config.client_secret_path
- assert oauth2_config.token_url == fhir_config.token_url
- assert oauth2_config.use_jwt_assertion == fhir_config.use_jwt_assertion
- assert oauth2_config.scope == fhir_config.scope
- assert oauth2_config.audience == fhir_config.audience
-
-
-# Connection String Parsing Tests (Core Functionality)
-@pytest.mark.parametrize(
- "connection_string,expected_error",
- [
- # Invalid scheme
- ("invalid://not-fhir", "Connection string must start with fhir://"),
- # Missing required params
- (
- "fhir://example.com/fhir/R4?client_id=test_client",
- "Missing required parameters",
- ),
- # Missing secrets
- (
- "fhir://example.com/fhir/R4?client_id=test&token_url=https://example.com/token",
- "Either 'client_secret' or 'client_secret_path' parameter must be provided",
- ),
- # Both secrets
- (
- "fhir://example.com/fhir/R4?client_id=test&client_secret=secret&client_secret_path=/path&token_url=https://example.com/token",
- "Cannot provide both 'client_secret' and 'client_secret_path' parameters",
- ),
- ],
-)
-def test_connection_string_parsing_validation(connection_string, expected_error):
- """Connection string parsing enforces validation rules."""
- with pytest.raises(ValueError, match=expected_error):
- parse_fhir_auth_connection_string(connection_string)
-
-
-def test_connection_string_parsing_handles_both_auth_types():
- """Connection string parsing correctly handles both standard and JWT authentication."""
- # Standard auth
- standard_string = "fhir://example.com/fhir/R4?client_id=test&client_secret=secret&token_url=https://example.com/token"
- standard_config = parse_fhir_auth_connection_string(standard_string)
- assert standard_config.client_secret == "secret"
- assert standard_config.client_secret_path is None
- assert not standard_config.use_jwt_assertion
-
- # JWT auth
- jwt_string = (
- "fhir://example.com/fhir/R4?client_id=test&client_secret_path=/path/key.pem&"
- "token_url=https://example.com/token&use_jwt_assertion=true"
+ token_manager = OAuth2TokenManager(config)
+
+ # Verify that the token manager has a threading lock
+ assert hasattr(token_manager, "_refresh_lock")
+ assert isinstance(token_manager._refresh_lock, type(threading.Lock()))
+
+
+def test_sync_token_manager_methods_are_not_async():
+ """Sync OAuth2TokenManager methods are synchronous, not async."""
+ import inspect
+
+ config = OAuth2Config(
+ client_id="test_client",
+ client_secret="test_secret",
+ token_url="https://test.fhir.org/oauth/token",
)
- jwt_config = parse_fhir_auth_connection_string(jwt_string)
- assert jwt_config.client_secret is None
- assert jwt_config.client_secret_path == "/path/key.pem"
- assert jwt_config.use_jwt_assertion
-
-
-def test_connection_string_parsing_handles_complex_parameters():
- """Connection string parsing correctly handles all parameters and URL encoding."""
- connection_string = (
- "fhir://example.com:8080/fhir/R4?"
- "client_id=test%20client&"
- "client_secret=test%20secret&"
- "token_url=https%3A//example.com/token&"
- "scope=system%2F*.read&"
- "audience=https://example.com/fhir&"
- "timeout=60&"
- "verify_ssl=false"
+
+ token_manager = OAuth2TokenManager(config)
+
+ # Verify get_access_token is not a coroutine function
+ assert not inspect.iscoroutinefunction(token_manager.get_access_token)
+ assert not inspect.iscoroutinefunction(token_manager._refresh_token)
+
+ # Test that calling the method doesn't return a coroutine
+ # Mock the token refresh method directly to avoid HTTP calls
+ from datetime import datetime, timedelta
+
+ with patch.object(token_manager, "_refresh_token") as mock_refresh:
+
+ def mock_refresh_side_effect():
+ token_manager._token = TokenInfo(
+ access_token="test_token",
+ expires_in=3600,
+ token_type="Bearer",
+ expires_at=datetime.now() + timedelta(seconds=3600),
+ )
+
+ mock_refresh.side_effect = mock_refresh_side_effect
+
+ result = token_manager.get_access_token()
+
+ # Result should be a string, not a coroutine
+ assert isinstance(result, str)
+ assert not inspect.iscoroutine(result)
+ assert result == "test_token"
+
+
+def test_sync_vs_async_token_manager_distinction():
+ """OAuth2TokenManager (sync) and AsyncOAuth2TokenManager (async) have distinct behaviors."""
+ from healthchain.gateway.clients.auth import AsyncOAuth2TokenManager
+ import inspect
+
+ config = OAuth2Config(
+ client_id="test_client",
+ client_secret="test_secret",
+ token_url="https://test.fhir.org/oauth/token",
)
- config = parse_fhir_auth_connection_string(connection_string)
+ sync_manager = OAuth2TokenManager(config)
+ async_manager = AsyncOAuth2TokenManager(config)
+
+ # Sync manager should have threading.Lock
+ assert hasattr(sync_manager, "_refresh_lock")
+ assert isinstance(sync_manager._refresh_lock, type(__import__("threading").Lock()))
+
+ # Async manager should have asyncio.Lock (or None initially)
+ assert hasattr(async_manager, "_refresh_lock")
+ assert async_manager._refresh_lock is None # Created lazily
- assert config.client_id == "test client" # URL decoded
- assert config.client_secret == "test secret" # URL decoded
- assert config.token_url == "https://example.com/token" # URL decoded
- assert config.scope == "system/*.read" # URL decoded
- assert config.base_url == "https://example.com:8080/fhir/R4"
- assert config.audience == "https://example.com/fhir"
- assert config.timeout == 60
- assert not config.verify_ssl
+ # Method signatures should be different
+ assert not inspect.iscoroutinefunction(sync_manager.get_access_token)
+ assert inspect.iscoroutinefunction(async_manager.get_access_token)
diff --git a/tests/gateway/test_auth_async.py b/tests/gateway/test_auth_async.py
new file mode 100644
index 00000000..f6a049a1
--- /dev/null
+++ b/tests/gateway/test_auth_async.py
@@ -0,0 +1,220 @@
+"""
+Tests for the OAuth2 authentication module in the HealthChain gateway system.
+
+This module tests OAuth2 token management, configuration, and connection string parsing.
+"""
+
+import pytest
+import tempfile
+import os
+from unittest.mock import patch, Mock
+from datetime import datetime, timedelta
+
+from healthchain.gateway.clients.auth import (
+ OAuth2Config,
+ TokenInfo,
+ AsyncOAuth2TokenManager,
+)
+
+
+@pytest.fixture
+def oauth2_config():
+ """Create a basic OAuth2 configuration for testing."""
+ return OAuth2Config(
+ client_id="test_client",
+ client_secret="test_secret",
+ token_url="https://example.com/oauth/token",
+ scope="system/*.read",
+ audience="https://example.com/fhir",
+ )
+
+
+@pytest.fixture
+def oauth2_config_jwt():
+ """Create an OAuth2 configuration for JWT assertion testing."""
+ return OAuth2Config(
+ client_id="test_client",
+ client_secret_path="/path/to/private.pem",
+ token_url="https://example.com/oauth/token",
+ scope="system/*.read",
+ audience="https://example.com/fhir",
+ use_jwt_assertion=True,
+ )
+
+
+@pytest.fixture
+def token_manager(oauth2_config):
+ """Create an OAuth2TokenManager for testing."""
+ return AsyncOAuth2TokenManager(oauth2_config)
+
+
+@pytest.fixture
+def token_manager_jwt(oauth2_config_jwt):
+ """Create an OAuth2TokenManager for JWT testing."""
+ return AsyncOAuth2TokenManager(oauth2_config_jwt)
+
+
+@pytest.fixture
+def mock_token_response():
+ """Create a mock token response."""
+ return {
+ "access_token": "test_access_token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "scope": "system/*.read",
+ }
+
+
+@pytest.fixture
+def temp_key_file():
+ """Create a temporary private key file for testing."""
+ key_content = """-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4f6a8v...
+-----END PRIVATE KEY-----"""
+
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
+ f.write(key_content)
+ temp_path = f.name
+
+ yield temp_path
+
+ # Cleanup
+ os.unlink(temp_path)
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.post")
+async def test_oauth2_token_manager_standard_flow(
+ mock_post, token_manager, mock_token_response
+):
+ """OAuth2TokenManager performs standard client credentials flow correctly."""
+ # Mock successful response
+ mock_response = Mock()
+ mock_response.json.return_value = mock_token_response
+ mock_response.raise_for_status.return_value = None
+ mock_post.return_value = mock_response
+
+ token = await token_manager.get_access_token()
+
+ # Verify token returned
+ assert token == "test_access_token"
+
+ # Verify correct request data for standard flow
+ call_args = mock_post.call_args
+ request_data = call_args[1]["data"]
+ assert request_data["grant_type"] == "client_credentials"
+ assert request_data["client_id"] == "test_client"
+ assert request_data["client_secret"] == "test_secret"
+ assert "client_assertion" not in request_data
+
+
+@pytest.mark.asyncio
+@patch("healthchain.gateway.clients.auth.AsyncOAuth2TokenManager._create_jwt_assertion")
+@patch("httpx.AsyncClient.post")
+async def test_oauth2_token_manager_jwt_flow(
+ mock_post, mock_create_jwt, token_manager_jwt, mock_token_response
+):
+ """OAuth2TokenManager performs JWT assertion flow correctly."""
+ mock_create_jwt.return_value = "mock_jwt_assertion"
+
+ # Mock successful response
+ mock_response = Mock()
+ mock_response.json.return_value = mock_token_response
+ mock_response.raise_for_status.return_value = None
+ mock_post.return_value = mock_response
+
+ token = await token_manager_jwt.get_access_token()
+ assert token == "test_access_token"
+
+ # Verify JWT-specific request data
+ call_args = mock_post.call_args
+ request_data = call_args[1]["data"]
+ assert request_data["grant_type"] == "client_credentials"
+ assert (
+ request_data["client_assertion_type"]
+ == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
+ )
+ assert request_data["client_assertion"] == "mock_jwt_assertion"
+ assert "client_secret" not in request_data
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.post")
+async def test_oauth2_token_manager_caching_and_refresh(
+ mock_post, token_manager, mock_token_response
+):
+ """OAuth2TokenManager caches valid tokens and refreshes expired ones."""
+ # Set up valid cached token
+ token_manager._token = TokenInfo(
+ access_token="cached_token",
+ expires_in=3600,
+ expires_at=datetime.now() + timedelta(hours=1),
+ )
+
+ # Should use cached token
+ token = await token_manager.get_access_token()
+ assert token == "cached_token"
+ mock_post.assert_not_called()
+
+ # Set expired token
+ token_manager._token = TokenInfo(
+ access_token="expired_token",
+ expires_in=3600,
+ expires_at=datetime.now() - timedelta(minutes=10),
+ )
+
+ # Mock refresh response
+ mock_response = Mock()
+ mock_response.json.return_value = mock_token_response
+ mock_response.raise_for_status.return_value = None
+ mock_post.return_value = mock_response
+
+ # Should refresh token
+ token = await token_manager.get_access_token()
+ assert token == "test_access_token"
+ mock_post.assert_called_once()
+
+
+@pytest.mark.asyncio
+@patch("httpx.AsyncClient.post")
+async def test_oauth2_token_manager_error_handling(mock_post, token_manager):
+ """OAuth2TokenManager handles HTTP errors gracefully."""
+ from httpx import HTTPStatusError, Request
+
+ mock_response = Mock()
+ mock_response.status_code = 401
+ mock_response.text = "Unauthorized"
+
+ mock_post.side_effect = HTTPStatusError(
+ "401 Unauthorized", request=Mock(spec=Request), response=mock_response
+ )
+
+ with pytest.raises(Exception, match="Failed to refresh token: 401"):
+ await token_manager.get_access_token()
+
+
+@patch("jwt.JWT.encode")
+@patch("jwt.jwk_from_pem")
+def test_oauth2_token_manager_jwt_assertion_creation(
+ mock_jwk_from_pem, mock_jwt_encode, token_manager_jwt, temp_key_file
+):
+ """OAuth2TokenManager creates valid JWT assertions with correct claims."""
+ token_manager_jwt.config.client_secret_path = temp_key_file
+
+ mock_key = Mock()
+ mock_jwk_from_pem.return_value = mock_key
+ mock_jwt_encode.return_value = "signed_jwt_token"
+
+ jwt_assertion = token_manager_jwt._create_jwt_assertion()
+
+ assert jwt_assertion == "signed_jwt_token"
+
+ # Verify JWT claims structure
+ call_args = mock_jwt_encode.call_args[0]
+ claims = call_args[0]
+ assert claims["iss"] == "test_client"
+ assert claims["sub"] == "test_client"
+ assert claims["aud"] == "https://example.com/oauth/token"
+ assert "jti" in claims
+ assert "iat" in claims
+ assert "exp" in claims
diff --git a/tests/gateway/test_core_base.py b/tests/gateway/test_base.py
similarity index 99%
rename from tests/gateway/test_core_base.py
rename to tests/gateway/test_base.py
index 6d1adb8c..1ac95ef7 100644
--- a/tests/gateway/test_core_base.py
+++ b/tests/gateway/test_base.py
@@ -12,7 +12,7 @@
from unittest.mock import Mock, AsyncMock
from typing import Dict, Any
-from healthchain.gateway.core.base import (
+from healthchain.gateway.base import (
BaseGateway,
BaseProtocolHandler,
EventCapability,
diff --git a/tests/gateway/test_base_auth.py b/tests/gateway/test_base_auth.py
new file mode 100644
index 00000000..4d883a85
--- /dev/null
+++ b/tests/gateway/test_base_auth.py
@@ -0,0 +1,288 @@
+"""
+Tests for shared OAuth2 authentication functionality in the HealthChain gateway system.
+
+This module tests shared validation logic, configuration, and connection string parsing
+that should work identically across sync and async implementations.
+"""
+
+import pytest
+import tempfile
+import os
+from unittest.mock import patch
+from datetime import datetime, timedelta
+
+from healthchain.gateway.clients.auth import (
+ OAuth2Config,
+ TokenInfo,
+ OAuth2TokenManager,
+)
+from healthchain.gateway.clients.fhir.base import (
+ FHIRAuthConfig,
+ parse_fhir_auth_connection_string,
+)
+
+
+@pytest.fixture
+def oauth2_config():
+ """Create a basic OAuth2 configuration for testing."""
+ return OAuth2Config(
+ client_id="test_client",
+ client_secret="test_secret",
+ token_url="https://example.com/oauth/token",
+ scope="system/*.read",
+ audience="https://example.com/fhir",
+ )
+
+
+@pytest.fixture
+def oauth2_config_jwt():
+ """Create an OAuth2 configuration for JWT assertion testing."""
+ return OAuth2Config(
+ client_id="test_client",
+ client_secret_path="/path/to/private.pem",
+ token_url="https://example.com/oauth/token",
+ scope="system/*.read",
+ audience="https://example.com/fhir",
+ use_jwt_assertion=True,
+ )
+
+
+@pytest.fixture
+def temp_key_file():
+ """Create a temporary private key file for testing."""
+ key_content = """-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4f6a8v...
+-----END PRIVATE KEY-----"""
+
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as f:
+ f.write(key_content)
+ temp_path = f.name
+
+ yield temp_path
+
+ # Cleanup
+ os.unlink(temp_path)
+
+
+@pytest.mark.parametrize(
+ "config_args,expected_error",
+ [
+ # Missing both secrets
+ (
+ {"client_id": "test", "token_url": "https://example.com/token"},
+ "Either client_secret or client_secret_path must be provided",
+ ),
+ # Both secrets provided
+ (
+ {
+ "client_id": "test",
+ "client_secret": "secret",
+ "client_secret_path": "/path",
+ "token_url": "https://example.com/token",
+ },
+ "Cannot provide both client_secret and client_secret_path",
+ ),
+ # client_secret_path without JWT assertion
+ (
+ {
+ "client_id": "test",
+ "client_secret_path": "/path",
+ "token_url": "https://example.com/token",
+ },
+ "client_secret_path can only be used with use_jwt_assertion=True",
+ ),
+ ],
+)
+def test_oauth2_config_validation_rules(config_args, expected_error):
+ """OAuth2Config enforces validation rules for secret configuration."""
+ with pytest.raises(ValueError, match=expected_error):
+ OAuth2Config(**config_args)
+
+
+def test_oauth2_config_secret_value_reads_from_file(temp_key_file):
+ """OAuth2Config reads secret from file when client_secret_path is provided."""
+ config = OAuth2Config(
+ client_id="test_client",
+ client_secret_path=temp_key_file,
+ token_url="https://example.com/token",
+ use_jwt_assertion=True,
+ )
+ secret_value = config.secret_value
+ assert "BEGIN PRIVATE KEY" in secret_value
+ assert "END PRIVATE KEY" in secret_value
+
+
+def test_oauth2_config_secret_value_handles_file_errors():
+ """OAuth2Config raises clear error when file cannot be read."""
+ config = OAuth2Config(
+ client_id="test_client",
+ client_secret_path="/nonexistent/file.pem",
+ token_url="https://example.com/token",
+ use_jwt_assertion=True,
+ )
+ with pytest.raises(ValueError, match="Failed to read secret from"):
+ _ = config.secret_value
+
+
+def test_token_info_expiration_logic():
+ """TokenInfo correctly calculates expiration with buffer."""
+ # Test near-expiry with buffer
+ near_expiry_token = TokenInfo(
+ access_token="test_token",
+ expires_in=240,
+ expires_at=datetime.now() + timedelta(minutes=4),
+ )
+ assert near_expiry_token.is_expired(
+ buffer_seconds=300
+ ) # 5 min buffer, expires in 4
+ assert not near_expiry_token.is_expired(
+ buffer_seconds=120
+ ) # 2 min buffer, expires in 4
+
+
+@pytest.fixture
+def token_manager_jwt(temp_key_file):
+ """Create an OAuth2TokenManager for JWT testing - uses sync manager for shared logic testing."""
+ oauth2_config_jwt = OAuth2Config(
+ client_id="test_client",
+ client_secret_path=temp_key_file,
+ token_url="https://example.com/oauth/token",
+ scope="system/*.read",
+ audience="https://example.com/fhir",
+ use_jwt_assertion=True,
+ )
+ return OAuth2TokenManager(oauth2_config_jwt)
+
+
+@patch("jwt.JWT.encode")
+@patch("jwt.jwk_from_pem")
+def test_oauth2_token_manager_jwt_assertion_creation(
+ mock_jwk_from_pem, mock_jwt_encode, token_manager_jwt
+):
+ """OAuth2TokenManager creates valid JWT assertions for authentication."""
+ mock_jwt_encode.return_value = "signed_jwt_token"
+ mock_jwk_from_pem.return_value = "mock_key"
+
+ jwt_assertion = token_manager_jwt._create_jwt_assertion()
+
+ assert jwt_assertion == "signed_jwt_token"
+
+ # Verify JWT claims structure
+ call_args = mock_jwt_encode.call_args[0]
+ claims = call_args[0]
+ assert claims["iss"] == "test_client"
+ assert claims["sub"] == "test_client"
+ assert claims["aud"] == "https://example.com/oauth/token"
+ assert "jti" in claims
+ assert "iat" in claims
+ assert "exp" in claims
+
+
+def test_fhir_auth_config_validation_mirrors_oauth2_config():
+ """FHIRAuthConfig enforces same validation rules as OAuth2Config."""
+ # Should fail with same validation error
+ with pytest.raises(
+ ValueError,
+ match="Either client_secret or client_secret_path must be provided",
+ ):
+ FHIRAuthConfig(
+ client_id="test_client",
+ token_url="https://example.com/token",
+ base_url="https://example.com/fhir/R4",
+ )
+
+
+def test_fhir_auth_config_to_oauth2_config_conversion():
+ """FHIRAuthConfig correctly converts to OAuth2Config preserving all auth settings."""
+ fhir_config = FHIRAuthConfig(
+ client_id="test_client",
+ client_secret_path="/path/to/private.pem",
+ token_url="https://example.com/token",
+ base_url="https://example.com/fhir/R4",
+ use_jwt_assertion=True,
+ scope="custom_scope",
+ audience="custom_audience",
+ )
+
+ oauth2_config = fhir_config.to_oauth2_config()
+
+ # Verify auth-related fields are preserved
+ assert oauth2_config.client_id == fhir_config.client_id
+ assert oauth2_config.client_secret_path == fhir_config.client_secret_path
+ assert oauth2_config.token_url == fhir_config.token_url
+ assert oauth2_config.use_jwt_assertion == fhir_config.use_jwt_assertion
+ assert oauth2_config.scope == fhir_config.scope
+ assert oauth2_config.audience == fhir_config.audience
+
+
+@pytest.mark.parametrize(
+ "connection_string,expected_error",
+ [
+ # Invalid scheme
+ ("invalid://not-fhir", "Connection string must start with fhir://"),
+ # Missing required params
+ (
+ "fhir://example.com/fhir/R4?client_id=test_client",
+ "Missing required parameters",
+ ),
+ # Missing secrets
+ (
+ "fhir://example.com/fhir/R4?client_id=test&token_url=https://example.com/token",
+ "Either 'client_secret' or 'client_secret_path' parameter must be provided",
+ ),
+ # Both secrets
+ (
+ "fhir://example.com/fhir/R4?client_id=test&client_secret=secret&client_secret_path=/path&token_url=https://example.com/token",
+ "Cannot provide both 'client_secret' and 'client_secret_path' parameters",
+ ),
+ ],
+)
+def test_connection_string_parsing_validation(connection_string, expected_error):
+ """Connection string parsing enforces validation rules."""
+ with pytest.raises(ValueError, match=expected_error):
+ parse_fhir_auth_connection_string(connection_string)
+
+
+def test_connection_string_parsing_handles_both_auth_types():
+ """Connection string parsing correctly handles both standard and JWT authentication."""
+ # Standard auth
+ standard_string = "fhir://example.com/fhir/R4?client_id=test&client_secret=secret&token_url=https://example.com/token"
+ standard_config = parse_fhir_auth_connection_string(standard_string)
+ assert standard_config.client_secret == "secret"
+ assert standard_config.client_secret_path is None
+ assert not standard_config.use_jwt_assertion
+
+ # JWT auth
+ jwt_string = (
+ "fhir://example.com/fhir/R4?client_id=test&client_secret_path=/path/key.pem&"
+ "token_url=https://example.com/token&use_jwt_assertion=true"
+ )
+ jwt_config = parse_fhir_auth_connection_string(jwt_string)
+ assert jwt_config.client_secret is None
+ assert jwt_config.client_secret_path == "/path/key.pem"
+ assert jwt_config.use_jwt_assertion
+
+
+def test_connection_string_parsing_handles_complex_parameters():
+ """Connection string parsing correctly handles all parameters and URL encoding."""
+ connection_string = (
+ "fhir://example.com:8080/fhir/R4?"
+ "client_id=test%20client&"
+ "client_secret=test%20secret&"
+ "token_url=https%3A//example.com/token&"
+ "scope=system%2F*.read&"
+ "audience=https://example.com/fhir&"
+ "timeout=60&"
+ "verify_ssl=false"
+ )
+
+ config = parse_fhir_auth_connection_string(connection_string)
+
+ assert config.client_id == "test client" # URL decoded
+ assert config.client_secret == "test secret" # URL decoded
+ assert config.token_url == "https://example.com/token" # URL decoded
+ assert config.scope == "system/*.read" # URL decoded
+ assert config.base_url == "https://example.com:8080/fhir/R4"
+ assert config.audience == "https://example.com/fhir"
+ assert config.timeout == 60
+ assert not config.verify_ssl
diff --git a/tests/gateway/test_base_connection_manager.py b/tests/gateway/test_base_connection_manager.py
new file mode 100644
index 00000000..15afebe7
--- /dev/null
+++ b/tests/gateway/test_base_connection_manager.py
@@ -0,0 +1,234 @@
+"""
+Tests for shared FHIR connection manager functionality in the HealthChain gateway system.
+
+This module tests shared connection string validation, source management, and error handling
+that should work identically across sync and async connection manager implementations.
+"""
+
+import pytest
+from unittest.mock import Mock
+
+from healthchain.gateway.clients.fhir.sync.connection import FHIRConnectionManager
+from healthchain.gateway.clients.fhir.aio.connection import AsyncFHIRConnectionManager
+from healthchain.gateway.fhir.errors import FHIRConnectionError
+from healthchain.gateway.api.protocols import FHIRServerInterfaceProtocol
+
+
+@pytest.fixture(params=["sync", "async"])
+def connection_manager(request):
+ """Fixture providing both sync and async connection managers."""
+ if request.param == "sync":
+ return FHIRConnectionManager()
+ else:
+ return AsyncFHIRConnectionManager(
+ max_connections=50, max_keepalive_connections=10, keepalive_expiry=30.0
+ )
+
+
+@pytest.fixture
+def mock_fhir_client():
+ """Create a mock FHIR client using protocol."""
+ client = Mock(spec=FHIRServerInterfaceProtocol)
+ client.base_url = "https://test.fhir.com/R4"
+ return client
+
+
+@pytest.mark.parametrize(
+ "connection_string,should_succeed",
+ [
+ # Valid connection strings
+ (
+ "fhir://epic.org/api/FHIR/R4?client_id=test&client_secret=secret&token_url=https://epic.org/token",
+ True,
+ ),
+ (
+ "fhir://localhost:8080/fhir?client_id=local&client_secret=pass&token_url=http://localhost/token",
+ True,
+ ),
+ # Invalid connection strings
+ ("http://not-fhir.com/api", False), # Wrong scheme
+ ("fhir://", False), # Missing hostname
+ ("invalid-string", False), # Not a URL
+ ],
+)
+def test_connection_manager_source_validation_and_parsing(
+ connection_manager, connection_string, should_succeed
+):
+ """Connection managers validate connection strings and parse hostnames correctly."""
+ if should_succeed:
+ connection_manager.add_source("test_source", connection_string)
+ assert "test_source" in connection_manager.sources
+ assert "test_source" in connection_manager._connection_strings
+ assert (
+ connection_manager._connection_strings["test_source"] == connection_string
+ )
+ else:
+ with pytest.raises(
+ FHIRConnectionError, match="Failed to parse connection string"
+ ):
+ connection_manager.add_source("test_source", connection_string)
+
+
+@pytest.mark.asyncio
+async def test_connection_manager_handles_invalid_source_gracefully(connection_manager):
+ """Connection managers handle requests for unknown sources gracefully."""
+ # Add one valid source
+ connection_manager.add_source(
+ "valid_source",
+ "fhir://valid.com/fhir?client_id=test&client_secret=secret&token_url=https://valid.com/token",
+ )
+
+ # Request client for non-existent source - should raise ValueError
+ with pytest.raises(ValueError, match="Unknown source: invalid_source"):
+ if hasattr(connection_manager, "get_client"):
+ # Check if it's async by seeing if the method is a coroutine function
+ import inspect
+
+ if inspect.iscoroutinefunction(connection_manager.get_client):
+ # For async version
+ await connection_manager.get_client("invalid_source")
+ else:
+ # For sync version
+ connection_manager.get_client("invalid_source")
+
+ # Verify the source was actually added correctly
+ assert "valid_source" in connection_manager.sources
+ assert "valid_source" in connection_manager._connection_strings
+
+
+def test_connection_manager_source_storage_consistency(connection_manager):
+ """Connection managers consistently store sources and connection strings."""
+ test_sources = [
+ (
+ "source1",
+ "fhir://source1.com/fhir?client_id=test&client_secret=secret&token_url=https://source1.com/token",
+ ),
+ (
+ "source2",
+ "fhir://source2.com/fhir?client_id=test&client_secret=secret&token_url=https://source2.com/token",
+ ),
+ (
+ "source3",
+ "fhir://source3.com/fhir?client_id=test&client_secret=secret&token_url=https://source3.com/token",
+ ),
+ ]
+
+ # Add all sources
+ for name, connection_string in test_sources:
+ connection_manager.add_source(name, connection_string)
+
+ # Verify all sources are stored consistently
+ for name, connection_string in test_sources:
+ assert name in connection_manager.sources
+ assert name in connection_manager._connection_strings
+ assert connection_manager._connection_strings[name] == connection_string
+
+ # Verify source count
+ assert len(connection_manager.sources) == 3
+ assert len(connection_manager._connection_strings) == 3
+
+
+def test_connection_manager_handles_duplicate_source_names(connection_manager):
+ """Connection managers handle duplicate source names by overwriting."""
+ original_connection = "fhir://original.com/fhir?client_id=test&client_secret=secret&token_url=https://original.com/token"
+ updated_connection = "fhir://updated.com/fhir?client_id=test&client_secret=secret&token_url=https://updated.com/token"
+
+ # Add original source
+ connection_manager.add_source("duplicate_source", original_connection)
+ assert (
+ connection_manager._connection_strings["duplicate_source"]
+ == original_connection
+ )
+
+ # Add source with same name but different connection string
+ connection_manager.add_source("duplicate_source", updated_connection)
+ assert (
+ connection_manager._connection_strings["duplicate_source"] == updated_connection
+ )
+
+ # Should only have one source with that name
+ source_count = sum(
+ 1 for name in connection_manager.sources.keys() if name == "duplicate_source"
+ )
+ assert source_count == 1
+
+
+def test_connection_manager_handles_connection_string_corruption(connection_manager):
+ """Connection managers handle corrupted connection strings gracefully."""
+ # Test with both sync and async managers
+ for manager_class in [FHIRConnectionManager, AsyncFHIRConnectionManager]:
+ if manager_class == AsyncFHIRConnectionManager:
+ manager = manager_class(max_connections=10)
+ else:
+ manager = manager_class()
+
+ # Add valid source first
+ manager.add_source(
+ "valid",
+ "fhir://valid.com/fhir?client_id=test&client_secret=secret&token_url=https://valid.com/token",
+ )
+
+ # Verify source was added correctly
+ assert "valid" in manager.sources
+ assert "valid" in manager._connection_strings
+
+ # Manually corrupt the connection string (simulating memory corruption)
+ manager._connection_strings["valid"] = "corrupted_string_not_url"
+
+ # The connection string should now be corrupted
+ assert manager._connection_strings["valid"] == "corrupted_string_not_url"
+
+
+def test_connection_manager_memory_cleanup_on_source_removal(connection_manager):
+ """Connection managers properly clean up memory when sources are removed."""
+ # Test with both sync and async managers
+ for manager_class in [FHIRConnectionManager, AsyncFHIRConnectionManager]:
+ if manager_class == AsyncFHIRConnectionManager:
+ manager = manager_class(max_connections=10)
+ else:
+ manager = manager_class()
+
+ # Add source
+ manager.add_source(
+ "temp_source",
+ "fhir://temp.com/fhir?client_id=test&client_secret=secret&token_url=https://temp.com/token",
+ )
+
+ # Verify source was added
+ assert "temp_source" in manager.sources
+ assert "temp_source" in manager._connection_strings
+
+ # Simulate source removal (manual cleanup to verify memory management)
+ del manager.sources["temp_source"]
+ del manager._connection_strings["temp_source"]
+
+ # Verify cleanup worked
+ assert "temp_source" not in manager.sources
+ assert "temp_source" not in manager._connection_strings
+
+
+def test_connection_manager_initialization_state(connection_manager):
+ """Connection managers initialize with empty state."""
+ assert len(connection_manager.sources) == 0
+ assert len(connection_manager._connection_strings) == 0
+ assert isinstance(connection_manager.sources, dict)
+ assert isinstance(connection_manager._connection_strings, dict)
+
+
+def test_connection_manager_source_name_validation(connection_manager):
+ """Connection managers accept various source name formats."""
+ valid_names = [
+ "simple_name",
+ "name-with-dashes",
+ "name.with.dots",
+ "name_123",
+ "UPPERCASE_NAME",
+ "MixedCase_Name-123.test",
+ ]
+
+ connection_string = "fhir://test.com/fhir?client_id=test&client_secret=secret&token_url=https://test.com/token"
+
+ for name in valid_names:
+ connection_manager.add_source(name, connection_string)
+ assert name in connection_manager.sources
+ assert name in connection_manager._connection_strings
diff --git a/tests/gateway/test_base_fhir_client.py b/tests/gateway/test_base_fhir_client.py
new file mode 100644
index 00000000..16360200
--- /dev/null
+++ b/tests/gateway/test_base_fhir_client.py
@@ -0,0 +1,200 @@
+"""
+Tests for shared FHIR client functionality in the HealthChain gateway system.
+
+This module tests shared initialization, validation, and utility logic that should work
+identically across sync and async FHIR client implementations.
+"""
+
+import pytest
+import json
+import httpx
+from unittest.mock import Mock, patch
+from fhir.resources.patient import Patient
+
+from healthchain.gateway.clients.fhir.sync import FHIRClient
+from healthchain.gateway.clients.fhir.aio import AsyncFHIRClient
+from healthchain.gateway.clients.fhir.base import FHIRClientError
+from healthchain.gateway.clients.fhir.base import FHIRAuthConfig
+
+
+@pytest.fixture
+def mock_auth_config():
+ """Create a mock FHIR auth configuration."""
+ return FHIRAuthConfig(
+ base_url="https://test.fhir.org/R4",
+ client_id="test_client",
+ client_secret="test_secret",
+ token_url="https://test.fhir.org/oauth/token",
+ scope="system/*.read",
+ timeout=30.0,
+ verify_ssl=True,
+ )
+
+
+@pytest.fixture(params=["sync", "async"])
+def fhir_client(request, mock_auth_config):
+ """Fixture providing both sync and async FHIR clients."""
+ if request.param == "sync":
+ with patch(
+ "healthchain.gateway.clients.auth.OAuth2TokenManager"
+ ) as mock_manager_class:
+ mock_manager = Mock()
+ mock_manager.get_access_token = Mock(return_value="test_token")
+ mock_manager_class.return_value = mock_manager
+
+ client = FHIRClient(auth_config=mock_auth_config)
+ client.token_manager = mock_manager
+ return client
+ else:
+ with patch(
+ "healthchain.gateway.clients.auth.OAuth2TokenManager"
+ ) as mock_manager_class:
+ mock_manager = Mock()
+ mock_manager.get_access_token = Mock(return_value="test_token")
+ mock_manager_class.return_value = mock_manager
+
+ client = AsyncFHIRClient(auth_config=mock_auth_config)
+ client.token_manager = mock_manager
+ return client
+
+
+class TestSharedFHIRClientInitialization:
+ """Test FHIR client initialization that should be identical across sync/async implementations."""
+
+ def test_fhir_client_initialization_and_configuration(self, mock_auth_config):
+ """FHIR clients initialize with correct configuration and headers."""
+ with patch("healthchain.gateway.clients.auth.OAuth2TokenManager"):
+ # Test sync client
+ sync_client = FHIRClient(auth_config=mock_auth_config)
+ assert sync_client.base_url == "https://test.fhir.org/R4/"
+ assert sync_client.timeout == 30.0
+ assert sync_client.verify_ssl is True
+ assert sync_client.base_headers["Accept"] == "application/fhir+json"
+ assert sync_client.base_headers["Content-Type"] == "application/fhir+json"
+
+ # Test async client
+ async_client = AsyncFHIRClient(auth_config=mock_auth_config)
+ assert async_client.base_url == "https://test.fhir.org/R4/"
+ assert async_client.timeout == 30.0
+ assert async_client.verify_ssl is True
+ assert async_client.base_headers["Accept"] == "application/fhir+json"
+ assert async_client.base_headers["Content-Type"] == "application/fhir+json"
+
+ def test_fhir_client_conforms_to_protocol(self, fhir_client):
+ """FHIR clients implement the required protocol methods."""
+ # Check that client has all required protocol methods
+ assert hasattr(fhir_client, "read")
+ assert hasattr(fhir_client, "search")
+ assert hasattr(fhir_client, "create")
+ assert hasattr(fhir_client, "update")
+ assert hasattr(fhir_client, "delete")
+ assert hasattr(fhir_client, "transaction")
+ assert hasattr(fhir_client, "capabilities")
+
+ # Check that methods are callable
+ assert callable(getattr(fhir_client, "read"))
+ assert callable(getattr(fhir_client, "search"))
+
+
+def test_fhir_client_url_building(fhir_client):
+ """FHIR clients build URLs correctly with and without parameters."""
+ # Without parameters
+ url = fhir_client._build_url("Patient/123")
+ assert url == "https://test.fhir.org/R4/Patient/123"
+
+ # With parameters (None values filtered)
+ params = {"name": "John", "active": True, "limit": None}
+ url = fhir_client._build_url("Patient", params)
+ assert "https://test.fhir.org/R4/Patient?" in url
+ assert "name=John" in url
+ assert "active=True" in url
+ assert "limit" not in url
+
+
+def test_fhir_client_resource_type_resolution(fhir_client):
+ """FHIR clients resolve resource types from classes, strings, and handle errors."""
+ # Test with FHIR resource class
+ type_name, resource_class = fhir_client._resolve_resource_type(Patient)
+ assert type_name == "Patient"
+ assert resource_class == Patient
+
+ # Test with string name
+ with patch("builtins.__import__") as mock_import:
+ mock_module = Mock()
+ mock_module.Patient = Patient
+ mock_import.return_value = mock_module
+
+ type_name, resource_class = fhir_client._resolve_resource_type("Patient")
+ assert type_name == "Patient"
+ assert resource_class == Patient
+ mock_import.assert_called_once_with(
+ "fhir.resources.patient", fromlist=["Patient"]
+ )
+
+ # Test invalid resource type
+ with pytest.raises(ModuleNotFoundError, match="No module named"):
+ fhir_client._resolve_resource_type("InvalidResource")
+
+
+@pytest.mark.parametrize(
+ "status_code,is_success,should_raise",
+ [
+ (200, True, False),
+ (201, True, False),
+ (400, False, True),
+ (404, False, True),
+ (500, False, True),
+ ],
+)
+def test_fhir_client_response_handling(
+ fhir_client, status_code, is_success, should_raise
+):
+ """FHIR clients handle HTTP status codes and error responses appropriately."""
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.is_success = is_success
+ mock_response.status_code = status_code
+ mock_response.json.return_value = {"resourceType": "OperationOutcome"}
+
+ if should_raise:
+ with pytest.raises(FHIRClientError) as exc_info:
+ fhir_client._handle_response(mock_response)
+ assert exc_info.value.status_code == status_code
+ else:
+ result = fhir_client._handle_response(mock_response)
+ assert result == {"resourceType": "OperationOutcome"}
+
+
+def test_fhir_client_error_extraction_and_invalid_json(fhir_client):
+ """FHIR clients extract error diagnostics and handle invalid JSON."""
+ # Test error extraction from OperationOutcome
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.is_success = False
+ mock_response.status_code = 422
+ mock_response.json.return_value = {
+ "resourceType": "OperationOutcome",
+ "issue": [{"diagnostics": "Validation failed on field X"}],
+ }
+
+ with pytest.raises(FHIRClientError) as exc_info:
+ fhir_client._handle_response(mock_response)
+ assert "Validation failed on field X" in str(exc_info.value)
+ assert exc_info.value.status_code == 422
+
+ # Test invalid JSON handling
+ mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "doc", 0)
+ mock_response.text = "Invalid response text"
+ mock_response.status_code = 500
+
+ with pytest.raises(FHIRClientError) as exc_info:
+ fhir_client._handle_response(mock_response)
+ assert "Invalid JSON response" in str(exc_info.value)
+
+
+def test_fhir_client_error_class():
+ """FHIRClientError preserves response data for debugging."""
+ response_data = {"resourceType": "OperationOutcome", "issue": []}
+ error = FHIRClientError("Test error", status_code=400, response_data=response_data)
+
+ assert error.status_code == 400
+ assert error.response_data == response_data
+ assert str(error) == "Test error"
diff --git a/tests/gateway/test_base_fhir_gateway.py b/tests/gateway/test_base_fhir_gateway.py
new file mode 100644
index 00000000..39f385c2
--- /dev/null
+++ b/tests/gateway/test_base_fhir_gateway.py
@@ -0,0 +1,221 @@
+import pytest
+from unittest.mock import Mock, patch, AsyncMock
+from typing import Dict, Any, List
+
+from fhir.resources.patient import Patient
+from fhir.resources.observation import Observation
+
+from healthchain.gateway.fhir import FHIRGateway, AsyncFHIRGateway
+
+
+class MockConnectionManager:
+ """Mock FHIR connection manager for testing."""
+
+ def __init__(self):
+ self.sources = {"test_source": Mock()}
+
+ def add_source(self, name: str, connection_string: str) -> None:
+ self.sources[name] = Mock()
+
+ def get_client(self, source: str = None):
+ return Mock()
+
+ def get_status(self) -> Dict[str, Any]:
+ return {
+ "max_connections": 100,
+ "sources": {"test_source": "connected"},
+ }
+
+
+class MockAsyncConnectionManager:
+ """Mock async FHIR connection manager for testing."""
+
+ def __init__(self):
+ self.sources = {"test_source": Mock()}
+
+ def add_source(self, name: str, connection_string: str) -> None:
+ self.sources[name] = Mock()
+
+ async def get_client(self, source: str = None):
+ return AsyncMock()
+
+ def get_pool_status(self) -> Dict[str, Any]:
+ return {
+ "max_connections": 100,
+ "sources": {"test_source": "connected"},
+ }
+
+ async def close(self) -> None:
+ pass
+
+
+@pytest.fixture(params=["sync", "async"])
+def fhir_gateway(request):
+ """Fixture providing both sync and async FHIR gateways."""
+ if request.param == "sync":
+ with patch(
+ "healthchain.gateway.clients.fhir.sync.connection.FHIRConnectionManager",
+ return_value=MockConnectionManager(),
+ ):
+ return FHIRGateway(use_events=False)
+ else:
+ with patch(
+ "healthchain.gateway.clients.fhir.sync.connection.FHIRConnectionManager",
+ return_value=MockAsyncConnectionManager(),
+ ):
+ return AsyncFHIRGateway(use_events=False)
+
+
+@pytest.fixture
+def test_patient():
+ """Fixture providing a test Patient resource."""
+ return Patient(id="123", active=True)
+
+
+def test_transform_handler_registration_with_correct_annotation(fhir_gateway):
+ """Transform handlers with correct return type annotations register successfully."""
+
+ @fhir_gateway.transform(Patient)
+ def transform_patient(id: str, source: str = None) -> Patient:
+ return Patient(id=id)
+
+ assert fhir_gateway._resource_handlers[Patient]["transform"] == transform_patient
+
+
+def test_transform_handler_validation_enforces_return_type_match(fhir_gateway):
+ """Transform handler registration validates return type matches decorator resource type."""
+
+ with pytest.raises(
+ TypeError, match="return type .* doesn't match decorator resource type"
+ ):
+
+ @fhir_gateway.transform(Patient)
+ def invalid_handler(id: str) -> Observation: # Wrong return type
+ return Observation()
+
+
+def test_aggregate_handler_registration_without_validation(fhir_gateway):
+ """Aggregate handlers register without return type validation."""
+
+ @fhir_gateway.aggregate(Patient)
+ def aggregate_patients(id: str = None, sources: List[str] = None):
+ return []
+
+ assert fhir_gateway._resource_handlers[Patient]["aggregate"] == aggregate_patients
+
+
+def test_handler_registration_creates_routes(fhir_gateway):
+ """Handler registration automatically creates corresponding API routes."""
+ initial_routes = len(fhir_gateway.routes)
+
+ @fhir_gateway.transform(Patient)
+ def transform_patient(id: str) -> Patient:
+ return Patient(id=id)
+
+ assert len(fhir_gateway.routes) == initial_routes + 1
+
+
+def test_empty_capability_statement_with_no_handlers(fhir_gateway):
+ """Gateway with no handlers generates minimal CapabilityStatement."""
+ capability = fhir_gateway.build_capability_statement()
+
+ assert capability.model_dump()["resourceType"] == "CapabilityStatement"
+ assert capability.status == "active"
+ assert capability.kind == "instance"
+
+
+def test_capability_statement_includes_registered_resources(fhir_gateway):
+ """CapabilityStatement includes resources with registered handlers."""
+
+ @fhir_gateway.transform(Patient)
+ def transform_patient(id: str) -> Patient:
+ return Patient(id=id)
+
+ @fhir_gateway.aggregate(Observation)
+ def aggregate_observations(id: str = None) -> List[Observation]:
+ return []
+
+ capability = fhir_gateway.build_capability_statement()
+ resources = capability.rest[0].resource
+ resource_types = [r.type for r in resources]
+
+ assert "Patient" in resource_types
+ assert "Observation" in resource_types
+
+
+def test_gateway_status_structure(fhir_gateway):
+ """Gateway status contains required fields with correct structure."""
+ status = fhir_gateway.get_gateway_status()
+
+ # Gateway type should match the actual class name
+ expected_type = (
+ "AsyncFHIRGateway"
+ if isinstance(fhir_gateway, AsyncFHIRGateway)
+ else "FHIRGateway"
+ )
+ assert status["gateway_type"] == expected_type
+ assert status["status"] == "active"
+ assert isinstance(status["timestamp"], str)
+ assert isinstance(status["version"], str)
+
+
+def test_supported_operations_tracking(fhir_gateway):
+ """Gateway accurately tracks registered operations."""
+ # Get initial status - should have no operations
+ initial_status = fhir_gateway.get_gateway_status()
+ assert initial_status["supported_operations"] == {}
+
+ @fhir_gateway.transform(Patient)
+ def transform_patient(id: str) -> Patient:
+ return Patient(id=id)
+
+ updated_status = fhir_gateway.get_gateway_status()
+
+ # Check that Patient transform operation is now tracked
+ assert "Patient" in updated_status["supported_operations"]
+ patient_ops = updated_status["supported_operations"]["Patient"]
+ assert len(patient_ops) == 1
+ assert patient_ops[0]["type"] == "transform"
+ assert patient_ops[0]["endpoint"] == "/transform/Patient/{id}"
+
+
+def test_supported_resources_property(fhir_gateway):
+ """supported_resources property returns correct resource names."""
+ # Initially no resources
+ assert fhir_gateway.supported_resources == []
+
+ @fhir_gateway.transform(Patient)
+ def transform_patient(id: str) -> Patient:
+ return Patient(id=id)
+
+ @fhir_gateway.aggregate(Observation)
+ def aggregate_observations() -> List[Observation]:
+ return []
+
+ supported = fhir_gateway.supported_resources
+ assert "Patient" in supported
+ assert "Observation" in supported
+
+
+def test_get_capabilities_method(fhir_gateway):
+ """get_capabilities method returns correct operation:resource pairs."""
+ # Initially no capabilities
+ assert fhir_gateway.get_capabilities() == []
+
+ @fhir_gateway.transform(Patient)
+ def transform_patient(id: str) -> Patient:
+ return Patient(id=id)
+
+ @fhir_gateway.aggregate(Patient)
+ def aggregate_patients() -> List[Patient]:
+ return []
+
+ capabilities = fhir_gateway.get_capabilities()
+ assert "transform:Patient" in capabilities
+ assert "aggregate:Patient" in capabilities
+
+
+def test_resource_name_extraction(fhir_gateway):
+ """_get_resource_name correctly extracts resource names from types."""
+ assert fhir_gateway._get_resource_name(Patient) == "Patient"
+ assert fhir_gateway._get_resource_name(Observation) == "Observation"
diff --git a/tests/gateway/test_cdshooks.py b/tests/gateway/test_cdshooks.py
index 4ef07fc4..ab7c31e2 100644
--- a/tests/gateway/test_cdshooks.py
+++ b/tests/gateway/test_cdshooks.py
@@ -1,9 +1,9 @@
import pytest
from unittest.mock import MagicMock
-from healthchain.gateway.protocols.cdshooks import (
+from healthchain.gateway.cds import CDSHooksConfig
+from healthchain.gateway.cds import (
CDSHooksService,
- CDSHooksConfig,
)
from healthchain.gateway.events.dispatcher import EventDispatcher
from healthchain.models.requests.cdsrequest import CDSRequest
diff --git a/tests/gateway/test_client_pool.py b/tests/gateway/test_client_pool.py
index 6ba3a55f..a68c95b8 100644
--- a/tests/gateway/test_client_pool.py
+++ b/tests/gateway/test_client_pool.py
@@ -3,7 +3,7 @@
import pytest
from unittest.mock import Mock, AsyncMock
-from healthchain.gateway.clients.pool import FHIRClientPool
+from healthchain.gateway.clients.pool import ClientPool
from healthchain.gateway.api.protocols import FHIRServerInterfaceProtocol
@@ -32,8 +32,8 @@ def factory(connection_string, limits=None):
@pytest.fixture
def client_pool():
- """Create a FHIRClientPool for testing."""
- return FHIRClientPool(
+ """Create a ClientPool for testing."""
+ return ClientPool(
max_connections=50, max_keepalive_connections=10, keepalive_expiry=3.0
)
@@ -46,11 +46,11 @@ def client_pool():
],
)
def test_client_pool_initialization(max_conn, keepalive_conn, expiry):
- """FHIRClientPool initializes with custom or default limits."""
+ """ClientPool initializes with custom or default limits."""
if max_conn == 100: # test defaults
- pool = FHIRClientPool()
+ pool = ClientPool()
else:
- pool = FHIRClientPool(
+ pool = ClientPool(
max_connections=max_conn,
max_keepalive_connections=keepalive_conn,
keepalive_expiry=expiry,
@@ -64,7 +64,7 @@ def test_client_pool_initialization(max_conn, keepalive_conn, expiry):
@pytest.mark.asyncio
async def test_client_creation_and_reuse(client_pool, mock_client_factory):
- """FHIRClientPool creates new clients and reuses existing ones."""
+ """ClientPool creates new clients and reuses existing ones."""
conn1 = "fhir://server1.example.com/R4"
conn2 = "fhir://server2.example.com/R4"
@@ -86,7 +86,7 @@ async def test_client_creation_and_reuse(client_pool, mock_client_factory):
@pytest.mark.asyncio
async def test_close_all_clients(client_pool, mock_client_factory):
- """FHIRClientPool closes all clients and handles missing close methods."""
+ """ClientPool closes all clients and handles missing close methods."""
conn1 = "fhir://server1.example.com/R4"
conn2 = "fhir://server2.example.com/R4"
@@ -109,7 +109,7 @@ async def test_close_all_clients(client_pool, mock_client_factory):
@pytest.mark.asyncio
async def test_pool_stats(client_pool, mock_client_factory):
- """FHIRClientPool provides accurate statistics."""
+ """ClientPool provides accurate statistics."""
# Empty pool stats
stats = client_pool.get_pool_stats()
assert stats["total_clients"] == 0
@@ -138,7 +138,7 @@ async def test_pool_stats(client_pool, mock_client_factory):
@pytest.mark.asyncio
async def test_pool_stats_without_pool_info(client_pool):
- """FHIRClientPool handles clients without connection pool info."""
+ """ClientPool handles clients without connection pool info."""
simple_client = Mock(spec=[])
client_pool._clients["simple"] = simple_client
@@ -149,7 +149,7 @@ async def test_pool_stats_without_pool_info(client_pool):
@pytest.mark.asyncio
async def test_client_factory_exceptions(client_pool):
- """FHIRClientPool propagates exceptions from client factory."""
+ """ClientPool propagates exceptions from client factory."""
def failing_factory(connection_string, limits=None):
raise ValueError("Factory failed")
@@ -160,7 +160,7 @@ def failing_factory(connection_string, limits=None):
@pytest.mark.asyncio
async def test_concurrent_client_creation(client_pool):
- """FHIRClientPool handles concurrent requests for same connection."""
+ """ClientPool handles concurrent requests for same connection."""
connection_string = "fhir://test.example.com/R4"
call_count = 0
diff --git a/tests/gateway/test_connection_manager.py b/tests/gateway/test_connection_manager.py
index d28f1118..b18f90bf 100644
--- a/tests/gateway/test_connection_manager.py
+++ b/tests/gateway/test_connection_manager.py
@@ -8,19 +8,16 @@
"""
import pytest
-from unittest.mock import Mock, AsyncMock
+from unittest.mock import Mock
-from healthchain.gateway.core.connection import FHIRConnectionManager
-from healthchain.gateway.core.errors import FHIRConnectionError
+from healthchain.gateway.clients.fhir.sync.connection import FHIRConnectionManager
from healthchain.gateway.api.protocols import FHIRServerInterfaceProtocol
@pytest.fixture
def connection_manager():
"""Create a connection manager for testing."""
- return FHIRConnectionManager(
- max_connections=50, max_keepalive_connections=10, keepalive_expiry=30.0
- )
+ return FHIRConnectionManager()
@pytest.fixture
@@ -31,47 +28,10 @@ def mock_fhir_client():
return client
-@pytest.mark.parametrize(
- "connection_string,should_succeed",
- [
- # Valid connection strings
- (
- "fhir://epic.org/api/FHIR/R4?client_id=test&client_secret=secret&token_url=https://epic.org/token",
- True,
- ),
- (
- "fhir://localhost:8080/fhir?client_id=local&client_secret=pass&token_url=http://localhost/token",
- True,
- ),
- # Invalid connection strings
- ("http://not-fhir.com/api", False), # Wrong scheme
- ("fhir://", False), # Missing hostname
- ("invalid-string", False), # Not a URL
- ],
-)
-def test_connection_manager_source_validation_and_parsing(
- connection_manager, connection_string, should_succeed
-):
- """FHIRConnectionManager validates connection strings and parses hostnames correctly."""
- if should_succeed:
- connection_manager.add_source("test_source", connection_string)
- assert "test_source" in connection_manager.sources
- assert "test_source" in connection_manager._connection_strings
- assert (
- connection_manager._connection_strings["test_source"] == connection_string
- )
- else:
- with pytest.raises(
- FHIRConnectionError, match="Failed to parse connection string"
- ):
- connection_manager.add_source("test_source", connection_string)
-
-
-@pytest.mark.asyncio
-async def test_connection_manager_client_retrieval_and_default_selection(
+def test_connection_manager_client_retrieval_and_default_selection(
connection_manager, mock_fhir_client
):
- """FHIRConnectionManager retrieves clients through pooling and selects defaults correctly."""
+ """FHIRConnectionManager retrieves clients and selects defaults correctly."""
# Add multiple sources
connection_manager.add_source(
"first",
@@ -82,19 +42,73 @@ async def test_connection_manager_client_retrieval_and_default_selection(
"fhir://second.com/fhir?client_id=test&client_secret=secret&token_url=https://second.com/token",
)
- connection_manager.client_pool.get_client = AsyncMock(return_value=mock_fhir_client)
+ # Mock the client creation method
+ connection_manager._create_server_from_connection_string = Mock(
+ return_value=mock_fhir_client
+ )
# Test specific source retrieval
- client = await connection_manager.get_client("first")
+ client = connection_manager.get_client("first")
assert client == mock_fhir_client
# Test default source selection (should use first available)
- client_default = await connection_manager.get_client()
+ client_default = connection_manager.get_client()
assert client_default == mock_fhir_client
- call_args = connection_manager.client_pool.get_client.call_args
+
+ # Verify the connection string was used correctly
+ call_args = connection_manager._create_server_from_connection_string.call_args
+ connection_string = call_args[0][0]
from urllib.parse import urlparse
- parsed_url = urlparse(call_args[0][0])
+ parsed_url = urlparse(connection_string)
assert (
parsed_url.hostname == "first.com"
) # Should use first source's connection string
+
+
+def test_connection_manager_cleanup_all_sources(connection_manager):
+ """FHIRConnectionManager properly cleans up all client connections."""
+ # Add multiple sources
+ connection_manager.add_source(
+ "source1",
+ "fhir://source1.com/fhir?client_id=test&client_secret=secret&token_url=https://source1.com/token",
+ )
+ connection_manager.add_source(
+ "source2",
+ "fhir://source2.com/fhir?client_id=test&client_secret=secret&token_url=https://source2.com/token",
+ )
+
+ # Mock clients with close methods
+ mock_client1 = Mock()
+ mock_client1.close = Mock()
+ mock_client2 = Mock()
+ mock_client2.close = Mock()
+
+ # Mock the client creation to return our mock clients
+ def mock_create_client(connection_string):
+ if "source1.com" in connection_string:
+ return mock_client1
+ else:
+ return mock_client2
+
+ connection_manager._create_server_from_connection_string = mock_create_client
+
+ # Get clients (this creates them)
+ client1 = connection_manager.get_client("source1")
+ client2 = connection_manager.get_client("source2")
+
+ # Verify we got the right clients
+ assert client1 == mock_client1
+ assert client2 == mock_client2
+
+ # Now test cleanup - since sync doesn't have built-in cleanup,
+ # we test that clients have close methods available
+ assert hasattr(client1, "close")
+ assert hasattr(client2, "close")
+
+ # Manually call close to verify they work
+ client1.close()
+ client2.close()
+
+ mock_client1.close.assert_called_once()
+ mock_client2.close.assert_called_once()
diff --git a/tests/gateway/test_connection_manager_async.py b/tests/gateway/test_connection_manager_async.py
new file mode 100644
index 00000000..b41f42f9
--- /dev/null
+++ b/tests/gateway/test_connection_manager_async.py
@@ -0,0 +1,63 @@
+"""
+Tests for the FHIR connection manager in the HealthChain gateway system.
+
+This module tests centralized connection management for FHIR sources:
+- Connection string parsing and validation
+- Source lifecycle management
+- Client pooling and retrieval
+"""
+
+import pytest
+from unittest.mock import Mock, AsyncMock
+
+from healthchain.gateway.clients.fhir.aio.connection import AsyncFHIRConnectionManager
+from healthchain.gateway.api.protocols import FHIRServerInterfaceProtocol
+
+
+@pytest.fixture
+def connection_manager():
+ """Create a connection manager for testing."""
+ return AsyncFHIRConnectionManager(
+ max_connections=50, max_keepalive_connections=10, keepalive_expiry=30.0
+ )
+
+
+@pytest.fixture
+def mock_fhir_client():
+ """Create a mock FHIR client using protocol."""
+ client = Mock(spec=FHIRServerInterfaceProtocol)
+ client.base_url = "https://test.fhir.com/R4"
+ return client
+
+
+@pytest.mark.asyncio
+async def test_connection_manager_client_retrieval_and_default_selection(
+ connection_manager, mock_fhir_client
+):
+ """AsyncFHIRConnectionManager retrieves clients through pooling and selects defaults correctly."""
+ # Add multiple sources
+ connection_manager.add_source(
+ "first",
+ "fhir://first.com/fhir?client_id=test&client_secret=secret&token_url=https://first.com/token",
+ )
+ connection_manager.add_source(
+ "second",
+ "fhir://second.com/fhir?client_id=test&client_secret=secret&token_url=https://second.com/token",
+ )
+
+ connection_manager.client_pool.get_client = AsyncMock(return_value=mock_fhir_client)
+
+ # Test specific source retrieval
+ client = await connection_manager.get_client("first")
+ assert client == mock_fhir_client
+
+ # Test default source selection (should use first available)
+ client_default = await connection_manager.get_client()
+ assert client_default == mock_fhir_client
+ call_args = connection_manager.client_pool.get_client.call_args
+ from urllib.parse import urlparse
+
+ parsed_url = urlparse(call_args[0][0])
+ assert (
+ parsed_url.hostname == "first.com"
+ ) # Should use first source's connection string
diff --git a/tests/gateway/test_error_handling.py b/tests/gateway/test_error_handling.py
index d3107df6..92287746 100644
--- a/tests/gateway/test_error_handling.py
+++ b/tests/gateway/test_error_handling.py
@@ -2,7 +2,7 @@
import pytest
-from healthchain.gateway.core.errors import (
+from healthchain.gateway.fhir.errors import (
FHIRConnectionError,
FHIRErrorHandler,
)
diff --git a/tests/gateway/test_fhir_client.py b/tests/gateway/test_fhir_client.py
index 743afe32..c705765a 100644
--- a/tests/gateway/test_fhir_client.py
+++ b/tests/gateway/test_fhir_client.py
@@ -5,18 +5,14 @@
"""
import pytest
-import json
import httpx
-from unittest.mock import Mock, AsyncMock, patch
+from unittest.mock import Mock, patch
from fhir.resources.patient import Patient
from fhir.resources.bundle import Bundle
from fhir.resources.capabilitystatement import CapabilityStatement
-from healthchain.gateway.clients.fhir import (
- AsyncFHIRClient,
- FHIRClientError,
-)
-from healthchain.gateway.clients.auth import FHIRAuthConfig
+from healthchain.gateway.clients.fhir.sync import FHIRClient
+from healthchain.gateway.clients.fhir.base import FHIRAuthConfig
@pytest.fixture
@@ -37,35 +33,35 @@ def mock_auth_config():
def fhir_client(mock_auth_config):
"""Create a FHIR client for testing."""
with patch(
- "healthchain.gateway.clients.fhir.OAuth2TokenManager"
+ "healthchain.gateway.clients.auth.OAuth2TokenManager"
) as mock_manager_class:
mock_manager = Mock()
# For sync access during initialization, use a regular Mock
- mock_manager.get_access_token = AsyncMock(return_value="test_token")
+ mock_manager.get_access_token = Mock(return_value="test_token")
mock_manager_class.return_value = mock_manager
- client = AsyncFHIRClient(auth_config=mock_auth_config)
+ client = FHIRClient(auth_config=mock_auth_config)
client.token_manager = mock_manager
return client
@pytest.fixture
def fhir_client_with_limits(mock_auth_config):
- """Create an AsyncFHIRClient with connection limits for testing."""
+ """Create an FHIRClient with connection limits for testing."""
limits = httpx.Limits(
max_connections=50,
max_keepalive_connections=10,
keepalive_expiry=30.0,
)
with patch(
- "healthchain.gateway.clients.fhir.OAuth2TokenManager"
+ "healthchain.gateway.clients.auth.OAuth2TokenManager"
) as mock_manager_class:
mock_manager = Mock()
# For sync access during initialization, use a regular Mock
- mock_manager.get_access_token = AsyncMock(return_value="test_token")
+ mock_manager.get_access_token = Mock(return_value="test_token")
mock_manager_class.return_value = mock_manager
- client = AsyncFHIRClient(auth_config=mock_auth_config, limits=limits)
+ client = FHIRClient(auth_config=mock_auth_config, limits=limits)
client.token_manager = mock_manager
return client
@@ -80,123 +76,21 @@ def mock_httpx_response():
return response
-def test_fhir_client_initialization_and_configuration(mock_auth_config):
- """AsyncFHIRClient initializes with correct configuration and headers."""
- with patch("healthchain.gateway.clients.fhir.OAuth2TokenManager"):
- client = AsyncFHIRClient(auth_config=mock_auth_config)
-
- # Test configuration
- assert client.base_url == "https://test.fhir.org/R4/"
- assert client.timeout == 30.0
- assert client.verify_ssl is True
-
- # Test headers
- assert client.base_headers["Accept"] == "application/fhir+json"
- assert client.base_headers["Content-Type"] == "application/fhir+json"
-
-
-def test_async_fhir_client_conforms_to_protocol(fhir_client):
- """AsyncFHIRClient implements the required protocol methods."""
- # Check that client has all required protocol methods
- assert hasattr(fhir_client, "read")
- assert hasattr(fhir_client, "search")
- assert hasattr(fhir_client, "create")
- assert hasattr(fhir_client, "update")
- assert hasattr(fhir_client, "delete")
- assert hasattr(fhir_client, "transaction")
- assert hasattr(fhir_client, "capabilities")
-
- # Check that methods are callable
- assert callable(getattr(fhir_client, "read"))
- assert callable(getattr(fhir_client, "search"))
-
-
-@pytest.mark.asyncio
-async def test_fhir_client_authentication_and_headers(fhir_client):
- """AsyncFHIRClient manages OAuth tokens and includes proper headers."""
+def test_fhir_client_authentication_and_headers(fhir_client):
+ """FHIRClient manages OAuth tokens and includes proper headers."""
# Test first call includes token and headers
- headers = await fhir_client._get_headers()
+ headers = fhir_client._get_headers()
assert headers["Authorization"] == "Bearer test_token"
assert headers["Accept"] == "application/fhir+json"
assert headers["Content-Type"] == "application/fhir+json"
# Test token refresh on subsequent calls
- await fhir_client._get_headers()
+ fhir_client._get_headers()
assert fhir_client.token_manager.get_access_token.call_count == 2
-def test_fhir_client_url_building(fhir_client):
- """AsyncFHIRClient builds URLs correctly with and without parameters."""
- # Without parameters
- url = fhir_client._build_url("Patient/123")
- assert url == "https://test.fhir.org/R4/Patient/123"
-
- # With parameters (None values filtered)
- params = {"name": "John", "active": True, "limit": None}
- url = fhir_client._build_url("Patient", params)
- assert "https://test.fhir.org/R4/Patient?" in url
- assert "name=John" in url
- assert "active=True" in url
- assert "limit" not in url
-
-
-@pytest.mark.parametrize(
- "status_code,is_success,should_raise",
- [
- (200, True, False),
- (201, True, False),
- (400, False, True),
- (404, False, True),
- (500, False, True),
- ],
-)
-def test_fhir_client_response_handling(
- fhir_client, status_code, is_success, should_raise
-):
- """AsyncFHIRClient handles HTTP status codes and error responses appropriately."""
- mock_response = Mock(spec=httpx.Response)
- mock_response.is_success = is_success
- mock_response.status_code = status_code
- mock_response.json.return_value = {"resourceType": "OperationOutcome"}
-
- if should_raise:
- with pytest.raises(FHIRClientError) as exc_info:
- fhir_client._handle_response(mock_response)
- assert exc_info.value.status_code == status_code
- else:
- result = fhir_client._handle_response(mock_response)
- assert result == {"resourceType": "OperationOutcome"}
-
-
-def test_fhir_client_error_extraction_and_invalid_json(fhir_client):
- """AsyncFHIRClient extracts error diagnostics and handles invalid JSON."""
- # Test error extraction from OperationOutcome
- mock_response = Mock(spec=httpx.Response)
- mock_response.is_success = False
- mock_response.status_code = 422
- mock_response.json.return_value = {
- "resourceType": "OperationOutcome",
- "issue": [{"diagnostics": "Validation failed on field X"}],
- }
-
- with pytest.raises(FHIRClientError) as exc_info:
- fhir_client._handle_response(mock_response)
- assert "Validation failed on field X" in str(exc_info.value)
- assert exc_info.value.status_code == 422
-
- # Test invalid JSON handling
- mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "doc", 0)
- mock_response.text = "Invalid response text"
- mock_response.status_code = 500
-
- with pytest.raises(FHIRClientError) as exc_info:
- fhir_client._handle_response(mock_response)
- assert "Invalid JSON response" in str(exc_info.value)
-
-
-@pytest.mark.asyncio
-async def test_fhir_client_crud_operations(fhir_client, mock_httpx_response):
- """AsyncFHIRClient performs CRUD operations correctly."""
+def test_fhir_client_crud_operations(fhir_client, mock_httpx_response):
+ """FHIRClient performs CRUD operations correctly."""
# Test READ operation
with patch.object(
fhir_client.client, "get", return_value=mock_httpx_response
@@ -204,7 +98,7 @@ async def test_fhir_client_crud_operations(fhir_client, mock_httpx_response):
with patch.object(
fhir_client, "_get_headers", return_value={"Authorization": "Bearer token"}
):
- result = await fhir_client.read(Patient, "123")
+ result = fhir_client.read(Patient, "123")
mock_get.assert_called_once_with(
"https://test.fhir.org/R4/Patient/123",
headers={"Authorization": "Bearer token"},
@@ -226,7 +120,7 @@ async def test_fhir_client_crud_operations(fhir_client, mock_httpx_response):
with patch.object(
fhir_client, "_get_headers", return_value={"Authorization": "Bearer token"}
):
- result = await fhir_client.create(patient)
+ result = fhir_client.create(patient)
call_args = mock_post.call_args
assert call_args[0][0] == "https://test.fhir.org/R4/Patient"
assert "content" in call_args[1]
@@ -242,16 +136,15 @@ async def test_fhir_client_crud_operations(fhir_client, mock_httpx_response):
fhir_client.client, "delete", return_value=mock_delete_response
) as mock_delete:
with patch.object(fhir_client, "_get_headers", return_value={}):
- result = await fhir_client.delete(Patient, "123")
+ result = fhir_client.delete(Patient, "123")
mock_delete.assert_called_once_with(
"https://test.fhir.org/R4/Patient/123", headers={}
)
assert result is True
-@pytest.mark.asyncio
-async def test_fhir_client_search_and_capabilities(fhir_client):
- """AsyncFHIRClient handles search operations and server capabilities."""
+def test_fhir_client_search_and_capabilities(fhir_client):
+ """FHIRClient handles search operations and server capabilities."""
# Test SEARCH operation
bundle_response = {
"resourceType": "Bundle",
@@ -267,7 +160,7 @@ async def test_fhir_client_search_and_capabilities(fhir_client):
) as mock_get:
with patch.object(fhir_client, "_get_headers", return_value={}):
params = {"name": "John", "active": True}
- result = await fhir_client.search(Patient, params)
+ result = fhir_client.search(Patient, params)
call_url = mock_get.call_args[0][0]
assert "Patient?" in call_url
@@ -291,7 +184,7 @@ async def test_fhir_client_search_and_capabilities(fhir_client):
fhir_client.client, "get", return_value=mock_response
) as mock_get:
with patch.object(fhir_client, "_get_headers", return_value={}):
- result = await fhir_client.capabilities()
+ result = fhir_client.capabilities()
mock_get.assert_called_once_with(
"https://test.fhir.org/R4/metadata", headers={}
)
@@ -299,53 +192,61 @@ async def test_fhir_client_search_and_capabilities(fhir_client):
assert result.status == "active"
-def test_fhir_client_resource_type_resolution(fhir_client):
- """AsyncFHIRClient resolves resource types from classes, strings, and handles errors."""
- # Test with FHIR resource class
- type_name, resource_class = fhir_client._resolve_resource_type(Patient)
- assert type_name == "Patient"
- assert resource_class == Patient
-
- # Test with string name
- with patch("builtins.__import__") as mock_import:
- mock_module = Mock()
- mock_module.Patient = Patient
- mock_import.return_value = mock_module
-
- type_name, resource_class = fhir_client._resolve_resource_type("Patient")
- assert type_name == "Patient"
- assert resource_class == Patient
- mock_import.assert_called_once_with(
- "fhir.resources.patient", fromlist=["Patient"]
- )
-
- # Test invalid resource type
- with pytest.raises(ModuleNotFoundError, match="No module named"):
- fhir_client._resolve_resource_type("InvalidResource")
-
-
-@pytest.mark.asyncio
-async def test_fhir_client_authentication_failure(fhir_client):
- """AsyncFHIRClient handles authentication failures."""
+def test_fhir_client_authentication_failure(fhir_client):
+ """FHIRClient handles authentication failures."""
fhir_client.token_manager.get_access_token.side_effect = Exception("Auth failed")
with pytest.raises(Exception, match="Auth failed"):
- await fhir_client._get_headers()
+ fhir_client._get_headers()
-@pytest.mark.asyncio
-async def test_fhir_client_http_timeout(fhir_client):
- """AsyncFHIRClient handles HTTP timeout errors."""
+def test_fhir_client_http_timeout(fhir_client):
+ """FHIRClient handles HTTP timeout errors."""
with patch.object(fhir_client.client, "get") as mock_get:
mock_get.side_effect = httpx.TimeoutException("Request timed out")
with pytest.raises(httpx.TimeoutException):
- await fhir_client.read(Patient, "123")
+ fhir_client.read(Patient, "123")
+
+
+def test_fhir_client_context_manager_lifecycle(mock_auth_config):
+ """FHIRClient context manager properly opens and closes connections."""
+ with patch("healthchain.gateway.clients.auth.OAuth2TokenManager"):
+ # Test context manager entry and exit
+ with FHIRClient(auth_config=mock_auth_config) as client:
+ assert client.client is not None
+ assert hasattr(client, "close")
+
+ # Mock the close method to verify it gets called
+ client.close = Mock()
+
+ # Verify close was called on exit
+ client.close.assert_called_once()
+
+
+def test_fhir_client_context_manager_handles_exceptions(mock_auth_config):
+ """FHIRClient context manager properly closes connections even when exceptions occur."""
+ with patch("healthchain.gateway.clients.auth.OAuth2TokenManager"):
+ try:
+ with FHIRClient(auth_config=mock_auth_config) as client:
+ client.close = Mock()
+ raise ValueError("Test exception")
+ except ValueError:
+ pass
+
+ # Verify close was still called despite the exception
+ client.close.assert_called_once()
+
+
+def test_fhir_client_manual_close_method(mock_auth_config):
+ """FHIRClient close method properly shuts down HTTP client."""
+ with patch("healthchain.gateway.clients.auth.OAuth2TokenManager"):
+ client = FHIRClient(auth_config=mock_auth_config)
+ # Mock the httpx client
+ client.client = Mock()
+ client.client.close = Mock()
-def test_fhir_client_error_class():
- """FHIRClientError preserves response data for debugging."""
- response_data = {"resourceType": "OperationOutcome", "issue": []}
- error = FHIRClientError("Test error", status_code=400, response_data=response_data)
+ # Test manual close
+ client.close()
- assert error.status_code == 400
- assert error.response_data == response_data
- assert str(error) == "Test error"
+ # Verify httpx client close was called
+ client.client.close.assert_called_once()
diff --git a/tests/gateway/test_fhir_client_async.py b/tests/gateway/test_fhir_client_async.py
new file mode 100644
index 00000000..1edf41dc
--- /dev/null
+++ b/tests/gateway/test_fhir_client_async.py
@@ -0,0 +1,212 @@
+"""
+Tests for FHIR client external API integration functionality.
+
+Focuses on HTTP operations, authentication, error handling, and response processing.
+"""
+
+import pytest
+import httpx
+from unittest.mock import Mock, AsyncMock, patch
+from fhir.resources.patient import Patient
+from fhir.resources.bundle import Bundle
+from fhir.resources.capabilitystatement import CapabilityStatement
+
+from healthchain.gateway.clients.fhir.aio import AsyncFHIRClient
+from healthchain.gateway.clients.fhir.base import FHIRAuthConfig
+
+
+@pytest.fixture
+def mock_auth_config():
+ """Create a mock FHIR auth configuration."""
+ return FHIRAuthConfig(
+ base_url="https://test.fhir.org/R4",
+ client_id="test_client",
+ client_secret="test_secret",
+ token_url="https://test.fhir.org/oauth/token",
+ scope="system/*.read",
+ timeout=30.0,
+ verify_ssl=True,
+ )
+
+
+@pytest.fixture
+def fhir_client(mock_auth_config):
+ """Create a FHIR client for testing."""
+ with patch(
+ "healthchain.gateway.clients.auth.OAuth2TokenManager"
+ ) as mock_manager_class:
+ mock_manager = Mock()
+ # For sync access during initialization, use a regular Mock
+ mock_manager.get_access_token = AsyncMock(return_value="test_token")
+ mock_manager_class.return_value = mock_manager
+
+ client = AsyncFHIRClient(auth_config=mock_auth_config)
+ client.token_manager = mock_manager
+ return client
+
+
+@pytest.fixture
+def fhir_client_with_limits(mock_auth_config):
+ """Create an AsyncFHIRClient with connection limits for testing."""
+ limits = httpx.Limits(
+ max_connections=50,
+ max_keepalive_connections=10,
+ keepalive_expiry=30.0,
+ )
+ with patch(
+ "healthchain.gateway.clients.auth.OAuth2TokenManager"
+ ) as mock_manager_class:
+ mock_manager = Mock()
+ # For sync access during initialization, use a regular Mock
+ mock_manager.get_access_token = AsyncMock(return_value="test_token")
+ mock_manager_class.return_value = mock_manager
+
+ client = AsyncFHIRClient(auth_config=mock_auth_config, limits=limits)
+ client.token_manager = mock_manager
+ return client
+
+
+@pytest.fixture
+def mock_httpx_response():
+ """Create a mock httpx response."""
+ response = Mock(spec=httpx.Response)
+ response.is_success = True
+ response.status_code = 200
+ response.json.return_value = {"resourceType": "Patient", "id": "123"}
+ return response
+
+
+@pytest.mark.asyncio
+async def test_fhir_client_authentication_and_headers(fhir_client):
+ """AsyncFHIRClient manages OAuth tokens and includes proper headers."""
+ # Test first call includes token and headers
+ headers = await fhir_client._get_headers()
+ assert headers["Authorization"] == "Bearer test_token"
+ assert headers["Accept"] == "application/fhir+json"
+ assert headers["Content-Type"] == "application/fhir+json"
+
+ # Test token refresh on subsequent calls
+ await fhir_client._get_headers()
+ assert fhir_client.token_manager.get_access_token.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_fhir_client_crud_operations(fhir_client, mock_httpx_response):
+ """AsyncFHIRClient performs CRUD operations correctly."""
+ # Test READ operation
+ with patch.object(
+ fhir_client.client, "get", return_value=mock_httpx_response
+ ) as mock_get:
+ with patch.object(
+ fhir_client, "_get_headers", return_value={"Authorization": "Bearer token"}
+ ):
+ result = await fhir_client.read(Patient, "123")
+ mock_get.assert_called_once_with(
+ "https://test.fhir.org/R4/Patient/123",
+ headers={"Authorization": "Bearer token"},
+ )
+ assert isinstance(result, Patient)
+ assert result.id == "123"
+
+ # Test CREATE operation
+ patient = Patient(id="123", active=True)
+ mock_httpx_response.json.return_value = {
+ "resourceType": "Patient",
+ "id": "new-123",
+ "active": True,
+ }
+
+ with patch.object(
+ fhir_client.client, "post", return_value=mock_httpx_response
+ ) as mock_post:
+ with patch.object(
+ fhir_client, "_get_headers", return_value={"Authorization": "Bearer token"}
+ ):
+ result = await fhir_client.create(patient)
+ call_args = mock_post.call_args
+ assert call_args[0][0] == "https://test.fhir.org/R4/Patient"
+ assert "content" in call_args[1]
+ assert isinstance(result, Patient)
+ assert result.id == "new-123"
+
+ # Test DELETE operation
+ mock_delete_response = Mock(spec=httpx.Response)
+ mock_delete_response.is_success = True
+ mock_delete_response.status_code = 204
+
+ with patch.object(
+ fhir_client.client, "delete", return_value=mock_delete_response
+ ) as mock_delete:
+ with patch.object(fhir_client, "_get_headers", return_value={}):
+ result = await fhir_client.delete(Patient, "123")
+ mock_delete.assert_called_once_with(
+ "https://test.fhir.org/R4/Patient/123", headers={}
+ )
+ assert result is True
+
+
+@pytest.mark.asyncio
+async def test_fhir_client_search_and_capabilities(fhir_client):
+ """AsyncFHIRClient handles search operations and server capabilities."""
+ # Test SEARCH operation
+ bundle_response = {
+ "resourceType": "Bundle",
+ "type": "searchset",
+ "entry": [{"resource": {"resourceType": "Patient", "id": "123"}}],
+ }
+ mock_response = Mock(spec=httpx.Response)
+ mock_response.is_success = True
+ mock_response.json.return_value = bundle_response
+
+ with patch.object(
+ fhir_client.client, "get", return_value=mock_response
+ ) as mock_get:
+ with patch.object(fhir_client, "_get_headers", return_value={}):
+ params = {"name": "John", "active": True}
+ result = await fhir_client.search(Patient, params)
+
+ call_url = mock_get.call_args[0][0]
+ assert "Patient?" in call_url
+ assert "name=John" in call_url
+ assert "active=True" in call_url
+ assert isinstance(result, Bundle)
+ assert result.type == "searchset"
+
+ # Test CAPABILITIES operation
+ capabilities_response = {
+ "resourceType": "CapabilityStatement",
+ "status": "active",
+ "kind": "instance",
+ "fhirVersion": "4.0.1",
+ "date": "2023-01-01T00:00:00Z",
+ "format": ["json"],
+ }
+ mock_response.json.return_value = capabilities_response
+
+ with patch.object(
+ fhir_client.client, "get", return_value=mock_response
+ ) as mock_get:
+ with patch.object(fhir_client, "_get_headers", return_value={}):
+ result = await fhir_client.capabilities()
+ mock_get.assert_called_once_with(
+ "https://test.fhir.org/R4/metadata", headers={}
+ )
+ assert isinstance(result, CapabilityStatement)
+ assert result.status == "active"
+
+
+@pytest.mark.asyncio
+async def test_fhir_client_authentication_failure(fhir_client):
+ """AsyncFHIRClient handles authentication failures."""
+ fhir_client.token_manager.get_access_token.side_effect = Exception("Auth failed")
+ with pytest.raises(Exception, match="Auth failed"):
+ await fhir_client._get_headers()
+
+
+@pytest.mark.asyncio
+async def test_fhir_client_http_timeout(fhir_client):
+ """AsyncFHIRClient handles HTTP timeout errors."""
+ with patch.object(fhir_client.client, "get") as mock_get:
+ mock_get.side_effect = httpx.TimeoutException("Request timed out")
+ with pytest.raises(httpx.TimeoutException):
+ await fhir_client.read(Patient, "123")
diff --git a/tests/gateway/test_fhir_gateway.py b/tests/gateway/test_fhir_gateway.py
index a802407f..cef091cf 100644
--- a/tests/gateway/test_fhir_gateway.py
+++ b/tests/gateway/test_fhir_gateway.py
@@ -1,11 +1,12 @@
import pytest
-from unittest.mock import AsyncMock, Mock, patch
-from typing import Dict, Any, List
+from unittest.mock import Mock, patch
+from typing import Dict, Any
from fhir.resources.patient import Patient
from fhir.resources.bundle import Bundle
-from healthchain.gateway.core.fhirgateway import FHIRGateway
+from healthchain.gateway.fhir import FHIRGateway
+from healthchain.gateway.fhir.errors import FHIRConnectionError
class MockConnectionManager:
@@ -17,18 +18,15 @@ def __init__(self):
def add_source(self, name: str, connection_string: str) -> None:
self.sources[name] = Mock()
- async def get_client(self, source: str = None):
- return AsyncMock()
+ def get_client(self, source: str = None):
+ return Mock()
- def get_pool_status(self) -> Dict[str, Any]:
+ def get_status(self) -> Dict[str, Any]:
return {
"max_connections": 100,
"sources": {"test_source": "connected"},
}
- async def close(self) -> None:
- pass
-
@pytest.fixture
def mock_connection_manager():
@@ -40,7 +38,7 @@ def mock_connection_manager():
def fhir_gateway(mock_connection_manager):
"""Fixture providing a FHIRGateway with mocked dependencies."""
with patch(
- "healthchain.gateway.core.fhirgateway.FHIRConnectionManager",
+ "healthchain.gateway.clients.fhir.sync.connection.FHIRConnectionManager",
return_value=mock_connection_manager,
):
return FHIRGateway(use_events=False)
@@ -52,116 +50,12 @@ def test_patient():
return Patient(id="123", active=True)
-def test_transform_handler_registration_with_correct_annotation(fhir_gateway):
- """Transform handlers with correct return type annotations register successfully."""
-
- @fhir_gateway.transform(Patient)
- def transform_patient(id: str, source: str = None) -> Patient:
- return Patient(id=id)
-
- assert fhir_gateway._resource_handlers[Patient]["transform"] == transform_patient
-
-
-def test_transform_handler_validation_enforces_return_type_match(fhir_gateway):
- """Transform handler registration validates return type matches decorator resource type."""
- from fhir.resources.observation import Observation
-
- with pytest.raises(
- TypeError, match="return type .* doesn't match decorator resource type"
- ):
-
- @fhir_gateway.transform(Patient)
- def invalid_handler(id: str) -> Observation: # Wrong return type
- return Observation()
-
-
-def test_aggregate_handler_registration_without_validation(fhir_gateway):
- """Aggregate handlers register without return type validation."""
-
- @fhir_gateway.aggregate(Patient)
- def aggregate_patients(id: str = None, sources: List[str] = None):
- return []
-
- assert fhir_gateway._resource_handlers[Patient]["aggregate"] == aggregate_patients
-
-
-def test_handler_registration_creates_routes(fhir_gateway):
- """Handler registration automatically creates corresponding API routes."""
- initial_routes = len(fhir_gateway.routes)
-
- @fhir_gateway.transform(Patient)
- def transform_patient(id: str) -> Patient:
- return Patient(id=id)
-
- assert len(fhir_gateway.routes) == initial_routes + 1
-
-
-def test_empty_capability_statement_with_no_handlers(fhir_gateway):
- """Gateway with no handlers generates minimal CapabilityStatement."""
- capability = fhir_gateway.build_capability_statement()
-
- assert capability.model_dump()["resourceType"] == "CapabilityStatement"
- assert capability.status == "active"
- assert capability.kind == "instance"
- assert capability.fhirVersion == "4.0.1"
-
-
-def test_capability_statement_includes_registered_resources(fhir_gateway):
- """CapabilityStatement includes resources with registered handlers."""
- from fhir.resources.observation import Observation
-
- @fhir_gateway.transform(Patient)
- def transform_patient(id: str) -> Patient:
- return Patient(id=id)
-
- @fhir_gateway.aggregate(Observation)
- def aggregate_observations(id: str = None) -> List[Observation]:
- return []
-
- capability = fhir_gateway.build_capability_statement()
- resources = capability.rest[0].resource
- resource_types = [r.type for r in resources]
-
- assert "Patient" in resource_types
- assert "Observation" in resource_types
-
-
-def test_gateway_status_structure(fhir_gateway):
- """Gateway status contains required fields with correct structure."""
- status = fhir_gateway.get_gateway_status()
-
- assert status["gateway_type"] == "FHIRGateway"
- assert status["status"] == "active"
- assert isinstance(status["timestamp"], str)
- assert isinstance(status["version"], str)
-
-
-def test_supported_operations_tracking(fhir_gateway):
- """Gateway accurately tracks registered operations."""
- initial_ops = fhir_gateway.get_gateway_status()["supported_operations"][
- "endpoints"
- ]["transform"]
-
- @fhir_gateway.transform(Patient)
- def transform_patient(id: str) -> Patient:
- return Patient(id=id)
-
- updated_status = fhir_gateway.get_gateway_status()
-
- assert (
- updated_status["supported_operations"]["endpoints"]["transform"]
- == initial_ops + 1
- )
- assert "Patient" in updated_status["supported_operations"]["resources"]
-
-
-@pytest.mark.asyncio
-async def test_read_operation_with_client_delegation(fhir_gateway, test_patient):
+def test_read_operation_with_client_delegation(fhir_gateway, test_patient):
"""Read operation delegates to client and handles results correctly."""
with patch.object(
fhir_gateway, "_execute_with_client", return_value=test_patient
) as mock_execute:
- result = await fhir_gateway.read(Patient, "123", "test_source")
+ result = fhir_gateway.read(Patient, "123", "test_source")
mock_execute.assert_called_once_with(
"read",
@@ -173,22 +67,20 @@ async def test_read_operation_with_client_delegation(fhir_gateway, test_patient)
assert result == test_patient
-@pytest.mark.asyncio
-async def test_read_operation_raises_on_not_found(fhir_gateway):
+def test_read_operation_raises_on_not_found(fhir_gateway):
"""Read operation raises ValueError when resource not found."""
with patch.object(fhir_gateway, "_execute_with_client", return_value=None):
with pytest.raises(ValueError, match="Resource Patient/123 not found"):
- await fhir_gateway.read(Patient, "123")
+ fhir_gateway.read(Patient, "123")
-@pytest.mark.asyncio
-async def test_create_operation_with_validation(fhir_gateway, test_patient):
+def test_create_operation_with_validation(fhir_gateway, test_patient):
"""Create operation validates input and returns created resource."""
created_patient = Patient(id="456", active=True)
with patch.object(
fhir_gateway, "_execute_with_client", return_value=created_patient
) as mock_execute:
- result = await fhir_gateway.create(test_patient)
+ result = fhir_gateway.create(test_patient)
mock_execute.assert_called_once_with(
"create",
@@ -199,17 +91,15 @@ async def test_create_operation_with_validation(fhir_gateway, test_patient):
assert result == created_patient
-@pytest.mark.asyncio
-async def test_update_operation_requires_resource_id(fhir_gateway):
+def test_update_operation_requires_resource_id(fhir_gateway):
"""Update operation validates that resource has ID."""
patient_without_id = Patient(active=True) # No ID
with pytest.raises(ValueError, match="Resource must have an ID for update"):
- await fhir_gateway.update(patient_without_id)
+ fhir_gateway.update(patient_without_id)
-@pytest.mark.asyncio
-async def test_search_operation_with_parameters(fhir_gateway):
+def test_search_operation_with_parameters(fhir_gateway):
"""Search operation passes parameters correctly to client."""
mock_bundle = Bundle(type="searchset", total=1)
params = {"name": "Smith", "active": "true"}
@@ -217,65 +107,30 @@ async def test_search_operation_with_parameters(fhir_gateway):
with patch.object(
fhir_gateway, "_execute_with_client", return_value=mock_bundle
) as mock_execute:
- result = await fhir_gateway.search(Patient, params, "test_source")
+ result = fhir_gateway.search(Patient, params, "test_source")
mock_execute.assert_called_once_with(
"search",
source="test_source",
resource_type=Patient,
- client_args=(Patient,),
- client_kwargs={"params": params},
+ client_args=(Patient, params),
)
assert result == mock_bundle
-@pytest.mark.asyncio
-async def test_modify_context_for_existing_resource(fhir_gateway, test_patient):
- """Modify context manager fetches, yields, and updates existing resources."""
- mock_client = AsyncMock()
- mock_client.read.return_value = test_patient
- mock_client.update.return_value = Patient(id="123", active=False)
-
- with patch.object(fhir_gateway, "get_client", return_value=mock_client):
- async with fhir_gateway.modify(Patient, "123") as patient:
- assert patient == test_patient
- patient.active = False
-
- mock_client.read.assert_called_once_with(Patient, "123")
- mock_client.update.assert_called_once_with(test_patient)
-
-
-@pytest.mark.asyncio
-async def test_modify_context_for_new_resource(fhir_gateway):
- """Modify context manager creates new resources when no ID provided."""
- created_patient = Patient(id="456", active=True)
- mock_client = AsyncMock()
- mock_client.create.return_value = created_patient
-
- with patch.object(fhir_gateway, "get_client", return_value=mock_client):
- async with fhir_gateway.modify(Patient) as patient:
- assert patient.id is None # New resource
- patient.active = True
-
- mock_client.create.assert_called_once()
- # Verify the created resource was updated with returned values
- assert patient.id == "456"
-
-
-@pytest.mark.asyncio
-async def test_execute_with_client_handles_client_errors(fhir_gateway):
+def test_execute_with_client_handles_client_errors(fhir_gateway):
"""_execute_with_client properly handles and re-raises client errors."""
- mock_client = AsyncMock()
+ mock_client = Mock()
mock_client.read.side_effect = Exception("Client error")
with patch.object(fhir_gateway, "get_client", return_value=mock_client):
with patch(
- "healthchain.gateway.core.fhirgateway.FHIRErrorHandler.handle_fhir_error"
+ "healthchain.gateway.fhir.errors.FHIRErrorHandler.handle_fhir_error"
) as mock_handler:
mock_handler.side_effect = Exception("Handled error")
with pytest.raises(Exception, match="Handled error"):
- await fhir_gateway._execute_with_client(
+ fhir_gateway._execute_with_client(
"read",
resource_type=Patient,
resource_id="123",
@@ -283,3 +138,64 @@ async def test_execute_with_client_handles_client_errors(fhir_gateway):
)
mock_handler.assert_called_once()
+
+
+def test_gateway_handles_source_initialization_errors():
+ """FHIRGateway handles errors during source initialization gracefully."""
+ # Mock connection manager to raise error on add_source
+ mock_manager = Mock()
+ mock_manager.add_source.side_effect = FHIRConnectionError(
+ code=500, message="Invalid connection string"
+ )
+
+ with patch(
+ "healthchain.gateway.clients.fhir.sync.connection.FHIRConnectionManager",
+ return_value=mock_manager,
+ ):
+ gateway = FHIRGateway(use_events=False)
+
+ # Should propagate the initialization error
+ with pytest.raises(FHIRConnectionError, match="Invalid connection string"):
+ gateway.add_source("bad_source", "invalid://connection/string")
+
+
+def test_gateway_concurrent_operation_resource_management():
+ """FHIRGateway manages resources correctly under concurrent access."""
+ import threading
+ from concurrent.futures import ThreadPoolExecutor
+
+ mock_manager = MockConnectionManager()
+
+ with patch(
+ "healthchain.gateway.clients.fhir.sync.connection.FHIRConnectionManager",
+ return_value=mock_manager,
+ ):
+ gateway = FHIRGateway(use_events=False)
+
+ # Track concurrent client usage
+ client_usage_count = 0
+ client_lock = threading.Lock()
+
+ def track_execute(*args, **kwargs):
+ nonlocal client_usage_count
+ with client_lock:
+ client_usage_count += 1
+ return Patient(id="test")
+
+ # Execute concurrent operations
+ def perform_read():
+ with patch.object(
+ gateway, "_execute_with_client", side_effect=track_execute
+ ):
+ return gateway.read(Patient, "123")
+
+ with ThreadPoolExecutor(max_workers=3) as executor:
+ futures = [executor.submit(perform_read) for _ in range(5)]
+ results = [future.result() for future in futures]
+
+ # Verify all operations completed successfully
+ assert len(results) == 5
+ assert all(isinstance(result, Patient) for result in results)
+
+ # Verify concurrent access was tracked
+ assert client_usage_count == 5
diff --git a/tests/gateway/test_fhir_gateway_async.py b/tests/gateway/test_fhir_gateway_async.py
new file mode 100644
index 00000000..2fbf996c
--- /dev/null
+++ b/tests/gateway/test_fhir_gateway_async.py
@@ -0,0 +1,182 @@
+import pytest
+from unittest.mock import AsyncMock, Mock, patch
+from typing import Dict, Any
+
+from fhir.resources.patient import Patient
+from fhir.resources.bundle import Bundle
+
+from healthchain.gateway.fhir import AsyncFHIRGateway
+
+
+class MockConnectionManager:
+ """Mock FHIR connection manager for testing."""
+
+ def __init__(self):
+ self.sources = {"test_source": Mock()}
+
+ def add_source(self, name: str, connection_string: str) -> None:
+ self.sources[name] = Mock()
+
+ async def get_client(self, source: str = None):
+ return AsyncMock()
+
+ def get_pool_status(self) -> Dict[str, Any]:
+ return {
+ "max_connections": 100,
+ "sources": {"test_source": "connected"},
+ }
+
+ async def close(self) -> None:
+ pass
+
+
+@pytest.fixture
+def mock_connection_manager():
+ """Fixture providing a mock connection manager."""
+ return MockConnectionManager()
+
+
+@pytest.fixture
+def fhir_gateway(mock_connection_manager):
+ """Fixture providing a AsyncFHIRGateway with mocked dependencies."""
+ with patch(
+ "healthchain.gateway.clients.fhir.sync.connection.FHIRConnectionManager",
+ return_value=mock_connection_manager,
+ ):
+ return AsyncFHIRGateway(use_events=False)
+
+
+@pytest.fixture
+def test_patient():
+ """Fixture providing a test Patient resource."""
+ return Patient(id="123", active=True)
+
+
+@pytest.mark.asyncio
+async def test_read_operation_with_client_delegation(fhir_gateway, test_patient):
+ """Read operation delegates to client and handles results correctly."""
+ with patch.object(
+ fhir_gateway, "_execute_with_client", return_value=test_patient
+ ) as mock_execute:
+ result = await fhir_gateway.read(Patient, "123", "test_source")
+
+ mock_execute.assert_called_once_with(
+ "read",
+ source="test_source",
+ resource_type=Patient,
+ resource_id="123",
+ client_args=(Patient, "123"),
+ )
+ assert result == test_patient
+
+
+@pytest.mark.asyncio
+async def test_read_operation_raises_on_not_found(fhir_gateway):
+ """Read operation raises ValueError when resource not found."""
+ with patch.object(fhir_gateway, "_execute_with_client", return_value=None):
+ with pytest.raises(ValueError, match="Resource Patient/123 not found"):
+ await fhir_gateway.read(Patient, "123")
+
+
+@pytest.mark.asyncio
+async def test_create_operation_with_validation(fhir_gateway, test_patient):
+ """Create operation validates input and returns created resource."""
+ created_patient = Patient(id="456", active=True)
+ with patch.object(
+ fhir_gateway, "_execute_with_client", return_value=created_patient
+ ) as mock_execute:
+ result = await fhir_gateway.create(test_patient)
+
+ mock_execute.assert_called_once_with(
+ "create",
+ source=None,
+ resource_type=Patient,
+ client_args=(test_patient,),
+ )
+ assert result == created_patient
+
+
+@pytest.mark.asyncio
+async def test_update_operation_requires_resource_id(fhir_gateway):
+ """Update operation validates that resource has ID."""
+ patient_without_id = Patient(active=True) # No ID
+
+ with pytest.raises(ValueError, match="Resource must have an ID for update"):
+ await fhir_gateway.update(patient_without_id)
+
+
+@pytest.mark.asyncio
+async def test_search_operation_with_parameters(fhir_gateway):
+ """Search operation passes parameters correctly to client."""
+ mock_bundle = Bundle(type="searchset", total=1)
+ params = {"name": "Smith", "active": "true"}
+
+ with patch.object(
+ fhir_gateway, "_execute_with_client", return_value=mock_bundle
+ ) as mock_execute:
+ result = await fhir_gateway.search(Patient, params, "test_source")
+
+ mock_execute.assert_called_once_with(
+ "search",
+ source="test_source",
+ resource_type=Patient,
+ client_args=(Patient,),
+ client_kwargs={"params": params},
+ )
+ assert result == mock_bundle
+
+
+@pytest.mark.asyncio
+async def test_modify_context_for_existing_resource(fhir_gateway, test_patient):
+ """Modify context manager fetches, yields, and updates existing resources."""
+ mock_client = AsyncMock()
+ mock_client.read.return_value = test_patient
+ mock_client.update.return_value = Patient(id="123", active=False)
+
+ with patch.object(fhir_gateway, "get_client", return_value=mock_client):
+ async with fhir_gateway.modify(Patient, "123") as patient:
+ assert patient == test_patient
+ patient.active = False
+
+ mock_client.read.assert_called_once_with(Patient, "123")
+ mock_client.update.assert_called_once_with(test_patient)
+
+
+@pytest.mark.asyncio
+async def test_modify_context_for_new_resource(fhir_gateway):
+ """Modify context manager creates new resources when no ID provided."""
+ created_patient = Patient(id="456", active=True)
+ mock_client = AsyncMock()
+ mock_client.create.return_value = created_patient
+
+ with patch.object(fhir_gateway, "get_client", return_value=mock_client):
+ async with fhir_gateway.modify(Patient) as patient:
+ assert patient.id is None # New resource
+ patient.active = True
+
+ mock_client.create.assert_called_once()
+ # Verify the created resource was updated with returned values
+ assert patient.id == "456"
+
+
+@pytest.mark.asyncio
+async def test_execute_with_client_handles_client_errors(fhir_gateway):
+ """_execute_with_client properly handles and re-raises client errors."""
+ mock_client = AsyncMock()
+ mock_client.read.side_effect = Exception("Client error")
+
+ with patch.object(fhir_gateway, "get_client", return_value=mock_client):
+ with patch(
+ "healthchain.gateway.fhir.errors.FHIRErrorHandler.handle_fhir_error"
+ ) as mock_handler:
+ mock_handler.side_effect = Exception("Handled error")
+
+ with pytest.raises(Exception, match="Handled error"):
+ await fhir_gateway._execute_with_client(
+ "read",
+ resource_type=Patient,
+ resource_id="123",
+ client_args=(Patient, "123"),
+ )
+
+ mock_handler.assert_called_once()
diff --git a/tests/gateway/test_integration.py b/tests/gateway/test_integration.py
new file mode 100644
index 00000000..9847008f
--- /dev/null
+++ b/tests/gateway/test_integration.py
@@ -0,0 +1,183 @@
+"""
+Integration tests for gateway components working together.
+
+Tests the complete flow: Gateway -> ConnectionManager -> Client -> HTTP
+"""
+
+import pytest
+from unittest.mock import Mock, AsyncMock, patch
+from fhir.resources.patient import Patient
+from fhir.resources.bundle import Bundle
+
+from healthchain.gateway.fhir import FHIRGateway, AsyncFHIRGateway
+
+
+def test_sync_gateway_full_integration_flow(standard_connection_string):
+ """Complete sync flow: Gateway -> ConnectionManager -> Client -> HTTP."""
+
+ # Mock the client creation at the connection manager level
+ with patch(
+ "healthchain.gateway.clients.fhir.sync.connection.FHIRConnectionManager.get_client"
+ ) as mock_get_client:
+ # Create a mock client with expected methods
+ mock_client = Mock()
+ mock_client.read.return_value = Patient(id="test-patient-123", active=True)
+ mock_get_client.return_value = mock_client
+
+ # Create gateway and add source (this will be mocked out)
+ gateway = FHIRGateway(use_events=False)
+ gateway.add_source("test_source", standard_connection_string)
+
+ # Execute operation - this should flow through all layers
+ result = gateway.read(Patient, "test-patient-123", "test_source")
+
+ # Verify the complete chain worked
+ assert isinstance(result, Patient)
+ assert result.id == "test-patient-123"
+ assert result.active is True
+
+ # Verify client method was called correctly
+ mock_client.read.assert_called_once_with(Patient, "test-patient-123")
+
+
+@pytest.mark.asyncio
+async def test_async_gateway_full_integration_flow(standard_connection_string):
+ """Complete async flow: Gateway -> ConnectionManager -> Client -> HTTP."""
+
+ # Mock the client creation at the async connection manager level
+ with patch(
+ "healthchain.gateway.clients.fhir.aio.connection.AsyncFHIRConnectionManager.get_client"
+ ) as mock_get_client:
+ # Create a mock async client with async methods
+ mock_client = Mock()
+ mock_client.read = AsyncMock(
+ return_value=Patient(id="test-patient-123", active=True)
+ )
+ mock_get_client.return_value = mock_client
+
+ # Mock the connection manager close to avoid async context manager issues
+ with patch(
+ "healthchain.gateway.clients.fhir.aio.connection.AsyncFHIRConnectionManager.close"
+ ):
+ # Create async gateway and add source
+ async with AsyncFHIRGateway(use_events=False) as gateway:
+ gateway.add_source("test_source", standard_connection_string)
+
+ # Execute operation - this should flow through all async layers
+ result = await gateway.read(Patient, "test-patient-123", "test_source")
+
+ # Verify the complete chain worked
+ assert isinstance(result, Patient)
+ assert result.id == "test-patient-123"
+ assert result.active is True
+
+ # Verify client method was called correctly
+ mock_client.read.assert_called_once_with(Patient, "test-patient-123")
+
+
+def test_sync_gateway_error_propagation_chain(standard_connection_string):
+ """HTTP errors properly propagate through: Client -> Gateway -> User."""
+ import httpx
+
+ # Mock the client to raise timeout error
+ with patch(
+ "healthchain.gateway.clients.fhir.sync.connection.FHIRConnectionManager.get_client"
+ ) as mock_get_client:
+ mock_client = Mock()
+ mock_client.read.side_effect = httpx.ConnectTimeout("Connection timed out")
+ mock_get_client.return_value = mock_client
+
+ # Create gateway
+ gateway = FHIRGateway(use_events=False)
+ gateway.add_source("test_source", standard_connection_string)
+
+ # Execute operation and expect proper error propagation
+ with pytest.raises(Exception) as exc_info:
+ gateway.read(Patient, "test-patient-123", "test_source")
+
+ # Verify error chain includes original timeout
+ assert (
+ "timed out" in str(exc_info.value).lower()
+ or "timeout" in str(exc_info.value).lower()
+ )
+
+
+def test_sync_gateway_multi_source_integration():
+ """Gateway handles multiple sources correctly in integration."""
+ connection_string1 = "fhir://source1.fhir.org/R4?client_id=client1&client_secret=secret1&token_url=https://source1.fhir.org/token"
+ connection_string2 = "fhir://source2.fhir.org/R4?client_id=client2&client_secret=secret2&token_url=https://source2.fhir.org/token"
+
+ # Mock the connection manager to return different clients for different sources
+ with patch(
+ "healthchain.gateway.clients.fhir.sync.connection.FHIRConnectionManager.get_client"
+ ) as mock_get_client:
+ # Create different mock clients for different sources
+ mock_client1 = Mock()
+ mock_client1.read.return_value = Patient(
+ id="123", active=True, name=[{"family": "Source1Patient"}]
+ )
+
+ mock_client2 = Mock()
+ mock_client2.read.return_value = Patient(
+ id="456", active=False, name=[{"family": "Source2Patient"}]
+ )
+
+ # Return appropriate client based on call order
+ mock_get_client.side_effect = [mock_client1, mock_client2]
+
+ # Create gateway with multiple sources
+ gateway = FHIRGateway(use_events=False)
+ gateway.add_source("source1", connection_string1)
+ gateway.add_source("source2", connection_string2)
+
+ # Test reading from specific sources
+ patient1 = gateway.read(Patient, "123", "source1")
+ patient2 = gateway.read(Patient, "456", "source2")
+
+ # Verify correct patients from correct sources
+ assert patient1.name[0].family == "Source1Patient"
+ assert patient2.name[0].family == "Source2Patient"
+
+ # Verify correct calls were made
+ assert mock_get_client.call_count == 2
+ mock_client1.read.assert_called_once_with(Patient, "123")
+ mock_client2.read.assert_called_once_with(Patient, "456")
+
+
+def test_sync_gateway_transaction_bundle_integration(
+ standard_connection_string, sample_bundle
+):
+ """Gateway properly handles transaction bundles through the full stack."""
+
+ # Mock the client to return transaction response
+ with patch(
+ "healthchain.gateway.clients.fhir.sync.connection.FHIRConnectionManager.get_client"
+ ) as mock_get_client:
+ mock_client = Mock()
+ mock_client.transaction.return_value = Bundle(
+ type="transaction-response",
+ entry=[
+ {
+ "response": {"status": "200 OK"},
+ "resource": {
+ "resourceType": "Patient",
+ "id": "123",
+ "active": True,
+ },
+ }
+ ],
+ )
+ mock_get_client.return_value = mock_client
+
+ # Create gateway and execute transaction
+ gateway = FHIRGateway(use_events=False)
+ gateway.add_source("test_source", standard_connection_string)
+
+ result = gateway.transaction(sample_bundle, "test_source")
+
+ # Verify transaction bundle was processed
+ assert isinstance(result, Bundle)
+ assert result.type == "transaction-response"
+
+ # Verify transaction method was called with the bundle
+ mock_client.transaction.assert_called_once_with(sample_bundle)
diff --git a/tests/gateway/test_notereader.py b/tests/gateway/test_notereader.py
index 865c884b..60caccce 100644
--- a/tests/gateway/test_notereader.py
+++ b/tests/gateway/test_notereader.py
@@ -1,7 +1,7 @@
import pytest
from unittest.mock import patch, MagicMock
-from healthchain.gateway.protocols.notereader import (
+from healthchain.gateway.soap.notereader import (
NoteReaderService,
NoteReaderConfig,
)
@@ -110,8 +110,8 @@ def test_notereader_gateway_process_result():
assert result.document == "test_dict"
-@patch("healthchain.gateway.protocols.notereader.Application")
-@patch("healthchain.gateway.protocols.notereader.WsgiApplication")
+@patch("healthchain.gateway.soap.notereader.Application")
+@patch("healthchain.gateway.soap.notereader.WsgiApplication")
def test_notereader_gateway_create_wsgi_app(mock_wsgi, mock_application):
"""Test WSGI app creation for SOAP service"""
mock_wsgi_instance = MagicMock()
diff --git a/tests/gateway/test_soap_server.py b/tests/gateway/test_soap_server.py
index 5c0985b6..569caff4 100644
--- a/tests/gateway/test_soap_server.py
+++ b/tests/gateway/test_soap_server.py
@@ -1,8 +1,8 @@
import pytest
from unittest.mock import MagicMock
-from healthchain.gateway.soap.epiccdsservice import CDSServices
-from healthchain.gateway.soap.model import ClientFault, ServerFault
+from healthchain.gateway.soap.utils.epiccds import CDSServices
+from healthchain.gateway.soap.utils.model import ClientFault, ServerFault
@pytest.fixture
diff --git a/tests/sandbox/test_cds_sandbox.py b/tests/sandbox/test_cds_sandbox.py
index bf51ec06..fc16f342 100644
--- a/tests/sandbox/test_cds_sandbox.py
+++ b/tests/sandbox/test_cds_sandbox.py
@@ -1,7 +1,7 @@
from unittest.mock import patch, MagicMock
import healthchain as hc
-from healthchain.gateway.protocols.cdshooks import CDSHooksService
+from healthchain.gateway.cds import CDSHooksService
from healthchain.gateway.api import HealthChainAPI
from healthchain.models.requests.cdsrequest import CDSRequest
from healthchain.models.responses.cdsresponse import CDSResponse, Card
diff --git a/tests/sandbox/test_clindoc_sandbox.py b/tests/sandbox/test_clindoc_sandbox.py
index b071b778..800d7aaf 100644
--- a/tests/sandbox/test_clindoc_sandbox.py
+++ b/tests/sandbox/test_clindoc_sandbox.py
@@ -1,7 +1,7 @@
from unittest.mock import patch, MagicMock
import healthchain as hc
-from healthchain.gateway.protocols.notereader import NoteReaderService
+from healthchain.gateway.soap.notereader import NoteReaderService
from healthchain.gateway.api import HealthChainAPI
from healthchain.models.requests import CdaRequest
from healthchain.models.responses.cdaresponse import CdaResponse