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
1 change: 1 addition & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies = [
"python-multipart>=0.0.6",
"aiofiles>=23.2.0",
"faker>=22.0.0",
"oehrpy>=0.1.0",
]

[project.optional-dependencies]
Expand Down
4 changes: 3 additions & 1 deletion api/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ class Settings(BaseSettings):
# Database (Prisma)
database_url: str = "postgresql://cis:cis@localhost:5432/cis"

# EHRBase
# EHRBase (oehrpy client configuration)
ehrbase_url: str = "http://localhost:8080/ehrbase/rest"
ehrbase_user: str | None = None
ehrbase_password: str | None = None
ehrbase_timeout: float = 30.0
ehrbase_verify_ssl: bool = True
Comment on lines +19 to +20
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "ehrbase_timeout|ehrbase_verify_ssl" --type=py -B 2 -A 2

Repository: platzhersh/open-cis

Length of output: 353


🏁 Script executed:

rg -n "EHRBaseClient" --type=py -B 3 -A 5

Repository: platzhersh/open-cis

Length of output: 4589


🏁 Script executed:

cat -n api/src/config.py

Repository: platzhersh/open-cis

Length of output: 963


🏁 Script executed:

cat -n api/src/ehrbase/client.py

Repository: platzhersh/open-cis

Length of output: 6448


Pass ehrbase_timeout and ehrbase_verify_ssl to the OehrpyClient initialization.

The new config settings are defined but never consumed. Update the _ensure_connected() method in api/src/ehrbase/client.py (lines 24–28) to pass timeout and verify_ssl parameters when instantiating OehrpyClient:

self._client = OehrpyClient(
    base_url=settings.ehrbase_url,
    username=settings.ehrbase_user,
    password=settings.ehrbase_password,
    timeout=settings.ehrbase_timeout,
    verify_ssl=settings.ehrbase_verify_ssl,
)
🤖 Prompt for AI Agents
In `@api/src/config.py` around lines 19 - 20, _update: The _ensure_connected()
method currently creates OehrpyClient without using the new config options;
modify the OehrpyClient instantiation inside _ensure_connected() to pass
timeout=settings.ehrbase_timeout and verify_ssl=settings.ehrbase_verify_ssl
along with base_url, username, and password so the client honors ehrbase_timeout
and ehrbase_verify_ssl from settings when constructed.


class Config:
env_file = ".env"
Expand Down
186 changes: 81 additions & 105 deletions api/src/ehrbase/client.py
Original file line number Diff line number Diff line change
@@ -1,170 +1,146 @@
"""EHRBase client wrapper using oehrpy SDK.

Wraps the oehrpy EHRBaseClient to provide a consistent interface
for Open CIS, with logging and error handling.
"""

from typing import Any

import httpx
from openehr_sdk.client import EHRBaseClient as OehrpyClient
from openehr_sdk.client import EHRBaseError

from src.config import settings


class EHRBaseClient:
"""Async client for EHRBase REST API."""
"""Async EHRBase client backed by oehrpy SDK."""

def __init__(self):
def __init__(self) -> None:
self.base_url = settings.ehrbase_url
self._client: httpx.AsyncClient | None = None
self._client: OehrpyClient | None = None

async def _get_client(self) -> httpx.AsyncClient:
async def _ensure_connected(self) -> OehrpyClient:
if self._client is None:
auth = None
if settings.ehrbase_user and settings.ehrbase_password:
auth = httpx.BasicAuth(settings.ehrbase_user, settings.ehrbase_password)

self._client = httpx.AsyncClient(
base_url=self.base_url,
auth=auth,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
},
self._client = OehrpyClient(
base_url=settings.ehrbase_url,
username=settings.ehrbase_user,
password=settings.ehrbase_password,
)
await self._client.connect()
return self._client
Comment on lines +22 to 30
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine the ehrbase client file
find . -type f -name "client.py" | grep -E "api|ehrbase"

Repository: platzhersh/open-cis

Length of output: 113


🏁 Script executed:

# Examine the settings to see if timeout/verify_ssl are defined
find . -type f -name "settings.py" | head -10

Repository: platzhersh/open-cis

Length of output: 45


🏁 Script executed:

# Check imports in the ehrbase client file
head -40 api/src/ehrbase/client.py 2>/dev/null || find . -path "*/api/*/ehrbase/client.py" -exec head -40 {} \;

Repository: platzhersh/open-cis

Length of output: 1405


🌐 Web query:

oehrpy OehrpyClient constructor parameters timeout verify_ssl httpx AsyncClient

💡 Result:

I couldn’t find any public docs/source for oehrpy or OehrpyClient specifically (it may be private, renamed, or misspelled), so I can’t quote its exact constructor signature.

That said, many OpenAPI-generated Python clients expose the same constructor kwargs and use them to build an internal httpx.Client / httpx.AsyncClient. In those clients, the constructor typically accepts:

  • base_url: str
  • cookies: dict[str, str] (default cookies sent on every request)
  • headers: dict[str, str] (default headers sent on every request)
  • timeout: httpx.Timeout | None (maximum request time; httpx.TimeoutException if exceeded) [1]
  • verify_ssl: bool | str | ssl.SSLContext (whether to verify TLS certs; maps to httpx verify=) [1]
  • follow_redirects: bool (default False) [1]
  • httpx_args: dict[str, Any] (extra kwargs forwarded into httpx.Client(...) and httpx.AsyncClient(...)) [1]

And httpx.AsyncClient(...) itself supports verify=... and timeout=... among many other kwargs that can be passed via httpx_args [2].

If you paste the OehrpyClient class (or link to its repo / PyPI page), I can confirm the exact parameter names and defaults for your version.

Sources: [1] [2]


🏁 Script executed:

# Find and examine settings/config file
find . -type f -name "settings.py" -o -name "config.py" | head -10

Repository: platzhersh/open-cis

Length of output: 82


🏁 Script executed:

# Search for ehrbase_timeout and ehrbase_verify_ssl in the codebase
rg "ehrbase_timeout|ehrbase_verify_ssl" --type py

Repository: platzhersh/open-cis

Length of output: 168


🏁 Script executed:

# Check for requirements, pyproject.toml, or setup files for openehr_sdk version
find . -type f \( -name "requirements*.txt" -o -name "pyproject.toml" -o -name "setup.py" -o -name "setup.cfg" -o -name "poetry.lock" \) | head -10

Repository: platzhersh/open-cis

Length of output: 83


🌐 Web query:

openehr_sdk EHRBaseClient constructor parameters timeout verify

💡 Result:

I couldn’t find any EHRBaseClient class/constructor in the ehrbase/openEHR_SDK project (the Java SDK). The SDK’s REST client entry point is DefaultRestClient (see the project’s own usage hint pointing to client/src/test/.../defaultrestclient for examples), not EHRBaseClient. (discourse.openehr.org)

So there are no EHRBaseClient(..., timeout=..., verify=...) constructor parameters to document for ehrbase/openEHR_SDK.

If your goal is to control timeouts / TLS verification with EHRbase:

  • Server-side timeouts (e.g., long AQL queries / uploads) are configured in EHRbase via Spring Boot properties (e.g., Hikari connection timeout, Tomcat connection timeout). (discourse.openehr.org)
  • Client-side TLS “verify” in Java is typically controlled by the JVM truststore (not a per-constructor verify flag).

If you meant a different library actually named openehr_sdk (Python or otherwise), paste:

  1. the package URL (PyPI/GitHub), and
  2. the exact import + constructor call you’re using,
    and I’ll map the correct timeout / verify parameters for that client.

Citations:


🏁 Script executed:

# Read the config file to see all settings
cat -n api/src/config.py

Repository: platzhersh/open-cis

Length of output: 963


🏁 Script executed:

# Check pyproject.toml for openehr_sdk version
cat -n api/pyproject.toml | head -50

Repository: platzhersh/open-cis

Length of output: 1476


🌐 Web query:

openehr_sdk python package PyPI EHRBaseClient

💡 Result:

  • There doesn’t appear to be a Python PyPI package named openehr_sdk (or similar) that provides an EHRBaseClient/EHRBaseClient class. The “openEHR SDK” from EHRbase is a Java SDK/client library (repo: ehrbase/openEHR_SDK). [1][2]
  • EHRbase itself is accessed primarily via the official openEHR REST API, and EHRbase publishes an OpenAPI specification you can use to generate a Python client (or you can call it directly with requests). [3][4]

Sources:
[1] https://github.com/ehrbase/openEHR_SDK
[2] https://www.ehrbase.org/
[3] https://docs.ehrbase.org/docs/EHRbase/Explore/openEHR-REST
[4] https://docs.ehrbase.org/api/hip-ehrbase/admin


🌐 Web query:

oehrpy PyPI package EHRBaseClient

💡 Result:

I couldn’t find any PyPI package named oehrpy (or a Python class named EHRBaseClient associated with it) via public indexing/search.

What does exist in the EHRbase ecosystem is an “EHRbase Client Library” referenced in the official EHRbase docs—but it’s a Java client/code-generator workflow (generate Java classes from an OPT), not a Python package. [1]

If you’re trying to talk to an EHRbase server from Python, the supported/standard approach is to call the official openEHR REST API endpoints that EHRbase implements. EHRbase documents that it implements openEHR REST API v1.0.2, and its API docs point you to the openEHR REST spec for endpoint details. [2] [3]

Sources:
[1] (docs.ehrbase.org)
[2] (docs.ehrbase.org)
[3] (docs.ehrbase.org)

Citations:


🏁 Script executed:

# Search for any references to oehrpy in the codebase and verify import path
rg "oehrpy|openehr_sdk" --type py -B2 -A2

Repository: platzhersh/open-cis

Length of output: 4196


🏁 Script executed:

# Check if there are any type stubs or additional config for oehrpy
find . -name "*.pyi" -o -name "py.typed" | head -20

Repository: platzhersh/open-cis

Length of output: 45


🏁 Script executed:

# Search for any local openehr_sdk or oehrpy package definition
find . -type f -name "__init__.py" | xargs grep -l "EHRBaseClient\|openehr_sdk\|oehrpy" 2>/dev/null | head -20

Repository: platzhersh/open-cis

Length of output: 92


🏁 Script executed:

# Check for git submodules
cat .gitmodules 2>/dev/null || echo "No .gitmodules file"

Repository: platzhersh/open-cis

Length of output: 82


🏁 Script executed:

# Look for any package directories that might define openehr_sdk
find . -type d -name "openehr*" -o -type d -name "*oehr*" 2>/dev/null | head -20

Repository: platzhersh/open-cis

Length of output: 109


🏁 Script executed:

# Check full client.py file to see all methods and how _ensure_connected is used
cat -n api/src/ehrbase/client.py

Repository: platzhersh/open-cis

Length of output: 6448


Pass ehrbase_timeout and ehrbase_verify_ssl from settings to OehrpyClient constructor.

Settings define ehrbase_timeout and ehrbase_verify_ssl (PR objectives), but they are not wired into the OehrpyClient initialization. Update the constructor call to include these configuration parameters. Confirm the exact parameter names match the OehrpyClient API (e.g., timeout, verify_ssl/verify, or similar).

Also verify whether connect() must be called on every _ensure_connected() invocation or if it can be deferred to the initial connection setup.

🤖 Prompt for AI Agents
In `@api/src/ehrbase/client.py` around lines 22 - 30, In _ensure_connected(), pass
the EHRBase configuration values from settings into the OehrpyClient constructor
(include the timeout and SSL verification flags—use the OehrpyClient API
parameter names, e.g., timeout=<settings.ehrbase_timeout> and verify_ssl or
verify=<settings.ehrbase_verify_ssl> as required) so the client is constructed
with those options; also avoid calling connect() on every invocation by only
calling await self._client.connect() when you create the client (i.e., inside
the if self._client is None block) rather than unconditionally, keeping the rest
of the method returning self._client.


async def create_ehr(self, ehr_id: str | None = None) -> dict[str, Any]:
"""Create a new EHR, optionally with a specific ID."""
client = await self._get_client()
headers = {"Prefer": "return=representation"}

if ehr_id:
response = await client.put(f"/openehr/v1/ehr/{ehr_id}", headers=headers)
else:
response = await client.post("/openehr/v1/ehr", headers=headers)

response.raise_for_status()
return response.json()
client = await self._ensure_connected()
ehr = await client.create_ehr(ehr_id=ehr_id)
return {"ehr_id": {"value": ehr.ehr_id}, "system_id": ehr.system_id}

async def get_ehr(self, ehr_id: str) -> dict[str, Any]:
"""Get an EHR by ID."""
client = await self._get_client()
response = await client.get(f"/openehr/v1/ehr/{ehr_id}")
response.raise_for_status()
return response.json()
client = await self._ensure_connected()
ehr = await client.get_ehr(ehr_id)
return {"ehr_id": {"value": ehr.ehr_id}, "system_id": ehr.system_id}

async def get_ehr_by_subject(
self, subject_id: str, subject_namespace: str = "cis"
) -> dict[str, Any] | None:
"""Get an EHR by subject (patient) ID."""
client = await self._get_client()
response = await client.get(
"/openehr/v1/ehr",
params={"subject_id": subject_id, "subject_namespace": subject_namespace}
)
if response.status_code == 404:
client = await self._ensure_connected()
try:
ehr = await client.get_ehr_by_subject(subject_id, subject_namespace)
return {"ehr_id": {"value": ehr.ehr_id}, "system_id": ehr.system_id}
except EHRBaseError:
return None
response.raise_for_status()
return response.json()

async def create_composition(
self,
ehr_id: str,
template_id: str,
composition: dict[str, Any],
format: str = "FLAT"
format: str = "FLAT",
) -> dict[str, Any]:
"""Create a composition in an EHR."""
client = await self._get_client()
response = await client.post(
f"/openehr/v1/ehr/{ehr_id}/composition",
json=composition,
headers={
"Prefer": "return=representation",
"Content-Type": "application/json"
},
params={"templateId": template_id, "format": format},
client = await self._ensure_connected()
result = await client.create_composition(
ehr_id=ehr_id,
composition=composition,
template_id=template_id,
format=format,
)
response.raise_for_status()
return response.json()
return {
"uid": {"value": result.uid},
"compositionUid": result.uid,
}

async def get_composition(self, ehr_id: str, composition_uid: str) -> dict[str, Any]:
"""Get a composition by UID in default format."""
client = await self._get_client()
response = await client.get(f"/openehr/v1/ehr/{ehr_id}/composition/{composition_uid}")
response.raise_for_status()
return response.json()
client = await self._ensure_connected()
result = await client.get_composition(ehr_id, composition_uid)
return result.composition or {}

async def get_composition_formatted(
self, ehr_id: str, composition_uid: str, format: str = "FLAT"
) -> dict[str, Any]:
"""Get a composition in a specific format (FLAT or STRUCTURED)."""
client = await self._get_client()
response = await client.get(
f"/openehr/v1/ehr/{ehr_id}/composition/{composition_uid}",
params={"format": format},
)
response.raise_for_status()
return response.json()
client = await self._ensure_connected()
result = await client.get_composition(ehr_id, composition_uid, format=format)
return result.composition or {}

async def delete_composition(self, ehr_id: str, composition_uid: str) -> bool:
"""Delete a composition by UID. Returns True if successful."""
client = await self._get_client()
response = await client.delete(
f"/openehr/v1/ehr/{ehr_id}/composition/{composition_uid}"
)
response.raise_for_status()
return response.status_code == 204
client = await self._ensure_connected()
await client.delete_composition(ehr_id, composition_uid)
return True

async def execute_aql(
self,
query: str,
parameters: dict[str, Any] | None = None
parameters: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Execute an AQL query."""
client = await self._get_client()
payload: dict[str, Any] = {"q": query}
if parameters:
payload["query_parameters"] = parameters

response = await client.post("/openehr/v1/query/aql", json=payload)
response.raise_for_status()
return response.json()
client = await self._ensure_connected()
result = await client.query(query, query_parameters=parameters)
return {
"columns": result.columns,
"rows": result.rows,
}

async def list_templates(self) -> list[dict[str, Any]]:
"""List all available templates."""
client = await self._get_client()
response = await client.get("/openehr/v1/definition/template/adl1.4")
response.raise_for_status()
return response.json()
client = await self._ensure_connected()
templates = await client.list_templates()
return [
{
"template_id": t.template_id,
"concept": t.concept,
"archetype_id": t.archetype_id,
}
for t in templates
]

async def upload_template(self, template_content: str) -> dict[str, Any]:
"""Upload an operational template (OPT)."""
client = await self._get_client()
response = await client.post(
"/openehr/v1/definition/template/adl1.4",
content=template_content,
headers={
"Content-Type": "application/xml",
"Accept": "application/json, application/xml",
}
)
response.raise_for_status()
# EHRBase may return empty response or XML on success
if response.headers.get("content-type", "").startswith("application/json"):
return response.json()
return {"status": "uploaded"}
client = await self._ensure_connected()
result = await client.upload_template(template_content)
return {"template_id": result.template_id, "status": "uploaded"}

async def get_template_example(
self, template_id: str, format: str = "FLAT"
) -> dict[str, Any]:
"""Get an example composition for a template."""
client = await self._get_client()
response = await client.get(
f"/openehr/v1/definition/template/adl1.4/{template_id}/example",
params={"format": format},
)
response.raise_for_status()
return response.json()

async def close(self):
"""Get template info."""
client = await self._ensure_connected()
result = await client.get_template(template_id)
return {"template_id": result.template_id, "concept": result.concept}

async def health_check(self) -> bool:
"""Check if EHRBase is available."""
client = await self._ensure_connected()
return await client.health_check()

async def close(self) -> None:
"""Close the underlying client."""
if self._client:
await self._client.aclose()
await self._client.close()
self._client = None


Expand Down
28 changes: 2 additions & 26 deletions api/src/ehrbase/compositions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,6 @@ async def get_composition(ehr_id: str, composition_uid: str) -> dict[str, Any]:
return await ehrbase_client.get_composition(ehr_id, composition_uid)


async def update_composition(
ehr_id: str,
composition_uid: str,
template_id: str,
composition_data: dict[str, Any],
format: str = "FLAT"
) -> dict[str, Any]:
"""Update an existing composition."""
client = await ehrbase_client._get_client()
response = await client.put(
f"/ehr/{ehr_id}/composition/{composition_uid}",
json=composition_data,
headers={
"Prefer": "return=representation",
"Content-Type": "application/json"
},
params={"templateId": template_id, "format": format},
)
response.raise_for_status()
return response.json()


async def delete_composition(ehr_id: str, composition_uid: str) -> None:
async def delete_composition(ehr_id: str, composition_uid: str) -> bool:
"""Delete a composition."""
client = await ehrbase_client._get_client()
response = await client.delete(f"/ehr/{ehr_id}/composition/{composition_uid}")
response.raise_for_status()
return await ehrbase_client.delete_composition(ehr_id, composition_uid)
8 changes: 8 additions & 0 deletions api/src/ehrbase/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import aiofiles
import httpx
from openehr_sdk.client import EHRBaseError

from src.ehrbase.client import ehrbase_client

Expand Down Expand Up @@ -51,6 +52,13 @@ async def upload_template_file(template_id: str, template_content: str) -> bool:
return True
logger.error(f"Failed to upload template {template_id}: HTTP {e.response.status_code}")
return False
except EHRBaseError as e:
# oehrpy may wrap 409 as EHRBaseError; check message for conflict
if "409" in str(e) or "conflict" in str(e).lower():
logger.info(f"Template {template_id} already exists")
return True
logger.error(f"Failed to upload template {template_id}: {e}")
return False
except Exception as e:
logger.error(f"Failed to upload template {template_id}: {e}")
return False
Expand Down
Loading