Coverage for src/lexigram/web/di/route_setup.py: 21%

71 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""Route registration helper for the Lexigram web layer.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any, cast 

6 

7from lexigram.contracts.exceptions.container import UnresolvableDependencyError 

8from lexigram.logging import get_logger 

9 

10if TYPE_CHECKING: 

11 from starlette.applications import Starlette 

12 

13 from lexigram.contracts.core.di import ContainerResolverProtocol 

14 from lexigram.web.config import WebConfig, WebProviderConfig 

15 from lexigram.web.di.provider import WebProvider 

16 from lexigram.web.routing.manager import WebRouterManager 

17 

18logger = get_logger(__name__) 

19 

20__all__ = ["RouteSetup"] 

21 

22 

23class RouteSetup: 

24 """Encapsulates route and mount registration for the Lexigram web layer. 

25 

26 Extracted from ``WebProvider`` to keep the provider focused on DI lifecycle 

27 (register/boot/shutdown) rather than application routing configuration. 

28 

29 Args: 

30 web_config: Web configuration; drives debug routes, static files, etc. 

31 provider_config: Provider-level configuration. 

32 router_manager: The router manager that discovers and mounts controllers. 

33 """ 

34 

35 def __init__( 

36 self, 

37 web_config: WebConfig, 

38 provider_config: WebProviderConfig, 

39 router_manager: WebRouterManager, 

40 ) -> None: 

41 self._web_config = web_config 

42 self._provider_config = provider_config 

43 self._router_manager = router_manager 

44 

45 async def configure( 

46 self, 

47 app: Starlette, 

48 container: ContainerResolverProtocol, 

49 provider_context: Any = None, 

50 ) -> None: 

51 """Register all routes and ASGI mounts on the application. 

52 

53 Phases: 

54 

55 1. Debug routes (guarded by ``web.enable_debug_routes_env_gate`` config). 

56 2. Prometheus ``/metrics`` ASGI app (when ``MonitoringProvider`` is active). 

57 3. Canonical health endpoint (before contributors so extension 

58 convenience routes cannot shadow the framework contract). 

59 4. Web contributor mounts (admin panels, sub-apps, etc.). 

60 5. Static file directory (when ``web_config.static`` is configured). 

61 6. Main controller discovery via :class:`~lexigram.web.routing.manager.WebRouterManager`. 

62 7. Register the Starlette app as the ASGI handler on the ``Application`` instance. 

63 

64 Args: 

65 app: The Starlette application to configure. 

66 container: The resolved DI container. 

67 provider_context: Optional reference to the parent ``WebProvider`` 

68 instance; forwarded to integrations that need provider-level 

69 state (e.g. ``DebugIntegration`` for Redis client caching). 

70 """ 

71 await self._register_debug_routes(app, container, provider_context) 

72 await self._mount_metrics(app, container) 

73 self._register_health_route(app) 

74 await self._mount_contributors(app, container, provider_context) 

75 self._mount_static(app) 

76 await self._router_manager.register_routes(app, container) 

77 await self._register_asgi_handler(app, container) 

78 

79 def _register_health_route(self, app: Starlette) -> None: 

80 """Register the canonical health endpoint before contributor mounts. 

81 

82 Contributors (e.g. the relay gateway) may offer their own ``/health`` 

83 convenience route with dedup logic; registering ours first guarantees 

84 the framework's 200/207/503 health contract wins regardless of which 

85 extensions are installed. Later duplicate registrations are no-ops. 

86 """ 

87 from lexigram.web.routing.health import register_health_route 

88 

89 # app is a Starlette instance; the health helper accepts any ASGI 

90 # application with a .routes attribute, so the nominal mismatch is safe. 

91 register_health_route( 

92 cast("WebProvider", self._router_manager.provider), 

93 app, # type: ignore[arg-type] # Starlette vs Application nominal mismatch 

94 ) 

95 

96 # ------------------------------------------------------------------ 

97 # Private helpers 

98 # ------------------------------------------------------------------ 

99 

100 async def _register_debug_routes( 

101 self, 

102 app: Starlette, 

103 container: ContainerResolverProtocol, 

104 provider_context: Any, 

105 ) -> None: 

106 """Register debug routes when enabled and gate allows registration.""" 

107 from lexigram.web.integrations.debug import DebugIntegration 

108 

109 debug_enabled = self._web_config.debug_routes or getattr( 

110 self._provider_config, 

111 "debug_routes", 

112 False, 

113 ) 

114 if not debug_enabled: 

115 return 

116 

117 if not getattr(self._web_config, "enable_debug_routes_env_gate", False): 

118 logger.warning( 

119 "debug_routes_gate_disabled", 

120 message="debug_routes=True in config but " 

121 "enable_debug_routes_env_gate=False. Debug routes will not be registered.", 

122 ) 

123 return 

124 

125 await DebugIntegration.configure(app, cast("Any", container), provider_context) 

126 

127 async def _mount_metrics( 

128 self, 

129 app: Starlette, 

130 container: ContainerResolverProtocol, 

131 ) -> None: 

132 """Mount Prometheus /metrics ASGI app when ``MonitoringProvider`` is active.""" 

133 try: 

134 metrics_asgi_app: Any = await container.resolve( 

135 cast("Any", "prometheus_metrics_app"), 

136 bypass_visibility=True, 

137 ) 

138 if metrics_asgi_app is not None: 

139 metrics_path = getattr(self._web_config, "metrics_path", "/metrics") 

140 app.mount(metrics_path, metrics_asgi_app) 

141 logger.info("web.metrics_endpoint_mounted", path=metrics_path) 

142 except (UnresolvableDependencyError, RuntimeError, AttributeError, TypeError): 

143 pass 

144 

145 async def _mount_contributors( 

146 self, 

147 app: Starlette, 

148 container: ContainerResolverProtocol, 

149 provider_context: Any, 

150 ) -> None: 

151 """Mount web contributor sub-apps generically. 

152 

153 Iterates all registered web contributors and calls their mount_to_app 

154 hook. Failures are isolated and logged; they do not block other 

155 contributors or route setup. 

156 

157 Args: 

158 app: The Starlette application. 

159 container: The DI container. 

160 provider_context: The WebProvider instance (for registry access). 

161 """ 

162 if provider_context is None or not hasattr( 

163 provider_context, "contributor_registry" 

164 ): 

165 return 

166 

167 registry = provider_context.contributor_registry 

168 for contributor in registry.get_all(): 

169 try: 

170 await contributor.mount_to_app(app, container) 

171 logger.debug( 

172 "web.contributor_mount_ok", 

173 contributor_id=contributor.contributor_id, 

174 ) 

175 except Exception as mount_exc: 

176 logger.error( 

177 "web.contributor_mount_failed", 

178 contributor_id=contributor.contributor_id, 

179 error=str(mount_exc), 

180 exc_type=type(mount_exc).__name__, 

181 ) 

182 

183 def _mount_static(self, app: Starlette) -> None: 

184 """Mount static file directory when configured.""" 

185 static_config = getattr(self._web_config, "static", None) 

186 if static_config is None or not getattr(static_config, "enabled", False): 

187 return 

188 

189 from starlette.staticfiles import StaticFiles 

190 

191 try: 

192 app.mount( 

193 static_config.prefix, 

194 StaticFiles( 

195 directory=static_config.directory, 

196 html=getattr(static_config, "html", False), 

197 ), 

198 name="static", 

199 ) 

200 logger.info( 

201 "web.static_files_mounted", 

202 prefix=static_config.prefix, 

203 directory=static_config.directory, 

204 ) 

205 except (OSError, RuntimeError, ValueError) as exc: 

206 logger.warning( 

207 "web.static_files_mount_failed", 

208 directory=static_config.directory, 

209 error=str(exc), 

210 ) 

211 

212 async def _register_asgi_handler( 

213 self, 

214 app: Starlette, 

215 container: ContainerResolverProtocol, 

216 ) -> None: 

217 """Register the Starlette app as the ASGI handler on the Application.""" 

218 from lexigram.app.base import Application 

219 

220 lex_app = None 

221 try: 

222 lex_app = await container.resolve( 

223 Application, 

224 bypass_visibility=True, 

225 ) 

226 except (AttributeError, UnresolvableDependencyError) as exc: 

227 logger.warning("app_resolution_failed", error=str(exc)) 

228 

229 if lex_app: 

230 if hasattr(lex_app, "set_asgi_handler"): 

231 lex_app.set_asgi_handler(app) 

232 else: 

233 lex_app._asgi_handler = app # type: ignore[attr-defined]