Coverage for src/lumen/api/metadata.py: 92%
63 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 constructs and returns the CapabilityStatement for the FHIR server.
3It details supported resource types, interactions, security profiles, and search parameters.
4"""
6from __future__ import annotations
8import json
9from datetime import date
10from typing import Annotated, Any
11from uuid import UUID
13import asyncpg
14from fastapi import APIRouter, Depends
15from fastapi.responses import Response
17from ..auth.jwks import discover_oidc_config
18from ..config import Settings, get_settings
19from ..core.exceptions import FHIR_JSON
20from ..db.pool import get_pool
21from ..fhir.params import SearchParamDef
22from ..fhir.resource_types import known_resource_types
23from ..search.registry import get_registry
24from .operations import operations_for
26router = APIRouter()
28_INTERACTIONS = [
29 {"code": "read"},
30 {"code": "vread"},
31 {"code": "history-instance"},
32 {"code": "search-type"},
33 {"code": "create"},
34 {"code": "update"},
35 {"code": "patch"},
36 {"code": "delete"},
37 {"code": "history-type"},
38]
40_OAUTH_URIS_EXT = "http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris"
43def _search_params(params: list[SearchParamDef]) -> list[dict[str, str]]:
44 result: list[dict[str, str]] = []
45 for sp in sorted(params, key=lambda sp: sp.name):
46 entry: dict[str, str] = {"name": sp.name, "type": sp.declared_type or sp.type}
47 if sp.definition:
48 entry["definition"] = sp.definition
49 result.append(entry)
50 return result
53async def _resource_list(
54 conn: asyncpg.Connection[asyncpg.Record] | asyncpg.pool.PoolConnectionProxy[asyncpg.Record],
55 tenant_id: UUID,
56) -> list[dict[str, Any]]:
57 registry = get_registry()
58 result: list[dict[str, Any]] = []
59 for t in sorted(known_resource_types()):
60 # Use fully merged params (base R4B + package-loaded custom params for this tenant).
61 params = await registry.get_params(conn, tenant_id, t)
62 result.append(
63 {
64 "type": t,
65 "interaction": _INTERACTIONS,
66 "conditionalCreate": True,
67 "conditionalUpdate": True,
68 "conditionalDelete": "single",
69 "searchParam": _search_params(params),
70 "operation": operations_for(t),
71 }
72 )
73 return result
76def _security_block(authorize_url: str, token_url: str) -> dict[str, Any]:
77 return {
78 "cors": True,
79 "service": [
80 {
81 "coding": [
82 {
83 "system": "http://terminology.hl7.org/CodeSystem/restful-security-service",
84 "code": "SMART-on-FHIR",
85 "display": "SMART-on-FHIR",
86 }
87 ]
88 }
89 ],
90 "extension": [
91 {
92 "url": _OAUTH_URIS_EXT,
93 "extension": [
94 {"url": "authorize", "valueUri": authorize_url},
95 {"url": "token", "valueUri": token_url},
96 ],
97 }
98 ],
99 }
102def _resource_list_base() -> list[dict[str, Any]]:
103 """Return resource list using base R4B params only (no DB access)."""
104 registry = get_registry()
105 return [
106 {
107 "type": t,
108 "interaction": _INTERACTIONS,
109 "conditionalCreate": True,
110 "conditionalUpdate": True,
111 "conditionalDelete": "single",
112 "searchParam": _search_params(registry.get_base_params(t)),
113 "operation": operations_for(t),
114 }
115 for t in sorted(known_resource_types())
116 ]
119async def _assemble_capability(
120 settings: Settings,
121 resource_list: list[dict[str, Any]],
122) -> dict[str, Any]:
123 rest_entry: dict[str, Any] = {
124 "mode": "server",
125 # System-level interactions Lumen supports (see api/bundle.py).
126 "interaction": [{"code": "transaction"}, {"code": "batch"}],
127 "resource": resource_list,
128 }
130 if settings.smart.enabled and settings.smart.tenant_map:
131 issuer = next(iter(settings.smart.tenant_map))
132 fetch_base = settings.smart.internal_issuer_map.get(issuer)
133 try:
134 oidc = await discover_oidc_config(issuer, fetch_base=fetch_base)
135 rest_entry["security"] = _security_block(
136 authorize_url=oidc.get("authorization_endpoint", ""),
137 token_url=oidc["token_endpoint"],
138 )
139 except Exception:
140 # If IdP is unreachable, serve the CapabilityStatement without security block
141 # rather than failing the entire metadata endpoint.
142 pass
144 return {
145 "resourceType": "CapabilityStatement",
146 "status": "active",
147 "date": str(date.today()),
148 "kind": "instance",
149 "software": {"name": "Lumen"},
150 "implementation": {"description": "Lumen FHIR R4B Server"},
151 "fhirVersion": "4.3.0",
152 "format": ["application/fhir+json", "application/fhir+xml", "application/json"],
153 "rest": [rest_entry],
154 }
157async def _build_capability_base(settings: Settings) -> dict[str, Any]:
158 """Build CapabilityStatement using base R4B params only (no DB required)."""
159 return await _assemble_capability(settings, _resource_list_base())
162async def _build_capability(
163 settings: Settings,
164 conn: asyncpg.Connection[asyncpg.Record] | asyncpg.pool.PoolConnectionProxy[asyncpg.Record],
165 tenant_id: UUID,
166) -> dict[str, Any]:
167 return await _assemble_capability(settings, await _resource_list(conn, tenant_id))
170@router.get("/metadata")
171async def metadata(
172 settings: Annotated[Settings, Depends(get_settings)],
173) -> Response:
174 try:
175 pool = get_pool()
176 async with pool.acquire() as conn:
177 body = await _build_capability(settings, conn, settings.default_tenant)
178 except RuntimeError:
179 # Pool not yet initialised (e.g. during startup health-checks or tests).
180 # Fall back to base R4B params only — custom/package params are omitted.
181 body = await _build_capability_base(settings)
182 return Response(content=json.dumps(body), media_type=FHIR_JSON)