mirror of
https://github.com/kennethreitz/responder.git
synced 2026-07-22 02:39:29 +00:00
1541 lines
60 KiB
Python
1541 lines
60 KiB
Python
import asyncio
|
|
import contextlib
|
|
import functools
|
|
import importlib
|
|
import inspect
|
|
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 .errors import (
|
|
INTERNAL_SERVER_ERROR,
|
|
PROBLEM_JSON,
|
|
legacy_error_payload,
|
|
problem_payload_for,
|
|
status_title,
|
|
)
|
|
from .formats import get_formats
|
|
from .models import Request, Response
|
|
from .params import _Depends
|
|
from .routes import Router, _is_pydantic_model
|
|
from .staticfiles import StaticFiles
|
|
from .statics import DEFAULT_CORS_PARAMS, 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
|
|
|
|
|
|
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)
|
|
if getattr(request.scope.get("api"), "problem_details", True):
|
|
return JSONResponse(
|
|
problem_payload_for(
|
|
request.scope,
|
|
exc.status_code,
|
|
exc.detail,
|
|
title=status_title(exc.status_code),
|
|
request=request,
|
|
exc=exc,
|
|
),
|
|
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(
|
|
problem_payload_for(
|
|
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):
|
|
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) -> 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 _normalize_openapi_responses(responses: Mapping[Any, Any]) -> dict[str, Any]:
|
|
if not isinstance(responses, Mapping):
|
|
raise TypeError("responses= must be a mapping of status codes to responses")
|
|
return {
|
|
str(status): _openapi_response(response)
|
|
for status, response in responses.items()
|
|
}
|
|
|
|
|
|
_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"))
|
|
|
|
|
|
def abort(status_code, *, detail=None, headers=None):
|
|
"""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")
|
|
|
|
: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.
|
|
"""
|
|
raise HTTPException(status_code=status_code, detail=detail, headers=headers)
|
|
|
|
|
|
class API:
|
|
"""The primary web-service class.
|
|
|
|
:param static_dir: The directory to use for static files. Will be created for you if it doesn't already exist.
|
|
:param templates_dir: The directory to use for templates. Will be created for you if it doesn't already exist.
|
|
: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="static",
|
|
static_route="/static",
|
|
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,
|
|
redirect_slashes=True,
|
|
max_request_size=None,
|
|
auto_etag=False,
|
|
auto_vary=True,
|
|
request_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,
|
|
health_route=None,
|
|
encoder=None,
|
|
json_ensure_ascii=False,
|
|
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. Set to ``None`` to disable. Created automatically if missing.
|
|
:param static_route: URL prefix for serving static files (default ``"/static"``).
|
|
: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 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. Bodies larger than this get a ``413`` response. ``None`` (the default) means unlimited.
|
|
: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 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 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 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 synchronous callable that can enrich or replace each problem-details payload. It receives ``(payload, request, exc)``; returning ``None`` means the payload was mutated in place.
|
|
: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
|
|
)
|
|
|
|
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,
|
|
trace_dispatch=trace_dispatch,
|
|
problem_details=problem_details,
|
|
)
|
|
self.router.api = self
|
|
|
|
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.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
|
|
|
|
if self.static_dir is not None:
|
|
self.static_dir.mkdir(parents=True, exist_ok=True)
|
|
self.mount(self.static_route, self.static_app)
|
|
|
|
self._session = 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._metrics = None
|
|
self._session_mw: _MW | None = None
|
|
|
|
if metrics_route:
|
|
from .ext.metrics import MetricsCollector
|
|
|
|
self.metrics = MetricsCollector()
|
|
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 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"
|
|
) 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.session()
|
|
|
|
@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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
@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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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)
|
|
elif self._request_id:
|
|
from .ext.logging import RequestIDMiddleware
|
|
|
|
app = RequestIDMiddleware(app)
|
|
return app
|
|
|
|
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
|
|
|
|
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:
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
)
|
|
|
|
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, to test all known routes against.
|
|
""" # noqa: E501 (Line too long)
|
|
for route in self.router.routes:
|
|
match, _ = route.matches(path)
|
|
if match:
|
|
return route
|
|
return None
|
|
|
|
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".
|
|
Also, it will become a default route.
|
|
:param methods: Optional list of HTTP methods (e.g. ``["GET", "POST"]``).
|
|
:param name: Optional route name for :meth:`url_for` reverse lookup.
|
|
""" # noqa: E501
|
|
|
|
if static and not endpoint:
|
|
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."
|
|
|
|
def redirect(
|
|
self,
|
|
resp,
|
|
location,
|
|
*,
|
|
set_text=True,
|
|
status_code=status_codes.HTTP_301,
|
|
allow_external=True,
|
|
):
|
|
"""
|
|
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.
|
|
: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,
|
|
allow_external=allow_external,
|
|
)
|
|
|
|
def on_event(self, event_type: str, **args):
|
|
"""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
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
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,
|
|
before=None,
|
|
after=None,
|
|
auth=_UNSET,
|
|
dependencies=None,
|
|
**options,
|
|
):
|
|
"""Decorator for creating new routes around function and class definitions.
|
|
|
|
Usage::
|
|
|
|
@api.route("/hello")
|
|
def hello(req, resp):
|
|
resp.text = "hello, world!"
|
|
|
|
With Pydantic models for validation and OpenAPI documentation::
|
|
|
|
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"], response_model=ItemOut)
|
|
async def create_item(req, resp, *, item: ItemIn):
|
|
resp.media = {"id": 1, **item.model_dump()}
|
|
|
|
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}
|
|
|
|
"""
|
|
|
|
def decorator(f):
|
|
auth_is_explicit = auth is not _UNSET
|
|
route_auth = self._auth if not auth_is_explicit else _as_tuple(auth)
|
|
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:
|
|
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 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
|
|
if responses is not None:
|
|
meta["responses"] = _normalize_openapi_responses(responses)
|
|
response_example_meta: dict[str, Any] = {}
|
|
if examples is not None:
|
|
_add_openapi_examples(response_example_meta, "200", 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 status_code, response in response_example_meta.items():
|
|
if status_code != "200" and status_code 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
|
|
self.add_route(route, f, **options)
|
|
return f
|
|
|
|
return decorator
|
|
|
|
def get(self, route=None, **options):
|
|
"""Register a route for ``GET`` (sugar for ``route(methods=["GET"])``)."""
|
|
return self.route(route, methods=["GET"], **options)
|
|
|
|
def post(self, route=None, **options):
|
|
"""Register a route for ``POST`` (sugar for ``route(methods=["POST"])``)."""
|
|
return self.route(route, methods=["POST"], **options)
|
|
|
|
def put(self, route=None, **options):
|
|
"""Register a route for ``PUT`` (sugar for ``route(methods=["PUT"])``)."""
|
|
return self.route(route, methods=["PUT"], **options)
|
|
|
|
def patch(self, route=None, **options):
|
|
"""Register a route for ``PATCH`` (sugar for ``route(methods=["PATCH"])``)."""
|
|
return self.route(route, methods=["PATCH"], **options)
|
|
|
|
def delete(self, route=None, **options):
|
|
"""Register a route for ``DELETE`` (sugar for ``route(methods=["DELETE"])``)."""
|
|
return self.route(route, methods=["DELETE"], **options)
|
|
|
|
def websocket_route(self, route=None, **options):
|
|
"""Register a WebSocket route (sugar for ``route(websocket=True)``)."""
|
|
return self.route(route, websocket=True, **options)
|
|
|
|
def graphql(
|
|
self,
|
|
route="/graphql",
|
|
*,
|
|
schema,
|
|
graphiql=True,
|
|
introspection=True,
|
|
max_depth=None,
|
|
):
|
|
"""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).
|
|
"""
|
|
from .ext.graphql import GraphQLView
|
|
|
|
self.add_route(
|
|
route,
|
|
GraphQLView(
|
|
api=self,
|
|
schema=schema,
|
|
graphiql=graphiql,
|
|
introspection=introspection,
|
|
max_depth=max_depth,
|
|
),
|
|
)
|
|
|
|
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 session(self, base_url="http://;"):
|
|
"""Testing HTTP client. Returns a Starlette TestClient instance,
|
|
able to send HTTP requests to the Responder application.
|
|
|
|
:param base_url: The base URL for the test client.
|
|
"""
|
|
|
|
if self._session is None:
|
|
from starlette.testclient import TestClient
|
|
|
|
self._session = TestClient(self, base_url=base_url)
|
|
return self._session
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
def serve(self, *, address=None, port=None, debug=False, server="uvicorn", **options):
|
|
"""
|
|
Run the application with an ASGI server.
|
|
|
|
If the ``PORT`` environment variable is set, requests will be served on that port
|
|
automatically to all known hosts.
|
|
|
|
: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 options: Additional keyword arguments to send to the selected server.
|
|
""" # noqa: E501
|
|
|
|
if "PORT" in os.environ:
|
|
if address is None:
|
|
address = "0.0.0.0" # noqa: S104
|
|
port = int(os.environ["PORT"])
|
|
|
|
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[server]"'
|
|
) from exc
|
|
|
|
server = granian_embed.Server(
|
|
self,
|
|
address=address,
|
|
port=port,
|
|
interface=granian_constants.Interfaces.ASGI,
|
|
**options,
|
|
)
|
|
asyncio.run(server.serve())
|
|
|
|
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)
|
|
|
|
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}
|
|
|
|
"""
|
|
return RouteGroup(api=self, prefix=prefix)
|
|
|
|
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)
|
|
|
|
|
|
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):
|
|
full_route = f"{self.prefix}{route}"
|
|
return self.api.route(full_route, **options)
|
|
|
|
def get(self, route=None, **options):
|
|
return self.route(route, methods=["GET"], **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 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 + "/")
|
|
|
|
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
|