Coverage for src/lumen/main.py: 88%
129 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 is the main entry point for the FastAPI application.
3It initializes the app, registers middlewares, includes routers, and manages lifecycles.
4"""
6from collections.abc import AsyncGenerator
7from contextlib import asynccontextmanager
9import structlog
10from asgi_correlation_id import CorrelationIdMiddleware
11from fastapi import FastAPI
12from fastapi.middleware.cors import CORSMiddleware
14from .admin.loader import get_installed_packages, load_package_tgz
15from .api.admin.derivation_rules import router as admin_derivation_rules_router
16from .api.admin.packages import router as admin_packages_router
17from .api.health import router as health_router
18from .api.metadata import router as metadata_router
19from .api.operations import router as operations_router
20from .api.resources import router as resources_router
21from .api.root import router as root_router
22from .api.tenant_settings import router as tenant_settings_router
23from .api.well_known import router as well_known_router
24from .auth.token import AuthError
25from .config import Settings, get_settings
26from .core.exceptions import (
27 OperationOutcomeError,
28 auth_error_handler,
29 generic_exception_handler,
30 not_found_handler,
31 operation_outcome_handler,
32)
33from .core.logging_middleware import AccessLogMiddleware
34from .core.middleware import FhirContentNegotiationMiddleware
35from .core.security_middleware import (
36 MaxBodySizeMiddleware,
37 RateLimitMiddleware,
38 SecurityHeadersMiddleware,
39)
40from .cortex.loader import load_plugins, unload_plugins
41from .db import close_pool, get_pool, init_pool
42from .derivation.registry import init_registry as init_derivation_registry
43from .logging import configure_logging
44from .observability import instrument_fastapi, setup_telemetry
45from .search.registry import init_registry
47logger = structlog.get_logger(__name__)
50async def seed_local_packages(settings: Settings) -> None:
51 """Seed any .tgz packages found in settings.seed_packages_dir into the default tenant."""
53 if not settings.seed_packages_dir:
54 return
56 import anyio
58 seed_dir = anyio.Path(settings.seed_packages_dir)
59 if not await seed_dir.exists() or not await seed_dir.is_dir():
60 await logger.awarning("package_seeding.dir_not_found", path=str(seed_dir))
61 return
63 pool = get_pool()
64 tenant_id = settings.default_tenant
66 async with pool.acquire() as conn:
67 installed = await get_installed_packages(conn, tenant_id)
69 async for tgz_file in seed_dir.glob("*.tgz"):
70 # E.g. kbv.ita.for-1.2.3.tgz -> kbv.ita.for@1.2.3.
71 # But extracting the version accurately from filename isn't foolproof.
72 # We let the loader extract the metadata.
73 file_bytes = await tgz_file.read_bytes()
74 import io
75 import tarfile
77 from .admin.loader import _extract_package_metadata # type: ignore[reportPrivateUsage]
79 try:
80 with tarfile.open(fileobj=io.BytesIO(file_bytes), mode="r:gz") as tar:
81 name, version = _extract_package_metadata(tar)
82 package_tag = f"{name}@{version}"
84 if package_tag in installed:
85 await logger.adebug(
86 "package_seeding.already_installed", package=package_tag
87 )
88 continue
90 # Proceed to install
91 async with conn.transaction():
92 await logger.ainfo(
93 "package_seeding.installing", package=package_tag, file=tgz_file.name
94 )
95 await load_package_tgz(conn, tenant_id, file_bytes)
96 except Exception as e:
97 await logger.aerror("package_seeding.failed", file=tgz_file.name, error=str(e))
100async def seed_registry_packages(settings: Settings) -> None:
101 """
102 Seed packages listed in LUMEN_SEED_REGISTRY_PACKAGES by downloading from
103 the FHIR registry.
104 """
105 if not settings.seed_registry_packages:
106 return
108 packages_to_seed = [p.strip() for p in settings.seed_registry_packages.split(",") if p.strip()]
109 if not packages_to_seed:
110 return
112 from .admin.registry import download_package
114 pool = get_pool()
115 tenant_id = settings.default_tenant
117 async with pool.acquire() as conn:
118 installed = await get_installed_packages(conn, tenant_id)
120 for package_spec in packages_to_seed:
121 # expected format: package.id@version
122 if "@" not in package_spec:
123 await logger.awarning(
124 "package_seeding.invalid_spec",
125 spec=package_spec,
126 reason="Missing '@' separator",
127 )
128 continue
130 package_id, version = package_spec.split("@", 1)
131 package_tag = f"{package_id}@{version}"
133 if package_tag in installed:
134 await logger.adebug("package_seeding.already_installed", package=package_tag)
135 continue
137 try:
138 await logger.ainfo("package_seeding.downloading", package=package_tag)
139 file_bytes = await download_package(package_id, version)
141 async with conn.transaction():
142 await logger.ainfo("package_seeding.installing_registry", package=package_tag)
143 await load_package_tgz(conn, tenant_id, file_bytes)
144 except Exception as e:
145 await logger.aerror("package_seeding.failed", package=package_tag, error=str(e))
148# use async decorator to use lifespan function inside the context manager
149@asynccontextmanager
150async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
151 """
152 - context manager to handle startup and shutdown events
153 - it manages when to do what step, e.g., database connection.
154 - runs before the server starts and after the server stops - between yield
155 """
156 settings = get_settings()
157 configure_logging(settings.log_level, settings.log_format)
158 init_registry()
159 init_derivation_registry()
160 await init_pool(settings.db)
161 await seed_local_packages(settings)
162 await seed_registry_packages(settings)
163 # Plugins load last: they may read from the DB in setup(), and a broken one
164 # must not prevent the conformant server underneath from coming up.
165 await load_plugins(settings.cortex)
166 await logger.ainfo("server.started", address=settings.server_address)
167 # pause lifespan during active server process
168 yield
169 await logger.ainfo("server.stopped")
170 await unload_plugins()
171 await close_pool()
174def create_app() -> FastAPI:
175 """
176 - basic FastAPI app creation with middleware, routers, and exception handlers
177 - it also sets up the lifespan context manager for startup and shutdown events
178 """
179 settings = get_settings()
181 # Configure OpenTelemetry providers + asyncpg instrumentation before the DB
182 # pool is created in the lifespan (no-op unless LUMEN_OTEL_ENABLED=true).
183 setup_telemetry(settings.otel)
185 app = FastAPI(
186 title="Lumen FHIR Server",
187 lifespan=lifespan,
188 docs_url="/docs",
189 redoc_url="/redoc",
190 )
191 # Instrument request spans with query-string redaction (no-op when disabled).
192 instrument_fastapi(app, settings.otel)
193 # use correlation id middleware (asgi) to add an id to every request for tracing and logging
194 app.add_middleware(CorrelationIdMiddleware, header_name="X-Request-ID")
195 app.add_middleware(AccessLogMiddleware)
196 # Check CORS settings from settings module
197 app.add_middleware(
198 CORSMiddleware,
199 allow_origins=settings.cors_origins,
200 allow_methods=["*"],
201 allow_headers=["*"],
202 )
203 app.add_middleware(FhirContentNegotiationMiddleware)
204 # Phase 16 hardening. Order matters: SecurityHeaders must be outermost so
205 # every response — including 429/413/406 rejections from the middleware
206 # below it — gets the baseline headers.
207 app.add_middleware(RateLimitMiddleware, settings=settings.ratelimit)
208 app.add_middleware(MaxBodySizeMiddleware, settings=settings.ratelimit)
209 app.add_middleware(SecurityHeadersMiddleware, settings=settings.security)
211 app.include_router(root_router)
212 app.include_router(health_router)
213 app.include_router(well_known_router)
214 app.include_router(metadata_router)
215 app.include_router(operations_router)
216 app.include_router(tenant_settings_router)
217 app.include_router(admin_packages_router, prefix="/admin", tags=["admin"])
218 app.include_router(admin_derivation_rules_router, prefix="/admin", tags=["admin"])
219 app.include_router(resources_router)
221 app.add_exception_handler(OperationOutcomeError, operation_outcome_handler) # type: ignore[arg-type]
222 app.add_exception_handler(AuthError, auth_error_handler) # type: ignore[arg-type]
223 app.add_exception_handler(404, not_found_handler) # type: ignore[arg-type]
224 app.add_exception_handler(Exception, generic_exception_handler) # type: ignore[arg-type]
226 return app
229lumen_app = create_app()
230app = lumen_app