import asyncio
import contextlib
import functools
import importlib
import inspect
import json
import logging
import os
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any, NamedTuple
__all__ = ["API"]
import uvicorn
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import State
from starlette.exceptions import HTTPException
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.errors import ServerErrorMiddleware
from starlette.middleware.exceptions import ExceptionMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.responses import JSONResponse, PlainTextResponse
from starlette.responses import Response as StarletteResponse
from starlette.types import ASGIApp
from . import status_codes
from .background import BackgroundQueue
from .contracts import (
inferred_response_model,
inferred_stream_model,
response_annotation_is_ignored,
response_status_allows_body,
response_type_adapter,
)
from .errors import (
INTERNAL_SERVER_ERROR,
PROBLEM_JSON,
Problem,
legacy_error_payload,
problem_payload_for_async,
status_title,
)
from .formats import get_formats
from .models import Request, Response
from .params import _Depends
from .routes import Router, _is_pydantic_model, _view_return_hint
from .routing import _AUTH_UNSET as _ROUTER_AUTH_UNSET
from .routing import Router as _IncludableRouter
from .routing import _normalize_prefix, _prefix_scoped_hook
from .staticfiles import StaticFiles
from .statics import (
DEFAULT_CORS_PARAMS,
DEFAULT_MAX_REQUEST_SIZE,
DEFAULT_OPENAPI_THEME,
)
from .templates import Templates
logger = logging.getLogger("responder")
_UNSET = object()
class _MW(NamedTuple):
"""A collected middleware spec, assembled lazily by build_middleware_stack."""
cls: type
options: dict
def _api_json_default_hook(api):
"""The API's ``json.dumps`` ``default=`` hook (user encoder included).
Mirrors ``Response._json_default_hook`` so problem payloads built outside
a ``Response`` (e.g. from a raised :class:`~responder.Problem`) serialize
the same types the media path does.
"""
formats = getattr(api, "formats", None)
json_format = formats.get("json") if formats else None
hook = getattr(json_format, "_responder_default_hook", None)
if hook is not None:
return hook
from .formats import _json_default
return _json_default
async def _negotiated_http_error(request, exc):
"""Render HTTPExceptions (404s and friends) as negotiated framework errors."""
headers = getattr(exc, "headers", None)
if exc.status_code in (204, 304):
return StarletteResponse(status_code=exc.status_code, headers=headers)
api = request.scope.get("api")
if getattr(api, "problem_details", True):
is_problem = isinstance(exc, Problem)
payload = await problem_payload_for_async(
request.scope,
exc.status_code,
exc.detail,
title=(exc.title if is_problem else None) or status_title(exc.status_code),
errors=exc.errors if is_problem else None,
request=request,
exc=exc,
)
if is_problem:
# Explicit Problem members layer on top of problem_handler
# enrichment, mirroring ``resp.problem()``. ``instance`` defaults
# to the request path per the RFC 9457 recommendation.
if exc.type is not None:
payload["type"] = exc.type
if exc.instance is not None:
payload["instance"] = exc.instance
else:
payload.setdefault("instance", request.url.path)
if exc.extensions:
payload.update(exc.extensions)
content = json.dumps(payload, default=_api_json_default_hook(api)).encode(
"utf-8"
)
return StarletteResponse(
content=content,
status_code=exc.status_code,
headers=headers,
media_type=PROBLEM_JSON,
)
return JSONResponse(
payload,
status_code=exc.status_code,
headers=headers,
media_type=PROBLEM_JSON,
)
if "json" in request.headers.get("accept", ""):
return JSONResponse(
legacy_error_payload(exc.status_code, exc.detail),
status_code=exc.status_code,
headers=headers,
)
return PlainTextResponse(exc.detail, status_code=exc.status_code, headers=headers)
async def _negotiated_server_error(request, exc):
"""Render unhandled 500s with the same default error contract."""
if getattr(request.scope.get("api"), "problem_details", True):
return JSONResponse(
await problem_payload_for_async(
request.scope,
500,
INTERNAL_SERVER_ERROR,
title=INTERNAL_SERVER_ERROR,
request=request,
exc=exc,
),
status_code=500,
media_type=PROBLEM_JSON,
)
if "json" in request.headers.get("accept", ""):
return JSONResponse(
legacy_error_payload(500, INTERNAL_SERVER_ERROR), status_code=500
)
return PlainTextResponse(INTERNAL_SERVER_ERROR, status_code=500)
def _read_text_if_exists(path: Path) -> str | None:
"""Return the file's text, or ``None`` if it doesn't exist (runs in a thread)."""
try:
return path.read_text()
except FileNotFoundError:
return None
def _const_provider(value):
"""Wrap a bare value as a zero-parameter dependency provider."""
def provider():
return value
return provider
def _as_tuple(value):
if value is None or value is _UNSET:
return ()
if isinstance(value, (list, tuple)):
return tuple(value)
return (value,)
def _auth_security_requirement(auth: Any) -> str | None:
if hasattr(auth, "security_requirement"):
return auth.security_requirement()
if hasattr(auth, "scheme_name"):
return auth.scheme_name
return None
def _auth_has_security_scheme(auth: Any) -> bool:
if not hasattr(auth, "security_scheme"):
return False
return auth.security_scheme() is not None
def _deep_merge_dicts(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict:
result: dict[str, Any] = dict(base)
for key, value in override.items():
if (
key in result
and isinstance(result[key], Mapping)
and isinstance(value, Mapping)
):
result[key] = _deep_merge_dicts(result[key], value)
else:
result[key] = value
return result
def _openapi_response(value: Any) -> dict[str, Any]:
if isinstance(value, str):
return {"description": value}
if value is None:
return {"description": "Response"}
if not isinstance(value, Mapping):
raise TypeError("OpenAPI response values must be strings or mappings")
return dict(value)
def _typed_response_status(status: Any) -> int:
if isinstance(status, bool):
raise TypeError("Typed response status codes must be integers")
try:
value = int(status)
except (TypeError, ValueError) as exc:
raise TypeError(
"Typed response models require an exact HTTP status code"
) from exc
if str(status) != str(value) or not 100 <= value <= 599:
raise ValueError(f"Invalid typed response status code {status!r}")
return value
def _validate_declared_response_model(model: Any, *, label: str) -> None:
if model is None or model is False:
raise TypeError(f"{label} must be a Pydantic TypeAdapter-compatible type")
try:
response_type_adapter(model).json_schema()
except Exception as exc:
raise TypeError(
f"{label} must be a Pydantic TypeAdapter-compatible type"
) from exc
def _normalize_openapi_responses(
responses: Mapping[Any, Any],
) -> tuple[dict[str, Any], dict[int, Any]]:
if not isinstance(responses, Mapping):
raise TypeError("responses= must be a mapping of status codes to responses")
metadata: dict[str, Any] = {}
models: dict[int, Any] = {}
for status, value in responses.items():
model = _UNSET
if isinstance(value, Mapping):
response = dict(value)
model = response.pop("model", _UNSET)
elif isinstance(value, str) or value is None:
response = _openapi_response(value)
else:
model = value
typed_status = _typed_response_status(status)
response = {"description": status_title(typed_status)}
if model is not _UNSET:
typed_status = _typed_response_status(status)
if not response_status_allows_body(typed_status):
raise ValueError(
f"HTTP {typed_status} cannot declare a response model because "
"that status does not allow a response body"
)
_validate_declared_response_model(
model, label=f"responses={{{typed_status}: ...}} model"
)
models[typed_status] = model
response.setdefault("description", status_title(typed_status))
metadata[str(status)] = response
return metadata, models
def _response_contract_views(endpoint: Any) -> list[Callable[..., Any]]:
if not inspect.isclass(endpoint):
return [endpoint]
names = ["on_request"]
names.extend(
f"on_{method}"
for method in (
"get",
"head",
"post",
"put",
"patch",
"delete",
"options",
"trace",
"connect",
)
)
return [view for name in names if callable(view := getattr(endpoint, name, None))]
def _diagnose_response_annotation(endpoint: Any, route: str | None) -> None:
for view in _response_contract_views(endpoint):
try:
raw = inspect.signature(view).return_annotation
except (TypeError, ValueError):
continue
if raw is inspect.Signature.empty:
continue
resolved = _view_return_hint(view)
if resolved is None and raw not in (None, type(None), "None", "NoneType"):
logger.warning(
"Return annotation %r on %s for route %r could not be resolved; "
"use a Pydantic TypeAdapter-compatible type or pass "
"response_model=False",
raw,
getattr(view, "__qualname__", getattr(view, "__name__", view)),
route,
)
continue
if response_annotation_is_ignored(resolved):
continue
if resolved is not None and inferred_response_model(resolved) is not None:
continue
logger.warning(
"Return annotation %r on %s for route %r cannot be used as a "
"response contract; use a Pydantic TypeAdapter-compatible type "
"or pass response_model=False",
raw,
getattr(view, "__qualname__", getattr(view, "__name__", view)),
route,
)
_OPENAPI_EXAMPLE_FIELDS = frozenset({"summary", "description", "value", "externalValue"})
def _openapi_examples(value: Any) -> dict[str, Any]:
if isinstance(value, Mapping) and value:
maybe_examples = {
str(name): dict(example)
for name, example in value.items()
if isinstance(example, Mapping)
and _OPENAPI_EXAMPLE_FIELDS.intersection(example)
}
if len(maybe_examples) == len(value):
return maybe_examples
return {"default": {"value": value}}
def _add_openapi_examples(
responses: dict[str, Any],
status_code: Any,
examples: Any,
*,
media_type: str = "application/json",
) -> None:
response = responses.setdefault(str(status_code), {})
content = response.setdefault("content", {})
media = content.setdefault(media_type, {})
media["examples"] = _openapi_examples(examples)
def _normalize_openapi_response_examples(
response_examples: Mapping[Any, Any],
) -> dict[str, Any]:
if not isinstance(response_examples, Mapping):
raise TypeError(
"response_examples= must be a mapping of status codes to examples"
)
responses: dict[str, Any] = {}
for status_code, examples in response_examples.items():
_add_openapi_examples(responses, status_code, examples)
return responses
def _registers_as_named_component(model):
"""Whether ``model`` should be auto-registered as a named OpenAPI component.
Parametrized generics (``Page[Item]``) are excluded — their bracketed names
aren't valid component keys, so the spec builder inlines them instead.
"""
meta = getattr(model, "__pydantic_generic_metadata__", None)
return _is_pydantic_model(model) and not (meta and meta.get("args"))
[docs]
def abort(
status_code,
*,
detail=None,
headers=None,
title=None,
type=None, # noqa: A002
instance=None,
errors=None,
**extensions,
):
"""Short-circuit the request with an HTTP error response.
Raises an ``HTTPException`` that Responder renders (as JSON or text per the
client's ``Accept`` header). Use it to bail out from anywhere in a handler
or dependency without importing Starlette directly.
Usage::
from responder import abort
@api.route("/admin")
def admin(req, resp):
if not req.session.get("is_admin"):
abort(403, detail="Forbidden")
Passing any of the RFC 9457 members — ``title``, ``type``, ``instance``,
``errors``, or extension keywords — raises :class:`~responder.Problem`
instead, carrying them into the rendered problem-details payload::
abort(
409,
detail="Plan quota exhausted",
type="https://api.example.com/errors/quota-exceeded",
balance=0,
)
:param status_code: The HTTP status code (e.g. ``404``).
:param detail: Optional error message; defaults to the status phrase.
:param headers: Optional dict of headers to attach to the error response.
:param title: Optional problem summary; defaults to the status phrase.
:param type: Optional URI identifying the problem type.
:param instance: Optional URI for this occurrence; defaults to the
request path in the rendered payload.
:param errors: Optional list of structured error dicts.
:param extensions: Extra keyword arguments become top-level extension
members of the problem payload.
"""
if (
title is None
and type is None
and instance is None
and errors is None
and not extensions
):
raise HTTPException(status_code=status_code, detail=detail, headers=headers)
raise Problem(
status_code,
detail,
title=title,
type=type,
instance=instance,
errors=errors,
headers=headers,
**extensions,
)
[docs]
class API:
"""The primary web-service class.
:param static_dir: The directory to use for static files (default ``static``). Mounted at ``static_route`` only if it exists — it is never created for you. Passing a ``static_dir`` explicitly that doesn't exist raises ``FileNotFoundError``.
:param templates_dir: The directory to use for templates (default ``templates``). It is not created for you.
:param auto_escape: If ``True``, HTML and XML templates will automatically be escaped.
:param enable_hsts: If ``True``, redirect HTTP requests to HTTPS and send a ``Strict-Transport-Security`` header.
:param security_headers: If ``True``, add common security headers (nosniff, X-Frame-Options, Referrer-Policy) to every response; pass a dict of ``SecurityHeadersMiddleware`` options to customize (e.g. ``content_security_policy``).
:param gzip: If ``True`` (the default), compress responses with GZip.
:param openapi_theme: OpenAPI documentation theme, must be one of ``elements``, ``rapidoc``, ``redoc``, ``swagger_ui``
""" # noqa: E501
status_codes = status_codes
def __init__(
self,
*,
debug=False,
title=None,
version=None,
description=None,
terms_of_service=None,
contact=None,
license=None, # noqa: A002
openapi=None,
openapi_servers=None,
openapi_route="/schema.yml",
static_dir=_UNSET,
static_route="/static",
implicit_static_fallback=False,
templates_dir="templates",
auto_escape=True,
secret_key=None,
enable_hsts=False,
security_headers=False,
docs_route=None,
cors=False,
cors_params=DEFAULT_CORS_PARAMS,
allowed_hosts=None,
openapi_theme=DEFAULT_OPENAPI_THEME,
lifespan=None,
gzip=True,
request_id=False,
enable_logging=False,
trust_proxy_headers=False,
csrf=False,
redirect_slashes=True,
max_request_size=DEFAULT_MAX_REQUEST_SIZE,
auto_etag=False,
auto_vary=True,
request_timeout=None,
ws_idle_timeout=None,
trace_dispatch=False,
sessions="auto",
session_backend=None,
session_cookie=None,
session_https_only=None,
session_same_site="lax",
session_max_age=14 * 24 * 3600,
metrics_route=None,
metrics_buckets=None,
health_route=None,
encoder=None,
json_ensure_ascii=False,
json_decimal="string",
problem_details=True,
problem_handler=None,
auth=None,
):
"""Create a new Responder API instance.
:param debug: If ``True``, enable debug mode with verbose error pages.
:param title: The title of the API, used in OpenAPI documentation.
:param version: The version string for the API (e.g. ``"1.0"``).
:param description: A longer description of the API for OpenAPI docs.
:param terms_of_service: URL to the API's terms of service.
:param contact: Contact information dict for the API (``name``, ``url``, ``email``).
:param license: License information dict (``name``, ``url``).
:param openapi: The OpenAPI version string (e.g. ``"3.0.2"``). Enables OpenAPI schema generation.
:param openapi_route: The URL path for the OpenAPI schema (default ``"/schema.yml"``).
:param static_dir: Directory for static files (default ``"static"``). Mounted at ``static_route`` only if the directory exists — it is never created implicitly. A ``static_dir`` passed explicitly that doesn't exist raises ``FileNotFoundError``. Set to ``None`` to disable.
:param static_route: URL prefix for serving static files (default ``"/static"``).
:param implicit_static_fallback: If ``True``, ``add_route(route)`` without an endpoint keeps the legacy static-fallback behavior. The default is ``False``: pass an endpoint explicitly, or serve static assets via ``static_dir``/``static_route``.
:param templates_dir: Directory for Jinja2 templates (default ``"templates"``).
:param auto_escape: If ``True``, auto-escape HTML/XML in templates.
:param secret_key: Secret key for signing cookie-based sessions. **Always set this in production.**
:param enable_hsts: If ``True``, redirect HTTP requests to HTTPS and send a ``Strict-Transport-Security`` header.
:param security_headers: If ``True``, add common security headers to every response; pass a dict of ``SecurityHeadersMiddleware`` options to customize.
:param docs_route: URL path for interactive API docs (e.g. ``"/docs"``). Enables OpenAPI if not already set.
:param cors: If ``True``, enable CORS middleware.
:param cors_params: Dict of CORS configuration (``allow_origins``, ``allow_methods``, etc.).
:param allowed_hosts: List of allowed hostnames (e.g. ``["example.com"]``). Defaults to ``["*"]``.
:param openapi_theme: Documentation UI theme: ``"swagger_ui"``, ``"redoc"``, ``"rapidoc"``, or ``"elements"``.
:param lifespan: An async context manager for startup/shutdown logic.
:param gzip: If ``True`` (the default), compress responses with GZip.
:param request_id: If ``True``, add ``X-Request-ID`` headers to all responses.
:param enable_logging: If ``True``, enable structured logging with per-request context (request ID, method, path, client IP).
:param trust_proxy_headers: If ``True``, honor the forwarding headers set by a reverse proxy — RFC 7239 ``Forwarded``, with fallback to ``X-Forwarded-Proto``/``X-Forwarded-Host``/``X-Forwarded-For``/``X-Real-IP`` — rewriting the request's scheme, host, and client address to what the original client sent (since 9.0; previously this flag only affected the IP recorded by ``enable_logging``). Redirects, URL building, HTTPS detection, and logged IPs are then correct behind nginx/Caddy/a load balancer. Only enable this when every request reaches Responder through a proxy you control that overwrites those headers — otherwise any client can spoof them.
:param csrf: If ``True``, protect unsafe requests (``POST``/``PUT``/``PATCH``/``DELETE``) with a session-bound CSRF token: requests must send the value of ``req.csrf_token`` in an ``X-CSRF-Token`` header or a ``csrf_token`` form field (``{{ req.csrf_input }}`` in templates), or they get a ``403``. Off by default; requires sessions. Override per route with ``@api.route(..., csrf=False)`` (e.g. for webhook endpoints) or ``csrf=True`` to protect a single route without the app-wide switch.
:param redirect_slashes: If ``True`` (the default), requests that miss only by a trailing slash are redirected (``307``) to the matching route.
:param max_request_size: Maximum request body size in bytes, enforced chunk-by-chunk as the body arrives. Bodies larger than this get a ``413`` response. Defaults to 100 MiB (since 9.0); pass ``None`` for the legacy unlimited behavior, or a larger value for big uploads (multipart uploads stream to disk, so a large cap does not mean large memory use).
:param auto_etag: If ``True``, GET responses automatically get a content-hash ``ETag`` and matching ``If-None-Match`` requests receive ``304 Not Modified``.
:param auto_vary: If ``True`` (the default since 6.0), content-negotiated responses get a ``Vary: Accept`` header (correct for shared caches). Pass ``False`` to opt out.
:param request_timeout: Seconds a handler may run before the request is answered with ``504 Gateway Timeout``. ``None`` (the default) means unlimited.
:param ws_idle_timeout: Seconds a WebSocket may wait for the next inbound message before the server closes it (code ``1001``). The deadline resets on every message received, so it bounds *idle* time, not total connection lifetime. ``None`` (the default) means unlimited.
:param trace_dispatch: If ``True``, emit debug logs for the documented route-dispatch order (before hooks, auth, dependencies, handler, after hooks).
:param secret_key: Signing key for cookie sessions. Defaults to ``None``: with ``sessions="auto"`` a random per-process key is generated (with a warning); the old public ``"NOTASECRET"`` default is rejected. Set this (or the ``RESPONDER_SECRET_KEY`` env var) for stable, multi-worker sessions.
:param sessions: ``"auto"`` (default) enables cookie sessions, auto-generating an ephemeral key if none is set; ``True`` requires a real ``secret_key`` (raises otherwise); ``False`` disables sessions entirely (``req.session`` then raises).
:param session_backend: Store session data server-side (e.g. ``MemorySessionBackend()``, ``RedisSessionBackend()`` from ``responder.ext.sessions``) with only an opaque ID in the cookie. ``None`` (the default) keeps signed cookie-payload sessions.
:param session_cookie: Name of the session cookie. ``None`` (the default) keeps the underlying middleware's default name.
:param session_https_only: Mark the session cookie ``Secure`` (only sent over HTTPS). ``None`` (the default) means Secure in production and off under ``debug``.
:param session_same_site: ``SameSite`` policy for the session cookie: ``"lax"`` (default), ``"strict"``, or ``"none"`` (requires a Secure cookie).
:param session_max_age: Session lifetime in seconds (default 14 days).
:param metrics_route: URL path (e.g. ``"/metrics"``) serving request counts and latency histograms in Prometheus text format.
:param metrics_buckets: Ascending histogram bucket upper bounds (in seconds) for the metrics endpoint's latency histogram. ``None`` (the default) uses ``responder.ext.metrics.BUCKETS`` (5ms-10s).
:param health_route: URL path (e.g. ``"/health"``) serving an aggregated readiness check (``200``/``503``); see :meth:`add_health_check`.
:param encoder: Optional ``obj -> serializable`` callable applied across **all** response formats (JSON, YAML, MessagePack) to serialize otherwise-unsupported types. Tried first, then falls back to the built-in conversions for ``datetime``, ``UUID``, ``Decimal``, ``set``, dataclasses, and Pydantic models.
:param json_ensure_ascii: If ``True``, escape non-ASCII in JSON as ``\\uXXXX``; ``False`` (the default since 6.0) emits raw UTF-8.
:param json_decimal: ``"string"`` (default) serializes ``Decimal`` values as precision-preserving strings; ``"float"`` preserves the legacy lossy conversion.
:param problem_details: If ``True`` (the default), framework-generated errors use RFC 9457-style ``application/problem+json`` responses. Pass ``False`` to keep the legacy JSON/plain-text negotiation.
:param problem_handler: Optional callable (sync or ``async def``) that can enrich or replace each problem-details payload. It receives ``(payload, request, exc)``; returning ``None`` means the payload was mutated in place. Async handlers are awaited on the negotiated error path and run to completion on a private event loop when invoked from synchronous call sites such as ``resp.problem()`` or route-level validation/timeout errors.
:param auth: Optional app-level auth helper or list of helpers. Routes inherit it by default; pass ``auth=None`` on a route to make that route public.
""" # noqa: E501
self.background = BackgroundQueue()
self.auth_policies = {}
self._auth = _as_tuple(auth)
# Resolved below if cookie sessions are enabled (else stays None).
self.secret_key = None
#: Application-level state. Set values at startup, read them anywhere
#: (handlers can reach it via ``req.api.state``).
self.state = State()
self.formats = get_formats(
encoder=encoder,
json_ensure_ascii=json_ensure_ascii,
json_decimal=json_decimal,
)
self.router = Router(
lifespan=lifespan,
formats=self.formats,
redirect_slashes=redirect_slashes,
max_request_size=max_request_size,
auto_etag=auto_etag,
auto_vary=auto_vary,
request_timeout=request_timeout,
ws_idle_timeout=ws_idle_timeout,
trace_dispatch=trace_dispatch,
problem_details=problem_details,
csrf=bool(csrf),
)
self.router.api = self
# Drain in-flight background tasks on shutdown rather than abandoning
# them when the process exits.
self.add_event_handler("shutdown", self._drain_background_tasks)
static_dir_explicit = static_dir is not _UNSET
if not static_dir_explicit:
static_dir = "static"
if static_dir is not None:
if static_route is None:
static_route = ""
static_dir = Path(static_dir).resolve()
self.static_dir = static_dir
self.static_route = static_route
self.implicit_static_fallback = bool(implicit_static_fallback)
self.hsts_enabled = enable_hsts
self._security_headers = security_headers
self.cors = cors
self.cors_params = cors_params
self.debug = debug
self.problem_details = bool(problem_details)
self.problem_handler = problem_handler
if not allowed_hosts:
allowed_hosts = ["*"]
self.allowed_hosts = allowed_hosts
# Never create static_dir implicitly: mount it only if it already
# exists. An explicitly-passed static_dir that's missing is an error;
# the default ("static") is simply skipped when absent.
if self.static_dir is not None:
if self.static_dir.is_dir():
self.mount(self.static_route, self.static_app)
elif static_dir_explicit:
raise FileNotFoundError(
f"static_dir {str(self.static_dir)!r} does not exist or is "
"not a directory. Create it, or pass static_dir=None to "
"disable static file serving."
)
self._session = None
self._session_base_url = None
self.default_endpoint = None
# Deferred middleware stack: collect config as data now and assemble the
# ASGI stack lazily on first request. ServerErrorMiddleware then becomes
# the outermost application layer (catching errors from every other
# middleware) while the observability tier (logging / request-id /
# metrics) wraps even it, so 500s still get X-Request-ID and a real
# logged status — the reconciliation v4.1 couldn't reach eagerly.
self._user_middleware: list[_MW] = []
self._middleware_stack: ASGIApp | None = None
self._exception_handlers: dict[Any, Callable] = {
HTTPException: _negotiated_http_error,
Exception: _negotiated_server_error,
}
self._gzip = gzip
self._cors_params = self.cors_params if cors else None
self._enable_logging = bool(enable_logging)
self._request_id = bool(request_id)
self._trust_proxy_headers = bool(trust_proxy_headers)
self._metrics = None
self._session_mw: _MW | None = None
if metrics_route:
from .ext.metrics import MetricsCollector
if metrics_buckets is None:
self.metrics = MetricsCollector()
else:
self.metrics = MetricsCollector(buckets=metrics_buckets)
self._metrics = self.metrics
def _metrics_view(req, resp):
resp.headers["Content-Type"] = "text/plain; version=0.0.4"
resp.content = self.metrics.render()
self.add_route(metrics_route, _metrics_view, static=False)
self._health_checks: dict[str, Callable] = {}
self._health_route = health_route
self._health_route_added = False
if health_route:
self._ensure_health_route()
# Sessions, secure by default. sessions="auto" (default) signs with the
# given key / RESPONDER_SECRET_KEY, else mints a random per-process key
# with a loud warning; sessions=True requires a real key; sessions=False
# disables session middleware entirely. Cookies are Secure in production
# (session_https_only=None) unless debug.
if sessions not in (True, False, "auto"):
raise ValueError("sessions= must be True, False, or 'auto'")
if sessions is False and session_backend is not None:
raise ValueError(
"session_backend was provided but sessions=False. Use "
"sessions='auto' (or True) to enable it, or drop the backend."
)
self.sessions_enabled = sessions is not False
if csrf and not self.sessions_enabled:
raise ValueError(
"csrf=True requires sessions: the CSRF token lives in the "
"session. Drop sessions=False, or disable CSRF protection."
)
if self.sessions_enabled:
effective_https_only = (
(not debug) if session_https_only is None else session_https_only
)
if session_same_site == "none" and not effective_https_only:
raise ValueError(
"session_same_site='none' requires a Secure cookie; set "
"session_https_only=True (browsers reject SameSite=None "
"without Secure)."
)
common_opts = {
"https_only": effective_https_only,
"same_site": session_same_site,
"max_age": session_max_age,
}
if session_backend is not None:
from .ext.sessions import ServerSessionMiddleware
opts = dict(common_opts)
if session_cookie is not None:
opts["cookie_name"] = session_cookie
self._session_mw = _MW(
ServerSessionMiddleware, {"backend": session_backend, **opts}
)
else:
from .ext.sessions import resolve_secret_key
self.secret_key = resolve_secret_key(
secret_key, sessions=sessions, debug=debug
)
opts = dict(common_opts)
if session_cookie is not None:
opts["session_cookie"] = session_cookie
self._session_mw = _MW(
SessionMiddleware, {"secret_key": self.secret_key, **opts}
)
if openapi or docs_route:
try:
from .ext.openapi import OpenAPISchema
except ImportError as ex:
raise ImportError(
"The dependencies for the OpenAPI extension are not installed. "
'Install them using: pip install "responder[orjson]"'
) from ex
self.openapi = OpenAPISchema(
app=self,
title=title,
version=version,
openapi=openapi,
docs_route=docs_route,
description=description,
terms_of_service=terms_of_service,
contact=contact,
license=license,
openapi_route=openapi_route,
static_route=static_route,
openapi_theme=openapi_theme,
servers=openapi_servers,
)
for auth_scheme in self._auth:
if _auth_has_security_scheme(auth_scheme):
self.add_security_scheme(auth_scheme)
self.templates = Templates(directory=templates_dir, autoescape=auto_escape)
# request_id / logging are installed as middleware in the observability
# tier by build_middleware_stack(); here we only configure the logger.
if enable_logging:
import logging as _logging
from .ext.logging import get_logger, setup_logging
log_level = _logging.DEBUG if debug else _logging.INFO
setup_logging(level=log_level)
self.log = get_logger("responder.app")
else:
import logging as _logging
self.log = _logging.getLogger("responder.app")
@property
def requests(self):
"""A test client connected to the ASGI app. Lazily initialized."""
return self._test_client()
@property
def async_requests(self):
"""An async test client wired to the ASGI app (``httpx.AsyncClient``).
The async mirror of :attr:`requests`; ``async with`` also runs the
app's lifespan. See :class:`responder.testing.AsyncTestClient`.
"""
from responder.testing import AsyncTestClient
return AsyncTestClient(self)
@property
def static_app(self):
"""The Starlette ``StaticFiles`` application for serving static assets."""
if not hasattr(self, "_static_app"):
assert self.static_dir is not None
self._static_app = StaticFiles(directory=self.static_dir)
return self._static_app
[docs]
def before_request(self, websocket=False):
"""Register a function to run before every request.
If the hook sets ``resp.status_code``, the route handler is skipped
and the response is sent immediately (short-circuiting).
:param websocket: If ``True``, register as a WebSocket before-request hook instead of HTTP.
Usage::
@api.before_request()
def check_auth(req, resp):
if "Authorization" not in req.headers:
resp.status_code = 401
resp.media = {"error": "unauthorized"}
""" # noqa: E501
# Allow both @api.before_request and @api.before_request().
if callable(websocket):
f = websocket
self.router.before_request(f, websocket=False)
return f
def decorator(f):
self.router.before_request(f, websocket=websocket)
return f
return decorator
[docs]
def dependency(self, name=None, *, scope="request"):
"""Register a dependency provider, injected into views by parameter name.
Any view parameter (beyond ``req`` and ``resp``) whose name matches a
registered dependency receives the provider's value. Providers may be
sync or async functions, or generators — code after ``yield`` runs as
teardown once the response is sent. Providers accepting a parameter
receive the current :class:`Request`. Each dependency is resolved at
most once per request. Path parameters take precedence over
dependencies of the same name.
:param name: The injection name. Defaults to the provider's ``__name__``.
:param scope: ``"request"`` (default) resolves per request;
``"app"`` resolves once on first use and caches for the
application's lifetime — generator teardown then runs at
shutdown. App-scoped providers cannot take parameters.
Usage::
@api.dependency()
async def db():
conn = await create_connection()
yield conn
await conn.close()
@api.route("/users/{id:int}")
async def get_user(req, resp, *, id, db):
resp.media = await db.fetch_user(id)
An app-scoped dependency, shared across all requests::
@api.dependency(scope="app")
async def pool():
pool = await create_pool()
yield pool
await pool.close() # runs at application shutdown
"""
if callable(name): # Used as a bare decorator: @api.dependency
self.router.add_dependency(name.__name__, name)
return name
def decorator(f):
self.router.add_dependency(name or f.__name__, f, scope=scope)
return f
return decorator
[docs]
def add_dependency(self, name, provider, *, scope="request"):
"""Register a dependency provider under an explicit name.
:param name: The view parameter name to inject as.
:param provider: The provider function (sync/async function or generator).
:param scope: ``"request"`` (default) or ``"app"``.
"""
self.router.add_dependency(name, provider, scope=scope)
[docs]
@contextlib.contextmanager
def dependency_overrides(self, **overrides):
"""Temporarily override dependencies (for tests); restores on exit.
Each value may be a provider (a callable, with full sub-dependency and
request injection) or a bare value, which is wrapped automatically.
Overrides are request-scoped, so they replace and bypass the cache of an
``app``-scoped dependency too::
with api.dependency_overrides(db=fake_db):
api.requests.get("/users")
:param overrides: ``name=provider_or_value`` pairs to override.
"""
registry = self.router.dependency_overrides
sentinel = object()
# Snapshot only the keys this block touches, and restore them
# individually — so nested or non-LIFO override blocks don't clobber
# each other's keys.
previous: dict[str, Any] = {
name: registry.get(name, sentinel) for name in overrides
}
for name, value in overrides.items():
provider = value if callable(value) else _const_provider(value)
registry[name] = (provider, "request")
try:
yield
finally:
for name, prev in previous.items():
if prev is sentinel:
registry.pop(name, None)
else:
registry[name] = prev
[docs]
def add_health_check(self, name, check):
"""Register a readiness check run by the health endpoint.
``check`` is a sync or async callable; it passes unless it returns
``False`` or raises. The endpoint returns ``200`` when every check
passes and ``503`` otherwise, with per-check JSON. The route (default
``/health``, or the ``health_route=`` you set) is added on first use.
:param name: A label for the check, used as its key in the JSON body.
:param check: The check callable.
"""
self._health_checks[name] = check
self._ensure_health_route()
def _ensure_health_route(self):
if self._health_route_added:
return
async def _health_view(req, resp):
checks: dict = {}
healthy = True
for name, check in self._health_checks.items():
try:
if inspect.iscoroutinefunction(check):
outcome = await check()
else:
outcome = await run_in_threadpool(check)
if inspect.isawaitable(outcome):
outcome = await outcome
passed = outcome is not False
checks[name] = {"status": "ok" if passed else "error"}
healthy = healthy and passed
except Exception as exc:
healthy = False
checks[name] = {"status": "error", "detail": str(exc)}
resp.status_code = status_codes.HTTP_200 if healthy else 503
resp.media = {"status": "ok" if healthy else "error", "checks": checks}
_health_view._include_in_schema = False # type: ignore[attr-defined]
self.add_route(self._health_route or "/health", _health_view, static=False)
self._health_route_added = True
[docs]
def after_request(self, f=None):
"""Register a function to run after every request.
Works both bare and called: ``@api.after_request`` or
``@api.after_request()``.
Usage::
@api.after_request
def add_request_id(req, resp):
resp.headers["X-Request-ID"] = str(uuid.uuid4())
"""
if callable(f): # used as a bare decorator: @api.after_request
self.router.after_request(f)
return f
def decorator(func):
self.router.after_request(func)
return func
return decorator
@property
def app(self) -> ASGIApp:
"""The assembled ASGI middleware stack, built lazily on first access."""
if self._middleware_stack is None:
self._middleware_stack = self.build_middleware_stack()
return self._middleware_stack
[docs]
def build_middleware_stack(self) -> ASGIApp:
"""Assemble the full ASGI stack from the collected configuration.
Outermost → innermost: logging/request-id → metrics → ServerError →
user middleware → trusted-host → hsts → cors → sessions → gzip →
ExceptionMiddleware → router. ServerErrorMiddleware is the outermost
*application* layer (it catches errors from every middleware below it),
while the observability tier wraps even it so a rendered 500 still
carries ``X-Request-ID`` and is logged with its real status.
"""
debug = self.debug
error_handler = self._exception_handlers.get(500) or self._exception_handlers.get(
Exception
)
exc_handlers = {
k: h for k, h in self._exception_handlers.items() if k not in (500, Exception)
}
app: ASGIApp = self.router
app = ExceptionMiddleware(app, handlers=exc_handlers, debug=debug)
if self._gzip:
app = GZipMiddleware(app)
if self._session_mw is not None:
app = self._session_mw.cls(app, **self._session_mw.options)
if self._cors_params is not None:
app = CORSMiddleware(app, **self._cors_params)
if self._security_headers:
from .middleware import SecurityHeadersMiddleware
opts = (
self._security_headers if isinstance(self._security_headers, dict) else {}
)
app = SecurityHeadersMiddleware(app, **opts)
if self.hsts_enabled:
from .middleware import HSTSMiddleware
app = HTTPSRedirectMiddleware(app)
app = HSTSMiddleware(app)
app = TrustedHostMiddleware(app, allowed_hosts=self.allowed_hosts)
for mw in reversed(self._user_middleware): # index 0 wrapped last = outermost
app = mw.cls(app, **mw.options)
app = ServerErrorMiddleware(app, handler=error_handler, debug=debug)
if self._metrics is not None:
from .ext.metrics import MetricsMiddleware
app = MetricsMiddleware(app, collector=self._metrics)
if self._enable_logging:
from .ext.logging import LoggingMiddleware
app = LoggingMiddleware(app, trust_proxy_headers=self._trust_proxy_headers)
elif self._request_id:
from .ext.logging import RequestIDMiddleware
app = RequestIDMiddleware(app)
if self._trust_proxy_headers:
# Outermost: every layer below (logging IPs, HTTPS redirects,
# trusted-host checks, secure-cookie logic, url building) sees the
# scheme/host/client the original client actually used.
from .middleware import ProxyHeadersMiddleware
app = ProxyHeadersMiddleware(app)
return app
[docs]
def add_middleware(self, middleware_cls, **middleware_config):
"""Add ASGI middleware to the application (valid after construction).
User middleware sits just inside ``ServerErrorMiddleware`` (so its
errors are caught and rendered) and the most-recently-added runs first.
To wrap *everything* (including error rendering), wrap the API object:
``asgi = MyMiddleware(api)``.
:param middleware_cls: A Starlette-compatible middleware class.
:param middleware_config: Keyword arguments passed to the constructor.
"""
self._user_middleware.insert(0, _MW(middleware_cls, middleware_config))
self._middleware_stack = None # rebuild lazily
[docs]
def middleware(self, middleware_type="http"):
"""Register function-style HTTP middleware (decorator).
The decorated function receives the Starlette ``Request`` and a
``call_next`` callable, and returns the response to send — no ASGI
class boilerplate required::
import time
@api.middleware("http")
async def add_timing(request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed = time.perf_counter() - start
response.headers["X-Response-Time"] = f"{elapsed:.4f}s"
return response
Synchronous functions work too: they are offloaded to the threadpool
(like sync views), and the ``call_next`` they receive is a plain
blocking callable.
Function middleware registers through the same stack as
:meth:`add_middleware`, so the two compose freely. **Order:** among
user middleware — function-style or class-based alike — the
most-recently-registered is the outermost and sees the request first
(and the response last). Non-HTTP traffic (WebSockets, lifespan)
passes through untouched.
:param middleware_type: Only ``"http"`` is supported (the default).
The decorator may also be applied bare (``@api.middleware``).
"""
from .middleware import FunctionMiddleware
if callable(middleware_type):
# Applied bare: @api.middleware
self.add_middleware(FunctionMiddleware, func=middleware_type)
return middleware_type
if middleware_type != "http":
raise ValueError(
f"Only 'http' middleware is supported, got {middleware_type!r}."
)
def decorator(func):
self.add_middleware(FunctionMiddleware, func=func)
return func
return decorator
[docs]
def add_exception_handler(self, exc_class_or_status_code, handler):
"""Register a handler for an exception type or status code.
``handler`` is a Responder-style ``(req, resp, exc)`` callable (sync or
async). A handler for ``500``/``Exception`` installs the catch-all
server-error handler (ignored under ``debug=True``, which shows the
traceback); any other exception/status routes through the exception
middleware.
"""
self._exception_handlers[exc_class_or_status_code] = self._wrap_exc_handler(
handler
)
self._middleware_stack = None
def _wrap_exc_handler(self, func):
"""Adapt a ``(req, resp, exc)`` handler to a Starlette ``(request, exc)``."""
is_async = inspect.iscoroutinefunction(func)
async def _adapter(request, exc):
req = Request(request.scope, request.receive, api=self, formats=self.formats)
resp = Response(req=req, formats=self.formats)
if is_async:
await func(req, resp, exc)
else:
# Keep a blocking handler off the event loop.
await run_in_threadpool(func, req, resp, exc)
if resp.status_code is None:
resp.status_code = 500
body, headers = await resp.body
return StarletteResponse(
content=body, status_code=resp.status_code, headers=headers
)
return _adapter
[docs]
def exception_handler(self, exception_cls):
"""Register a handler for a specific exception type.
Usage::
@api.exception_handler(ValueError)
async def handle_value_error(req, resp, exc):
resp.status_code = 400
resp.media = {"error": str(exc)}
"""
def decorator(func):
self.add_exception_handler(exception_cls, func)
return func
return decorator
[docs]
def schema(self, name, **options):
"""
Decorator for creating new routes around function and class definitions.
Usage::
from marshmallow import Schema, fields
@api.schema("Pet")
class PetSchema(Schema):
name = fields.Str()
"""
def decorator(f):
self.openapi.add_schema(name=name, schema=f, **options)
return f
return decorator
[docs]
def generate_client(self, path=None, *, class_name="APIClient", language="python"):
"""Generate a client from this app's OpenAPI schema.
Returns source code by default. When ``path`` is provided, writes the
client module there and returns the resulting ``Path``.
Usage::
source = api.generate_client(class_name="AcmeClient")
api.generate_client("clients/acme.py", class_name="AcmeClient")
api.generate_client(
"clients/acme.ts",
class_name="AcmeClient",
language="typescript",
)
"""
if not hasattr(self, "openapi"):
raise RuntimeError(
"OpenAPI is not enabled; pass openapi=... (or docs_route=...) "
"to API() before generating a client."
)
from .ext.clientgen import generate_client, write_client
if path is None:
return generate_client(self.openapi, class_name=class_name, language=language)
return write_client(self.openapi, path, class_name=class_name, language=language)
[docs]
def path_matches_route(self, path):
"""Given a path portion of a URL, tests that it matches against any registered route.
:param path: The path portion of a URL (e.g. ``"/hello"``), to test all
known routes against. An ASGI scope mapping is also accepted.
""" # noqa: E501 (Line too long)
for route in self.router.routes:
scopes: tuple[Any, ...]
if isinstance(path, str):
# The documented contract takes a plain path string; build
# minimal HTTP and websocket scopes for it, matching by path
# regardless of any per-route method restriction. Each route
# type only ever matches the scope of its own kind.
http_scope = {"type": "http", "path": path}
methods = getattr(route, "methods", None)
if methods:
http_scope["method"] = next(iter(methods))
scopes = (http_scope, {"type": "websocket", "path": path})
else:
scopes = (path,)
for scope in scopes:
match, _ = route.matches(scope)
if match:
return route
return None
[docs]
def add_route(
self,
route=None,
endpoint=None,
*,
default=False,
static=True,
check_existing=True,
websocket=False,
before_request=False,
methods=None,
name=None,
):
"""Adds a route to the API.
:param route: A string representation of the route.
:param endpoint: The endpoint for the route -- can be a callable, or a class.
:param default: If ``True``, all unknown requests will route to this view.
:param static: If ``True`` and no endpoint was passed, render
``static/index.html`` as a legacy default route only
when ``implicit_static_fallback=True``.
:param methods: Optional list of HTTP methods (e.g. ``["GET", "POST"]``).
:param name: Optional route name for :meth:`url_for` reverse lookup.
Calling ``add_route()`` without an ``endpoint`` now raises by default.
Pass an endpoint explicitly, or serve static assets via
``static_dir``/``static_route``. Apps that need the old fallback
during migration can pass ``API(implicit_static_fallback=True)``.
""" # noqa: E501
if static and not endpoint:
if not self.implicit_static_fallback:
raise ValueError(
"Calling add_route() without an endpoint requires "
"implicit_static_fallback=True. Pass an endpoint explicitly "
"(with default=True for a catch-all), or serve static assets "
"via static_dir/static_route."
)
if self.static_dir is None:
raise ValueError(
"Cannot add a static fallback route: static_dir is disabled"
)
endpoint = self._static_response
default = True
self.router.add_route(
route,
endpoint,
default=default,
websocket=websocket,
before_request=before_request,
check_existing=check_existing,
methods=methods,
name=name,
)
async def _static_response(self, req, resp):
assert self.static_dir is not None
index = (self.static_dir / "index.html").resolve()
# Read off the event loop — this runs from an async dispatch path.
contents = await run_in_threadpool(_read_text_if_exists, index)
if contents is not None:
resp.html = contents
else:
resp.status_code = status_codes.HTTP_404
resp.text = "Not found."
[docs]
def redirect(
self,
resp: Response,
location: str,
*,
set_text: bool = True,
status_code: int | None = None,
permanent: bool = False,
allow_external: bool = True,
) -> None:
"""
Redirects a given response to a given location.
:param resp: The Response to mutate.
:param location: The location of the redirect.
:param set_text: If ``True``, sets the Redirect body content automatically.
:param status_code: an `API.status_codes` attribute, or an integer,
representing the HTTP status code of the redirect.
Defaults to ``307`` (``308`` with ``permanent=True``).
:param permanent: If ``True``, send a ``308 Permanent Redirect`` instead
of the default ``307``.
:param allow_external: If ``False``, refuse (with a ``400``) to redirect to
an external URL — pass this for user-supplied locations.
"""
resp.redirect(
location,
set_text=set_text,
status_code=status_code,
permanent=permanent,
allow_external=allow_external,
)
[docs]
def on_event(self, event_type: str, **args: Any) -> Callable[..., Any]:
"""Decorator for registering functions or coroutines to run at certain events
Supported events: startup, shutdown
Usage::
@api.on_event('startup')
async def open_database_connection_pool():
...
@api.on_event('shutdown')
async def close_database_connection_pool():
...
"""
def decorator(func):
self.add_event_handler(event_type, func, **args)
return func
return decorator
[docs]
def add_event_handler(self, event_type, handler):
"""Adds an event handler to the API.
:param event_type: A string in ("startup", "shutdown")
:param handler: The function to run. Can be either a function or a coroutine.
"""
self.router.add_event_handler(event_type, handler)
async def _drain_background_tasks(self):
"""Shutdown handler: drain the background pool off the event loop."""
await run_in_threadpool(self.background.shutdown)
[docs]
def add_security_scheme(self, name, scheme=None, *, default=False):
"""Register an OpenAPI security scheme (enables Swagger's Authorize button).
Accepts either an auth helper carrying its own definition
(``add_security_scheme(BearerAuth(...))``) or an explicit ``name`` plus a
scheme dict. With ``default=True`` the scheme becomes a global
requirement applied to every documented operation.
:param name: The scheme name, or an auth helper object.
:param scheme: The OpenAPI security-scheme dict (omit when passing a helper).
:param default: If ``True``, require this scheme on all operations.
"""
if not hasattr(self, "openapi"):
raise RuntimeError(
"OpenAPI is not enabled; pass openapi=... (or docs_route=...) to API()."
)
if scheme is None: # an auth helper carrying its own name + definition
name, scheme = name.scheme_name, name.security_scheme()
self.openapi.add_security_scheme(name, scheme, default=default)
[docs]
def policy(self, name, auth):
"""Create and register a named auth policy.
The returned policy can be passed anywhere ``auth=`` accepts an auth
helper. Naming a policy makes route declarations describe intent while
preserving the wrapped auth scheme's runtime and OpenAPI behavior::
admin = api.policy("admin", bearer.requires("admin"))
@api.get("/admin", auth=admin)
def dashboard(req, resp, *, user): ...
"""
from .ext.auth import AuthPolicy
policy = AuthPolicy(name, auth)
if policy.name in self.auth_policies:
raise ValueError(f"Auth policy {policy.name!r} is already registered")
self.auth_policies[policy.name] = policy
if hasattr(self, "openapi") and _auth_has_security_scheme(policy):
self.add_security_scheme(policy)
return policy
[docs]
def route(
self,
route=None,
*,
response_model=None,
params_model=None,
include_in_schema=True,
security=None,
tags=None,
summary=None,
description=None,
operation_id=None,
deprecated=None,
responses=None,
examples=None,
response_examples=None,
openapi_extra=None,
status_code=None,
before=None,
after=None,
auth=_UNSET,
csrf=None,
dependencies=None,
_stream_mode=None,
_stream_model=None,
_stream_heartbeat=None,
**options,
):
"""Decorator for creating new routes around function and class definitions.
Usage::
@api.route("/hello")
def hello(req, resp):
resp.text = "hello, world!"
A route can declare its default success status with ``status_code=``;
``resp.status_code`` is pre-seeded with it before the handler runs
(assigning ``resp.status_code`` in the handler still wins), and the
OpenAPI document keys the success response under it instead of
``200``::
@api.route("/items", methods=["POST"], status_code=201)
async def create_item(req, resp, *, item: ItemIn):
resp.media = {"id": 1, **item.model_dump()}
Return annotations provide response validation and OpenAPI
documentation without repeating the model in the decorator::
from pydantic import BaseModel
class ItemIn(BaseModel):
name: str
price: float
class ItemOut(BaseModel):
id: int
name: str
price: float
@api.route("/items", methods=["POST"])
async def create_item(req, resp, *, item: ItemIn) -> ItemOut:
return ItemOut(id=1, **item.model_dump())
Pydantic ``TypeAdapter``-compatible annotations such as
``list[ItemOut]`` and ``ItemOut | ErrorOut`` also infer. Pass
``response_model=False`` to disable inference for one route.
Additional statuses can declare their own validated contract with
``responses={404: ErrorOut}``. Use
``responses={404: {"model": ErrorOut, "description": "Not found"}}``
to add OpenAPI metadata to that contract.
Query parameters validate the same way with ``params_model`` —
invalid queries get a ``422``, valid ones land on
``req.state.validated_params``::
class SearchParams(BaseModel):
q: str
limit: int = 10
@api.route("/search", params_model=SearchParams)
async def search(req, resp):
params = req.state.validated_params
resp.media = {"q": params.q, "limit": params.limit}
"""
default_status_code = None
if status_code is not None:
default_status_code = int(status_code)
if not 100 <= default_status_code <= 599:
raise ValueError(
f"status_code= must be a valid HTTP status code (got {status_code!r})"
)
success_key = (
str(default_status_code) if default_status_code is not None else "200"
)
def decorator(f):
if _stream_mode is not None:
if _stream_mode not in ("sse", "ndjson"):
raise ValueError(f"Unknown typed stream mode {_stream_mode!r}")
if options.get("websocket"):
raise ValueError(
"Typed event streams are HTTP routes, not WebSockets"
)
if response_model is not None:
raise TypeError(
"Typed event streams use event_model= or item_model=; "
"response_model= describes buffered responses"
)
if default_status_code is not None and not response_status_allows_body(
default_status_code
):
raise ValueError(
f"HTTP {default_status_code} cannot carry a typed stream"
)
if _stream_heartbeat is not None and (
_stream_mode != "sse"
or isinstance(_stream_heartbeat, bool)
or not isinstance(_stream_heartbeat, (int, float))
or _stream_heartbeat <= 0
):
raise ValueError(
"A typed SSE heartbeat must be a positive number of seconds"
)
target = getattr(f, "on_get", None) if inspect.isclass(f) else f
stream_model = _stream_model
if stream_model is None and target is not None:
stream_model = inferred_stream_model(_view_return_hint(target))
if stream_model is None:
model_option = (
"event_model=" if _stream_mode == "sse" else "item_model="
)
raise TypeError(
f"@api.{_stream_mode} requires an iterator return annotation "
f"or an explicit {model_option} type"
)
_validate_declared_response_model(
stream_model,
label=(
"event_model=" if _stream_mode == "sse" else "item_model="
),
)
f._stream_mode = _stream_mode
f._stream_model = stream_model
f._stream_heartbeat = _stream_heartbeat
else:
# Route metadata is snapshotted during registration. Clearing
# these prevents a shared handler's earlier stream decoration
# from leaking into a later ordinary route.
f._stream_mode = None
f._stream_model = None
f._stream_heartbeat = None
auth_is_explicit = auth is not _UNSET
route_auth = self._auth if not auth_is_explicit else _as_tuple(auth)
# Per-route override of API(csrf=...): False exempts (e.g. a
# webhook receiver), True protects a single route app-wide-off.
# Always record this registration's value (None = "inherit the
# app default") on the view so add_route can snapshot it onto the
# Route — otherwise the attribute lingers on a shared function
# across re-registrations, or is inherited through a CBV's MRO,
# silently rewriting another route's protection.
if csrf:
if options.get("websocket"):
raise ValueError(
f"csrf=True is not supported on the WebSocket route "
f"{route!r}: the CSRF token check only runs on the HTTP "
"dispatch path, so it would silently never enforce. "
"Validate the Origin header in the handler instead."
)
if not self.sessions_enabled:
raise ValueError(
f"csrf=True on route {route!r} requires sessions: the "
"CSRF token lives in the session. Drop sessions=False, "
"or leave the route unprotected."
)
f._csrf = None if csrf is None else bool(csrf)
if before is not None:
f._route_before = _as_tuple(before)
if after is not None:
f._route_after = _as_tuple(after)
if dependencies is not None:
route_dependencies = _as_tuple(dependencies)
invalid = [d for d in route_dependencies if not isinstance(d, _Depends)]
if invalid:
raise TypeError(
"Route dependencies must be declared as Depends(...) markers."
)
f._route_dependencies = route_dependencies
if route_auth:
f._route_auth = route_auth
if security is None:
requirements = []
for a in route_auth:
requirement = _auth_security_requirement(a)
if requirement is None:
continue
if isinstance(requirement, list):
requirements.extend(requirement)
else:
requirements.append(requirement)
if requirements:
f._security = requirements
if hasattr(self, "openapi"):
for auth_scheme in route_auth:
if _auth_has_security_scheme(auth_scheme):
self.add_security_scheme(auth_scheme)
elif auth_is_explicit and security is None:
f._security = []
if response_model is not None:
if response_model is not False:
_validate_declared_response_model(
response_model, label="response_model="
)
f._response_model = response_model
# Generic response models (list[Model], unions, Page[Item], …)
# carry no single component name; the OpenAPI builder unpacks
# them via TypeAdapter.
if hasattr(self, "openapi") and _registers_as_named_component(
response_model
):
self.openapi.add_schema(
response_model.__name__, response_model, check_existing=False
)
if params_model is not None:
f._params_model = params_model
if default_status_code is not None:
f._default_status_code = default_status_code
if security is not None:
f._security = security
meta = {}
if tags is not None:
meta["tags"] = tags
if summary is not None:
meta["summary"] = summary
if description is not None:
meta["description"] = description
if operation_id is not None:
meta["operationId"] = operation_id
if deprecated is not None:
meta["deprecated"] = deprecated
response_models: dict[int, Any] = {}
if responses is not None:
normalized_responses, response_models = _normalize_openapi_responses(
responses
)
meta["responses"] = normalized_responses
if _stream_mode is not None and int(success_key) in response_models:
raise TypeError(
"A typed stream cannot also declare a buffered model for its "
f"{success_key} success response"
)
f._response_models = response_models
response_example_meta: dict[str, Any] = {}
if examples is not None:
_add_openapi_examples(response_example_meta, success_key, examples)
if response_examples is not None:
response_example_meta = _deep_merge_dicts(
response_example_meta,
_normalize_openapi_response_examples(response_examples),
)
if response_example_meta:
known_responses = set(meta.get("responses", {}))
for example_status, response in response_example_meta.items():
if (
example_status != success_key
and example_status not in known_responses
):
response.setdefault("description", "Response")
meta["responses"] = _deep_merge_dicts(
meta.get("responses", {}),
response_example_meta,
)
if openapi_extra is not None:
if not isinstance(openapi_extra, Mapping):
raise TypeError("openapi_extra= must be a mapping")
meta = _deep_merge_dicts(meta, openapi_extra)
if meta:
f._openapi_meta = meta
if not include_in_schema:
f._include_in_schema = False
if (
response_model is None
and _stream_mode is None
and not options.get("websocket")
):
_diagnose_response_annotation(f, route)
self.add_route(route, f, **options)
return f
return decorator
[docs]
def get(self, route=None, **options):
"""Register a route for ``GET`` (sugar for ``route(methods=["GET"])``)."""
return self.route(route, methods=["GET"], **options)
[docs]
def sse(self, route=None, *, event_model=None, heartbeat=None, **options):
"""Register a typed Server-Sent Events ``GET`` route.
The event data contract is inferred from ``AsyncIterator[Item]`` or
``AsyncIterator[SSE[Item]]``. Pass ``event_model=Item`` when the return
annotation cannot express it.
"""
if heartbeat is not None and (
isinstance(heartbeat, bool) or not isinstance(heartbeat, (int, float))
or heartbeat <= 0
):
raise ValueError("heartbeat= must be a positive number of seconds")
if "methods" in options:
raise TypeError("@api.sse always registers a GET route")
return self.route(
route,
methods=["GET"],
_stream_mode="sse",
_stream_model=event_model,
_stream_heartbeat=heartbeat,
**options,
)
[docs]
def ndjson(self, route=None, *, item_model=None, **options):
"""Register a typed newline-delimited JSON ``GET`` route."""
if "methods" in options:
raise TypeError("@api.ndjson always registers a GET route")
return self.route(
route,
methods=["GET"],
_stream_mode="ndjson",
_stream_model=item_model,
**options,
)
[docs]
def post(self, route=None, **options):
"""Register a route for ``POST`` (sugar for ``route(methods=["POST"])``)."""
return self.route(route, methods=["POST"], **options)
[docs]
def put(self, route=None, **options):
"""Register a route for ``PUT`` (sugar for ``route(methods=["PUT"])``)."""
return self.route(route, methods=["PUT"], **options)
[docs]
def patch(self, route=None, **options):
"""Register a route for ``PATCH`` (sugar for ``route(methods=["PATCH"])``)."""
return self.route(route, methods=["PATCH"], **options)
[docs]
def delete(self, route=None, **options):
"""Register a route for ``DELETE`` (sugar for ``route(methods=["DELETE"])``)."""
return self.route(route, methods=["DELETE"], **options)
[docs]
def head(self, route=None, **options):
"""Register a route for ``HEAD`` (sugar for ``route(methods=["HEAD"])``)."""
return self.route(route, methods=["HEAD"], **options)
[docs]
def options(self, route=None, **options):
"""Register a route for ``OPTIONS`` (sugar for ``route(methods=["OPTIONS"])``)."""
return self.route(route, methods=["OPTIONS"], **options)
[docs]
def websocket_route(self, route=None, **options):
"""Register a WebSocket route (sugar for ``route(websocket=True)``)."""
return self.route(route, websocket=True, **options)
[docs]
def graphql(
self,
route="/graphql",
*,
schema,
graphiql=True,
introspection=True,
max_depth=None,
partial_data_status=200,
):
"""Mount a GraphQL API at the given route.
Usage::
import graphene
class Query(graphene.ObjectType):
hello = graphene.String(name=graphene.String(default_value="stranger"))
def resolve_hello(self, info, name):
return f"Hello {name}"
api.graphql("/graphql", schema=graphene.Schema(query=Query))
For production, disable the in-browser IDE and introspection and cap
query depth::
api.graphql(
"/graphql", schema=schema,
graphiql=api.debug, introspection=api.debug, max_depth=10,
)
:param route: The URL path for the GraphQL endpoint.
:param schema: A Graphene schema instance.
:param graphiql: Serve the in-browser GraphiQL IDE for HTML ``GET``
requests (default ``True``).
:param introspection: Allow schema-introspection queries (default ``True``).
:param max_depth: Reject queries nested deeper than this (default unlimited).
:param partial_data_status: HTTP status for GraphQL responses that
contain both ``data`` and ``errors``. ``200`` (default) follows
the GraphQL-over-HTTP spec; ``400`` preserves Responder 8.x
behavior.
"""
from .ext.graphql import GraphQLView
self.add_route(
route,
GraphQLView(
api=self,
schema=schema,
graphiql=graphiql,
introspection=introspection,
max_depth=max_depth,
partial_data_status=partial_data_status,
),
)
[docs]
def mount(self, route, app):
"""Mounts an WSGI / ASGI application at a given route.
:param route: String representation of the route to be used
(shouldn't be parameterized).
:param app: The other WSGI / ASGI app.
"""
self.router.apps.update({route: app})
def _test_client(self, base_url="http://;", **options):
"""Build (or return the cached) Starlette TestClient for this app.
The client is cached per ``base_url``: repeated calls with the same
``base_url`` return the same client, while a different ``base_url``
builds a fresh one instead of silently reusing the old address.
Passing extra ``TestClient`` options builds a fresh client.
"""
if options:
from starlette.testclient import TestClient
return TestClient(self, base_url=base_url, **options)
if self._session is None or self._session_base_url != base_url:
from starlette.testclient import TestClient
self._session = TestClient(self, base_url=base_url)
self._session_base_url = base_url
return self._session
[docs]
def test_client(self, base_url="http://;", **options):
"""Return a Starlette ``TestClient`` connected to this app.
This is the configurable form of :attr:`requests`; pass normal
Starlette ``TestClient`` options such as
``raise_server_exceptions=False`` or a custom ``base_url``.
"""
return self._test_client(base_url=base_url, **options)
[docs]
def url_for(self, endpoint, **params):
"""Given an endpoint, returns a rendered URL for its route.
:param endpoint: The route endpoint you're searching for.
:param params: Data to pass into the URL generator (for parameterized URLs).
"""
return self.router.url_for(endpoint, **params)
[docs]
def template(self, filename, *args, **kwargs):
r"""Render a Jinja2 template file with the provided values.
:param filename: The filename of the jinja2 template, in ``templates_dir``.
:param \*args: Data to pass into the template.
:param \*\*kwargs: Data to pass into the template.
"""
return self.templates.render(filename, *args, **kwargs)
[docs]
def template_string(self, source, *args, **kwargs):
r"""Render a Jinja2 template string with the provided values.
:param source: The template to use, a Jinja2 template string.
:param \*args: Data to pass into the template.
:param \*\*kwargs: Data to pass into the template.
"""
return self.templates.render_string(source, *args, **kwargs)
[docs]
def serve(
self,
*,
address=None,
port=None,
debug=False,
server="uvicorn",
port_precedence="explicit",
**options,
):
"""
Run the application with an ASGI server.
If the ``PORT`` environment variable is set and no explicit ``port=``
is provided, requests will be served on that port automatically to all
known hosts. If both are set and disagree, the explicit ``port=`` wins
by default.
:param address: The address to bind to.
:param port: The port to bind to. If none is provided, one will be selected at random.
:param debug: Whether to run application in debug mode.
:param server: Server backend to use: ``"uvicorn"`` (default) or ``"granian"``.
:param port_precedence: ``"explicit"`` (default) lets an explicit ``port=`` win over a conflicting ``PORT`` environment variable; ``"env"`` preserves the legacy behavior.
:param options: Additional keyword arguments to send to the selected server.
""" # noqa: E501
if port_precedence not in ("env", "explicit"):
raise ValueError("port_precedence= must be 'env' or 'explicit'")
if "PORT" in os.environ:
env_port = int(os.environ["PORT"])
if port is not None and port != env_port:
if port_precedence == "env":
port = env_port
else:
port = env_port
if address is None:
address = "0.0.0.0" # noqa: S104
if address is None:
address = "127.0.0.1"
if port is None:
port = 5042
if debug:
options["log_level"] = "debug"
if server == "uvicorn":
uvicorn.run(self, host=address, port=port, **options)
return
if server == "granian":
self._serve_granian(address=address, port=port, **options)
return
raise ValueError(
f"Unsupported server {server!r}. Expected 'uvicorn' or 'granian'."
)
def _serve_granian(self, *, address, port, **options):
workers = options.pop("workers", None)
if workers not in (None, 1):
raise ValueError(
'api.run(server="granian") uses Granian embedded mode and does '
"not support multiple workers. Use the Granian CLI for "
"multi-worker production deployments."
)
try:
granian_embed = importlib.import_module("granian.server.embed")
granian_constants = importlib.import_module("granian.constants")
except ImportError as exc:
raise RuntimeError(
"Granian is not installed. Install it with: "
'pip install "responder[orjson,server]"'
) from exc
server = granian_embed.Server(
self,
address=address,
port=port,
interface=granian_constants.Interfaces.ASGI,
**options,
)
asyncio.run(server.serve())
[docs]
def run(self, **kwargs):
"""Run the application. Shorthand for :meth:`serve` that inherits the ``debug`` setting.
:param kwargs: Keyword arguments passed through to :meth:`serve`.
""" # noqa: E501
if "debug" not in kwargs:
kwargs.update({"debug": self.debug})
self.serve(**kwargs)
[docs]
def group(self, prefix):
"""Create a route group with a shared URL prefix.
Usage::
v1 = api.group("/v1")
@v1.route("/users")
def list_users(req, resp):
resp.media = []
@v1.route("/users/{id:int}")
def get_user(req, resp, *, id):
resp.media = {"id": id}
For routes declared in separate modules (without an ``API`` instance),
use :class:`responder.Router` with :meth:`include_router` instead.
"""
return RouteGroup(api=self, prefix=prefix)
[docs]
def include_router(
self,
router: _IncludableRouter,
*,
prefix: str = "",
tags: list[str] | None = None,
dependencies: Any = None,
auth: Any = _UNSET,
) -> None:
"""Attach a standalone :class:`responder.Router`'s recorded declarations.
Each recorded route is replayed through :meth:`route`, so auth
inheritance, ``Depends`` guards, and OpenAPI metadata behave exactly as
if the route had been declared on the API directly. Group-level values
compose: prefixes concatenate, ``tags`` merge (group tags first),
``dependencies`` run before route-level ones, and a route's own
``auth=`` wins over the router's, which wins over the one given here.
Inclusion is a snapshot — routes declared on the router afterwards are
not picked up. Including the same router at two prefixes is fine, but
including the same *view function* twice with different effective
auth/tags/dependencies raises, since route metadata is attached to the
view itself.
:param router: The :class:`responder.Router` to include.
:param prefix: URL prefix prepended to the router's own prefix.
:param tags: OpenAPI tags merged into every included route.
:param dependencies: ``Depends(...)`` guards run before every included
route.
:param auth: Auth helper(s) for included routes that don't set their own.
"""
if not isinstance(router, _IncludableRouter):
raise TypeError(
f"include_router() expects a responder.Router, "
f"got {type(router).__name__}"
)
prefix = _normalize_prefix(prefix)
include_tags = tuple(tags) if tags else ()
include_deps = _as_tuple(dependencies)
include_auth = _ROUTER_AUTH_UNSET if auth is _UNSET else auth
for decl in router._effective_routes():
decl = decl.merged_under(prefix, include_tags, include_deps, include_auth)
self._guard_conflicting_route_meta(decl)
kwargs: dict[str, Any] = dict(decl.options)
if decl.tags:
kwargs["tags"] = list(decl.tags)
if decl.dependencies:
kwargs["dependencies"] = list(decl.dependencies)
if decl.auth is not _ROUTER_AUTH_UNSET:
kwargs["auth"] = decl.auth
self.route(decl.route, **kwargs)(decl.endpoint)
for hook_decl in router._effective_hooks():
hook_decl = hook_decl.merged_under(prefix)
hook = hook_decl.hook
if hook_decl.prefix:
hook = _prefix_scoped_hook(hook, hook_decl.prefix)
self.router.before_request(hook, websocket=hook_decl.websocket)
def _guard_conflicting_route_meta(self, decl: Any) -> None:
"""Refuse to silently rewrite a view's route metadata on re-inclusion.
Auth, dependencies, and OpenAPI tags are attached to the view function
itself, so replaying the same view with *different* effective values
would rewrite the earlier registration's behavior in place — whether
it came from a prior inclusion or a direct ``api.route()`` call
(e.g. an include with weaker ``auth=`` must not downgrade a route
that was registered directly with stronger auth).
"""
endpoint = decl.endpoint
effective_auth = (
self._auth if decl.auth is _ROUTER_AUTH_UNSET else _as_tuple(decl.auth)
)
effective = (effective_auth, decl.dependencies, decl.tags)
previous = getattr(endpoint, "_responder_router_meta", None)
if previous is None and self._endpoint_is_registered(endpoint):
# Registered directly via api.route(): reconstruct the metadata
# the view currently carries so this inclusion cannot rewrite it.
previous = (
tuple(getattr(endpoint, "_route_auth", ())),
tuple(getattr(endpoint, "_route_dependencies", ())),
tuple(getattr(endpoint, "_openapi_meta", {}).get("tags", ())),
)
if previous is not None and previous != effective:
name = getattr(endpoint, "__name__", repr(endpoint))
raise ValueError(
f"Cannot include view {name!r} again with different "
"auth/tags/dependencies: route metadata is attached to the "
"view itself, so a second inclusion would silently rewrite "
"the first. Include it with identical settings, or use "
"separate view functions/routers."
)
try:
endpoint._responder_router_meta = effective
except (AttributeError, TypeError): # e.g. functools.partial
pass
def _endpoint_is_registered(self, endpoint: Any) -> bool:
"""Whether ``endpoint`` already backs a route on this API."""
return any(
getattr(route, "endpoint", None) is endpoint for route in self.router.routes
)
async def __call__(self, scope, receive, send):
# Expose the API on the scope up front so error handlers wrapping the
# whole stack (e.g. ServerErrorMiddleware) can read problem_details even
# for failures raised above the router, where scope["api"] is otherwise
# not yet set.
if scope["type"] in ("http", "websocket"):
scope["api"] = self
await self.app(scope, receive, send)
[docs]
class RouteGroup:
"""A group of routes with a shared URL prefix.
Before-request hooks registered on a group only run for requests whose
path falls under the group's prefix.
"""
def __init__(self, api, prefix):
self.api = api
self.prefix = prefix.rstrip("/")
def route(self, route=None, **options):
# A missing path must surface as the usual "a route path is required"
# error, not silently register a literal "/prefixNone" path.
full_route = f"{self.prefix}{route}" if route is not None else None
return self.api.route(full_route, **options)
def get(self, route=None, **options):
return self.route(route, methods=["GET"], **options)
def sse(self, route=None, **options):
full_route = f"{self.prefix}{route}" if route is not None else None
return self.api.sse(full_route, **options)
def ndjson(self, route=None, **options):
full_route = f"{self.prefix}{route}" if route is not None else None
return self.api.ndjson(full_route, **options)
def post(self, route=None, **options):
return self.route(route, methods=["POST"], **options)
def put(self, route=None, **options):
return self.route(route, methods=["PUT"], **options)
def patch(self, route=None, **options):
return self.route(route, methods=["PATCH"], **options)
def delete(self, route=None, **options):
return self.route(route, methods=["DELETE"], **options)
def head(self, route=None, **options):
return self.route(route, methods=["HEAD"], **options)
def options(self, route=None, **options):
return self.route(route, methods=["OPTIONS"], **options)
def websocket_route(self, route=None, **options):
return self.route(route, websocket=True, **options)
def _path_in_group(self, path):
return path == self.prefix or path.startswith(self.prefix + "/")
[docs]
def before_request(self, websocket=False):
"""Register a hook that runs before requests under this group's prefix."""
def decorator(f):
if inspect.iscoroutinefunction(f):
async def hook(target, *rest):
if self._path_in_group(target.url.path):
await f(target, *rest)
else:
def hook(target, *rest):
if self._path_in_group(target.url.path):
f(target, *rest)
functools.wraps(f)(hook)
self.api.router.before_request(hook, websocket=websocket)
return f
return decorator