Skip to content

EHRbase Client

EHRbase client for OpenEHR operations.

EhrAlreadyExistsError

Bases: Exception

Raised when attempting to create an EHR that already exists.

Source code in app/ehrbase_client.py
17
18
19
20
class EhrAlreadyExistsError(Exception):
    """Raised when attempting to create an EHR that already exists."""

    pass

EhrbaseClientError

Bases: Exception

Raised when an EHRbase operation fails.

Wraps underlying network/server errors to provide clean, user-safe messages without leaking internal details.

Source code in app/ehrbase_client.py
23
24
25
26
27
28
class EhrbaseClientError(Exception):
    """Raised when an EHRbase operation fails.

    Wraps underlying network/server errors to provide clean,
    user-safe messages without leaking internal details.
    """

get_auth_header

get_auth_header()

Get Basic Auth header for EHRbase.

Source code in app/ehrbase_client.py
37
38
39
40
41
42
def get_auth_header() -> dict[str, str]:
    """Get Basic Auth header for EHRbase."""
    _require_clinical_services()
    credentials = f"{settings.EHRBASE_API_USER}:{settings.EHRBASE_API_PASSWORD.get_secret_value()}"
    encoded = base64.b64encode(credentials.encode()).decode()
    return {"Authorization": f"Basic {encoded}"}

create_ehr

create_ehr(subject_id, subject_namespace='fhir')

Create a new EHR in EHRbase.

Parameters:

Name Type Description Default
subject_id str

The FHIR Patient ID

required
subject_namespace str

The namespace (default: 'fhir')

'fhir'

Returns:

Type Description
dict[str, Any]

EHR response containing ehr_id

Raises:

Type Description
ValueError

If subject_id is empty or invalid

Source code in app/ehrbase_client.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def create_ehr(
    subject_id: str, subject_namespace: str = "fhir"
) -> dict[str, Any]:
    """
    Create a new EHR in EHRbase.

    Args:
        subject_id: The FHIR Patient ID
        subject_namespace: The namespace (default: 'fhir')

    Returns:
        EHR response containing ehr_id

    Raises:
        ValueError: If subject_id is empty or invalid
    """
    # Defensive programming: validate inputs
    if not subject_id or not subject_id.strip():
        raise ValueError("subject_id cannot be empty")
    if not subject_namespace or not subject_namespace.strip():
        raise ValueError("subject_namespace cannot be empty")
    url = f"{settings.EHRBASE_URL}/rest/openehr/v1/ehr"
    headers = {
        **get_auth_header(),
        "Content-Type": "application/json",
    }

    payload = {
        "_type": "EHR_STATUS",
        "subject": {
            "external_ref": {
                "id": {
                    "_type": "GENERIC_ID",
                    "value": subject_id,
                    "scheme": subject_namespace,
                },
                "namespace": subject_namespace,
                "type": "PERSON",
            }
        },
        "is_modifiable": True,
        "is_queryable": True,
    }

    response = requests.post(url, json=payload, headers=headers)
    if response.status_code == 409:
        raise EhrAlreadyExistsError(
            f"EHR already exists for subject {subject_id}"
        )
    try:
        response.raise_for_status()
    except requests.HTTPError as exc:
        logger.error(
            "Failed to create EHR for subject %s: %s", subject_id, exc
        )
        raise EhrbaseClientError("Failed to create clinical record") from exc
    return response.json()  # type: ignore[no-any-return]

get_ehr_by_subject

get_ehr_by_subject(subject_id, subject_namespace='fhir')

Get an EHR by subject ID.

Parameters:

Name Type Description Default
subject_id str

The FHIR Patient ID

required
subject_namespace str

The namespace (default: 'fhir')

'fhir'

Returns:

Type Description
dict[str, Any] | None

EHR response or None if not found

Raises:

Type Description
ValueError

If subject_id is empty

Source code in app/ehrbase_client.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def get_ehr_by_subject(
    subject_id: str, subject_namespace: str = "fhir"
) -> dict[str, Any] | None:
    """
    Get an EHR by subject ID.

    Args:
        subject_id: The FHIR Patient ID
        subject_namespace: The namespace (default: 'fhir')

    Returns:
        EHR response or None if not found

    Raises:
        ValueError: If subject_id is empty
    """
    # Defensive programming: validate inputs
    if not subject_id or not subject_id.strip():
        raise ValueError("subject_id cannot be empty")
    url = f"{settings.EHRBASE_URL}/rest/openehr/v1/ehr"
    headers = get_auth_header()
    params = {"subject_id": subject_id, "subject_namespace": subject_namespace}

    try:
        response = requests.get(url, params=params, headers=headers)
        if response.status_code == 404:
            return None
        response.raise_for_status()
        return response.json()  # type: ignore[no-any-return]
    except requests.RequestException as exc:
        if isinstance(exc, requests.HTTPError) and exc.response is not None:
            if exc.response.status_code == 404:
                return None
        logger.error("Failed to get EHR for subject %s: %s", subject_id, exc)
        raise EhrbaseClientError("Failed to retrieve clinical record") from exc

get_or_create_ehr

get_or_create_ehr(subject_id, subject_namespace='fhir')

Get existing EHR or create new one for a subject.

Handles the race condition where two concurrent requests may both attempt to create an EHR for the same patient. If creation fails because another request created it first (409 Conflict), we fetch the existing EHR instead.

Parameters:

Name Type Description Default
subject_id str

The FHIR Patient ID

required
subject_namespace str

The namespace (default: 'fhir')

'fhir'

Returns:

Type Description
str

ehr_id (UUID string)

Source code in app/ehrbase_client.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def get_or_create_ehr(subject_id: str, subject_namespace: str = "fhir") -> str:
    """
    Get existing EHR or create new one for a subject.

    Handles the race condition where two concurrent requests may both
    attempt to create an EHR for the same patient. If creation fails
    because another request created it first (409 Conflict), we fetch
    the existing EHR instead.

    Args:
        subject_id: The FHIR Patient ID
        subject_namespace: The namespace (default: 'fhir')

    Returns:
        ehr_id (UUID string)
    """
    ehr = get_ehr_by_subject(subject_id, subject_namespace)
    if ehr:
        return ehr["ehr_id"]["value"]  # type: ignore[no-any-return]

    try:
        new_ehr = create_ehr(subject_id, subject_namespace)
        return new_ehr["ehr_id"]["value"]  # type: ignore[no-any-return]
    except EhrAlreadyExistsError:
        # Another request created the EHR between our check and create
        logger.info(
            "EHR creation race condition resolved for subject %s",
            subject_id,
        )
        ehr = get_ehr_by_subject(subject_id, subject_namespace)
        if ehr:
            return ehr["ehr_id"]["value"]  # type: ignore[no-any-return]
        raise

upload_template

upload_template(template_xml)

Upload an OpenEHR template (OPT) to EHRbase.

Validates XML well-formedness before uploading to fail fast with a clear error message.

Parameters:

Name Type Description Default
template_xml str

The template in XML format

required

Returns:

Type Description
dict[str, Any]

Template upload response

Raises:

Type Description
ValueError

If the XML is malformed.

EhrbaseClientError

If the upload fails.

Source code in app/ehrbase_client.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def upload_template(template_xml: str) -> dict[str, Any]:
    """
    Upload an OpenEHR template (OPT) to EHRbase.

    Validates XML well-formedness before uploading to fail fast
    with a clear error message.

    Args:
        template_xml: The template in XML format

    Returns:
        Template upload response

    Raises:
        ValueError: If the XML is malformed.
        EhrbaseClientError: If the upload fails.
    """
    # Fail-fast: validate XML well-formedness before sending
    try:
        xml_fromstring(template_xml)
    except ParseError as exc:
        raise ValueError(f"Invalid template XML: {exc}") from exc

    url = f"{settings.EHRBASE_URL}/rest/openehr/v1/definition/template/adl1.4"
    headers = {
        **get_auth_header(),
        "Content-Type": "application/xml",
    }

    try:
        response = requests.post(url, data=template_xml, headers=headers)
        response.raise_for_status()
        return response.json()  # type: ignore[no-any-return]
    except requests.RequestException as exc:
        logger.error("Failed to upload template to EHRbase: %s", exc)
        raise EhrbaseClientError("Failed to upload clinical template") from exc

list_templates

list_templates()

List all templates available in EHRbase.

Returns:

Type Description
list[str]

List of template IDs

Raises:

Type Description
EhrbaseClientError

If the request fails.

Source code in app/ehrbase_client.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def list_templates() -> list[str]:
    """
    List all templates available in EHRbase.

    Returns:
        List of template IDs

    Raises:
        EhrbaseClientError: If the request fails.
    """
    url = f"{settings.EHRBASE_URL}/rest/openehr/v1/definition/template/adl1.4"
    headers = get_auth_header()

    try:
        response = requests.get(url, headers=headers)
        response.raise_for_status()
        return response.json()  # type: ignore[no-any-return]
    except requests.RequestException as exc:
        logger.error("Failed to list EHRbase templates: %s", exc)
        raise EhrbaseClientError(
            "Failed to retrieve clinical templates"
        ) from exc

create_composition

create_composition(ehr_id, template_id, composition_data)

Create a composition (clinical document) in EHRbase.

Parameters:

Name Type Description Default
ehr_id str

The EHR UUID

required
template_id str

The template ID

required
composition_data dict[str, Any]

The composition content in JSON format

required

Returns:

Type Description
dict[str, Any]

Created composition response

Raises:

Type Description
EhrbaseClientError

If the request fails.

Source code in app/ehrbase_client.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def create_composition(
    ehr_id: str, template_id: str, composition_data: dict[str, Any]
) -> dict[str, Any]:
    """
    Create a composition (clinical document) in EHRbase.

    Args:
        ehr_id: The EHR UUID
        template_id: The template ID
        composition_data: The composition content in JSON format

    Returns:
        Created composition response

    Raises:
        EhrbaseClientError: If the request fails.
    """
    url = f"{settings.EHRBASE_URL}/rest/openehr/v1/ehr/{ehr_id}/composition"
    headers = {
        **get_auth_header(),
        "Content-Type": "application/json",
        "Prefer": "return=representation",
    }

    try:
        response = requests.post(url, json=composition_data, headers=headers)
        response.raise_for_status()
        return response.json()  # type: ignore[no-any-return]
    except requests.RequestException as exc:
        logger.error(
            "Failed to create composition for EHR %s: %s", ehr_id, exc
        )
        raise EhrbaseClientError("Failed to create clinical document") from exc

get_composition

get_composition(ehr_id, composition_uid)

Retrieve a composition by UID.

Parameters:

Name Type Description Default
ehr_id str

The EHR UUID

required
composition_uid str

The composition UID

required

Returns:

Type Description
dict[str, Any]

Composition data

Raises:

Type Description
EhrbaseClientError

If the request fails.

Source code in app/ehrbase_client.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
def get_composition(ehr_id: str, composition_uid: str) -> dict[str, Any]:
    """
    Retrieve a composition by UID.

    Args:
        ehr_id: The EHR UUID
        composition_uid: The composition UID

    Returns:
        Composition data

    Raises:
        EhrbaseClientError: If the request fails.
    """
    url = f"{settings.EHRBASE_URL}/rest/openehr/v1/ehr/{ehr_id}/composition/{composition_uid}"
    headers = get_auth_header()

    try:
        response = requests.get(url, headers=headers)
        response.raise_for_status()
        return response.json()  # type: ignore[no-any-return]
    except requests.RequestException as exc:
        logger.error("Failed to get composition %s: %s", composition_uid, exc)
        raise EhrbaseClientError(
            "Failed to retrieve clinical document"
        ) from exc

query_aql

query_aql(aql_query)

Execute an AQL query against EHRbase.

Parameters:

Name Type Description Default
aql_query str

The AQL query string

required

Returns:

Type Description
dict[str, Any]

Query results

Raises:

Type Description
EhrbaseClientError

If the request fails.

Source code in app/ehrbase_client.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def query_aql(aql_query: str) -> dict[str, Any]:
    """
    Execute an AQL query against EHRbase.

    Args:
        aql_query: The AQL query string

    Returns:
        Query results

    Raises:
        EhrbaseClientError: If the request fails.
    """
    url = f"{settings.EHRBASE_URL}/rest/openehr/v1/query/aql"
    headers = {
        **get_auth_header(),
        "Content-Type": "application/json",
    }

    payload = {"q": aql_query}

    try:
        response = requests.post(url, json=payload, headers=headers)
        response.raise_for_status()
        return response.json()  # type: ignore[no-any-return]
    except requests.RequestException as exc:
        logger.error("AQL query failed: %s", exc)
        raise EhrbaseClientError("Failed to query clinical records") from exc

list_compositions_for_ehr

list_compositions_for_ehr(ehr_id)

List all compositions for an EHR using AQL.

Parameters:

Name Type Description Default
ehr_id str

The EHR UUID

required

Returns:

Type Description
list[dict[str, Any]]

List of compositions

Source code in app/ehrbase_client.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
def list_compositions_for_ehr(ehr_id: str) -> list[dict[str, Any]]:
    """
    List all compositions for an EHR using AQL.

    Args:
        ehr_id: The EHR UUID

    Returns:
        List of compositions
    """
    aql = f"""
    SELECT c
    FROM EHR e[ehr_id/value='{ehr_id}']
    CONTAINS COMPOSITION c
    """

    result = query_aql(aql)
    return result.get("rows", [])  # type: ignore[no-any-return]

create_letter_composition

create_letter_composition(patient_id, title, body, author_name=None)

Create a letter/correspondence composition in OpenEHR.

Parameters:

Name Type Description Default
patient_id str

FHIR Patient ID

required
title str

Letter title

required
body str

Letter content (markdown)

required
author_name str | None

Optional author name

None

Returns:

Type Description
dict[str, Any]

Created composition response with composition_uid

Source code in app/ehrbase_client.py
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
def create_letter_composition(
    patient_id: str, title: str, body: str, author_name: str | None = None
) -> dict[str, Any]:
    """
    Create a letter/correspondence composition in OpenEHR.

    Args:
        patient_id: FHIR Patient ID
        title: Letter title
        body: Letter content (markdown)
        author_name: Optional author name

    Returns:
        Created composition response with composition_uid
    """
    from datetime import UTC, datetime

    # Get or create EHR for this patient
    ehr_id = get_or_create_ehr(patient_id)

    # Create a simple letter composition
    # Using a generic composition structure for letters
    composition_data = {
        "_type": "COMPOSITION",
        "name": {"_type": "DV_TEXT", "value": title},
        "archetype_node_id": "openEHR-EHR-COMPOSITION.report.v1",
        "language": {
            "_type": "CODE_PHRASE",
            "terminology_id": {
                "_type": "TERMINOLOGY_ID",
                "value": "ISO_639-1",
            },
            "code_string": "en",
        },
        "territory": {
            "_type": "CODE_PHRASE",
            "terminology_id": {
                "_type": "TERMINOLOGY_ID",
                "value": "ISO_3166-1",
            },
            "code_string": "US",
        },
        "category": {
            "_type": "DV_CODED_TEXT",
            "value": "event",
            "defining_code": {
                "_type": "CODE_PHRASE",
                "terminology_id": {
                    "_type": "TERMINOLOGY_ID",
                    "value": "openehr",
                },
                "code_string": "433",
            },
        },
        "composer": {
            "_type": "PARTY_IDENTIFIED",
            "name": author_name or "System",
        },
        "context": {
            "_type": "EVENT_CONTEXT",
            "start_time": {
                "_type": "DV_DATE_TIME",
                "value": datetime.now(UTC).isoformat(),
            },
            "setting": {
                "_type": "DV_CODED_TEXT",
                "value": "other care",
                "defining_code": {
                    "_type": "CODE_PHRASE",
                    "terminology_id": {
                        "_type": "TERMINOLOGY_ID",
                        "value": "openehr",
                    },
                    "code_string": "238",
                },
            },
        },
        "content": [
            {
                "_type": "EVALUATION",
                "name": {"_type": "DV_TEXT", "value": "Letter"},
                "archetype_node_id": "openEHR-EHR-EVALUATION.clinical_synopsis.v1",
                "data": {
                    "_type": "ITEM_TREE",
                    "name": {"_type": "DV_TEXT", "value": "Tree"},
                    "archetype_node_id": "at0001",
                    "items": [
                        {
                            "_type": "ELEMENT",
                            "name": {"_type": "DV_TEXT", "value": "Synopsis"},
                            "archetype_node_id": "at0002",
                            "value": {"_type": "DV_TEXT", "value": body},
                        }
                    ],
                },
            }
        ],
    }

    return create_composition(
        ehr_id, "openEHR-EHR-COMPOSITION.report.v1", composition_data
    )

get_letter_composition

get_letter_composition(patient_id, composition_uid)

Retrieve a letter composition from OpenEHR.

Parameters:

Name Type Description Default
patient_id str

FHIR Patient ID

required
composition_uid str

The composition UID

required

Returns:

Type Description
dict[str, Any] | None

Composition data or None if not found

Source code in app/ehrbase_client.py
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
def get_letter_composition(
    patient_id: str, composition_uid: str
) -> dict[str, Any] | None:
    """
    Retrieve a letter composition from OpenEHR.

    Args:
        patient_id: FHIR Patient ID
        composition_uid: The composition UID

    Returns:
        Composition data or None if not found
    """
    try:
        ehr = get_ehr_by_subject(patient_id)
        if not ehr:
            return None

        ehr_id = ehr["ehr_id"]["value"]
        return get_composition(ehr_id, composition_uid)
    except EhrbaseClientError:
        raise
    except Exception as exc:
        logger.error(
            "Failed to get letter composition %s: %s",
            composition_uid,
            exc,
        )
        raise EhrbaseClientError("Failed to retrieve letter") from exc

list_letters_for_patient

list_letters_for_patient(patient_id)

List all letter compositions for a patient.

Parameters:

Name Type Description Default
patient_id str

FHIR Patient ID

required

Returns:

Type Description
list[dict[str, Any]]

List of letter compositions with metadata

Source code in app/ehrbase_client.py
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
def list_letters_for_patient(patient_id: str) -> list[dict[str, Any]]:
    """
    List all letter compositions for a patient.

    Args:
        patient_id: FHIR Patient ID

    Returns:
        List of letter compositions with metadata
    """
    try:
        ehr = get_ehr_by_subject(patient_id)
        if not ehr:
            return []

        ehr_id = ehr["ehr_id"]["value"]

        # Query for all report compositions (letters)
        aql = f"""
        SELECT
            c/uid/value as composition_uid,
            c/name/value as title,
            c/context/start_time/value as created_at,
            c/composer/name as author
        FROM EHR e[ehr_id/value='{ehr_id}']
        CONTAINS COMPOSITION c[openEHR-EHR-COMPOSITION.report.v1]
        ORDER BY c/context/start_time/value DESC
        """

        result = query_aql(aql)
        return result.get("rows", [])  # type: ignore[no-any-return]
    except EhrbaseClientError:
        raise
    except Exception as exc:
        logger.error(
            "Failed to list letters for patient %s: %s", patient_id, exc
        )
        raise EhrbaseClientError("Failed to retrieve letters") from exc