Skip to content

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}