Coverage for src/lumen/fhir/terminology_pre_expansion.py: 97%
62 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
1"""
2Stage 3b (docs/architecture/profile-validation.md, Level 3): external
3Pre-Expansion for ValueSets the local expansion engine can't resolve — nested
4`compose.include.valueSet` refs, `filter` rules, or a referenced CodeSystem too
5large to load locally (e.g. LOINC/SNOMED). Calls a public FHIR terminology
6server's `$expand` operation once, at ValueSet-write time, and caches the flat
7result in `valueset_expansion_cache`. Resource *validation* itself never makes
8a network call — it only ever reads that cache table (see
9`terminology_binding.py::_resolve_value_set`).
11Opt-in per tenant (`terminology_pre_expansion_enabled`, default off): this
12makes an outbound call to a third-party server Lumen doesn't control, which
13deployments that need to stay air-gapped must explicitly allow. A failure here
14(unreachable server, timeout, malformed response) never fails the ValueSet
15write itself — best-effort only, logged and otherwise ignored.
16"""
18from __future__ import annotations
20import json
21import typing
22from typing import Any
23from uuid import UUID
25import asyncpg
26import httpx
27import structlog
29from ..config import get_settings
30from .terminology_binding import JsonDict, expand_locally
32logger = structlog.get_logger(__name__)
35async def _is_pre_expansion_enabled(
36 conn: asyncpg.Connection[asyncpg.Record] | Any, tenant_id: UUID
37) -> bool:
38 row = await conn.fetchrow(
39 "SELECT settings FROM tenant_settings WHERE tenant_id = $1", tenant_id
40 )
41 if row:
42 raw = row["settings"]
43 settings_dict = json.loads(raw) if isinstance(raw, str) else dict(raw)
44 if "terminology_pre_expansion_enabled" in settings_dict:
45 return bool(settings_dict["terminology_pre_expansion_enabled"])
46 return get_settings().tenant.terminology_pre_expansion_enabled
49async def fetch_external_expansion(
50 server_url: str, value_set_url: str, timeout_seconds: float
51) -> list[tuple[str, str]] | None:
52 """Call a public terminology server's `$expand` for `value_set_url`.
54 Best-effort: returns None (never raises) on any failure — unreachable
55 server, timeout, non-2xx response, or a malformed body.
56 """
57 try:
58 async with httpx.AsyncClient(timeout=timeout_seconds) as client:
59 resp = await client.get(
60 f"{server_url.rstrip('/')}/ValueSet/$expand",
61 params={"url": value_set_url},
62 )
63 resp.raise_for_status()
64 body: JsonDict = resp.json()
65 except Exception as exc:
66 await logger.awarning(
67 "terminology.pre_expansion_failed", value_set_url=value_set_url, error=str(exc)
68 )
69 return None
71 expansion = body.get("expansion")
72 contains = (
73 typing.cast(JsonDict, expansion).get("contains") if isinstance(expansion, dict) else None
74 )
75 if not isinstance(contains, list):
76 return None
77 out: list[tuple[str, str]] = []
78 for raw_entry in typing.cast("list[Any]", contains):
79 if not isinstance(raw_entry, dict):
80 continue
81 entry = typing.cast(JsonDict, raw_entry)
82 system = entry.get("system")
83 code = entry.get("code")
84 if isinstance(system, str) and isinstance(code, str):
85 out.append((system, code))
86 return out
89async def store_expansion_cache(
90 conn: asyncpg.Connection[asyncpg.Record] | Any,
91 tenant_id: UUID,
92 value_set_url: str,
93 contains: list[tuple[str, str]],
94) -> None:
95 await conn.execute(
96 """
97 INSERT INTO valueset_expansion_cache (tenant_id, value_set_url, contains, fetched_at)
98 VALUES ($1, $2, $3::jsonb, now())
99 ON CONFLICT (tenant_id, value_set_url)
100 DO UPDATE SET contains = EXCLUDED.contains, fetched_at = EXCLUDED.fetched_at
101 """,
102 tenant_id,
103 value_set_url,
104 [{"system": s, "code": c} for s, c in contains],
105 )
108async def load_expansion_cache(
109 conn: asyncpg.Connection[asyncpg.Record] | Any,
110 tenant_id: UUID,
111 value_set_url: str,
112) -> list[tuple[str, str]] | None:
113 row = await conn.fetchrow(
114 "SELECT contains FROM valueset_expansion_cache WHERE tenant_id = $1 AND value_set_url = $2",
115 tenant_id,
116 value_set_url,
117 )
118 if row is None:
119 return None
120 contains = row["contains"]
121 return [(c["system"], c["code"]) for c in contains if "system" in c and "code" in c]
124async def maybe_pre_expand(
125 conn: asyncpg.Connection[asyncpg.Record] | Any,
126 tenant_id: UUID,
127 value_set: JsonDict,
128) -> None:
129 """On a ValueSet write: if pre-expansion is enabled for this tenant and the
130 local expansion engine can't resolve it, fetch + cache an external
131 expansion. Best-effort — never raises, so a ValueSet write never fails
132 because of this.
133 """
134 url = value_set.get("url")
135 if not isinstance(url, str):
136 return
137 if not await _is_pre_expansion_enabled(conn, tenant_id):
138 return
139 if await expand_locally(conn, tenant_id, value_set) is not None:
140 return # locally resolvable already — no need for an external call
142 settings = get_settings().terminology
143 contains = await fetch_external_expansion(settings.server_url, url, settings.timeout_seconds)
144 if contains is not None:
145 await store_expansion_cache(conn, tenant_id, url, contains)