Source code for responder.routes

from __future__ import annotations

import asyncio
import dataclasses
import inspect
import logging
import re
import sys
import traceback
import urllib.parse
import weakref
from collections import defaultdict
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Any, Union, cast

if TYPE_CHECKING:
    from http.cookies import Morsel

__all__ = [
    "Route",
    "WebSocketRoute",
    "Router",
    "DependencyError",
    "DependencyCycleError",
    "DependencyScopeError",
    "DependencyResolutionError",
    "RouteNotFoundError",
]

from starlette.concurrency import run_in_threadpool
from starlette.exceptions import HTTPException
from starlette.responses import JSONResponse
from starlette.responses import Response as StarletteResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.websockets import (
    WebSocket,
    WebSocketClose,
    WebSocketDisconnect,
    WebSocketState,
)

from . import status_codes
from .errors import (
    INTERNAL_SERVER_ERROR,
    PROBLEM_JSON,
    legacy_error_payload,
    problem_bytes_for,
    problem_payload_for,
)
from .formats import get_formats
from .models import Request, Response
from .params import _Depends

logger = logging.getLogger("responder")


[docs] class DependencyError(Exception): """Base class for dependency-injection configuration/resolution errors."""
[docs] class DependencyCycleError(DependencyError): """A dependency depends on itself (directly or transitively)."""
[docs] class DependencyScopeError(DependencyError): """An app-scoped dependency illegally depends on the request or a request-scoped dependency."""
[docs] class DependencyResolutionError(DependencyError): """A dependency parameter is neither the request nor a registered dependency."""
class RouteNotFoundError(LookupError): """``url_for`` was asked to reverse an endpoint or route name that no registered route matches.""" _UUID_RE = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" _CONVERTORS = { "int": (int, r"\d+"), "str": (str, r"[^/]+"), "float": (float, r"\d+(?:\.\d+)?"), "path": (str, r".+"), "uuid": (str, _UUID_RE), } PARAM_RE = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}") _KNOWN_HTTP_METHODS = frozenset( {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT"} ) def _class_view_methods(endpoint: Any) -> set[str]: """HTTP methods a class-based view instance implements via ``on_*`` handlers, plus implicit HEAD-for-GET and OPTIONS. OPTIONS is always included: when a class defines no ``on_options`` handler (and no ``on_request``), CBV dispatch answers OPTIONS automatically with ``200`` + ``Allow`` — the same treatment method-restricted function routes get at the router level — so advertising it is accurate. """ methods = { name[3:].upper() for name in dir(endpoint) if name.startswith("on_") and name[3:].upper() in _KNOWN_HTTP_METHODS and callable(getattr(endpoint, name, None)) } if "GET" in methods: methods.add("HEAD") methods.add("OPTIONS") return methods def _auto_options_view(allow: str) -> Callable: """A synthetic view answering ``OPTIONS`` with ``200`` + ``Allow`` for a class-based view that defines neither ``on_options`` nor ``on_request``, mirroring the router-level automatic OPTIONS response for method-restricted function routes.""" def options_view(req: Request, resp: Response, **kwargs: Any) -> None: resp.status_code = status_codes.HTTP_200 resp.headers["Allow"] = allow resp.content = b"" return options_view # Headers that frame a specific body; a replacement response builds its own # (via ``Response.body``), so stale ones from the abandoned body must not leak. _BODY_FRAMING_HEADERS = frozenset( {"content-type", "content-length", "content-range", "transfer-encoding"} ) def _copy_response_metadata(source: Response, target: Response) -> None: """Copy user-set headers and cookies from ``source`` onto ``target``. Used when a timed-out request's response is rebuilt from scratch: metadata that completed before the view started (request-ID headers, CORS, cookies from before_request hooks) carries over, while headers framing the old body do not — ``Response`` keeps body-derived headers out of ``.headers`` (they are computed from the body at render time), so the dict is copied wholesale minus the framing names a view may have set explicitly (e.g. ``resp.file()``'s Content-Length). Reads are a single snapshot: the abandoned view thread may keep mutating ``source`` afterwards, but ``target`` stays isolated. """ for key, value in list(source.headers.items()): if key.lower() not in _BODY_FRAMING_HEADERS: target.headers[key] = value for key, morsel in list(source.cookies.items()): # Morsel.copy() returns a Morsel at runtime; typeshed types it as # the inherited dict.copy, hence the cast. target.cookies[key] = cast("Morsel[str]", morsel.copy()) def compile_path(path: str) -> tuple[re.Pattern, dict[str, type], dict[str, str]]: path_re = "^" param_convertors: dict[str, type] = {} param_convertor_names: dict[str, str] = {} idx = 0 for match in PARAM_RE.finditer(path): param_name, convertor_type = match.groups(default="str") convertor_type = convertor_type.lstrip(":") if convertor_type not in _CONVERTORS: raise ValueError( f"Unknown path convertor {convertor_type!r} in route {path!r}. " f"Available convertors: {', '.join(sorted(_CONVERTORS))}." ) if param_name in param_convertors: raise ValueError( f"Duplicate path parameter {param_name!r} in route {path!r}." ) convertor, convertor_re = _CONVERTORS[convertor_type] path_re += re.escape(path[idx : match.start()]) path_re += rf"(?P<{param_name}>{convertor_re})" param_convertors[param_name] = convertor param_convertor_names[param_name] = convertor_type idx = match.end() path_re += re.escape(path[idx:]) + "$" return re.compile(path_re), param_convertors, param_convertor_names _VIEW_PARAM_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() def _view_param_names(view: Callable, skip: int = 2) -> tuple[str, ...]: """Return the names of a view's parameters beyond the first ``skip`` (``req, resp`` for HTTP views, ``ws`` for WebSocket handlers). Results are cached per underlying function, since signature inspection is comparatively expensive and views never change shape at runtime. """ cache_key = getattr(view, "__func__", view) try: return _VIEW_PARAM_CACHE[cache_key][skip:] except (KeyError, TypeError): pass try: parameters = inspect.signature(view).parameters except (TypeError, ValueError): return () names = [] for param in parameters.values(): if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): continue names.append(param.name) result = tuple(names) try: _VIEW_PARAM_CACHE[cache_key] = result except TypeError: pass return result[skip:] _VIEW_HINTS_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() def _view_type_hints(view: Callable) -> dict: """Resolved type hints for a view, cached per underlying function. Returns an empty dict if hints can't be resolved (e.g. an unresolvable forward reference), so type-hint features degrade gracefully. """ cache_key = getattr(view, "__func__", view) try: return _VIEW_HINTS_CACHE[cache_key] except (KeyError, TypeError): pass try: import typing hints = typing.get_type_hints(view) except Exception: hints = {} try: _VIEW_HINTS_CACHE[cache_key] = hints except TypeError: pass return hints def _is_pydantic_model(tp: Any) -> bool: """Whether ``tp`` is a Pydantic ``BaseModel`` subclass (duck-typed).""" return ( isinstance(tp, type) and hasattr(tp, "model_validate") and hasattr(tp, "model_fields") ) _RESPONSE_ADAPTER_CACHE: dict = {} def _response_type_adapter(tp): """A cached ``TypeAdapter`` for a generic response_model (list/union/etc.).""" try: return _RESPONSE_ADAPTER_CACHE[tp] except (KeyError, TypeError): pass from pydantic import TypeAdapter adapter = TypeAdapter(tp) try: _RESPONSE_ADAPTER_CACHE[tp] = adapter except TypeError: pass return adapter _REQUEST_TYPES = (Request, WebSocket) _HTTP_REQUEST_NAMES = frozenset({"req", "request"}) _WS_REQUEST_NAMES = frozenset({"ws", "websocket", "req", "request"}) _RESERVED_DEP_NAMES = frozenset( {"req", "request", "resp", "response", "ws", "websocket"} ) _AUTH_INJECTION_NAMES = frozenset({"auth", "principal", "user"}) _DEP_PARAMS_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() def _dep_param_specs(provider: Callable) -> tuple[tuple[str, Any], ...]: """Cached ``(name, annotation)`` specs for a provider's injectable params.""" key = getattr(provider, "__func__", provider) try: return _DEP_PARAMS_CACHE[key] except (KeyError, TypeError): pass try: params = inspect.signature(provider).parameters except (TypeError, ValueError): specs: tuple[tuple[str, Any], ...] = () else: hints = _view_type_hints(provider) specs = tuple( (n, hints.get(n)) for n, p in params.items() if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD) ) try: _DEP_PARAMS_CACHE[key] = specs except TypeError: pass return specs _DEPENDS_PARAMS_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() def _depends_params(view: Callable) -> dict[str, _Depends]: """Cached ``{param_name: Depends(...)}`` for a view/provider's inline ``Depends`` defaults. Signature inspection is comparatively expensive and runs on the per-request path.""" key = getattr(view, "__func__", view) try: return _DEPENDS_PARAMS_CACHE[key] except (KeyError, TypeError): pass try: params = inspect.signature(view).parameters except (TypeError, ValueError): result: dict[str, _Depends] = {} else: result = { name: param.default for name, param in params.items() if isinstance(param.default, _Depends) } try: _DEPENDS_PARAMS_CACHE[key] = result except TypeError: pass return result _BODY_MODEL_CANDIDATES_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() def _body_model_candidates(endpoint: Callable) -> tuple[tuple[str, Any], ...]: """Cached ``(name, model)`` pairs for an endpoint's Pydantic-model body parameters that have no default. Works for function views and (bound) CBV methods alike — the cache keys on ``__func__``, so per-request bound methods memoize correctly. Marker-driven parameters (e.g. an ``Annotated[Model, Form()]`` form model) are excluded; they are resolved by the marker pipeline instead. The per-request path/dependency/auth filtering is applied by the caller; only the signature/hint inspection is memoized here (it runs on every write request otherwise).""" key = getattr(endpoint, "__func__", endpoint) try: return _BODY_MODEL_CANDIDATES_CACHE[key] except (KeyError, TypeError): pass from .params import marker_params hints = _view_type_hints(endpoint) marker_names = {spec.name for spec in marker_params(endpoint, hints)} try: sig_params: Any = inspect.signature(endpoint).parameters except (TypeError, ValueError): sig_params = {} candidates = tuple( (name, hints[name]) for name in _view_param_names(endpoint) if _is_pydantic_model(hints.get(name)) and name not in marker_names and ( name not in sig_params or sig_params[name].default is inspect.Parameter.empty ) ) try: _BODY_MODEL_CANDIDATES_CACHE[key] = candidates except TypeError: pass return candidates def _is_wsgi_app(app: Any) -> bool: """Whether ``app`` looks like a WSGI (rather than ASGI) application. ASGI apps are coroutine functions, callables whose ``__call__`` is a coroutine, or expose ``__asgi_app__``; anything else with a two-positional call signature (``environ, start_response``) is treated as WSGI. Deciding this from the signature — instead of from the text of a ``TypeError`` raised while calling the app — means a genuine ``TypeError`` inside a mounted ASGI sub-app keeps its real traceback rather than being misclassified as WSGI. """ if hasattr(app, "__asgi_app__") or not callable(app): return False # ASGI if the app (a function) or its bound ``__call__`` (an instance) is a # coroutine. ``inspect.signature`` resolves an instance to its ``__call__`` # signature (self excluded), so a two-positional signature => WSGI. if inspect.iscoroutinefunction(app): return False if not inspect.isroutine(app) and inspect.iscoroutinefunction(app.__call__): return False try: positional = [ p for p in inspect.signature(app).parameters.values() if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) ] except (TypeError, ValueError): return False return len(positional) == 2 _ASGI_STYLE_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() def _is_asgi_style(fn: Callable) -> bool: """Whether ``fn`` has an ASGI call signature (``scope, receive, send``). Used to decide how a custom default endpoint is dispatched: ASGI-style callables (like the built-in :meth:`Router.default_response`) are invoked directly, while Responder ``(req, resp)`` views are dispatched through normal :class:`Route` semantics. Cached per underlying function. """ if inspect.isclass(fn): return False key = getattr(fn, "__func__", fn) try: return _ASGI_STYLE_CACHE[key] except (KeyError, TypeError): pass if not _is_async(fn): result = False # ASGI apps are always awaitable; sync => a view. else: try: params = inspect.signature(fn).parameters.values() except (TypeError, ValueError): result = True # Uninspectable async callable: assume ASGI. else: positional = [ p for p in params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) ] result = ( any(p.kind == p.VAR_POSITIONAL for p in params) or len(positional) == 3 ) try: _ASGI_STYLE_CACHE[key] = result except TypeError: pass return result def _quote_url_params( params: dict[str, Any], convertor_names: dict[str, str] ) -> dict[str, str]: """URL-quote path-parameter values for URL building (``Route.url``). ``{param:path}`` segments keep ``/`` unescaped; every other parameter value is fully percent-encoded (spaces become ``%20``, slashes ``%2F``, etc.). Note that ASGI servers percent-decode the path before route matching, so a non-path parameter value containing ``/`` cannot produce a matchable URL: the ``%2F`` decodes back to ``/`` and the default ``[^/]+`` segment pattern never matches it. Values without ``/`` (e.g. containing spaces) round-trip fine. Use a ``{param:path}`` convertor when values may contain slashes. """ return { name: urllib.parse.quote( str(value), safe="/" if convertor_names.get(name) == "path" else "" ) for name, value in params.items() } def _accepts_arg_count(view: Callable, count: int) -> bool: try: params = inspect.signature(view).parameters.values() except (TypeError, ValueError): return True positional = [ p for p in params if p.kind in ( p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD, ) ] return any(p.kind == p.VAR_POSITIONAL for p in params) or len(positional) >= count def _is_request_param(name: str, annotation: Any, names: frozenset[str]) -> bool: """Whether a provider parameter should receive the request/websocket.""" if name in names: return True return isinstance(annotation, type) and issubclass(annotation, _REQUEST_TYPES) _STRINGY_PATH_CONVERTORS = frozenset({"str", "path", "uuid"}) _PATH_PARAM_ADAPTERS_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() def _path_param_adapters(view: Callable) -> dict[str, Any]: """Pydantic adapters for bare, same-name path params on ``view``. Explicit ``Path(...)`` markers are excluded here; they are resolved by the marker pipeline so aliases, metadata, and constraints stay centralized. """ key = getattr(view, "__func__", view) try: return _PATH_PARAM_ADAPTERS_CACHE[key] except (KeyError, TypeError): pass try: from pydantic import TypeAdapter except ImportError: # pragma: no cover - pydantic is a core dep return {} from .params import marker_params hints = _view_type_hints(view) explicit_path_params = { spec.name for spec in marker_params(view, hints) if spec.location == "path" } adapters: dict[str, Any] = {} parameters: Any try: parameters = inspect.signature(view).parameters except (TypeError, ValueError): parameters = {} for param in parameters.values(): if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): continue if param.name in _RESERVED_DEP_NAMES or param.name in explicit_path_params: continue annotation = hints.get(param.name) if annotation is None: continue try: adapter = TypeAdapter(annotation) except Exception: adapter = None if adapter is not None: adapters[param.name] = adapter try: _PATH_PARAM_ADAPTERS_CACHE[key] = adapters except TypeError: pass return adapters def _coerce_typed_path_params( view: Callable, path_params: dict[str, Any], convertor_names: dict[str, str] ) -> dict[str, Any]: """Validate/coerce bare same-name path params from handler annotations. This only applies to string-like route segments (plain ``{id}``, ``{id:path}``, ``{id:uuid}``) so explicit route convertors such as ``{id:int}`` keep their existing runtime behavior unless the user opts into ``Path(...)`` markers. """ adapters = _path_param_adapters(view) if not adapters: return {} values: dict[str, Any] = {} errors: list[dict] = [] for name, raw in path_params.items(): if convertor_names.get(name) not in _STRINGY_PATH_CONVERTORS: continue adapter = adapters.get(name) if adapter is None: continue try: values[name] = adapter.validate_python(raw) except Exception as exc: if hasattr(exc, "errors"): for err in exc.errors(): err = dict(err) err["loc"] = ["path", name] errors.append(err) else: errors.append({"loc": ["path", name], "msg": str(exc)}) if errors: raise _MarkerValidationError(errors) return values class _MarkerValidationError(Exception): """Carries aggregated 422 errors from Query/Header/Cookie/Path markers.""" def __init__(self, errors): self.errors = errors async def _get_form(request): """Parse the request's form/multipart body once (spooling large uploads to disk via Starlette). Delegates to the shared Request helper.""" return await request._parsed_form() def _form_value(form, spec): """Pull a Form()/File() marker's raw value from parsed form data.""" if spec.location == "file": files = [v for v in form.getlist(spec.lookup) if not isinstance(v, str)] if spec.is_sequence: return files if files else ... return files[0] if files else ... if spec.is_sequence: values = [v for v in form.getlist(spec.lookup) if isinstance(v, str)] return values if values else ... value = form.get(spec.lookup) return value if isinstance(value, str) else ... def _form_model_data(form: Any, model: Any) -> dict[str, Any]: """Collect a Pydantic form model's raw field values from parsed form data. Text fields come through as strings (coerced by the model); fields whose submitted values are uploaded files receive the ``UploadFile`` objects directly. Sequence-typed fields collect every value sent under the field name; scalar fields take the last one. Fields absent from the form are omitted, so model defaults apply and missing required fields surface as Pydantic ``missing`` errors. """ from .params import _is_sequence data: dict[str, Any] = {} for name, field in model.model_fields.items(): key = getattr(field, "alias", None) or name raw = form.getlist(key) if not raw: continue files = [v for v in raw if not isinstance(v, str)] chosen: list[Any] = files if files else list(raw) data[key] = chosen if _is_sequence(field.annotation) else chosen[-1] return data def _validate_form_model( spec: Any, form: Any, values: dict[str, Any], errors: list[dict] ) -> None: """Bind an entire parsed form onto a Pydantic-model ``Form()`` parameter. Field-level failures are appended to ``errors`` with ``["form", field]`` locations, matching the shape JSON body-model validation reports. """ data = _form_model_data(form, spec.annotation) if form is not None else {} if not data and not spec.required: values[spec.name] = spec.marker.default return try: values[spec.name] = spec.annotation.model_validate(data) except Exception as exc: if hasattr(exc, "errors"): for err in exc.errors(): err = dict(err) err["loc"] = ["form", *err.get("loc", ())] errors.append(err) else: errors.append({"loc": ["form", spec.lookup], "msg": str(exc)}) async def _resolve_markers( view: Callable, request: Request | WebSocket, path_params: dict[str, Any] ) -> tuple[dict, set]: """Validate a view's Query/Header/Cookie/Path/Form/File markers into kwargs. Returns ``({param: value}, drop_keys)`` where ``drop_keys`` are path-param names a renamed ``Path`` marker consumed (so the raw URL key doesn't leak as an unexpected kwarg); raises :class:`_MarkerValidationError` on any validation failure. Works for function views, CBV methods, and WebSocket handlers alike (a WebSocket has no body, so ``Form()``/``File()`` markers on a handler resolve as missing). """ from .params import marker_params, raw_value specs = marker_params(view, _view_type_hints(view)) if not specs: return {}, set() form = None if any(s.location in ("form", "file") for s in specs) and hasattr( request, "_parsed_form" ): form = await _get_form(request) values: dict[str, Any] = {} drop: set = set() errors: list[dict] = [] for spec in specs: if spec.location == "path" and spec.lookup != spec.name: drop.add(spec.lookup) if spec.name in path_params and spec.location != "path": continue # path parameter wins over a marker of the same name if spec.location == "form" and _is_pydantic_model(spec.annotation): _validate_form_model(spec, form, values, errors) continue if spec.location in ("form", "file"): raw = _form_value(form, spec) if form is not None else ... else: raw = raw_value(spec, request, path_params) if raw is ...: if spec.required: errors.append( { "loc": [spec.location, spec.lookup], "msg": "field required", "type": "missing", } ) else: values[spec.name] = spec.marker.default continue if spec.adapter is None: values[spec.name] = raw continue try: values[spec.name] = spec.adapter.validate_python(raw) except Exception as exc: if hasattr(exc, "errors"): for err in exc.errors(): err = dict(err) err["loc"] = [spec.location, spec.lookup] errors.append(err) else: errors.append( {"loc": [spec.location, spec.lookup], "msg": str(exc)} ) if errors: raise _MarkerValidationError(errors) return values, drop _ASYNC_CACHE: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() def _is_async(fn: Callable) -> bool: """Whether ``fn`` (or its ``__call__``) is a coroutine function, cached. A view/hook's async-ness is fixed at definition time, so this memoizes the (comparatively costly) ``inspect`` check off the per-request hot path. """ key = getattr(fn, "__func__", fn) try: return _ASYNC_CACHE[key] except (KeyError, TypeError): pass result = inspect.iscoroutinefunction(fn) or inspect.iscoroutinefunction( getattr(fn, "__call__", None) # noqa: B004 - inspecting __call__, not testing callability ) try: _ASYNC_CACHE[key] = result except TypeError: pass return result async def _invoke_provider( provider: Callable, kwargs: dict ) -> tuple[Any, Callable | None]: """Call a provider with pre-resolved kwargs, returning ``(value, teardown)``. Providers may be sync/async functions or sync/async generators (code after ``yield`` runs as teardown), including callable instances whose ``__call__`` is a generator. Sub-dependencies and the request are passed in via ``kwargs`` by the resolver. """ # For a callable instance, the generator-ness lives on __call__, not the # object itself; inspect that (calling provider(**kwargs) still dispatches # to __call__). For a plain function/method, inspect it directly. target = provider if not (inspect.isfunction(provider) or inspect.ismethod(provider)): target = getattr(provider, "__call__", provider) # noqa: B004 - inspecting __call__, not testing callability if inspect.isasyncgenfunction(target): agen = provider(**kwargs) value = await agen.__anext__() async def teardown_async(): try: await agen.__anext__() except StopAsyncIteration: pass return value, teardown_async if inspect.isgeneratorfunction(target): gen = provider(**kwargs) value = await run_in_threadpool(next, gen) async def teardown_sync(): await run_in_threadpool(lambda: next(gen, None)) return value, teardown_sync # ``_is_async`` (unlike ``iscoroutinefunction``) also detects a callable # instance whose ``__call__`` is async — e.g. an auth scheme used as a # dependency — so those are awaited rather than run in a thread. if _is_async(provider): return await provider(**kwargs), None return await run_in_threadpool(provider, **kwargs), None class _RequestResolver: """Resolves a request's dependency graph: recursive sub-dependencies, whole-graph memoization, cycle detection, and reverse-topological teardown. """ __slots__ = ( "registry", "app_deps", "request", "req_names", "override_names", "cache", "provider_cache", "teardowns", "stack", ) def __init__( self, registry, app_deps, request, req_names, override_names=frozenset() ): self.registry = registry self.app_deps = app_deps self.request = request self.req_names = req_names self.override_names = override_names self.cache: dict[str, Any] = {} self.provider_cache: dict[Any, Any] = {} self.teardowns: list[Callable] = [] # Resolution stack of (identity, label) pairs. Cycle detection uses # the identity (registry name or provider object id) — never the bare # ``__name__``, which two distinct providers may share — while labels # keep error messages readable. self.stack: list[tuple[Any, str]] = [] def _check_cycle(self, key: Any, label: str) -> None: keys = [k for k, _ in self.stack] if key in keys: path = [lbl for _, lbl in self.stack[keys.index(key) :]] + [label] raise DependencyCycleError("Dependency cycle: " + " -> ".join(path)) def _chain(self) -> str: return " -> ".join(label for _, label in self.stack) def _depends_on_override(self, name, seen=None): """Whether app-dep ``name`` transitively depends on an overridden dep. Such an app-dep must be resolved request-scoped (not served from — or written to — the app cache), so a ``dependency_overrides`` block reaches deep into the app-scoped graph and restores cleanly afterward. """ if not self.override_names: return False if seen is None: seen = set() if name in seen: return False seen.add(name) if name in self.override_names: return True provider, _scope = self.registry[name] for pname, _ann in _dep_param_specs(provider): if pname in self.registry and self._depends_on_override(pname, seen): return True return False async def resolve(self, name): if name in self.cache: # whole-graph memo return self.cache[name] provider, scope = self.registry[name] if scope == "app" and not self._depends_on_override(name): value = await self.app_deps.resolve(name, self.registry) self.cache[name] = value return value key = ("dep", name) self._check_cycle(key, name) self.stack.append((key, name)) try: kwargs: dict[str, Any] = {} specs = _dep_param_specs(provider) depends = _depends_params(provider) for pname, ann in specs: if _is_request_param(pname, ann, self.req_names): kwargs[pname] = self.request elif pname in depends: kwargs[pname] = await self.resolve_provider(depends[pname].provider) elif pname in self.registry: kwargs[pname] = await self.resolve(pname) else: chain = self._chain() raise DependencyResolutionError( f"Parameter {pname!r} of dependency {name!r} is neither the " f"request (name it 'req' / annotate 'Request'), a registered " f"dependency, nor an inline Depends(...). " f"Dependency chain: {chain}." ) finally: self.stack.pop() value, teardown = await _invoke_provider(provider, kwargs) self.cache[name] = value if teardown is not None: self.teardowns.append(teardown) return value async def resolve_provider(self, provider: Callable) -> Any: key = provider try: return self.provider_cache[key] except (KeyError, TypeError): pass label = getattr(provider, "__name__", provider.__class__.__name__) ident = ("provider", id(provider)) self._check_cycle(ident, label) self.stack.append((ident, label)) try: kwargs: dict[str, Any] = {} depends = _depends_params(provider) for pname, ann in _dep_param_specs(provider): if _is_request_param(pname, ann, self.req_names): kwargs[pname] = self.request elif pname in depends: kwargs[pname] = await self.resolve_provider(depends[pname].provider) elif pname in self.registry: kwargs[pname] = await self.resolve(pname) else: chain = self._chain() raise DependencyResolutionError( f"Parameter {pname!r} of dependency provider {label!r} " "is neither the request, a registered dependency, nor an " f"inline Depends(...). Dependency chain: {chain}." ) finally: self.stack.pop() value, teardown = await _invoke_provider(provider, kwargs) try: self.provider_cache[key] = value except TypeError: pass if teardown is not None: self.teardowns.append(teardown) return value async def teardown(self): for td in reversed(self.teardowns): try: await td() except Exception: logger.exception("Dependency teardown failed") def _accepts_json(scope: Scope) -> bool: """Whether the request's Accept header asks for JSON.""" for key, value in scope.get("headers", []): if key == b"accept": return b"json" in value return False def _validation_errors(exc: Any) -> list[dict] | None: """Extract structured validation errors from an exception if available.""" error_values = getattr(exc, "errors", None) if error_values is None: return None if callable(error_values): try: error_values = error_values() except TypeError: return None if error_values is None: return None if isinstance(error_values, tuple): return list(error_values) if isinstance(error_values, list): return error_values return [{"msg": str(error_values)}] def _error_payload(scope, status_code, detail=None, *, title=None, errors=None): if scope.get("problem_details"): return problem_payload_for( scope, status_code, detail, title=title, errors=errors ) return legacy_error_payload(status_code, detail, title=title, errors=errors) def _trace(scope: Scope, stage: str, **values: Any) -> None: if not scope.get("trace_dispatch"): return route = scope.get("route_pattern") or scope.get("path") bits = " ".join(f"{key}={value!r}" for key, value in values.items()) logger.debug("dispatch.%s %s %s", stage, route, bits) def _callable_label(fn: Callable) -> str: return getattr(fn, "__name__", fn.__class__.__name__) def _fresh_child_scope(child_scope: dict) -> dict: return {k: dict(v) if isinstance(v, dict) else v for k, v in child_scope.items()} class BaseRoute: route: str endpoint: Callable #: Per-route CSRF override, snapshotted at registration; ``None`` inherits #: the app-wide ``API(csrf=...)`` default. _csrf: bool | None = None def url(self, **params: Any) -> str: raise NotImplementedError() def matches(self, scope: Scope) -> tuple[bool, dict]: raise NotImplementedError() async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: raise NotImplementedError() async def _dispatch_hook(self, hook: Callable, *args: Any) -> None: """Invoke a hook, awaiting coroutines and offloading sync callables. Shared by the HTTP and websocket before/after hook runners; each caller layers its own short-circuit and error-handling policy around it. """ if _is_async(hook): await hook(*args) else: await run_in_threadpool(hook, *args) async def _route_auth_injections( self, request: Request | WebSocket ) -> dict[str, Any]: route_auth = getattr(self.endpoint, "_route_auth", ()) if not route_auth: return {} principals = [] for auth in route_auth: if hasattr(auth, "authenticate"): principal = await auth.authenticate(request) elif _is_async(auth): principal = await auth(request) else: principal = await run_in_threadpool(auth, request) if principal is not None or getattr(auth, "optional_auth", False): principals.append(principal) value = principals[0] if len(principals) == 1 else principals request.state.auth = value request.state.user = value return {"auth": value, "principal": value, "user": value} async def _run_route_dependencies(self, resolver: _RequestResolver) -> None: for dependency in getattr(self.endpoint, "_route_dependencies", ()): await resolver.resolve_provider(dependency.provider) class Route(BaseRoute): """An HTTP route that maps a URL pattern to an endpoint. Supports path parameters with type convertors (``{id:int}``, ``{slug:str}``, ``{pk:uuid}``, ``{value:float}``, ``{rest:path}``). """ def __init__( self, route: str, endpoint: Callable, *, before_request: bool = False, methods: list[str] | None = None, name: str | None = None, ) -> None: if not route.startswith("/"): raise ValueError(f"Route path must start with '/', got {route!r}.") self.route = route self.endpoint = endpoint self.before_request = before_request self.name = name self.methods: set[str] | None = {m.upper() for m in methods} if methods else None self.path_re: re.Pattern self.param_convertors: dict[str, type] self.param_convertor_names: dict[str, str] ( self.path_re, self.param_convertors, self.param_convertor_names, ) = compile_path(route) # Strip type annotations for URL generation (e.g. {id:int} -> {id}) self._url_template = PARAM_RE.sub(r"{\1}", route) def __repr__(self) -> str: return f"<Route {self.route!r}={self.endpoint!r}>" def url(self, **params: Any) -> str: """The route's URL with ``params`` substituted (values URL-quoted; ``{param:path}`` segments keep their slashes).""" return self._url_template.format( **_quote_url_params(params, self.param_convertor_names) ) @property def path_template(self) -> str: """The route with convertor annotations stripped (``/users/{id}``).""" return self._url_template @property def endpoint_name(self) -> str: return self.endpoint.__name__ @property def description(self) -> str | None: return self.endpoint.__doc__ def matches(self, scope: Scope) -> tuple[bool, dict]: if scope["type"] != "http": return False, {} if self.methods: method = scope.get("method", "").upper() # HEAD is implicitly supported wherever GET is. if method not in self.methods and not ( method == "HEAD" and "GET" in self.methods ): return False, {} path = scope["path"] match = self.path_re.match(path) if match is None: return False, {} matched_params = match.groupdict() for key, value in matched_params.items(): matched_params[key] = self.param_convertors[key](value) return True, {"path_params": {**matched_params}} def _exchange(self, scope: Scope, receive: Receive) -> tuple[Request, Response]: formats = scope.get("formats") or get_formats() request = Request(scope, receive, api=scope.get("api"), formats=formats) response = Response( req=request, formats=formats, auto_etag=scope.get("auto_etag", False), auto_vary=scope.get("auto_vary", False), ) return request, response def _problem_content_type(self, scope: Scope, response: Response) -> None: if scope.get("problem_details"): response.mimetype = PROBLEM_JSON response.headers["Content-Type"] = PROBLEM_JSON def _set_error_response( self, scope: Scope, response: Response, status_code: int, detail: str | None = None, *, title: str | None = None, errors: list[dict] | None = None, exc: Exception | None = None, ) -> None: response.reset_for_error() if scope.get("problem_details"): response.content = problem_bytes_for( scope, status_code, detail, title=title, errors=errors, request=response.req, exc=exc, ) self._problem_content_type(scope, response) else: response.media = _error_payload( scope, status_code, detail, title=title, errors=errors ) async def _send_validation_error( self, scope: Scope, receive: Receive, send: Send, response: Response, exc: Exception, ) -> None: response.status_code = 422 errors = _validation_errors(exc) if errors is None: errors = [{"msg": str(exc)}] self._set_error_response( scope, response, 422, "Validation failed", title="Validation Error", errors=errors, exc=exc, ) await response(scope, receive, send) async def _run_hooks( self, hooks: Iterable[Callable], request: Request, response: Response ) -> bool: for hook in hooks: _trace(request._starlette.scope, "before_hook", hook=_callable_label(hook)) args = (request, response) if _accepts_arg_count(hook, 2) else (request,) await self._dispatch_hook(hook, *args) if response.status_code is not None: return False return True async def _validate_params_model(self, request: Request) -> None: params_model = getattr(self.endpoint, "_params_model", None) if params_model is None: return data = {} for key in request.params: values = request.params.get_list(key) data[key] = values if len(values) > 1 else values[-1] request.state.validated_params = params_model(**data) async def _body_injections( self, scope: Scope, request: Request, path_params: dict, views: list[Callable], ) -> list[dict[str, Any]]: """Per-view Pydantic body-model injections, parallel to ``views``. The body is read and parsed at most once per request, and each ``(name, model)`` pair is validated once — so a class-based view whose ``on_request`` and ``on_post`` both declare the same model share a single validated instance. """ injections: list[dict[str, Any]] = [{} for _ in views] if request.method not in ("POST", "PUT", "PATCH", "DELETE"): return injections dep_names = scope.get("dependencies") or {} auth_names = ( _AUTH_INJECTION_NAMES if getattr(self.endpoint, "_route_auth", ()) else frozenset() ) body: Any = None parsed = False validated: dict[tuple[str, Any], Any] = {} for index, view in enumerate(views): model_params = [ (name, model) for name, model in _body_model_candidates(view) if name not in path_params and name not in dep_names and name not in auth_names ] if not model_params: continue if not parsed: body = await request.media() parsed = True if not isinstance(body, dict): raise TypeError("Request body must be a JSON object") view_values: dict[str, Any] = {} for name, model in model_params: key = (name, model) if key not in validated: validated[key] = model.model_validate(body) view_values[name] = validated[key] injections[index] = view_values return injections async def _validate_inputs( self, scope: Scope, receive: Receive, send: Send, request: Request, response: Response, path_params: dict, views: list[Callable], ) -> tuple[bool, list[dict[str, Any]]]: try: await self._validate_params_model(request) injections = await self._body_injections( scope, request, path_params, views ) except HTTPException: raise except Exception as exc: await self._send_validation_error(scope, receive, send, response, exc) return False, [] return True, injections def _views_for(self, request: Request) -> list[Callable]: if not inspect.isclass(self.endpoint): return [self.endpoint] endpoint = self.endpoint() views = [] on_request = getattr(endpoint, "on_request", None) if on_request: views.append(on_request) method_name = f"on_{request.method.lower()}" view = getattr(endpoint, method_name, None) if view is None and request.method.upper() == "HEAD": # HEAD is implicitly served by on_get, mirroring function views # (the response side already strips the body for HEAD). view = getattr(endpoint, "on_get", None) if view is not None: views.append(view) elif on_request is None: allow = ", ".join(sorted(_class_view_methods(endpoint))) if request.method.upper() == "OPTIONS": # No on_options handler: answer OPTIONS automatically with # 200 + Allow, mirroring the router-level treatment of # method-restricted function routes. return [_auto_options_view(allow)] # RFC 9110 §15.5.6: a 405 must list the methods the target # supports, mirroring the router-level function-route 405. raise HTTPException( status_code=status_codes.HTTP_405, headers={"Allow": allow} ) return views async def _view_kwargs( self, view: Callable, request: Request, resolver: _RequestResolver, path_params: dict, injected: dict[str, Any], auth_injected: dict[str, Any], ) -> dict[str, Any]: kwargs = dict(path_params) kwargs.update( _coerce_typed_path_params(view, path_params, self.param_convertor_names) ) if injected: kwargs.update(injected) marker_values, drop_keys = await _resolve_markers(view, request, path_params) for key in drop_keys: kwargs.pop(key, None) kwargs.update(marker_values) depends_params = _depends_params(view) dependencies = resolver.registry for name in _view_param_names(view): if name in kwargs: continue if name in depends_params: kwargs[name] = await resolver.resolve_provider( depends_params[name].provider ) elif name in dependencies: kwargs[name] = await resolver.resolve(name) elif name in auth_injected: kwargs[name] = auth_injected[name] return kwargs async def _invoke_view( self, view: Callable, request: Request, response: Response, kwargs: dict ) -> Any: if _is_async(view): return await view(request, response, **kwargs) return await run_in_threadpool(view, request, response, **kwargs) def _apply_result(self, response: Response, result: Any) -> None: if result is None: return if isinstance(result, tuple): body, *rest = result if rest: response.status_code = rest[0] if len(rest) > 1 and rest[1]: response.headers.update(rest[1]) result = body if isinstance(result, (dict, list)): response.media = result elif isinstance(result, str): response.text = result elif isinstance(result, bytes): response.content = result elif hasattr(result, "model_dump") or ( dataclasses.is_dataclass(result) and not isinstance(result, type) ): response.media = result async def _run_views( self, views: list[Callable], request: Request, response: Response, resolver: _RequestResolver, path_params: dict, injections: list[dict[str, Any]], auth_injected: dict[str, Any], ) -> None: for view, injected in zip(views, injections, strict=True): _trace(request._starlette.scope, "handler", view=_callable_label(view)) kwargs = await self._view_kwargs( view, request, resolver, path_params, injected, auth_injected ) result = await self._invoke_view(view, request, response, kwargs) self._apply_result(response, result) def _response_model(self) -> tuple[Any, bool]: resp_model = getattr(self.endpoint, "_response_model", None) explicit_model = resp_model is not None if resp_model is None and not inspect.isclass(self.endpoint): return_hint = _view_type_hints(self.endpoint).get("return") if _is_pydantic_model(return_hint): resp_model = return_hint return resp_model, explicit_model def _response_model_failure( self, scope: Scope, response: Response, exc: Exception | None = None ) -> None: response.status_code = 500 self._set_error_response( scope, response, 500, INTERNAL_SERVER_ERROR, errors=_validation_errors(exc) if exc is not None else None, exc=exc, ) def _validate_response_model(self, scope: Scope, response: Response) -> None: resp_model, explicit_model = self._response_model() if ( resp_model is not None and _is_pydantic_model(resp_model) and ( isinstance(response.media, dict) or hasattr(response.media, "model_dump") ) ): try: response.media = resp_model.model_validate( response.media ).model_dump(mode="json") except Exception as exc: logger.exception("response_model validation failed") if getattr(scope.get("api"), "debug", False): raise self._response_model_failure(scope, response, exc) elif ( explicit_model and not _is_pydantic_model(resp_model) and response.media is not None ): try: adapter = _response_type_adapter(resp_model) response.media = adapter.dump_python( adapter.validate_python(response.media), mode="json" ) except Exception as exc: logger.exception("response_model validation failed") if getattr(scope.get("api"), "debug", False): raise self._response_model_failure(scope, response, exc) async def _send_timeout_response( self, scope: Scope, receive: Receive, send: Send, response: Response ) -> None: """Send a 504 on ``response`` — a fresh object carrying only the metadata snapshotted from the abandoned response (hook-set headers, cookies). Content is built directly rather than via ``_set_error_response``: its ``reset_for_error()`` would wipe that carried-over metadata, and there is no stale body here to reset. """ response.status_code = 504 if scope.get("problem_details"): response.content = problem_bytes_for( scope, 504, "Request timed out", request=response.req ) self._problem_content_type(scope, response) elif _accepts_json(scope): response.media = _error_payload(scope, 504, "Request timed out") else: response.text = "Request timed out" await response(scope, receive, send) async def _run_after_hooks( self, scope: Scope, request: Request, response: Response ) -> None: route_after = getattr(self.endpoint, "_route_after", ()) after_requests = scope.get("after_requests", []) for hook in (*route_after, *after_requests): _trace(scope, "after_hook", hook=_callable_label(hook)) args = (request, response) if _accepts_arg_count(hook, 2) else (request,) try: await self._dispatch_hook(hook, *args) except Exception as exc: logger.exception("after_request hook failed") if getattr(scope.get("api"), "debug", False): raise response.status_code = 500 self._set_error_response( scope, response, 500, INTERNAL_SERVER_ERROR, exc=exc ) return async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: request, response = self._exchange(scope, receive) path_params = scope.get("path_params", {}) before_requests = scope.get("before_requests", {"http": [], "ws": []}) route_before = getattr(self.endpoint, "_route_before", ()) if not await self._run_hooks( (*before_requests.get("http", []), *route_before), request, response ): await response(scope, receive, send) return # Seed the route's declared default success status (route(status_code=…)) # after the hooks ran — a hook that sets resp.status_code skips the # handler, and an explicit assignment in the view still wins. default_status = getattr(self.endpoint, "_default_status_code", None) if default_status is not None: response.status_code = default_status route_csrf = getattr(self, "_csrf", None) if scope.get("csrf_enabled", False) if route_csrf is None else route_csrf: _trace(scope, "csrf") from .csrf import enforce_csrf await enforce_csrf(request) _trace(scope, "auth") auth_injected = await self._route_auth_injections(request) views = self._views_for(request) ok, injections = await self._validate_inputs( scope, receive, send, request, response, path_params, views ) if not ok: return dependencies = scope.get("dependencies") or {} resolver = _RequestResolver( dependencies, scope.get("app_dependencies"), request, _HTTP_REQUEST_NAMES, scope.get("dependency_override_names", frozenset()), ) try: try: _trace(scope, "dependencies") await self._run_route_dependencies(resolver) run = self._run_views( views, request, response, resolver, path_params, injections, auth_injected, ) timeout = scope.get("request_timeout") if timeout: await asyncio.wait_for(run, timeout) else: await run except asyncio.TimeoutError: # An abandoned sync view (stuck in the threadpool — it cannot # be cancelled) may keep mutating ``response`` after the # timeout fires; build the 504 on a fresh Response so its late # writes can't corrupt what we send. Headers and cookies that # were legitimately set before the view hung (before_request # hooks finish before the view starts) still belong on the # 504, so snapshot them onto the fresh object. timeout_response = self._exchange(scope, receive)[1] _copy_response_metadata(response, timeout_response) await self._send_timeout_response( scope, receive, send, timeout_response ) return except _MarkerValidationError as exc: await self._send_validation_error(scope, receive, send, response, exc) return self._validate_response_model(scope, response) await self._run_after_hooks(scope, request, response) if response.status_code is None: response.status_code = status_codes.HTTP_200 elif ( default_status == 204 and response.status_code == 204 and response.content is None and response.media is None and response._stream is None ): # A route declared status_code=204: an untouched body must go # out empty, not as the JSON ``null`` default body. response.content = b"" response.headers.pop("Content-Type", None) await response(scope, receive, send) finally: await resolver.teardown() def __eq__(self, other: object) -> bool: if not isinstance(other, Route): return NotImplemented return self.route == other.route and self.endpoint == other.endpoint def __hash__(self) -> int: # Mirror __eq__ (route + endpoint) so equal routes hash equal. return hash(self.route) ^ hash(self.endpoint) class WebSocketRoute(BaseRoute): """A WebSocket route that maps a URL pattern to a WebSocket handler.""" def __init__( self, route: str, endpoint: Callable, *, before_request: bool = False, name: str | None = None, ) -> None: if not route.startswith("/"): raise ValueError(f"Route path must start with '/', got {route!r}.") self.route = route self.endpoint = endpoint self.before_request = before_request self.name = name self.path_re: re.Pattern self.param_convertors: dict[str, type] self.param_convertor_names: dict[str, str] ( self.path_re, self.param_convertors, self.param_convertor_names, ) = compile_path(route) self._url_template = PARAM_RE.sub(r"{\1}", route) def __repr__(self) -> str: return f"<Route {self.route!r}={self.endpoint!r}>" def url(self, **params: Any) -> str: """The route's URL with ``params`` substituted (values URL-quoted; ``{param:path}`` segments keep their slashes).""" return self._url_template.format( **_quote_url_params(params, self.param_convertor_names) ) @property def path_template(self) -> str: """The route with convertor annotations stripped (``/ws/{room}``).""" return self._url_template @property def endpoint_name(self) -> str: return self.endpoint.__name__ @property def description(self) -> str | None: return self.endpoint.__doc__ def matches(self, scope: Scope) -> tuple[bool, dict]: if scope["type"] != "websocket": return False, {} path = scope["path"] match = self.path_re.match(path) if match is None: return False, {} matched_params = match.groupdict() for key, value in matched_params.items(): matched_params[key] = self.param_convertors[key](value) return True, {"path_params": {**matched_params}} async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ws = WebSocket(scope, receive, send) idle_timeout = scope.get("ws_idle_timeout") if idle_timeout is not None: self._apply_idle_timeout(ws, idle_timeout) before_requests = scope.get("before_requests", {"http": [], "ws": []}) route_before = getattr(self.endpoint, "_route_before", ()) route_after = getattr(self.endpoint, "_route_after", ()) resolver = None try: if not await self._run_before_hooks( (*before_requests.get("ws", []), *route_before), ws ): return auth_injected = await self._route_auth_injections(ws) if WebSocketState.DISCONNECTED in (ws.client_state, ws.application_state): return path_params = scope.get("path_params", {}) dependencies = scope.get("dependencies") or {} app_deps = scope.get("app_dependencies") override_names = scope.get("dependency_override_names", frozenset()) resolver = _RequestResolver( dependencies, app_deps, ws, _WS_REQUEST_NAMES, override_names ) await self._run_route_dependencies(resolver) param_names = tuple(_view_param_names(self.endpoint, skip=1)) kwargs = { name: path_params[name] for name in param_names if name in path_params } kwargs.update( { name: value for name, value in _coerce_typed_path_params( self.endpoint, path_params, self.param_convertor_names ).items() if name in param_names } ) # Query()/Header()/Cookie()/Path() markers resolve from the # WebSocket handshake (query string, headers, cookies) exactly as # they do for HTTP views; a validation failure raises # _MarkerValidationError, closing the socket with 1008 below. marker_values, drop_keys = await _resolve_markers( self.endpoint, ws, path_params ) for key in drop_keys: kwargs.pop(key, None) kwargs.update(marker_values) depends_params = _depends_params(self.endpoint) for name in param_names: if name in kwargs: continue if name in depends_params: kwargs[name] = await resolver.resolve_provider( depends_params[name].provider ) elif name in dependencies: kwargs[name] = await resolver.resolve(name) elif name in auth_injected: kwargs[name] = auth_injected[name] await self.endpoint(ws, **kwargs) await self._run_after_hooks(route_after, ws) except HTTPException: await self._close_if_connected(ws, code=1008) except _MarkerValidationError: await self._close_if_connected(ws, code=1008) except TimeoutError: # No inbound message arrived within ws_idle_timeout; close the # idle connection with 1001 (going away) instead of hanging. await self._close_if_connected(ws, code=1001) except WebSocketDisconnect: # The client went away mid-handler; nothing to close or report. raise except Exception: # An unhandled handler exception: close with 1011 (internal # error) so clients can tell a server bug from a network drop, # then re-raise for the server / test client to surface. logger.exception("Unhandled exception in WebSocket handler") await self._close_if_connected(ws, code=1011) raise finally: if resolver is not None: await resolver.teardown() @staticmethod def _apply_idle_timeout(ws: WebSocket, timeout: float) -> None: """Shadow ``ws.receive`` so each awaited receive must resolve within ``timeout`` seconds. The deadline resets on every message, so it bounds idle time between messages, not the total connection lifetime. On expiry ``asyncio.wait_for`` raises ``TimeoutError``, handled in ``__call__``. """ original_receive = ws.receive async def receive_with_timeout() -> Any: return await asyncio.wait_for(original_receive(), timeout) ws.receive = receive_with_timeout # type: ignore[method-assign] async def _run_before_hooks( self, hooks: Iterable[Callable], ws: WebSocket ) -> bool: for hook in hooks: await self._dispatch_hook(hook, ws) # If a hook closed the connection, short-circuit the endpoint. if WebSocketState.DISCONNECTED in (ws.client_state, ws.application_state): return False return True async def _run_after_hooks( self, hooks: Iterable[Callable], ws: WebSocket ) -> None: for hook in hooks: try: await self._dispatch_hook(hook, ws) except Exception: # A failing after-hook must not escape into the ASGI task and # crash the (often already-closed) websocket. Log it and move # on, re-raising only under debug for visibility. logger.exception("websocket after_request hook failed") if getattr(ws.scope.get("api"), "debug", False): raise async def _close_if_connected(self, ws: WebSocket, *, code: int) -> None: if WebSocketState.DISCONNECTED not in ( ws.client_state, ws.application_state, ): await ws.close(code=code) def __eq__(self, other: object) -> bool: if not isinstance(other, WebSocketRoute): return NotImplemented return self.route == other.route and self.endpoint == other.endpoint def __hash__(self) -> int: # Mirror __eq__ (route + endpoint) so equal routes hash equal. return hash(self.route) ^ hash(self.endpoint) class _AppDependencyState: """Holds app-scoped dependency values for the lifetime of the application.""" __slots__ = ("cache", "lock", "teardowns") def __init__(self) -> None: self.cache: dict[str, Any] = {} self.lock = asyncio.Lock() self.teardowns: list[Callable] = [] async def resolve(self, name: str, registry: dict[str, Any]) -> Any: if name in self.cache: return self.cache[name] async with self.lock: return await self._resolve_locked(name, registry, []) async def _resolve_locked( self, name: str, registry: dict[str, Any], stack: list[str] ) -> Any: # Runs with self.lock held; recurses via this method (never re-acquires # the non-reentrant lock), so app-dependency graphs can't deadlock. if name in self.cache: return self.cache[name] if name in stack: path = stack[stack.index(name) :] + [name] raise DependencyCycleError("App dependency cycle: " + " -> ".join(path)) provider, _scope = registry[name] stack.append(name) try: kwargs: dict[str, Any] = {} for pname, ann in _dep_param_specs(provider): if _is_request_param( pname, ann, _WS_REQUEST_NAMES | _HTTP_REQUEST_NAMES ): raise DependencyScopeError( f"App-scoped dependency {name!r} cannot receive the request." ) if pname not in registry: raise DependencyResolutionError( f"App-scoped dependency {name!r}: unknown parameter {pname!r}." ) if registry[pname][1] != "app": raise DependencyScopeError( f"App-scoped dependency {name!r} cannot depend on " f"request-scoped dependency {pname!r}." ) kwargs[pname] = await self._resolve_locked(pname, registry, stack) finally: stack.pop() value, teardown = await _invoke_provider(provider, kwargs) self.cache[name] = value if teardown is not None: self.teardowns.append(teardown) return value async def shutdown(self) -> None: try: while self.teardowns: teardown = self.teardowns.pop() try: await teardown() except Exception: logger.exception("App-scoped dependency teardown failed") finally: self.cache.clear() class Router: """The core router that dispatches incoming requests to matching routes. Handles route matching, before/after request hooks, lifespan events, and mounted sub-applications. """ def __init__( self, routes: list[BaseRoute] | None = None, default_response: Callable | None = None, before_requests: dict[str, list[Callable]] | None = None, lifespan: Callable | None = None, formats: dict[str, Callable] | None = None, redirect_slashes: bool = True, max_request_size: int | None = None, auto_etag: bool = False, auto_vary: bool = False, request_timeout: float | None = None, ws_idle_timeout: float | None = None, trace_dispatch: bool = False, problem_details: bool = True, csrf: bool = False, ) -> None: self.routes: list[BaseRoute] = [] if routes is None else list(routes) self.apps: dict[str, Union[ASGIApp, Any]] = {} self.default_endpoint: Callable = ( self.default_response if default_response is None else default_response ) self.before_requests: dict[str, list[Callable]] = ( {"http": [], "ws": []} if before_requests is None else before_requests ) self.after_requests: list[Callable] = [] self.events: defaultdict[str, list[Callable]] = defaultdict(list) self.dependencies: dict[str, tuple[Callable, str]] = {} # Test-time overrides: same registry shape, always request-scoped so # they take precedence over (and bypass the cache of) any real dep. self.dependency_overrides: dict[str, tuple[Callable, str]] = {} self.app_dependencies = _AppDependencyState() self.api: Any = None # Set by API.__init__; reaches views as req.api. self.redirect_slashes = redirect_slashes self.max_request_size = max_request_size self.auto_etag = auto_etag self.auto_vary = auto_vary self.request_timeout = request_timeout self.ws_idle_timeout = ws_idle_timeout self.trace_dispatch = trace_dispatch self.problem_details = problem_details self.csrf = csrf self._route_cache: dict[tuple[str, str], tuple[BaseRoute, dict]] = {} # Bumped whenever the route table changes; cheap invalidation key for # derived artifacts (e.g. the cached OpenAPI document). self._generation = 0 self.formats: dict[str, Callable] = ( get_formats() if formats is None else formats ) self._lifespan_handler = lifespan # Route wrapper for a Responder-view default endpoint (built lazily). self._default_route: Route | None = None # Prepared (normalized prefix, ASGI app) mounts, rebuilt when # ``self.apps`` changes — see ``_sorted_mounts``. self._mounts: list[tuple[str, Any]] = [] self._mounts_snapshot: tuple | None = None def add_route( self, route: str | None = None, endpoint: Callable | None = None, *, default: bool = False, websocket: bool = False, before_request: bool = False, check_existing: bool = False, methods: list[str] | None = None, name: str | None = None, ) -> None: """Adds a route to the router. :param route: A string representation of the route :param endpoint: The endpoint for the route -- can be callable, or class. :param default: If ``True``, all unknown requests will route to this view. :param methods: Optional list of HTTP methods (e.g. ["GET", "POST"]). """ if endpoint is None: raise ValueError("An endpoint is required to add a route") if before_request: if websocket: self.before_requests.setdefault("ws", []).append(endpoint) else: self.before_requests.setdefault("http", []).append(endpoint) return if route is None: raise ValueError("A route path is required to add a route") if check_existing: new_methods = {m.upper() for m in methods} if methods else None for item in self.routes: if item.route != route: continue # Same path is allowed only for HTTP routes whose methods are # disjoint — e.g. @api.get and @api.post on one path. A # method-less route (or a WebSocket route) answers # unconditionally, so it always conflicts. existing_methods = getattr(item, "methods", None) if ( not websocket and isinstance(item, Route) and new_methods is not None and existing_methods is not None and new_methods.isdisjoint(existing_methods) ): continue raise ValueError(f"Route '{route}' already exists") if default: self.default_endpoint = endpoint new_route: BaseRoute if websocket: new_route = WebSocketRoute(route, endpoint, name=name) else: new_route = Route(route, endpoint, methods=methods, name=name) # Freeze this registration's per-route CSRF override onto the Route # itself. Reading it off the endpoint at dispatch time would leak the # value across re-registrations of a shared function, or inherit it # through a CBV's MRO. For classes, read the class's own __dict__ so a # subclass never picks up a base's exemption. ``None`` = inherit the # app-wide default. if inspect.isclass(endpoint): new_route._csrf = endpoint.__dict__.get("_csrf") else: new_route._csrf = getattr(endpoint, "_csrf", None) self.routes.append(new_route) self._route_cache.clear() self._generation += 1 def mount(self, route: str, app: Any) -> None: """Mounts ASGI / WSGI applications at a given route. The prefix is normalized (a trailing slash is stripped, so ``/admin/`` and ``/admin`` are equivalent; ``""`` mounts at the root), and a WSGI app is wrapped for ASGI dispatch once, here, rather than per request. """ route = route.rstrip("/") if _is_wsgi_app(app): from a2wsgi import WSGIMiddleware app = WSGIMiddleware(app) self.apps.update({route: app}) def add_event_handler(self, event_type: str, handler: Callable) -> None: if event_type not in ("startup", "shutdown"): raise ValueError( f"Only 'startup' and 'shutdown' events are supported, " f"not {event_type!r}." ) self.events[event_type].append(handler) async def trigger_event(self, event_type: str) -> None: for handler in self.events.get(event_type, []): if inspect.iscoroutinefunction(handler): await handler() else: # Run blocking startup/shutdown handlers off the event loop. await run_in_threadpool(handler) def add_dependency( self, name: str, provider: Callable, scope: str = "request" ) -> None: """Register a dependency provider, injectable into views by parameter name. :param scope: ``"request"`` (resolved per request, the default) or ``"app"`` (resolved once, torn down at shutdown). """ if scope not in ("request", "app"): raise ValueError( f"Dependency scope must be 'request' or 'app', not {scope!r}" ) if name in _RESERVED_DEP_NAMES: raise ValueError( f"Dependency name {name!r} is reserved (req/request/resp/response/" f"ws/websocket)." ) if scope == "app": # App-scoped providers may depend on other (app-scoped) providers, # but never on the request — they outlive any single request. for pname, ann in _dep_param_specs(provider): if _is_request_param( pname, ann, _WS_REQUEST_NAMES | _HTTP_REQUEST_NAMES ): raise ValueError( "App-scoped dependency providers cannot receive the " "request — they outlive any single request." ) self.dependencies[name] = (provider, scope) def before_request(self, endpoint: Callable, websocket: bool = False) -> None: if websocket: self.before_requests.setdefault("ws", []).append(endpoint) else: self.before_requests.setdefault("http", []).append(endpoint) def after_request(self, endpoint: Callable) -> None: self.after_requests.append(endpoint) def url_for(self, endpoint: Callable | str, **params: Any) -> str: # An explicit route name wins (decouples reversal from function identity, # so lambdas and shared function names are addressable). if isinstance(endpoint, str): for route in self.routes: if getattr(route, "name", None) == endpoint: return route.url(**params) for route in self.routes: # Callable-instance endpoints (e.g. class-based views registered as # objects) have no __name__; never let a missing name match anything. endpoint_name = getattr(route.endpoint, "__name__", None) if endpoint == route.endpoint or ( endpoint_name is not None and endpoint == endpoint_name ): return route.url(**params) raise RouteNotFoundError(f"No route is registered for {endpoint!r}") async def default_response(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] == "websocket": websocket_close = WebSocketClose() await websocket_close(scope, receive, send) return raise HTTPException(status_code=status_codes.HTTP_404) def _resolve_route(self, scope: Scope) -> BaseRoute | None: key = (scope.get("method", "ws"), scope["path"]) cached = self._route_cache.get(key) if cached is not None: # LRU: re-insert on hit so hot entries survive eviction (below). self._route_cache.pop(key, None) self._route_cache[key] = cached route, child_scope = cached # Copy path_params so per-request mutation can't poison the cache. scope.update(_fresh_child_scope(child_scope)) scope["route_pattern"] = getattr(route, "path_template", route.route) return route for route in self.routes: matches, child_scope = route.matches(scope) if matches: scope.update(_fresh_child_scope(child_scope)) scope["route_pattern"] = getattr(route, "path_template", route.route) if len(self._route_cache) >= 1024: # Evict the least-recently-used entry instead of clearing # wholesale, so high-cardinality parameterized paths can't # thrash the hot static entries. self._route_cache.pop(next(iter(self._route_cache)), None) self._route_cache[key] = (route, _fresh_child_scope(child_scope)) return route return None async def lifespan(self, scope: Scope, receive: Receive, send: Send) -> None: message = await receive() assert message["type"] == "lifespan.startup" if self._lifespan_handler is not None: # Modern lifespan context manager pattern. Expose the API as # scope["app"] so a standard ``async def lifespan(app)`` receives # the application instead of None. on_event handlers still fire # (LIFO around the context manager): startup events run after # ``__aenter__``, shutdown events before ``__aexit__`` — so e.g. # the API's background-task draining isn't silently skipped. scope["app"] = self.api try: ctx = self._lifespan_handler(scope["app"]) await ctx.__aenter__() except BaseException: msg = traceback.format_exc() await send({"type": "lifespan.startup.failed", "message": msg}) raise try: await self.trigger_event("startup") except BaseException: msg = traceback.format_exc() try: await ctx.__aexit__(*sys.exc_info()) except Exception: logger.exception("Lifespan exit failed after startup error") await send({"type": "lifespan.startup.failed", "message": msg}) raise await send({"type": "lifespan.startup.complete"}) message = await receive() assert message["type"] == "lifespan.shutdown" try: try: await self.trigger_event("shutdown") finally: await ctx.__aexit__(None, None, None) except BaseException: # A raising __aexit__ (or shutdown event) must still reach # app-dependency teardown and report the failure. msg = traceback.format_exc() await self.app_dependencies.shutdown() await send({"type": "lifespan.shutdown.failed", "message": msg}) raise await self.app_dependencies.shutdown() else: # Legacy on_event("startup") / on_event("shutdown") pattern try: await self.trigger_event("startup") except BaseException: msg = traceback.format_exc() await send({"type": "lifespan.startup.failed", "message": msg}) raise await send({"type": "lifespan.startup.complete"}) message = await receive() assert message["type"] == "lifespan.shutdown" try: await self.trigger_event("shutdown") except BaseException: # A raising shutdown handler must still reach app-dependency # teardown and report the failure (mirrors the lifespan= path). msg = traceback.format_exc() await self.app_dependencies.shutdown() await send({"type": "lifespan.shutdown.failed", "message": msg}) raise await self.app_dependencies.shutdown() await send({"type": "lifespan.shutdown.complete"}) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: assert scope["type"] in ("http", "websocket", "lifespan") if scope["type"] == "lifespan": await self.lifespan(scope, receive, send) return await self._dispatch(scope, receive, send) async def _dispatch(self, scope: Scope, receive: Receive, send: Send) -> None: path = scope["path"] root_path = scope.get("root_path", "") # Check "primary" mounted routes first (before submounted apps) route = self._resolve_route(scope) scope["before_requests"] = self.before_requests scope["after_requests"] = self.after_requests scope["dependencies"] = ( {**self.dependencies, **self.dependency_overrides} if self.dependency_overrides else self.dependencies ) scope["dependency_override_names"] = frozenset(self.dependency_overrides) scope["app_dependencies"] = self.app_dependencies scope["formats"] = self.formats scope["api"] = self.api scope["max_request_size"] = self.max_request_size scope["auto_etag"] = self.auto_etag scope["auto_vary"] = self.auto_vary scope["request_timeout"] = self.request_timeout scope["ws_idle_timeout"] = self.ws_idle_timeout scope["trace_dispatch"] = self.trace_dispatch scope["problem_details"] = self.problem_details scope["csrf_enabled"] = self.csrf if route is not None: await route(scope, receive, send) return # Call into a submounted app, if one exists. Longer (more specific) # prefixes win, and a prefix only matches on a path-segment boundary # so that, e.g., "/subscribe" is not mis-routed into a mount at "/sub" # (the empty-prefix root catch-all still matches everything). for path_prefix, app in self._sorted_mounts(): if path == path_prefix or path.startswith(path_prefix + "/"): scope["path"] = path[len(path_prefix) :] or "/" scope["root_path"] = root_path + path_prefix await app(scope, receive, send) return # A near-miss on the trailing slash gets redirected to the real route, # preserving the method and query string (307). if scope["type"] == "http" and self.redirect_slashes and path != "/": alternate = path[:-1] if path.endswith("/") else path + "/" # Match by path only (not method): a POST to the slashed variant # redirects just like a GET — 307 preserves the method, and the # follow-up request earns the proper 405 + Allow if needed. if any( route.path_re.match(alternate) for route in self.routes if isinstance(route, Route) ): query_string = scope.get("query_string", b"") # Percent-encode the decoded path so the Location header is # latin-1 safe (same safe set as Starlette's RedirectResponse). location = urllib.parse.quote(alternate, safe="/:@&=+$,;~*!')(") + ( f"?{query_string.decode('latin-1')}" if query_string else "" ) redirect = StarletteResponse( status_code=307, headers={"Location": location} ) await redirect(scope, receive, send) return # The path exists but no route accepts this method: answer OPTIONS # with the allowed methods, and everything else with 405. if scope["type"] == "http": allowed = self._allowed_methods(path) if allowed: headers = {"Allow": ", ".join(sorted(allowed))} response: StarletteResponse if scope.get("method", "").upper() == "OPTIONS": response = StarletteResponse(status_code=200, headers=headers) elif self.problem_details or _accepts_json(scope): content = _error_payload(scope, status_codes.HTTP_405) media_type = ( PROBLEM_JSON if self.problem_details else "application/json" ) response = JSONResponse( content, status_code=status_codes.HTTP_405, headers=headers, media_type=media_type, ) else: response = StarletteResponse( content="Method Not Allowed", status_code=status_codes.HTTP_405, headers=headers, ) await response(scope, receive, send) return await self._dispatch_default(scope, receive, send) def _sorted_mounts(self) -> list[tuple[str, Any]]: """Mounted apps as ``(normalized prefix, ASGI app)``, longest first. ``self.apps`` may also be populated directly (``API.mount`` does), so prefix normalization and one-time WSGI wrapping are (re)applied here whenever the registry changes — never per request, since a fresh ``WSGIMiddleware`` costs a ``ThreadPoolExecutor`` per instance. """ snapshot = tuple((prefix, id(app)) for prefix, app in self.apps.items()) if snapshot != self._mounts_snapshot: mounts: list[tuple[str, Any]] = [] for prefix, app in self.apps.items(): if _is_wsgi_app(app): from typing import cast from a2wsgi import WSGIMiddleware app = WSGIMiddleware(cast(Any, app)) mounts.append((prefix.rstrip("/"), app)) mounts.sort(key=lambda kv: len(kv[0]), reverse=True) self._mounts = mounts self._mounts_snapshot = snapshot return self._mounts async def _dispatch_default( self, scope: Scope, receive: Receive, send: Send ) -> None: """Invoke the default endpoint for an unmatched request. The built-in default (and any user-supplied ASGI-style callable) is invoked directly. A Responder view — e.g. one registered via ``add_route(default=True)`` — is dispatched through normal :class:`Route` semantics (hooks, response handling) with empty ``path_params``. """ endpoint = self.default_endpoint if _is_asgi_style(endpoint): await endpoint(scope, receive, send) return if scope["type"] == "websocket": # A Responder HTTP view can't answer a websocket handshake; # close it exactly like the built-in default. await WebSocketClose()(scope, receive, send) return route = self._default_route if route is None or route.endpoint is not endpoint: route = self._default_route = Route("/", endpoint) scope["path_params"] = {} await route(scope, receive, send) def _allowed_methods(self, path: str) -> set[str]: """The union of methods accepted by method-restricted routes matching ``path``.""" allowed: set[str] = set() for route in self.routes: if isinstance(route, Route) and route.methods and route.path_re.match(path): allowed.update(route.methods) if allowed: if "GET" in allowed: allowed.add("HEAD") allowed.add("OPTIONS") return allowed