src/genro_asgi/routed_application.py¶
Source from this local checkout, regenerated when the reader rebuilds.
Line links use #L<number>; a GitHub line range opens its first line.
1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 # https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """RoutedApplication: the application base wiring genro-routes into the core.16 17 ``RoutedApplication`` composes the app-side contract (``BaseApplication``)18 with the genro-routes ``RoutingClass``: handlers are ``@route``-decorated19 methods on subclasses, and external ``RoutingClass`` instances mount as20 sub-trees via ``add_branches({"name": ..., "instance": child})``. The constructor peels21 ``db_name`` (the ``request.db`` seam resolves it against the server's22 database registry) and plugs the ``auth`` plugin on the app router, so23 entries declaring ``auth_rule`` are filtered by the request's authorization24 tags — attached children inherit the plug.25 26 The config-driven plugins (the ``openapi`` dialect today) are armed LAZILY:27 on the first ``route`` access made after the app is attached to a server, the28 app calls ``server.arm_router(self.route)`` (once, guarded), so a server built29 with a ``plugins`` section plugs them onto every routed app it hosts. A30 composition whose server lacks the ``PluginMixin`` exposes no ``arm_router``31 and arms nothing — the app degrades to the ``auth`` plug alone.32 33 The ASGI dispatch (``__call__``) is the per-app routing engine: build a34 ``Request`` bound to this app, eager-parse it (``init``), resolve the node35 from the request path — already mount-relative, the server demux strips the36 prefix (D3) — with the identity tags of ``scope["auth"]`` as auth filters,37 bind kwargs, execute (async handlers stay on the loop, sync handlers go38 through ``server.run_sync`` — the Macro 1 pool protocol), then answer via39 ``request.response.set_result(value, metadata)``. Resolution failures raise40 core exceptions (``ROUTER_ERRORS``: unknown or unavailable path →41 ``HTTPNotFound``; a ruled entry denied with no identity → ``HTTPUnauthorized``,42 with an identity whose tags do not match → ``HTTPForbidden``) that propagate to43 the server's ``ErrorMiddleware``.44 A call the handler cannot take becomes an HTTP answer — never a 500 — on the45 two exception codes genro-routes distinguishes: ``signature_error`` (the bind46 against the handler signature fails: unknown keyword, missing required47 argument, too many positionals) → ``HTTPBadRequest`` (400);48 ``validation_error`` (the signature is satisfied and pydantic rejects the49 values) → ``HTTPUnprocessableContent`` (422). The handler BODY is mapped to50 neither: whatever it raises — a ``TypeError`` included, sync body or async —51 propagates and reaches ``ErrorMiddleware`` as a 500. There is no local52 cleanup drain: the server ``finally`` owns end-of-request cleanups.53 54 Kwargs binding: ``bind_kwargs`` starts from ``request.handler_kwargs()``55 (query + body by content-type) and reconciles a hydrated JSON body with a56 scalar-parameter handler — when the node's neutral ``params`` block declares57 fields (pydantic plugin) and the handler does not itself absorb ``body_data``58 or ``**kwargs``, the body dict is spread over the declared names through59 ``spread_over_params`` (extras dropped). Auth without the middleware: when60 ``scope`` carries no ``auth`` key the resolution runs unfiltered — the public61 router exposes exactly what the auth plugin leaves untagged.62 """63 64 from __future__ import annotations65 66 import asyncio67 import importlib68 from collections.abc import Callable69 from typing import TYPE_CHECKING, Any70 71 from genro_routes import RoutingClass, is_result_wrapper72 73 from .application import BaseApplication74 from .exceptions import (75 HTTPBadRequest,76 HTTPForbidden,77 HTTPNotFound,78 HTTPUnauthorized,79 HTTPUnprocessableContent,80 )81 from .request import Request82 from .streaming import StreamingResponse83 84 if TYPE_CHECKING:85 from genro_routes import Router, RouterNode86 87 from .types import Receive, Scope, Send88 89 __all__ = ["RoutedApplication"]90 91 92 class _HandlerSignatureInvalid(Exception):93 """Marker raised by the node's ``signature_error`` exception mapping.94 95 genro-routes binds the call against the handler signature before running96 it: an unknown keyword, a missing required argument or too many positionals97 re-raise as this class, the binding ``TypeError`` kept as ``__cause__``.98 """99 100 101 class _HandlerArgumentsInvalid(Exception):102 """Marker raised by the node's ``validation_error`` exception mapping.103 104 The signature is satisfied and pydantic rejects the values: genro-routes105 re-raises the ``pydantic.ValidationError`` as this class, the original error106 kept as ``__cause__``; the dispatcher catches rejected values without107 importing pydantic (the sibling of the MCP engine's marker).108 """109 110 111 class RoutedApplication(BaseApplication, RoutingClass):112 """Application base serving ``@route`` handlers through the app router.113 114 Constructor kwargs peeled here: ``db_name`` — the server database code115 the ``request.db`` seam resolves for this app (``None`` falls back to116 ``"default"``). The rest flows down the D16 chain (``code``/``mount`` to117 ``BaseApplication``).118 """119 120 # genro-routes resolution error codes mapped to core HTTP exceptions (the121 # node.error string-code contract): an unknown or unavailable path answers122 # 404; a ruled entry denied answers on the HTTP distinction the router123 # already draws — 401 "I do not know who you are" when no identity was124 # presented, 403 "I know, and it is not enough" when the presented tags do125 # not match. The 401 is what ``ErrorMiddleware`` negotiates into a login126 # challenge, so a browser reaching a protected route lands on the login127 # page instead of a dead end.128 ROUTER_ERRORS: dict[str, type[Exception]] = {129 "not_found": HTTPNotFound,130 "not_available": HTTPNotFound,131 "not_authorized": HTTPForbidden,132 "not_authenticated": HTTPUnauthorized,133 }134 135 def __init__(self, **kwargs: Any) -> None:136 self._armed: bool = False137 self._db_name: str | None = kwargs.pop("db_name", None)138 super().__init__(**kwargs)139 self.route.plug("auth")140 141 @property142 def db_name(self) -> str | None:143 """Server database code for the ``request.db`` seam (``None`` → default)."""144 return self._db_name145 146 def _import_routing_class(self, module_path: str) -> RoutingClass:147 """Import and instantiate a ``RoutingClass`` from a ``"pkg.mod:Class"`` path.148 149 The class is instantiated with no arguments; an API needing constructor150 arguments is supplied as a ready ``routing_class=`` instance instead.151 Shared by the ``OpenApiApplication`` and ``McpApplication`` subclasses.152 """153 module_name, class_name = module_path.split(":")154 mod = importlib.import_module(module_name)155 cls = getattr(mod, class_name)156 return cls() # type: ignore[no-any-return]157 158 @property159 def route(self) -> Router:160 """The app router; arms the server's configured plugins on first access.161 162 The first access made once a server owns this app triggers163 ``server.arm_router`` (once, guarded by ``_armed``); accesses before164 attachment — the ``auth`` plug in ``__init__`` — arm nothing.165 """166 router: Router = super().route167 arm_router = getattr(self.server, "arm_router", None)168 if not self._armed and arm_router is not None:169 self._armed = True170 arm_router(router)171 return router172 173 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:174 """Resolve the request in the app router, execute, respond.175 176 Raises the mapped ``ROUTER_ERRORS`` exception when resolution fails;177 the server's ``ErrorMiddleware`` answers it. A call that does not fit178 the handler signature surfaces as ``HTTPBadRequest`` (400); values the179 handler's pydantic validation rejects surface as180 ``HTTPUnprocessableContent`` (422). Both keep the original error as181 ``__cause__``. What the handler body raises is mapped to neither: it182 propagates as a 500.183 184 A handler that answers with a ``StreamingResponse`` — an SSE stream, a185 long download — speaks the wire itself: it is called with the ASGI186 triple and nothing is buffered.187 """188 server = self.server189 if server is None:190 raise RuntimeError(f"{type(self).__name__} dispatch requires an owning server")191 request = Request(scope, receive, server=server, application=self)192 await request.init()193 errors = {194 **self.ROUTER_ERRORS,195 "signature_error": _HandlerSignatureInvalid,196 "validation_error": _HandlerArgumentsInvalid,197 }198 node = self.route.node(request.path, errors=errors, **self.auth_filters(scope))199 call = self.make_callable(node, request)200 try:201 if asyncio.iscoroutinefunction(node):202 result = await call()203 else:204 result = await server.run_sync(call)205 except _HandlerSignatureInvalid as exc:206 detail = exc.__cause__ or exc207 raise HTTPBadRequest(f"Arguments do not fit the handler: {detail}") from exc208 except _HandlerArgumentsInvalid as exc:209 detail = exc.__cause__ or exc210 raise HTTPUnprocessableContent(f"Invalid argument values: {detail}") from exc211 if isinstance(result, StreamingResponse):212 await result(scope, receive, send)213 return214 if is_result_wrapper(result):215 request.response.set_result(result.value, {**node.metadata, **result.metadata})216 else:217 request.response.set_result(result, node.metadata)218 await request.response(scope, receive, send)219 220 def auth_filters(self, scope: Scope) -> dict[str, str]:221 """Auth filters for node resolution, from the scope identity.222 223 An ``Avatar`` on ``scope["auth"]`` becomes the comma-separated224 ``auth_tags`` the auth plugin evaluates entry rules against. No225 identity — key absent (middleware off) or ``None`` (anonymous) —226 passes no filter: the plugin still denies every ruled entry.227 """228 avatar = scope.get("auth")229 if avatar is None:230 return {}231 return {"auth_tags": ",".join(avatar.tags)}232 233 def make_callable(self, node: RouterNode, request: Request) -> Callable[[], Any]:234 """Package the node invocation as the zero-arg call the dispatcher runs.235 236 The node declares its handler's nature (genro-routes marks async237 entries so ``asyncio.iscoroutinefunction(node)`` is honest); the238 dispatcher reads that same nature to pick the vehicle — the returned239 async callable is awaited on the loop, the sync one goes through the240 server pool. ``bind_kwargs`` decides the arguments.241 """242 kwargs = self.bind_kwargs(node, request)243 244 # asyncio.iscoroutinefunction, not inspect's: on 3.11 genro-routes245 # falls back to the asyncio sentinel, which only the asyncio check reads.246 if asyncio.iscoroutinefunction(node):247 248 async def acall() -> Any:249 return await node(**kwargs)250 251 return acall252 253 def call() -> Any:254 try:255 return node(**kwargs)256 finally:257 self.route_cleanup()258 259 return call260 261 def route_cleanup(self) -> None:262 """Per-dispatch cleanup on the executor thread — a consumer seam.263 264 The sync dispatch runs this on the SAME pool thread the handler just265 ran on, after it returned or raised: the place to release whatever266 thread-local resources the handler's code opened (a legacy db267 connection lives and must die on its own thread). No-op by default,268 same consumer-seam discipline as ``wsgi_app`` and ``build_registry``.269 The async path never calls it — an async handler owns its awaits.270 """271 272 def bind_kwargs(self, node: RouterNode, request: Request) -> dict[str, Any]:273 """Reconcile the request kwargs with the handler's declared parameters.274 275 Base: ``request.handler_kwargs()``. A hydrated JSON body arrives as a276 single ``body_data`` dict; a REST handler declares scalar parameters,277 so the dict is spread over the fields the handler accepts (from the278 node's neutral ``params`` block — never ``inspect``). The whole279 ``body_data`` is kept when the handler itself declares it, accepts280 ``**kwargs``, or exposes no signature (no pydantic plugin).281 282 A handler that declares ``_request`` is given the live ``Request``: the283 login surface of the ``_server`` app asked for it first, and the284 websocket channel command asks for it now — both need what only the285 request knows, the cookie it came with. It was the server app's own286 override until #68 moved it here, because the seam is nobody's private287 business (owner, 2026-09-07).288 """289 kwargs = request.handler_kwargs()290 fields = node.params.get("fields") or []291 if any(f["name"] == "_request" for f in fields):292 # The declarative seam for a handler that needs the request itself293 # — the cookie it carries, the session on it, the server behind it.294 # It is declared UNANNOTATED, so it stays out of the pydantic model295 # and out of the public schema, and it overrides any same-named296 # value that arrived on the wire: nobody sends themselves a297 # request. No ambient state: it travels as an ordinary argument.298 kwargs["_request"] = request299 body = kwargs.get("body_data")300 if not isinstance(body, dict):301 return kwargs302 fields = node.params.get("fields")303 if fields is None:304 return kwargs305 param_names = {306 f["name"] for f in fields if f["kind"] not in ("var_positional", "var_keyword")307 }308 accepts_kwargs = any(f["kind"] == "var_keyword" for f in fields)309 if "body_data" in param_names or accepts_kwargs:310 return kwargs311 del kwargs["body_data"]312 kwargs.update(self.spread_over_params(node, body))313 return kwargs314 315 def spread_over_params(self, node: RouterNode, data: dict[str, Any]) -> dict[str, Any]:316 """Fit a dict of values to the handler's declared parameters.317 318 Shared by every wire dialect (REST body today, MCP arguments later):319 keeps ``data`` whole when the handler declares no signature320 (``fields`` is ``None`` — no pydantic plugin) or accepts ``**kwargs``;321 otherwise keeps only the declared names, dropping extras. An empty322 ``fields`` list is a known no-parameter handler: everything drops.323 """324 fields = node.params.get("fields")325 if fields is None:326 return data327 if any(f["kind"] == "var_keyword" for f in fields):328 return data329 param_names = {330 f["name"] for f in fields if f["kind"] not in ("var_positional", "var_keyword")331 }332 return {k: v for k, v in data.items() if k in param_names}