Spaces:
Sleeping
Sleeping
| """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), | |
| ) | |
| 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 | |
| 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) | |
| 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]) | |
| 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 | |
| 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 | |
| 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 | |
| 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) | |
| 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") | |
| 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() | |