Coverage for src/lexigram/web/routing/route_handlers.py: 13%
191 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Registry for route handlers in lexigram-web."""
3from __future__ import annotations
5import inspect
6from typing import TYPE_CHECKING, Any, Protocol, cast
8from lexigram.logging import get_logger
10logger = get_logger(__name__)
12if TYPE_CHECKING:
13 from collections.abc import Callable
15 from lexigram.app.base import Application
16 from lexigram.web.routing.manager import WebRouterManager
19class RouteHandlerProtocol(Protocol):
20 """Protocol for route handlers."""
22 async def register(self, manager: WebRouterManager, app: Application) -> None:
23 """Register routes into the application."""
24 ...
27def _is_body_model(annotation: type) -> bool:
28 """Return True if *annotation* is a Pydantic BaseModel or DomainModel subclass.
30 Both types can be deserialised from a JSON request body. Imported lazily
31 to avoid circular-dependency issues at module load time.
33 Args:
34 annotation: The type annotation to test.
36 Returns:
37 True when the annotation is a recognised model type.
38 """
39 try:
40 from pydantic import BaseModel
42 if issubclass(annotation, BaseModel):
43 return True
44 except ImportError:
45 pass
46 try:
47 from lexigram.domain import DomainModel
49 if issubclass(annotation, DomainModel):
50 return True
51 except ImportError:
52 pass
53 return False
56def _wrap_script_handler(fn: Callable[..., Any]) -> Callable[..., Any]:
57 """Wrap a script-mode standalone handler to be Starlette-compatible.
59 Handles parameter binding:
60 - Path params: matched by name from ``request.path_params``
61 - Body param: ``data: dict`` / Pydantic ``BaseModel`` / ``DomainModel`` subclass
62 (or any unhinted param) ← parsed from JSON body
63 - ``request``: passes the raw Starlette ``Request`` object
64 Return values: dict/list → JSONResponse, None → 204, (body, status) tuple
65 """
66 from starlette.requests import Request
67 from starlette.responses import JSONResponse, Response
69 sig = inspect.signature(fn)
70 params = list(sig.parameters.values())
72 async def endpoint(request: Request) -> Response:
73 kwargs: dict[str, Any] = {}
75 # Parse JSON body once (only for methods that carry a body)
76 body: dict | list | None = None
77 if request.method in ("POST", "PUT", "PATCH"):
78 try:
79 body = await request.json()
80 except (ValueError, UnicodeDecodeError):
81 body = None
83 for param in params:
84 name = param.name
85 annotation = param.annotation
87 if annotation is inspect.Parameter.empty:
88 # Unnamed hint: treat as body if body available, else skip
89 if body is not None:
90 kwargs[name] = body
91 elif annotation is Request or (
92 isinstance(annotation, type) and issubclass(annotation, Request)
93 ):
94 kwargs[name] = request
95 elif name in request.path_params:
96 kwargs[name] = request.path_params[name]
97 elif annotation is dict or (
98 isinstance(annotation, type) and issubclass(annotation, dict)
99 ):
100 kwargs[name] = body or {}
101 elif isinstance(annotation, type) and _is_body_model(annotation):
102 body_dict: dict[str, Any] = body if isinstance(body, dict) else {}
103 try:
104 from pydantic import BaseModel, ValidationError
106 if issubclass(annotation, BaseModel):
107 try:
108 kwargs[name] = annotation(**body_dict)
109 except ValidationError as exc:
110 return JSONResponse(
111 {"errors": exc.errors()}, status_code=422
112 )
113 else:
114 kwargs[name] = annotation(**body_dict)
115 except ImportError:
116 kwargs[name] = body_dict
117 elif name in request.query_params:
118 raw = request.query_params[name]
119 # Best-effort type coercion for query params
120 if annotation in (int,):
121 try:
122 kwargs[name] = int(raw)
123 except ValueError:
124 kwargs[name] = raw
125 elif annotation in (float,):
126 try:
127 kwargs[name] = float(raw)
128 except ValueError:
129 kwargs[name] = raw
130 elif annotation in (bool,):
131 kwargs[name] = raw.lower() in ("1", "true", "yes")
132 else:
133 kwargs[name] = raw
134 elif isinstance(annotation, type) and not annotation.__module__.startswith(
135 "builtins"
136 ):
137 # Try to resolve arbitrary service from DI container (script-mode DI injection)
138 try:
139 from lexigram.contracts.exceptions.container import (
140 UnresolvableDependencyError,
141 )
143 container = getattr(
144 getattr(request.app, "state", None), "container", None
145 )
146 if container is not None:
147 kwargs[name] = await container.resolve(annotation)
148 except (UnresolvableDependencyError, AttributeError, LookupError):
149 pass
150 elif param.default is not inspect.Parameter.empty:
151 # Optional param with default — skip (let Python fill default)
152 pass
154 result = await fn(**kwargs)
156 # Tuple (body, status_code)
157 if isinstance(result, tuple) and len(result) == 2:
158 body_out, status = result
159 if isinstance(body_out, Response):
160 return body_out
161 return JSONResponse(body_out, status_code=int(status))
163 if result is None:
164 return Response(status_code=204)
165 if isinstance(result, Response):
166 return result
167 return JSONResponse(result)
169 return endpoint
172def _wrap_websocket_handler(handler_cls: type) -> Callable:
173 """Wrap a AbstractWebSocketHandler class to be Starlette/ASGI compatible.
175 Resolves the handler instance from the DI container (preferring singletons
176 for broadcast support) and dispatches the connection to his ``handle()``
177 method.
178 """
180 from lexigram.web.transport.websockets import WebSocket
182 async def endpoint(starlette_ws: Any) -> None:
183 # Resolve handler from container if available
184 container = None
185 if hasattr(starlette_ws, "app"):
186 container = getattr(
187 getattr(starlette_ws.app, "state", None), "container", None
188 )
190 handler: Any = None
191 if container is not None:
192 try:
193 # Try to resolve from container (preferred for singleton/lifecycle)
194 handler = await container.resolve(handler_cls)
195 except (LookupError, RuntimeError):
196 # Fallback to direct instantiation if not injectable
197 handler = handler_cls()
198 else:
199 handler = handler_cls()
201 # Wrap Starlette WebSocket in Lexigram WebSocket
202 ws = WebSocket(starlette_ws)
204 # Dispatch to Lexigram handler
205 await handler.handle(ws)
207 return endpoint
210class CoreRouteHandler:
211 """Handles core routes from application decorators."""
213 async def register(self, manager: WebRouterManager, app: Application) -> None:
214 starlette = manager.provider.starlette
215 if starlette is None:
216 raise RuntimeError("Starlette application not initialized")
218 # `app` here is the Starlette instance. Resolve the Lexigram Application
219 # from the container to access script-mode _pending_routes.
220 lex_app: Any = app
221 try:
222 from lexigram.app.base import Application as LexigramApp
223 from lexigram.contracts.exceptions.container import (
224 UnresolvableDependencyError,
225 )
227 starlette_app = manager.provider.starlette
228 container = getattr(starlette_app, "state", None)
229 container = getattr(container, "container", None) if container else None
230 if container is not None:
231 resolved = await container.resolve(
232 LexigramApp,
233 bypass_visibility=True,
234 )
235 if resolved is not None:
236 lex_app = resolved
237 except (
238 LookupError,
239 RuntimeError,
240 AttributeError,
241 TypeError,
242 UnresolvableDependencyError,
243 ) as _exc:
244 logger.debug("quickstart_app_resolution_skipped", reason=str(_exc))
246 pending = getattr(lex_app, "_pending_routes", [])
247 if not isinstance(pending, (list, tuple)):
248 pending = []
250 if not pending:
251 return
253 logger.info("Registering %d core routes from application", len(pending))
255 for route_def in pending:
256 # WEBSOCKET routes should be wrapped using _wrap_websocket_handler
257 # to bridge the ASGI interface (REVISION COR-1)
258 if route_def.method == "WEBSOCKET":
259 use_handler = _wrap_websocket_handler(route_def.handler)
260 else:
261 use_handler = _wrap_script_handler(route_def.handler)
263 await manager.add_route(
264 path=str(route_def.path),
265 handler=use_handler,
266 method=str(route_def.method),
267 origin_type="core",
268 handler_metadata=route_def.handler,
269 )
271 # Consume pending routes to prevent double registration (REVISION MAJ-6)
272 if hasattr(lex_app, "_pending_routes"):
273 lex_app._pending_routes = []
276class ControllerRouteHandler:
277 """Handles controller-based routes.
279 Discovers controllers from two sources and merges them:
280 1. Controllers registered directly on the ``WebProvider`` instance.
281 2. Controllers declared in the ``controllers=`` field of any ``@module``
282 decorated class that is registered with the Application container.
283 """
285 async def register(self, manager: WebRouterManager, app: Application) -> None:
286 # Start with directly-registered controllers
287 seen: set[type] = set()
288 controllers_to_register: list[type] = []
290 for ctrl in manager.provider.controllers:
291 if ctrl not in seen:
292 seen.add(ctrl)
293 controllers_to_register.append(ctrl)
295 # Auto-discover controllers from @module-decorated classes
296 try:
297 from lexigram.di.module import Module as LexigramModule
299 if hasattr(app, "container") and app.container is not None:
300 # Iterate all registered singletons — find module metadata owners
301 # Use the registry to discover all registered classes
302 container = cast("Any", app.container)
303 for descriptor in list(container._registry.all()):
304 registered_cls = descriptor.service_type
305 meta = getattr(registered_cls, "__lexigram_module__", None)
306 if meta is not None and isinstance(meta, LexigramModule):
307 for ctrl_cls in meta.controllers:
308 if ctrl_cls not in seen:
309 seen.add(ctrl_cls)
310 controllers_to_register.append(ctrl_cls)
311 logger.debug(
312 "auto_discovered_controller_from_module",
313 controller=ctrl_cls.__name__,
314 module=meta.name,
315 )
316 except (
317 ImportError,
318 AttributeError,
319 LookupError,
320 RuntimeError,
321 TypeError,
322 ) as exc:
323 logger.debug("module_controller_discovery_skipped", reason=str(exc))
325 for controller_cls in controllers_to_register:
326 await manager.register_controller_routes(controller_cls, app.container)
329class OpenAPIRouteHandler:
330 """Handles OpenAPI documentation routes."""
332 async def register(self, manager: WebRouterManager, _app: Application) -> None:
333 web_config = manager.provider.web_config
334 openapi_title = web_config.openapi_title
336 if openapi_title and manager.provider.openapi_generator:
337 from lexigram.web.routing.openapi import (
338 register_openapi_routes,
339 )
341 register_openapi_routes(manager.provider) # type: ignore[arg-type]
344class HealthRouteHandler:
345 """Handles health check routes."""
347 async def register(self, manager: WebRouterManager, app: Application) -> None:
348 from lexigram.web.routing.health import register_health_route
350 register_health_route(manager.provider, app) # type: ignore[arg-type]
353class DebugRouteHandler:
354 """Handles debug routes when enabled."""
356 async def register(self, manager: WebRouterManager, _app: Application) -> None:
357 if manager.should_enable_debug_routes():
358 from lexigram.web.routing.debug import (
359 register_debug_routes,
360 )
362 register_debug_routes(manager.provider) # type: ignore[arg-type]
365class RouteHandlerRegistry:
366 """Registry for route source handlers.
368 Each handler (controllers, OpenAPI, health, debug) knows how to
369 discover and register its routes with the router manager.
370 """
372 def __init__(self) -> None:
373 self._handlers: list[RouteHandlerProtocol] = [
374 CoreRouteHandler(),
375 ControllerRouteHandler(),
376 OpenAPIRouteHandler(),
377 HealthRouteHandler(),
378 DebugRouteHandler(),
379 ]
381 def add_handler(self, handler: RouteHandlerProtocol) -> None:
382 """Add a custom route source handler.
384 **Public Extension Point** — use this to inject custom route discovery
385 logic without subclassing ``WebProvider``. Custom handlers run after
386 all built-in handlers, so they can reference routes registered by the
387 framework.
389 Register via the provider::
391 class MyRouteHandler:
392 async def register_routes(self, router_manager, container):
393 router_manager.add_route("/my/path", my_view, methods=["GET"])
395 class AppProvider(WebProvider):
396 async def boot(self, container):
397 await super().boot(container)
398 route_handler_registry = await container.resolve(RouteHandlerRegistry)
399 route_handler_registry.add_handler(MyRouteHandler())
401 Args:
402 handler: A :class:`RouteHandlerProtocol` implementation.
403 """
404 self._handlers.append(handler)
406 @property
407 def handlers(self) -> list[RouteHandlerProtocol]:
408 """Get all registered handlers."""
409 return self._handlers