Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions cookbook/multi_ehr_data_aggregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
Requirements:
- pip install healthchain python-dotenv

FHIR Sources:
- Epic Sandbox: Set EPIC_* environment variables
- Cerner Open Sandbox: No auth needed

Run:
- python data_aggregation.py
"""
Expand Down Expand Up @@ -96,9 +100,7 @@ def get_unified_patient(patient_id: str, sources: List[str]) -> Bundle:
doc = Document(data=merged_bundle)
doc = pipeline(doc)

# print([outcome.model_dump() for outcome in doc.fhir.operation_outcomes])

return doc.fhir.bundle.model_dump()
return doc.fhir.bundle

app = HealthChainAPI()
app.register_gateway(gateway)
Expand Down
2 changes: 1 addition & 1 deletion docs/cookbook/ml_model_deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ No FHIR parsing code needed—define the mapping once, use it everywhere.

!!! tip "Explore Interactively"

Step through the full flow in [notebooks/fhir_ml_workflow.ipynb](../../notebooks/fhir_ml_workflow.ipynb): FHIR bundle → Dataset → DataFrame → inference → RiskAssessment.
Step through the full flow in [notebooks/fhir_ml_workflow.ipynb](https://github.com/dotimplement/HealthChain/blob/main/notebooks/fhir_ml_workflow.ipynb): FHIR bundle → Dataset → DataFrame → inference → RiskAssessment.

Now let's see how this pipeline plugs into each deployment pattern.

Expand Down
67 changes: 63 additions & 4 deletions docs/cookbook/setup_fhir_sandboxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,41 @@ openssl req -new -x509 -key privatekey.pem -out publickey509.pem -subj '/CN=myap

Where `/CN=myapp` is the subject name (e.g., your app name). The subject name doesn't have functional impact but is required for creating an X.509 certificate.

#### Upload Public Key
#### Register Public Key via JWKS URL

1. In your Epic app configuration, upload the `publickey509.pem` file
2. Click **Save**
3. Note down your **Non-Production Client ID**
Epic now requires registering your public key via a **JWKS (JSON Web Key Set) URL** instead of direct file upload. For quick and dirty development/testing purposes, you can use ngrok to expose your JWKS server publicly.

1. **Set up a JWKS server**:
```bash
# Ensure your .env has the private key path
# EPIC_CLIENT_SECRET_PATH=path/to/privatekey.pem
# EPIC_KEY_ID=healthchain-demo-key

python scripts/serve_jwks.py
```

2. **Get a free static domain from ngrok**:
- Sign up at [ngrok.com](https://ngrok.com)
- Claim your free static domain from the dashboard
- Example: `your-app.ngrok-free.app`

3. **Expose your JWKS server**:
```bash
ngrok http 9999 --domain=your-app.ngrok-free.app
```

4. **Register in Epic App Orchard**:

- In your Epic app configuration, locate the **Non-Production JWK Set URL** field
- Enter: `https://your-app.ngrok-free.app/.well-known/jwks.json`
- Click **Save**
- Note down your **Non-Production Client ID**

The JWKS must be:

- Publicly accessible without authentication
- Served over HTTPS
- Stable (URL should not change)

![Epic Sandbox Client ID](../assets/images/epicsandbox3.png)

Expand All @@ -73,8 +103,11 @@ EPIC_CLIENT_ID=your_non_production_client_id
EPIC_CLIENT_SECRET_PATH=path/to/privatekey.pem
EPIC_TOKEN_URL=https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token
EPIC_USE_JWT_ASSERTION=true
EPIC_KEY_ID=healthchain-demo-key # Must match the kid in your JWKS
```

**Important**: The `EPIC_KEY_ID` must match the Key ID (`kid`) you used when creating your JWKS. This allows Epic to identify which key to use for JWT verification.

### Using Epic Sandbox in Code

```python
Expand All @@ -91,6 +124,20 @@ gateway = FHIRGateway()
gateway.add_source("epic", EPIC_URL)
```

### Testing Your Connection

After configuration:

```bash
python scripts/check_epic_connection.py
```

This script will:
1. Load your Epic configuration
2. Create a JWT assertion with the `kid` header
3. Request an access token from Epic
4. Test a FHIR endpoint query

### Available Test Patients

Epic provides [sample test patients](https://fhir.epic.com/Documentation?docId=testpatients) including:
Expand All @@ -99,6 +146,18 @@ Epic provides [sample test patients](https://fhir.epic.com/Documentation?docId=t
- **Linda Ross** - Patient ID: `eIXesllypH3M9tAA5WdJftQ3`
- Many others with various clinical scenarios

???+ note "Troubleshooting (click to expand)"
**Token request fails after JWKS registration:**
- Wait 15-30 minutes for Epic to propagate changes
- Verify your JWKS URL is publicly accessible (test in browser)
- Check that `EPIC_KEY_ID` matches the `kid` in your JWKS
- Ensure the ngrok tunnel is still running

**JWKS format errors:**
- Verify the JWKS structure at your URL matches Epic's requirements
- Check that `n` and `e` are properly base64url encoded (no padding)
- Algorithm should be RS384, RS256, or RS512

---
## Cerner Sandbox

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/gateway/soap_cda.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,4 @@ The response includes additional structured sections extracted from the clinical
| Gateway Receives | `CdaRequest` | Processed by your service |
| Gateway Returns | Your processed result | `CdaResponse` |

You can use the [CdaAdapter](../pipeline/adapters/cdaadapter.md) to handle conversion between CDA documents and HealthChain pipeline data containers.
You can use the [CdaAdapter](../io/adapters/cdaadapter.md) to handle conversion between CDA documents and HealthChain pipeline data containers.
4 changes: 2 additions & 2 deletions docs/reference/io/adapters/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Unlike the legacy connector pattern, adapters are used explicitly and provide cl

Adapters parse data from specific healthcare formats into FHIR resources and store them in a `Document` container for processing.

([Document API Reference](../../api/containers.md#healthchain.io.containers.document.Document))
([Document API Reference](../../../api/containers.md#healthchain.io.containers.document))

| Adapter | Input Format | Output Format | FHIR Resources | Document Access |
|---------|--------------|---------------|----------------|-----------------|
Expand Down Expand Up @@ -67,7 +67,7 @@ print(f"Allergies: {doc.fhir.allergy_list}")
response = adapter.format(doc) # Document → CdaResponse
```

For more details on the Document container, see [Document](../containers/document.md).
For more details on the Document container, see [Document](../../io/containers/document.md).

## Adapter Configuration

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/io/containers/dataset.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,4 @@ dataset.remove_column('temp_feature') # Drop a feature

## API Reference

See the [Dataset API Reference](../../api/containers.md#healthchain.io.containers.dataset) for detailed class documentation.
See the [Dataset API Reference](../../../api/containers.md#healthchain.io.containers.dataset) for detailed class documentation.
2 changes: 1 addition & 1 deletion docs/reference/io/containers/document.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,4 @@ print(doc.models.get_output("my_model", "task"))

## API Reference

See [Document API Reference](../../api/containers.md#healthchain.io.containers.document) for full details.
See [Document API Reference](../../../api/containers.md#healthchain.io.containers.document) for full details.
2 changes: 1 addition & 1 deletion docs/reference/pipeline/components/fhirproblemextractor.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,4 @@ pipeline = MedicalCodingPipeline(

- [FHIR Condition Resources](https://www.hl7.org/fhir/condition.html)
- [Medical Coding Pipeline](../prebuilt_pipelines/medicalcoding.md)
- [Document Container](../data_container.md)
- [Document Container](../../io/containers/document.md)
7 changes: 6 additions & 1 deletion healthchain/gateway/cds/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,11 @@ def _register_base_routes(self):
# Discovery endpoint
discovery_path = self.config.discovery_path.lstrip("/")

@self.get(f"/{discovery_path}", response_model_exclude_none=True)
@self.get(
f"/{discovery_path}",
response_model=CDSServiceInformation,
response_model_exclude_none=True,
)
async def discovery_handler(cds: "CDSHooksService" = Depends(get_self_service)):
"""CDS Hooks discovery endpoint."""
return cds.handle_discovery()
Expand All @@ -132,6 +136,7 @@ async def service_handler(
path=endpoint,
endpoint=service_handler,
methods=["POST"],
response_model=CDSResponse,
response_model_exclude_none=True,
summary=f"CDS Hook: {hook_id}",
description=f"Execute CDS Hook service: {hook_id}",
Expand Down
11 changes: 7 additions & 4 deletions healthchain/gateway/clients/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class OAuth2Config(BaseModel):
scope: Optional[str] = None
audience: Optional[str] = None # For Epic and other systems that require audience
use_jwt_assertion: bool = False # Use JWT client assertion instead of client secret
key_id: Optional[str] = None # Key ID (kid) for JWT header - required for JWKS

def model_post_init(self, __context) -> None:
"""Validate that exactly one of client_secret or client_secret_path is provided."""
Expand Down Expand Up @@ -219,8 +220,9 @@ def _create_jwt_assertion(self) -> str:
), # Expires in 5 minutes
}

# Create and sign JWT
signed_jwt = JWT().encode(claims, key, alg="RS384")
# Create and sign JWT with optional kid header
headers = {"kid": self.config.key_id} if self.config.key_id else None
signed_jwt = JWT().encode(claims, key, alg="RS384", optional_headers=headers)

return signed_jwt

Expand Down Expand Up @@ -356,7 +358,8 @@ def _create_jwt_assertion(self) -> str:
), # Expires in 5 minutes
}

# Create and sign JWT
signed_jwt = JWT().encode(claims, key, alg="RS384")
# Create and sign JWT with optional kid header
headers = {"kid": self.config.key_id} if self.config.key_id else None
signed_jwt = JWT().encode(claims, key, alg="RS384", optional_headers=headers)

return signed_jwt
8 changes: 8 additions & 0 deletions healthchain/gateway/clients/fhir/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class FHIRAuthConfig(BaseModel):
scope: Optional[str] = "system/*.read system/*.write"
audience: Optional[str] = None
use_jwt_assertion: bool = False # Use JWT client assertion (Epic/SMART style)
key_id: Optional[str] = None # Key ID (kid) for JWT header - required for JWKS

@property
def requires_auth(self) -> bool:
Expand Down Expand Up @@ -99,6 +100,7 @@ def to_oauth2_config(self) -> OAuth2Config:
scope=self.scope,
audience=self.audience,
use_jwt_assertion=self.use_jwt_assertion,
key_id=self.key_id,
)

@classmethod
Expand All @@ -119,6 +121,7 @@ def from_env(cls, env_prefix: str) -> "FHIRAuthConfig":
{env_prefix}_TIMEOUT (optional, default: 30)
{env_prefix}_VERIFY_SSL (optional, default: true)
{env_prefix}_USE_JWT_ASSERTION (optional, default: false)
{env_prefix}_KEY_ID (optional, for JWKS identification)

Returns:
FHIRAuthConfig instance
Expand Down Expand Up @@ -161,6 +164,7 @@ def from_env(cls, env_prefix: str) -> "FHIRAuthConfig":
use_jwt_assertion = (
os.getenv(f"{env_prefix}_USE_JWT_ASSERTION", "false").lower() == "true"
)
key_id = os.getenv(f"{env_prefix}_KEY_ID")

return cls(
client_id=client_id,
Expand All @@ -173,6 +177,7 @@ def from_env(cls, env_prefix: str) -> "FHIRAuthConfig":
timeout=timeout,
verify_ssl=verify_ssl,
use_jwt_assertion=use_jwt_assertion,
key_id=key_id,
)

def to_connection_string(self) -> str:
Expand Down Expand Up @@ -215,6 +220,8 @@ def to_connection_string(self) -> str:
params["verify_ssl"] = "false"
if self.use_jwt_assertion:
params["use_jwt_assertion"] = "true"
if self.key_id:
params["key_id"] = self.key_id

# Build connection string
query_string = urllib.parse.urlencode(params)
Expand Down Expand Up @@ -411,4 +418,5 @@ def parse_fhir_auth_connection_string(connection_string: str) -> FHIRAuthConfig:
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",
key_id=params.get("key_id"),
)
5 changes: 4 additions & 1 deletion healthchain/gateway/fhir/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,6 @@ def _register_operation_route(
methods=["GET"],
summary=summary,
description=description,
response_model_exclude_none=True,
response_class=FHIRResponse,
tags=self.tags,
include_in_schema=True,
Expand All @@ -403,6 +402,10 @@ def _execute_handler(fhir: "BaseFHIRGateway", *args) -> Any:
try:
handler_func = fhir._resource_handlers[resource_type][operation]
result = handler_func(*args)

# Serialize FHIR resources excluding None values
if isinstance(result, Resource):
return result.model_dump(exclude_none=True)
return result
except Exception as e:
logger.error(f"Error in {operation} handler: {str(e)}")
Expand Down
Loading