Files
responder/responder/routes.py
T
kennethreitz e442877fbe Add Atelier contract example
Introduce a polished examples/atelier.py app as the canonical contract fixture for auth policies, OpenAPI metadata, response helpers, problem details, and generated clients.

Also keep auth-injected user/principal/auth parameters out of request-body model inference so authenticated JSON endpoints document and bind the intended body model.
2026-06-30 12:47:48 -04:00

1833 lines
66 KiB
Python

from __future__ import annotations
import asyncio
import dataclasses
import inspect
import logging
import re
import traceback
import weakref
from collections import defaultdict
from collections.abc import Callable
from typing import Any, Union
__all__ = [
"Route",
"WebSocketRoute",
"Router",
"DependencyError",
"DependencyCycleError",
"DependencyScopeError",
"DependencyResolutionError",
]
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, 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")
class DependencyError(Exception):
"""Base class for dependency-injection configuration/resolution errors."""
class DependencyCycleError(DependencyError):
"""A dependency depends on itself (directly or transitively)."""
class DependencyScopeError(DependencyError):
"""An app-scoped dependency illegally depends on the request or a
request-scoped dependency."""
class DependencyResolutionError(DependencyError):
"""A dependency parameter is neither the request nor a registered
dependency."""
_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_]*)?}")
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(":")
assert convertor_type in _CONVERTORS.keys(), (
f"Unknown path convertor '{convertor_type}'"
)
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
def _depends_params(view: Callable) -> dict[str, _Depends]:
try:
params = inspect.signature(view).parameters
except (TypeError, ValueError):
return {}
return {
name: param.default
for name, param in params.items()
if isinstance(param.default, _Depends)
}
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 ...
async def _resolve_markers(view, request, path_params) -> 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 and CBV methods alike.
"""
from .params import marker_params, raw_value
specs = marker_params(view, _view_type_hints(view))
if not specs:
return {}, set()
form = (
await _get_form(request)
if any(s.location in ("form", "file") for s in specs)
else None
)
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 in ("form", "file"):
raw = _form_value(form, spec)
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] = []
self.stack: list[str] = []
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
if name in self.stack:
path = self.stack[self.stack.index(name) :] + [name]
raise DependencyCycleError("Dependency cycle: " + " -> ".join(path))
self.stack.append(name)
try:
kwargs: dict[str, Any] = {}
specs = _dep_param_specs(provider)
for pname, ann in specs:
if _is_request_param(pname, ann, self.req_names):
kwargs[pname] = self.request
elif pname in self.registry:
kwargs[pname] = await self.resolve(pname)
else:
chain = " -> ".join(self.stack)
raise DependencyResolutionError(
f"Parameter {pname!r} of dependency {name!r} is neither the "
f"request (name it 'req' / annotate 'Request') nor a "
f"registered dependency. 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):
key = provider
try:
return self.provider_cache[key]
except (KeyError, TypeError):
pass
label = getattr(provider, "__name__", provider.__class__.__name__)
if label in self.stack:
path = self.stack[self.stack.index(label) :] + [label]
raise DependencyCycleError("Dependency cycle: " + " -> ".join(path))
self.stack.append(label)
try:
kwargs: dict[str, Any] = {}
for pname, ann in _dep_param_specs(provider):
if _is_request_param(pname, ann, self.req_names):
kwargs[pname] = self.request
elif pname in self.registry:
kwargs[pname] = await self.resolve(pname)
else:
chain = " -> ".join(self.stack)
raise DependencyResolutionError(
f"Parameter {pname!r} of dependency provider {label!r} "
"is neither the request nor a registered dependency. "
f"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
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) -> 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:
assert route.startswith("/"), "Route path must start with '/'"
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:
return self._url_template.format(**params)
@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
) -> 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, 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
) -> dict[str, Any]:
if inspect.isclass(self.endpoint) or request.method not in (
"POST",
"PUT",
"PATCH",
"DELETE",
):
return {}
hints = _view_type_hints(self.endpoint)
dep_names = scope.get("dependencies") or {}
auth_names = (
_AUTH_INJECTION_NAMES
if getattr(self.endpoint, "_route_auth", ())
else frozenset()
)
try:
sig_params: Any = inspect.signature(self.endpoint).parameters
except (TypeError, ValueError):
sig_params = {}
model_params = [
(name, hints[name])
for name in _view_param_names(self.endpoint)
if name not in path_params
and name not in dep_names
and name not in auth_names
and _is_pydantic_model(hints.get(name))
and (
name not in sig_params
or sig_params[name].default is inspect.Parameter.empty
)
]
if not model_params:
return {}
body = await request.media()
if not isinstance(body, dict):
raise TypeError("Request body must be a JSON object")
return {name: model.model_validate(body) for name, model in model_params}
async def _validate_inputs(
self,
scope: Scope,
receive: Receive,
send: Send,
request: Request,
response: Response,
path_params: dict,
) -> tuple[bool, dict[str, Any]]:
try:
await self._validate_params_model(request)
injected = await self._body_injections(scope, request, path_params)
except HTTPException:
raise
except Exception as exc:
await self._send_validation_error(scope, receive, send, response, exc)
return False, {}
return True, injected
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()}"
try:
views.append(getattr(endpoint, method_name))
except AttributeError as ex:
if on_request is None:
raise HTTPException(status_code=status_codes.HTTP_405) from ex
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 and view is self.endpoint:
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,
injected: dict[str, Any],
auth_injected: dict[str, Any],
) -> None:
for view in views:
_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:
response.status_code = 504
if scope.get("problem_details"):
self._set_error_response(scope, response, 504, "Request timed out")
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
_trace(scope, "auth")
auth_injected = await self._route_auth_injections(request)
ok, injected = await self._validate_inputs(
scope, receive, send, request, response, path_params
)
if not ok:
return
views = self._views_for(request)
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,
injected,
auth_injected,
)
timeout = scope.get("request_timeout")
if timeout:
await asyncio.wait_for(run, timeout)
else:
await run
except asyncio.TimeoutError:
await self._send_timeout_response(scope, receive, send, 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
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:
assert route.startswith("/"), "Route path must start with '/'"
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:
return self._url_template.format(**params)
@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)
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
}
)
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)
finally:
if resolver is not None:
await resolver.teardown()
async def _run_before_hooks(self, hooks, 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, 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) -> 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, registry, stack) -> 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,
trace_dispatch: bool = False,
problem_details: bool = True,
) -> 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.trace_dispatch = trace_dispatch
self.problem_details = problem_details
self._route_cache: dict[tuple[str, str], tuple[BaseRoute, dict]] = {}
self.formats: dict[str, Callable] = (
get_formats() if formats is None else formats
)
self._lifespan_handler = lifespan
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)
self.routes.append(new_route)
self._route_cache.clear()
def mount(self, route: str, app: Any) -> None:
"""Mounts ASGI / WSGI applications at a given route"""
self.apps.update({route: app})
def add_event_handler(self, event_type: str, handler: Callable) -> None:
assert event_type in (
"startup",
"shutdown",
), f"Only 'startup' and 'shutdown' events are supported, not {event_type}."
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:
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 | None:
# 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:
if endpoint in (route.endpoint, route.endpoint.__name__):
return route.url(**params)
return None
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:
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:
self._route_cache.clear()
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
try:
ctx = self._lifespan_handler(scope.get("app"))
await ctx.__aenter__()
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"
await ctx.__aexit__(None, None, None)
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"
await self.trigger_event("shutdown")
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["trace_dispatch"] = self.trace_dispatch
scope["problem_details"] = self.problem_details
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 sorted(
self.apps.items(), key=lambda kv: len(kv[0]), reverse=True
):
if path == path_prefix or path.startswith(path_prefix + "/"):
scope["path"] = path[len(path_prefix) :] or "/"
scope["root_path"] = root_path + path_prefix
if not (inspect.iscoroutinefunction(app) or hasattr(app, "__asgi_app__")):
# Check if it looks like a WSGI app (callable with fewer params)
try:
await app(scope, receive, send)
return
except TypeError as exc:
# Only fall back to WSGI if the error is about call signature
if "argument" not in str(exc) and "positional" not in str(exc):
raise
from typing import cast
from a2wsgi import WSGIMiddleware
wsgi_app = WSGIMiddleware(cast(Any, app))
await cast(Any, wsgi_app)(scope, receive, send)
return
else:
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 + "/"
alternate_scope = dict(scope, path=alternate)
if any(route.matches(alternate_scope)[0] for route in self.routes):
query_string = scope.get("query_string", b"")
location = alternate + (
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.default_endpoint(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