clarke / backend /fhir /mock_api.py
yashvshetty's picture
Clarke: NHS clinical documentation system
9636a02
Raw
History Blame
14 kB
"""Mock FHIR API server for local development and fallback deployments."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import FastAPI, HTTPException, Query
from loguru import logger
FHIR_BASE_URL = "http://localhost:8080/fhir"
DEFAULT_BUNDLES_DIR = Path(__file__).resolve().parents[2] / "data" / "fhir_bundles"
app = FastAPI(title="Clarke Mock FHIR API", version="1.0.0")
class BundleIndex:
"""In-memory index for patient bundles keyed by patient id and resource type."""
def __init__(self, bundles_dir: Path) -> None:
"""Initialise and preload all FHIR bundles from disk.
Args:
bundles_dir (Path): Directory containing per-patient bundle JSON files.
Returns:
None: Instance state is populated in-place.
"""
self.bundles_dir = bundles_dir
self.patient_resources: dict[str, dict[str, Any]] = {}
self.resources_by_type: dict[str, dict[str, list[dict[str, Any]]]] = {}
self._load_bundles()
def _load_bundles(self) -> None:
"""Load all bundle files and build lookup indexes for API routes.
Args:
None: Uses instance `bundles_dir`.
Returns:
None: Internal index maps are rebuilt.
"""
if not self.bundles_dir.exists():
logger.bind(component="mock_fhir_api").warning(
"Bundle directory does not exist", bundles_dir=str(self.bundles_dir)
)
return
for bundle_file in sorted(self.bundles_dir.glob("*.json")):
with bundle_file.open("r", encoding="utf-8") as file_handle:
bundle = json.load(file_handle)
for entry in bundle.get("entry", []):
resource = entry.get("resource", {})
resource_type = resource.get("resourceType")
if not resource_type:
continue
patient_id = self._extract_patient_id(resource)
if not patient_id and resource_type == "Patient":
patient_id = resource.get("id")
if not patient_id:
continue
if resource_type == "Patient":
self.patient_resources[patient_id] = resource
self.resources_by_type.setdefault(resource_type, {}).setdefault(patient_id, []).append(resource)
logger.bind(component="mock_fhir_api").info(
"Loaded mock FHIR bundles",
patients=len(self.patient_resources),
bundles_dir=str(self.bundles_dir),
)
@staticmethod
def _extract_patient_id(resource: dict[str, Any]) -> str | None:
"""Extract patient identifier from common FHIR reference fields.
Args:
resource (dict[str, Any]): A FHIR resource object.
Returns:
str | None: Patient id (e.g. `pt-001`) when detectable, otherwise None.
"""
reference_candidates = [
resource.get("subject", {}).get("reference"),
resource.get("patient", {}).get("reference"),
]
for candidate in reference_candidates:
if candidate and isinstance(candidate, str) and candidate.startswith("Patient/"):
return candidate.split("/", maxsplit=1)[1]
return None
def get_patient(self, patient_id: str) -> dict[str, Any] | None:
"""Return a single patient resource by id.
Args:
patient_id (str): Patient identifier.
Returns:
dict[str, Any] | None: Patient resource when present, else None.
"""
return self.patient_resources.get(patient_id)
def get_resources(self, resource_type: str, patient_id: str) -> list[dict[str, Any]]:
"""Return all indexed resources of a given type for a patient.
Args:
resource_type (str): FHIR resource type name.
patient_id (str): Patient identifier.
Returns:
list[dict[str, Any]]: Matching resources, empty list when none found.
"""
return self.resources_by_type.get(resource_type, {}).get(patient_id, [])
def search_patients(self, name_term: str) -> list[dict[str, Any]]:
"""Search patients by case-insensitive name matching across given/family/prefix.
Args:
name_term (str): Name fragment entered by caller.
Returns:
list[dict[str, Any]]: Matching patient resources.
"""
lowered = name_term.lower().strip()
matches: list[dict[str, Any]] = []
for patient in self.patient_resources.values():
for name in patient.get("name", []):
tokens = []
tokens.extend(name.get("prefix", []))
tokens.extend(name.get("given", []))
if family := name.get("family"):
tokens.append(family)
if any(lowered in str(token).lower() for token in tokens):
matches.append(patient)
break
return matches
BUNDLE_INDEX = BundleIndex(Path(os.getenv("MOCK_FHIR_BUNDLES_DIR", str(DEFAULT_BUNDLES_DIR))))
def build_search_bundle(resource_type: str, resources: list[dict[str, Any]]) -> dict[str, Any]:
"""Build a FHIR searchset bundle payload from resource rows.
Args:
resource_type (str): Resource type represented in `resources`.
resources (list[dict[str, Any]]): FHIR resources to include.
Returns:
dict[str, Any]: FHIR Bundle with `entry` and `total` fields.
"""
entries = [
{
"fullUrl": f"{FHIR_BASE_URL}/{resource_type}/{resource.get('id', '')}",
"resource": resource,
}
for resource in resources
]
return {
"resourceType": "Bundle",
"type": "searchset",
"total": len(resources),
"entry": entries,
}
def get_effective_sort_key(resource: dict[str, Any]) -> str:
"""Resolve date-like sort key for FHIR resources supporting `_sort=-date`.
Args:
resource (dict[str, Any]): Resource candidate.
Returns:
str: Date-like string usable for lexicographic descending sort.
"""
return (
resource.get("effectiveDateTime")
or resource.get("issued")
or resource.get("authoredOn")
or resource.get("onsetDateTime")
or resource.get("period", {}).get("start")
or ""
)
def get_patient_or_404(patient_id: str) -> dict[str, Any]:
"""Resolve patient resource or raise a 404 HTTP exception.
Args:
patient_id (str): Patient identifier to lookup.
Returns:
dict[str, Any]: Existing patient resource.
"""
patient_resource = BUNDLE_INDEX.get_patient(patient_id)
if not patient_resource:
raise HTTPException(status_code=404, detail=f"Patient not found: {patient_id}")
return patient_resource
@app.get("/fhir/Patient/{patient_id}")
async def read_patient(patient_id: str) -> dict[str, Any]:
"""Return a Patient resource by id.
Args:
patient_id (str): Patient identifier path parameter.
Returns:
dict[str, Any]: Patient resource payload.
"""
return get_patient_or_404(patient_id)
@app.get("/fhir/Patient")
async def search_patient(name: str = Query(default=""), _count: int = Query(default=10, ge=1)) -> dict[str, Any]:
"""Search patients by name and return a FHIR searchset bundle.
Args:
name (str): Name fragment for case-insensitive matching.
_count (int): Maximum result size.
Returns:
dict[str, Any]: Search bundle of Patient resources.
"""
resources = BUNDLE_INDEX.search_patients(name) if name else list(BUNDLE_INDEX.patient_resources.values())
return build_search_bundle("Patient", resources[:_count])
def list_patient_resources(resource_type: str, patient: str, limit: int, sort_desc_by_date: bool = False) -> dict[str, Any]:
"""Fetch filtered patient resources and wrap them as a searchset bundle.
Args:
resource_type (str): FHIR resource type to return.
patient (str): Patient identifier.
limit (int): Maximum number of resources to include.
sort_desc_by_date (bool): Whether to sort descending by date-like fields.
Returns:
dict[str, Any]: Search bundle for the requested resource type.
"""
get_patient_or_404(patient)
resources = list(BUNDLE_INDEX.get_resources(resource_type, patient))
if sort_desc_by_date:
resources.sort(key=get_effective_sort_key, reverse=True)
return build_search_bundle(resource_type, resources[:limit])
@app.get("/fhir/Condition")
async def list_conditions(
patient: str,
clinical_status: str | None = Query(default=None, alias="clinical-status"),
_count: int = Query(default=20, ge=1),
) -> dict[str, Any]:
"""List Condition resources for a patient with optional active-status filtering.
Args:
patient (str): Patient identifier query parameter.
clinical_status (str | None): Optional status filter (e.g. `active`).
_count (int): Maximum number of rows.
Returns:
dict[str, Any]: Condition search bundle.
"""
bundle = list_patient_resources("Condition", patient, _count)
if clinical_status:
filtered_entries = [
item
for item in bundle["entry"]
if any(
coding.get("code") == clinical_status
for coding in item["resource"].get("clinicalStatus", {}).get("coding", [])
)
]
bundle["entry"] = filtered_entries
bundle["total"] = len(filtered_entries)
return bundle
@app.get("/fhir/MedicationRequest")
async def list_medication_requests(
patient: str,
status: str | None = Query(default=None),
_count: int = Query(default=20, ge=1),
) -> dict[str, Any]:
"""List MedicationRequest resources for a patient with optional status filter.
Args:
patient (str): Patient identifier query parameter.
status (str | None): Optional medication status filter.
_count (int): Maximum number of rows.
Returns:
dict[str, Any]: MedicationRequest search bundle.
"""
bundle = list_patient_resources("MedicationRequest", patient, _count, sort_desc_by_date=True)
if status:
filtered_entries = [item for item in bundle["entry"] if item["resource"].get("status") == status]
bundle["entry"] = filtered_entries
bundle["total"] = len(filtered_entries)
return bundle
@app.get("/fhir/Observation")
async def list_observations(
patient: str,
category: str | None = Query(default=None),
_sort: str | None = Query(default=None),
_count: int = Query(default=20, ge=1),
) -> dict[str, Any]:
"""List Observation resources for a patient with category/count/sort controls.
Args:
patient (str): Patient identifier query parameter.
category (str | None): Optional observation category code.
_sort (str | None): Optional sort string (`-date` expected).
_count (int): Maximum number of rows.
Returns:
dict[str, Any]: Observation search bundle.
"""
sort_desc_by_date = _sort == "-date"
bundle = list_patient_resources("Observation", patient, _count, sort_desc_by_date=sort_desc_by_date)
if category:
filtered_entries = [
item
for item in bundle["entry"]
if any(
coding.get("code") == category
for block in item["resource"].get("category", [])
for coding in block.get("coding", [])
)
]
bundle["entry"] = filtered_entries
bundle["total"] = len(filtered_entries)
return bundle
@app.get("/fhir/AllergyIntolerance")
async def list_allergies(patient: str, _count: int = Query(default=20, ge=1)) -> dict[str, Any]:
"""List AllergyIntolerance resources for a patient.
Args:
patient (str): Patient identifier query parameter.
_count (int): Maximum number of rows.
Returns:
dict[str, Any]: AllergyIntolerance search bundle.
"""
return list_patient_resources("AllergyIntolerance", patient, _count)
@app.get("/fhir/DiagnosticReport")
async def list_diagnostic_reports(
patient: str,
_sort: str | None = Query(default=None),
_count: int = Query(default=5, ge=1),
) -> dict[str, Any]:
"""List DiagnosticReport resources for a patient with optional date sorting.
Args:
patient (str): Patient identifier query parameter.
_sort (str | None): Optional sort string (`-date` expected).
_count (int): Maximum number of rows.
Returns:
dict[str, Any]: DiagnosticReport search bundle.
"""
return list_patient_resources("DiagnosticReport", patient, _count, sort_desc_by_date=_sort == "-date")
@app.get("/fhir/Encounter")
async def list_encounters(
patient: str,
_sort: str | None = Query(default=None),
_count: int = Query(default=3, ge=1),
) -> dict[str, Any]:
"""List Encounter resources for a patient with optional date sorting.
Args:
patient (str): Patient identifier query parameter.
_sort (str | None): Optional sort string (`-date` expected).
_count (int): Maximum number of rows.
Returns:
dict[str, Any]: Encounter search bundle.
"""
return list_patient_resources("Encounter", patient, _count, sort_desc_by_date=_sort == "-date")
def main() -> None:
"""Run the mock FHIR API server.
Args:
None: Reads host/port from environment variables.
Returns:
None: Starts uvicorn process.
"""
host = os.getenv("MOCK_FHIR_HOST", "0.0.0.0")
port = int(os.getenv("MOCK_FHIR_PORT", "8080"))
uvicorn.run("backend.fhir.mock_api:app", host=host, port=port, reload=False)
if __name__ == "__main__":
main()