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

1""" 

2This is the main entry point for the FastAPI application. 

3It initializes the app, registers middlewares, includes routers, and manages lifecycles. 

4""" 

5 

6from collections.abc import AsyncGenerator 

7from contextlib import asynccontextmanager 

8 

9import structlog 

10from asgi_correlation_id import CorrelationIdMiddleware 

11from fastapi import FastAPI 

12from fastapi.middleware.cors import CORSMiddleware 

13 

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 

46 

47logger = structlog.get_logger(__name__) 

48 

49 

50async def seed_local_packages(settings: Settings) -> None: 

51 """Seed any .tgz packages found in settings.seed_packages_dir into the default tenant.""" 

52 

53 if not settings.seed_packages_dir: 

54 return 

55 

56 import anyio 

57 

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 

62 

63 pool = get_pool() 

64 tenant_id = settings.default_tenant 

65 

66 async with pool.acquire() as conn: 

67 installed = await get_installed_packages(conn, tenant_id) 

68 

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 

76 

77 from .admin.loader import _extract_package_metadata # type: ignore[reportPrivateUsage] 

78 

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}" 

83 

84 if package_tag in installed: 

85 await logger.adebug( 

86 "package_seeding.already_installed", package=package_tag 

87 ) 

88 continue 

89 

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)) 

98 

99 

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 

107 

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 

111 

112 from .admin.registry import download_package 

113 

114 pool = get_pool() 

115 tenant_id = settings.default_tenant 

116 

117 async with pool.acquire() as conn: 

118 installed = await get_installed_packages(conn, tenant_id) 

119 

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 

129 

130 package_id, version = package_spec.split("@", 1) 

131 package_tag = f"{package_id}@{version}" 

132 

133 if package_tag in installed: 

134 await logger.adebug("package_seeding.already_installed", package=package_tag) 

135 continue 

136 

137 try: 

138 await logger.ainfo("package_seeding.downloading", package=package_tag) 

139 file_bytes = await download_package(package_id, version) 

140 

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)) 

146 

147 

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() 

172 

173 

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() 

180 

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) 

184 

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) 

210 

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) 

220 

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] 

225 

226 return app 

227 

228 

229lumen_app = create_app() 

230app = lumen_app