Coverage for src/lumen/api/terminology_ops.py: 97%
88 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"""
2This module implements API handlers for standard FHIR terminology operations.
3It routes requests for $lookup, $expand, and $translate to the internal terminology engine.
4"""
6from __future__ import annotations
8import json
9from datetime import UTC, datetime
10from typing import Any
12import asyncpg
13from fastapi.responses import Response
15from ..core.exceptions import FHIR_JSON, OperationOutcomeError
16from ..db.pool import get_pool
17from ..db.repository import ReadResult, ResourceRepository
18from ..fhir import terminology as tx
19from ..search.registry import get_registry
20from .operations import OperationContext, OpScope, operation
23def _parameters(parameters: list[dict[str, Any]]) -> Response:
24 body = {"resourceType": "Parameters", "parameter": parameters}
25 return Response(content=json.dumps(body), media_type=FHIR_JSON)
28def _require(ctx: OperationContext, name: str) -> str:
29 value = ctx.request.query_params.get(name)
30 if not value:
31 raise OperationOutcomeError(400, "invalid", f"Missing required parameter '{name}'")
32 return value
35async def _resolve(
36 repo: ResourceRepository, ctx: OperationContext, expected_type: str
37) -> ReadResult:
38 """Resolve the operation's target resource by instance id or by ``url`` param."""
39 if ctx.resource_type != expected_type:
40 raise OperationOutcomeError(
41 400, "not-supported", f"Operation is only supported on {expected_type}"
42 )
43 if ctx.scope is OpScope.INSTANCE:
44 assert ctx.resource_id is not None
45 result = await repo.read(ctx.tenant_id, expected_type, ctx.resource_id)
46 ref = f"{expected_type}/{ctx.resource_id}"
47 else:
48 url = _require(ctx, "url")
49 result = await repo.find_current_by_url(ctx.tenant_id, expected_type, url)
50 ref = url
51 if result is None:
52 raise OperationOutcomeError(404, "not-found", f"{expected_type} {ref} not found")
53 return result
56def _terminology_error(exc: tx.TerminologyError) -> OperationOutcomeError:
57 status = 404 if exc.code == "not-found" else 400
58 return OperationOutcomeError(status, exc.code, str(exc))
61# ── CodeSystem/$lookup ─────────────────────────────────────────────────────────
64@operation("lookup", OpScope.TYPE)
65async def op_lookup(ctx: OperationContext) -> Response:
66 if ctx.resource_type != "CodeSystem":
67 raise OperationOutcomeError(400, "not-supported", "$lookup is only supported on CodeSystem")
68 system = _require(ctx, "system")
69 code = _require(ctx, "code")
71 pool: asyncpg.Pool[asyncpg.Record] = get_pool()
72 async with pool.acquire() as conn:
73 cs = await ResourceRepository(conn, get_registry()).find_current_by_url(
74 ctx.tenant_id, "CodeSystem", system
75 )
76 if cs is None:
77 raise OperationOutcomeError(404, "not-found", f"CodeSystem {system} not found")
79 concept = tx.lookup_concept(cs["resource"], code)
80 if concept is None:
81 raise OperationOutcomeError(404, "code-invalid", f"Code '{code}' not found in {system}")
83 params: list[dict[str, Any]] = []
84 if isinstance(cs["resource"].get("name"), str):
85 params.append({"name": "name", "valueString": cs["resource"]["name"]})
86 if isinstance(concept.get("display"), str):
87 params.append({"name": "display", "valueString": concept["display"]})
88 params.append({"name": "code", "valueCode": code})
89 return _parameters(params)
92# ── ValueSet/$expand ───────────────────────────────────────────────────────────
95@operation("expand", OpScope.TYPE, OpScope.INSTANCE)
96async def op_expand(ctx: OperationContext) -> Response:
97 pool: asyncpg.Pool[asyncpg.Record] = get_pool()
98 async with pool.acquire() as conn:
99 repo = ResourceRepository(conn, get_registry())
100 value_set = (await _resolve(repo, ctx, "ValueSet"))["resource"]
102 systems: dict[str, dict[str, Any]] = {}
103 for system_url in tx.referenced_systems(value_set):
104 cs = await repo.find_current_by_url(ctx.tenant_id, "CodeSystem", system_url)
105 if cs is not None:
106 systems[system_url] = cs["resource"]
108 try:
109 contains = tx.expand_valueset(value_set, systems)
110 except tx.TerminologyError as exc:
111 raise _terminology_error(exc) from exc
113 expanded = {
114 **value_set,
115 "expansion": {
116 "timestamp": datetime.now(UTC).isoformat(),
117 "total": len(contains),
118 "contains": contains,
119 },
120 }
121 return Response(content=json.dumps(expanded), media_type=FHIR_JSON)
124# ── ConceptMap/$translate ──────────────────────────────────────────────────────
127@operation("translate", OpScope.TYPE, OpScope.INSTANCE)
128async def op_translate(ctx: OperationContext) -> Response:
129 code = _require(ctx, "code")
130 system = ctx.request.query_params.get("system")
131 target = ctx.request.query_params.get("target")
133 pool: asyncpg.Pool[asyncpg.Record] = get_pool()
134 async with pool.acquire() as conn:
135 repo = ResourceRepository(conn, get_registry())
136 concept_map = (await _resolve(repo, ctx, "ConceptMap"))["resource"]
138 matches = tx.translate(concept_map, system, code, target)
140 params: list[dict[str, Any]] = [{"name": "result", "valueBoolean": bool(matches)}]
141 for match in matches:
142 params.append(
143 {
144 "name": "match",
145 "part": [
146 {"name": "equivalence", "valueCode": match["equivalence"]},
147 {"name": "concept", "valueCoding": match["concept"]},
148 ],
149 }
150 )
151 return _parameters(params)