mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce3ab46d59 | |||
| 6f9c87d71c | |||
| 29d0621d98 | |||
| 30fa2dfda7 | |||
| 43c803a426 | |||
| ff6d530338 | |||
| a375984310 | |||
| 46c6f440c5 | |||
| c87e8c876d | |||
| f86c7eed70 | |||
| 9d492a383c | |||
| 77ae49aaef | |||
| 74c872ed57 | |||
| 724b769c9e | |||
| 4f02016ed6 |
@@ -20,11 +20,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [
|
||||
"3.9",
|
||||
"3.10",
|
||||
"3.11",
|
||||
"3.12",
|
||||
"3.13",
|
||||
"3.14",
|
||||
"3.14t",
|
||||
"pypy3.11",
|
||||
]
|
||||
env:
|
||||
UV_SYSTEM_PYTHON: true
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# .readthedocs.yml
|
||||
# Read the Docs configuration file
|
||||
|
||||
# Details
|
||||
# - https://docs.readthedocs.io/en/stable/config-file/v2.html
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: "ubuntu-24.04"
|
||||
tools:
|
||||
python: "3.12"
|
||||
|
||||
python:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
||||
|
||||
sphinx:
|
||||
configuration: docs/source/conf.py
|
||||
|
||||
# Use standard HTML builder.
|
||||
builder: html
|
||||
|
||||
# Fail on all warnings to avoid broken references.
|
||||
fail_on_warning: true
|
||||
|
||||
# Optionally build your docs in additional formats such as PDF
|
||||
#formats:
|
||||
# - pdf
|
||||
+56
-1
@@ -7,6 +7,57 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v3.5.0] - 2026-03-24
|
||||
|
||||
### Added
|
||||
|
||||
- CI validation for Python 3.14, 3.14 free-threaded, and PyPy 3.11
|
||||
- Marimo notebook mounting docs and example
|
||||
- Type annotations for `routes.py`
|
||||
- `uv.lock` for reproducible installs
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced deprecated `asyncio.iscoroutinefunction` with `inspect.iscoroutinefunction` ahead of Python 3.16 removal
|
||||
- Narrowed broad `except Exception` to specific exceptions in response model serialization and websocket chat example
|
||||
- Improved GraphQL API interface with expanded test coverage
|
||||
- Code formatting cleanup via pyproject-fmt and ruff
|
||||
- Dropped Python 3.9 from CI
|
||||
|
||||
### Fixed
|
||||
|
||||
- WSGI mount returning 400 when requesting the exact mount root path
|
||||
- Werkzeug 3.1.7 compatibility for trusted host validation in tests
|
||||
- `future.result` bare property access in background task test (now properly calls `future.result()`)
|
||||
- OpenAPI template packaging and static file serving
|
||||
- RST title underline warning breaking docs CI
|
||||
|
||||
### Removed
|
||||
|
||||
- Read the Docs configuration (docs hosted on GitHub Pages)
|
||||
|
||||
## [v3.4.0] - 2026-03-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded to Starlette 1.0
|
||||
- Added comprehensive docstrings across the codebase
|
||||
- Expanded API reference documentation
|
||||
|
||||
## [v3.3.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
- Full documentation rewrite: tutorials for REST APIs, SQLAlchemy, Flask migration
|
||||
- Auth, WebSocket, middleware, and configuration guides
|
||||
- Testing docs with prose, examples, and tips
|
||||
- GitHub Pages deployment for docs
|
||||
|
||||
### Changed
|
||||
|
||||
- Reworked homepage prose
|
||||
- Rewrote CLI and API reference docs
|
||||
|
||||
## [v3.2.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
@@ -411,7 +462,11 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
- Conception!
|
||||
|
||||
[unreleased]: https://github.com/kennethreitz/responder/compare/v3.0.0..HEAD
|
||||
[unreleased]: https://github.com/kennethreitz/responder/compare/v3.5.0..HEAD
|
||||
[v3.5.0]: https://github.com/kennethreitz/responder/compare/v3.4.0..v3.5.0
|
||||
[v3.4.0]: https://github.com/kennethreitz/responder/compare/v3.3.0..v3.4.0
|
||||
[v3.3.0]: https://github.com/kennethreitz/responder/compare/v3.2.0..v3.3.0
|
||||
[v3.2.0]: https://github.com/kennethreitz/responder/compare/v3.0.0..v3.2.0
|
||||
[v3.0.0]: https://github.com/kennethreitz/responder/compare/v2.0.5..v3.0.0
|
||||
[v2.0.5]: https://github.com/kennethreitz/responder/compare/v2.0.4..v2.0.5
|
||||
[v2.0.4]: https://github.com/kennethreitz/responder/compare/v2.0.3..v2.0.4
|
||||
|
||||
@@ -17,7 +17,7 @@ if __name__ == "__main__":
|
||||
|
||||
$ pip install responder
|
||||
|
||||
That's it. Supports Python 3.9+.
|
||||
That's it. Supports Python 3.10+.
|
||||
|
||||
## The Basics
|
||||
|
||||
|
||||
@@ -43,6 +43,45 @@ status code, headers, and cookies.
|
||||
:inherited-members:
|
||||
|
||||
|
||||
Route Groups
|
||||
------------
|
||||
|
||||
Group related routes under a shared URL prefix — useful for API versioning
|
||||
and organizing large applications.
|
||||
|
||||
.. autoclass:: responder.api.RouteGroup
|
||||
:members:
|
||||
|
||||
|
||||
Background Queue
|
||||
----------------
|
||||
|
||||
Run tasks in background threads without blocking the response. Available
|
||||
as ``api.background``.
|
||||
|
||||
.. autoclass:: responder.background.BackgroundQueue
|
||||
:members:
|
||||
|
||||
|
||||
Query Dict
|
||||
----------
|
||||
|
||||
A dictionary subclass for query string parameters with multi-value support.
|
||||
|
||||
.. autoclass:: responder.models.QueryDict
|
||||
:members:
|
||||
|
||||
|
||||
Rate Limiter
|
||||
------------
|
||||
|
||||
In-memory token bucket rate limiter. Limits requests per client IP address
|
||||
and returns ``429 Too Many Requests`` when exceeded.
|
||||
|
||||
.. autoclass:: responder.ext.ratelimit.RateLimiter
|
||||
:members:
|
||||
|
||||
|
||||
Status Code Helpers
|
||||
-------------------
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ Installation
|
||||
|
||||
$ uv pip install responder
|
||||
|
||||
Python 3.9 and above. That's it.
|
||||
Python 3.10 and above. That's it.
|
||||
|
||||
|
||||
.. toctree::
|
||||
|
||||
@@ -416,6 +416,22 @@ Requests to ``/flask/`` will be handled by Flask. Everything else goes
|
||||
through Responder. Both WSGI and ASGI apps are supported — Responder
|
||||
wraps WSGI apps in an ASGI adapter automatically.
|
||||
|
||||
You can also mount `marimo <https://marimo.io/>`_ notebooks as
|
||||
interactive dashboards within your API::
|
||||
|
||||
import marimo
|
||||
|
||||
server = (
|
||||
marimo.create_asgi_app()
|
||||
.with_app(path="", root="./notebooks/dashboard.py")
|
||||
.with_app(path="/analysis", root="./notebooks/analysis.py")
|
||||
)
|
||||
|
||||
api.mount("/notebooks", server.build())
|
||||
|
||||
Notebooks are served at ``/notebooks/`` and ``/notebooks/analysis``,
|
||||
with full interactivity — reactive cells, widgets, plots, and all.
|
||||
|
||||
|
||||
Cookies
|
||||
-------
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Writing Middleware
|
||||
=================
|
||||
==================
|
||||
|
||||
Middleware sits between the server and your route handlers, processing
|
||||
every request and response that flows through your application. It's the
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Mount marimo notebooks inside a Responder API.
|
||||
|
||||
Requirements:
|
||||
pip install responder marimo
|
||||
|
||||
Usage:
|
||||
python examples/marimo_mount.py
|
||||
|
||||
Then visit:
|
||||
http://127.0.0.1:5042/hello → Responder JSON endpoint
|
||||
http://127.0.0.1:5042/notebooks/ → Interactive marimo notebook
|
||||
"""
|
||||
|
||||
import marimo
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
|
||||
@api.route("/hello")
|
||||
def hello(req, resp):
|
||||
resp.media = {"message": "Hello from Responder!"}
|
||||
|
||||
|
||||
# Mount marimo notebooks at /notebooks
|
||||
server = marimo.create_asgi_app().with_app(path="", root="notebooks/hello.py")
|
||||
api.mount("/notebooks", server.build())
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
@@ -1,8 +1,9 @@
|
||||
# Complete REST API example with Pydantic validation.
|
||||
# https://responder.kennethreitz.org/tutorial-rest.html
|
||||
import responder
|
||||
from pydantic import BaseModel
|
||||
|
||||
import responder
|
||||
|
||||
|
||||
class BookIn(BaseModel):
|
||||
title: str
|
||||
@@ -35,8 +36,13 @@ def list_books(req, resp):
|
||||
resp.media = list(books_db.values())
|
||||
|
||||
|
||||
@api.route("/books", methods=["POST"], check_existing=False,
|
||||
request_model=BookIn, response_model=BookOut)
|
||||
@api.route(
|
||||
"/books",
|
||||
methods=["POST"],
|
||||
check_existing=False,
|
||||
request_model=BookIn,
|
||||
response_model=BookOut,
|
||||
)
|
||||
async def create_book(req, resp):
|
||||
global next_id
|
||||
data = await req.media()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# WebSocket chat room example.
|
||||
# https://responder.kennethreitz.org/tutorial-websockets.html
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
@@ -35,7 +37,7 @@ def index(req, resp):
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
@api.route("/chat", websocket=True)
|
||||
@@ -47,7 +49,7 @@ async def chat(ws):
|
||||
message = await ws.receive_text()
|
||||
for client in connected:
|
||||
await client.send_text(message)
|
||||
except Exception:
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
connected.discard(ws)
|
||||
|
||||
+57
-74
@@ -8,11 +8,11 @@ requires = [
|
||||
name = "responder"
|
||||
description = "A familiar HTTP Service Framework for Python."
|
||||
readme = "README.md"
|
||||
license = {text = "Apache 2.0"}
|
||||
license = { text = "Apache 2.0" }
|
||||
authors = [
|
||||
{ name = "Kenneth Reitz", email = "me@kennethreitz.org" },
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Web Environment",
|
||||
@@ -20,19 +20,21 @@ classifiers = [
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Programming Language :: Python :: Free Threading",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
dynamic = [ "version" ]
|
||||
dependencies = [
|
||||
"a2wsgi",
|
||||
"apispec>=1.0.0",
|
||||
"apispec>=1",
|
||||
"chardet",
|
||||
"docopt-ng",
|
||||
"graphene>=3",
|
||||
@@ -42,17 +44,15 @@ dependencies = [
|
||||
"pueblo[sfa-full]>=0.0.11",
|
||||
"pydantic>=2",
|
||||
"python-multipart",
|
||||
"starlette[full]>=0.40",
|
||||
"starlette[full]>=1",
|
||||
"uvicorn[standard]",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
develop = [
|
||||
optional-dependencies.develop = [
|
||||
"pyproject-fmt",
|
||||
"ruff",
|
||||
"validate-pyproject",
|
||||
]
|
||||
docs = [
|
||||
optional-dependencies.docs = [
|
||||
"alabaster<1.1",
|
||||
"myst-parser",
|
||||
"sphinx>=5,<9",
|
||||
@@ -60,8 +60,8 @@ docs = [
|
||||
"sphinx-copybutton",
|
||||
"sphinx-design-elements",
|
||||
]
|
||||
release = ["build", "twine"]
|
||||
test = [
|
||||
optional-dependencies.release = [ "build", "twine" ]
|
||||
optional-dependencies.test = [
|
||||
"flask",
|
||||
"mypy",
|
||||
"pytest",
|
||||
@@ -69,32 +69,22 @@ test = [
|
||||
"pytest-mock",
|
||||
"pytest-rerunfailures",
|
||||
]
|
||||
urls.Documentation = "https://responder.kennethreitz.org"
|
||||
urls.Homepage = "https://github.com/kennethreitz/responder"
|
||||
urls.Issues = "https://github.com/kennethreitz/responder/issues"
|
||||
urls.Repository = "https://github.com/kennethreitz/responder"
|
||||
scripts.responder = "responder.ext.cli:cli"
|
||||
|
||||
[project.scripts]
|
||||
responder = "responder.ext.cli:cli"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/kennethreitz/responder"
|
||||
Documentation = "https://responder.kennethreitz.org"
|
||||
Repository = "https://github.com/kennethreitz/responder"
|
||||
Issues = "https://github.com/kennethreitz/responder/issues"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "responder.__version__.__version__"}
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
responder = ["py.typed"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
exclude = ["tests"]
|
||||
[tool.setuptools]
|
||||
dynamic.version = { attr = "responder.__version__.__version__" }
|
||||
package-data.responder = [ "py.typed", "ext/openapi/docs/*.html" ]
|
||||
packages.find.exclude = [ "tests" ]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 90
|
||||
|
||||
extend-exclude = [
|
||||
"docs/source/conf.py",
|
||||
]
|
||||
|
||||
lint.select = [
|
||||
# Builtins
|
||||
"A",
|
||||
@@ -122,59 +112,20 @@ lint.select = [
|
||||
# flake8-2020
|
||||
"YTT",
|
||||
]
|
||||
|
||||
lint.extend-ignore = [
|
||||
"S101", # Allow use of `assert`.
|
||||
"S101", # Allow use of `assert`.
|
||||
]
|
||||
|
||||
lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module
|
||||
|
||||
lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module
|
||||
lint.per-file-ignores."tests/*" = [
|
||||
"ERA001", # Found commented-out code.
|
||||
"S101", # Allow use of `assert`, and `print`.
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = """
|
||||
-rfEXs -p pytester --strict-markers --verbosity=3
|
||||
--cov --cov-report=term-missing --cov-report=xml
|
||||
"""
|
||||
filterwarnings = [
|
||||
"error::UserWarning",
|
||||
]
|
||||
log_level = "DEBUG"
|
||||
log_cli_level = "DEBUG"
|
||||
log_format = "%(asctime)-15s [%(name)-36s] %(levelname)-8s: %(message)s"
|
||||
minversion = "2.0"
|
||||
testpaths = [
|
||||
"responder",
|
||||
"tests",
|
||||
]
|
||||
markers = [
|
||||
]
|
||||
xfail_strict = true
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = false
|
||||
omit = [
|
||||
"*.html",
|
||||
"tests/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 0
|
||||
show_missing = true
|
||||
exclude_lines = [
|
||||
"# pragma: no cover",
|
||||
"raise NotImplemented",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
packages = [
|
||||
"responder",
|
||||
]
|
||||
exclude = [
|
||||
]
|
||||
exclude = []
|
||||
check_untyped_defs = true
|
||||
explicit_package_bases = true
|
||||
ignore_missing_imports = true
|
||||
@@ -182,3 +133,35 @@ implicit_optional = true
|
||||
install_types = true
|
||||
namespace_packages = true
|
||||
non_interactive = true
|
||||
|
||||
[tool.pytest]
|
||||
ini_options.addopts = """
|
||||
-rfEXs -p pytester --strict-markers --verbosity=3
|
||||
--cov --cov-report=term-missing --cov-report=xml
|
||||
"""
|
||||
ini_options.filterwarnings = [
|
||||
"error::UserWarning",
|
||||
]
|
||||
ini_options.log_level = "DEBUG"
|
||||
ini_options.log_cli_level = "DEBUG"
|
||||
ini_options.log_format = "%(asctime)-15s [%(name)-36s] %(levelname)-8s: %(message)s"
|
||||
ini_options.minversion = "2.0"
|
||||
ini_options.testpaths = [
|
||||
"responder",
|
||||
"tests",
|
||||
]
|
||||
ini_options.markers = []
|
||||
ini_options.xfail_strict = true
|
||||
|
||||
[tool.coverage]
|
||||
run.branch = false
|
||||
run.omit = [
|
||||
"*.html",
|
||||
"tests/*",
|
||||
]
|
||||
report.exclude_lines = [
|
||||
"# pragma: no cover",
|
||||
"raise NotImplemented",
|
||||
]
|
||||
report.fail_under = 0
|
||||
report.show_missing = true
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.3.0"
|
||||
__version__ = "3.5.0"
|
||||
|
||||
+65
-4
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
@@ -61,6 +62,31 @@ class API:
|
||||
lifespan=None,
|
||||
request_id=False,
|
||||
):
|
||||
"""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 all HTTP requests to HTTPS.
|
||||
: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 request_id: If ``True``, add ``X-Request-ID`` headers to all responses.
|
||||
""" # noqa: E501
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
self.secret_key = secret_key
|
||||
@@ -136,9 +162,7 @@ class API:
|
||||
import uuid as _uuid
|
||||
|
||||
def _add_request_id(req, resp):
|
||||
rid = req.headers.get(
|
||||
"X-Request-ID", str(_uuid.uuid4())
|
||||
)
|
||||
rid = req.headers.get("X-Request-ID", str(_uuid.uuid4()))
|
||||
resp.headers["X-Request-ID"] = rid
|
||||
|
||||
self.router.after_request(_add_request_id)
|
||||
@@ -150,12 +174,30 @@ class API:
|
||||
|
||||
@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
|
||||
|
||||
def decorator(f):
|
||||
self.router.before_request(f, websocket=websocket)
|
||||
return f
|
||||
@@ -180,6 +222,21 @@ class API:
|
||||
return decorator
|
||||
|
||||
def add_middleware(self, middleware_cls, **middleware_config):
|
||||
"""Add ASGI middleware to the application.
|
||||
|
||||
Middleware wraps the entire application and can inspect or modify
|
||||
every request and response. Middleware is applied in reverse order —
|
||||
the last middleware added runs first.
|
||||
|
||||
:param middleware_cls: A Starlette-compatible middleware class.
|
||||
:param middleware_config: Keyword arguments passed to the middleware constructor.
|
||||
|
||||
Usage::
|
||||
|
||||
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||||
api.add_middleware(HTTPSRedirectMiddleware)
|
||||
|
||||
"""
|
||||
self.app = middleware_cls(self.app, **middleware_config)
|
||||
|
||||
def exception_handler(self, exception_cls):
|
||||
@@ -200,7 +257,7 @@ class API:
|
||||
|
||||
req = Request(request.scope, request.receive, formats=get_formats())
|
||||
resp = Response(req=req, formats=get_formats())
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
if inspect.iscoroutinefunction(func):
|
||||
await func(req, resp, exc)
|
||||
else:
|
||||
func(req, resp, exc)
|
||||
@@ -501,6 +558,10 @@ class API:
|
||||
uvicorn.run(self, host=address, port=port, **options)
|
||||
|
||||
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)
|
||||
|
||||
+41
-1
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import multiprocessing
|
||||
import traceback
|
||||
|
||||
@@ -9,7 +10,33 @@ __all__ = ["BackgroundQueue"]
|
||||
|
||||
|
||||
class BackgroundQueue:
|
||||
"""A queue for running tasks in background threads.
|
||||
|
||||
Uses a ``ThreadPoolExecutor`` sized to the number of CPUs. Access it
|
||||
via ``api.background``.
|
||||
|
||||
Usage::
|
||||
|
||||
# As a decorator — fire and forget
|
||||
@api.background.task
|
||||
def send_email(to, subject):
|
||||
...
|
||||
|
||||
send_email("user@example.com", "Hello")
|
||||
|
||||
# Direct submission
|
||||
future = api.background.run(send_email, "user@example.com", "Hello")
|
||||
|
||||
# As a callable (supports async functions)
|
||||
await api.background(send_email, "user@example.com", "Hello")
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, n=None):
|
||||
"""Create a new background queue.
|
||||
|
||||
:param n: Number of worker threads. Defaults to CPU count.
|
||||
"""
|
||||
if n is None:
|
||||
n = multiprocessing.cpu_count()
|
||||
|
||||
@@ -18,11 +45,24 @@ class BackgroundQueue:
|
||||
self.results = []
|
||||
|
||||
def run(self, f, *args, **kwargs):
|
||||
"""Submit a function to run in a background thread.
|
||||
|
||||
:param f: The function to run.
|
||||
:returns: A ``concurrent.futures.Future`` for the result.
|
||||
"""
|
||||
f = self.pool.submit(f, *args, **kwargs)
|
||||
self.results.append(f)
|
||||
return f
|
||||
|
||||
def task(self, f):
|
||||
"""Decorator that wraps a function to run in the background thread pool.
|
||||
|
||||
The decorated function returns a ``Future`` instead of blocking.
|
||||
Exceptions are printed to stderr via traceback.
|
||||
|
||||
:param f: The function to wrap.
|
||||
"""
|
||||
|
||||
def on_future_done(fs):
|
||||
try:
|
||||
fs.result()
|
||||
@@ -37,6 +77,6 @@ class BackgroundQueue:
|
||||
return do_task
|
||||
|
||||
async def __call__(self, func, *args, **kwargs) -> None:
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return await asyncio.create_task(func(*args, **kwargs))
|
||||
return await run_in_threadpool(func, *args, **kwargs)
|
||||
|
||||
@@ -4,12 +4,43 @@ from .templates import GRAPHIQL
|
||||
|
||||
|
||||
class GraphQLView:
|
||||
"""A class-based view that serves a GraphQL API.
|
||||
|
||||
Handles query resolution from multiple sources (JSON body, query
|
||||
parameters, raw request text) and renders the GraphiQL IDE for
|
||||
browser requests.
|
||||
|
||||
:param api: The Responder API instance.
|
||||
:param schema: A Graphene schema instance.
|
||||
"""
|
||||
|
||||
def __init__(self, *, api, schema):
|
||||
self.api = api
|
||||
self.schema = schema
|
||||
|
||||
@staticmethod
|
||||
def _parse_variables(raw):
|
||||
"""Parse variables from a string (query param) or return as-is (dict)."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return raw
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req, resp):
|
||||
"""Extract query, variables, and operationName from the request.
|
||||
|
||||
Supports multiple input sources, checked in order:
|
||||
|
||||
1. JSON body (``Content-Type: application/json``)
|
||||
2. Form data (``Content-Type: application/x-www-form-urlencoded``)
|
||||
3. Query parameters (``?query=...&variables=...&operationName=...``)
|
||||
4. Raw request text
|
||||
"""
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
if "query" not in json_media:
|
||||
@@ -22,9 +53,22 @@ class GraphQLView:
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
|
||||
# Support query/q in params.
|
||||
if "form" in req.mimetype:
|
||||
form_data = await req.media("form")
|
||||
if "query" in form_data:
|
||||
return (
|
||||
form_data["query"],
|
||||
GraphQLView._parse_variables(form_data.get("variables")),
|
||||
form_data.get("operationName"),
|
||||
)
|
||||
|
||||
# Support query/variables/operationName in query params.
|
||||
if "query" in req.params:
|
||||
return req.params["query"], None, None
|
||||
return (
|
||||
req.params["query"],
|
||||
GraphQLView._parse_variables(req.params.get("variables")),
|
||||
req.params.get("operationName"),
|
||||
)
|
||||
if "q" in req.params:
|
||||
return req.params["q"], None, None
|
||||
|
||||
@@ -32,6 +76,7 @@ class GraphQLView:
|
||||
return await req.text, None, None
|
||||
|
||||
async def graphql_response(self, req, resp):
|
||||
"""Process a GraphQL request and populate the response."""
|
||||
show_graphiql = req.method == "get" and req.accepts("text/html")
|
||||
|
||||
if show_graphiql:
|
||||
|
||||
@@ -129,7 +129,9 @@ class OpenAPISchema:
|
||||
op["requestBody"] = {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": f"#/components/schemas/{model_name}"}
|
||||
"schema": {
|
||||
"$ref": f"#/components/schemas/{model_name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,7 @@ class RateLimiter:
|
||||
def _cleanup(self, key):
|
||||
now = time.time()
|
||||
cutoff = now - self.period
|
||||
self._buckets[key] = [
|
||||
t for t in self._buckets[key] if t > cutoff
|
||||
]
|
||||
self._buckets[key] = [t for t in self._buckets[key] if t > cutoff]
|
||||
|
||||
def check(self, req, resp):
|
||||
"""Check rate limit. Sets 429 status if exceeded."""
|
||||
|
||||
+79
-10
@@ -49,6 +49,12 @@ class CaseInsensitiveDict(dict):
|
||||
|
||||
|
||||
class QueryDict(dict):
|
||||
"""A dictionary for query string parameters that handles multi-value keys.
|
||||
|
||||
Single-value access returns the last value for a key. Use :meth:`get_list`
|
||||
to retrieve all values for a multi-value parameter.
|
||||
"""
|
||||
|
||||
def __init__(self, query_string):
|
||||
self.update(parse_qs(query_string))
|
||||
|
||||
@@ -117,6 +123,13 @@ class QueryDict(dict):
|
||||
|
||||
|
||||
class Request:
|
||||
"""An HTTP request, passed to each view as the first argument.
|
||||
|
||||
Provides access to headers, cookies, query parameters, the request body,
|
||||
session data, and more. Most properties are synchronous; reading the body
|
||||
(via :attr:`content`, :attr:`text`, or :meth:`media`) requires ``await``.
|
||||
"""
|
||||
|
||||
__slots__ = [
|
||||
"_starlette",
|
||||
"formats",
|
||||
@@ -153,6 +166,7 @@ class Request:
|
||||
|
||||
@property
|
||||
def mimetype(self):
|
||||
"""The MIME type of the request body, from the ``Content-Type`` header."""
|
||||
return self.headers.get("Content-Type", "")
|
||||
|
||||
@property
|
||||
@@ -270,6 +284,7 @@ class Request:
|
||||
|
||||
@property
|
||||
def is_secure(self):
|
||||
"""``True`` if the request was made over HTTPS."""
|
||||
return self.url.scheme == "https"
|
||||
|
||||
def accepts(self, content_type):
|
||||
@@ -315,6 +330,22 @@ def content_setter(mimetype):
|
||||
|
||||
|
||||
class Response:
|
||||
"""An HTTP response, passed to each view as the second argument.
|
||||
|
||||
Mutate this object to control what gets sent back to the client. Set
|
||||
:attr:`text`, :attr:`html`, :attr:`media`, or :attr:`content` to define
|
||||
the body. Use :attr:`headers` and :meth:`set_cookie` to control metadata.
|
||||
|
||||
:var text: Set the response body as plain text (sets ``Content-Type: text/plain``).
|
||||
:var html: Set the response body as HTML (sets ``Content-Type: text/html``).
|
||||
:var media: Set a Python object (dict, list) to be serialized as JSON (or negotiated format).
|
||||
:var content: Set the raw response body as bytes.
|
||||
:var status_code: The HTTP status code (e.g. ``200``, ``404``). Defaults to ``200`` if not set.
|
||||
:var headers: A dict of response headers.
|
||||
:var cookies: A ``SimpleCookie`` holding cookies to set on the response.
|
||||
:var session: A dict of session data. Changes are persisted in a signed cookie.
|
||||
""" # noqa: E501
|
||||
|
||||
__slots__ = [
|
||||
"req",
|
||||
"status_code",
|
||||
@@ -334,23 +365,34 @@ class Response:
|
||||
|
||||
def __init__(self, req, *, formats):
|
||||
self.req = req
|
||||
#: The HTTP Status Code to use for the Response.
|
||||
self.status_code: int | None = None
|
||||
self.content = None #: A bytes representation of the response body.
|
||||
self.content = None
|
||||
self.mimetype = None
|
||||
self.encoding = DEFAULT_ENCODING
|
||||
self.media = None #: A Python object that will be content-negotiated and
|
||||
#: sent back to the client. Typically, in JSON formatting.
|
||||
self.media = None
|
||||
self._stream = None
|
||||
self.headers = {} #: A Python dictionary of ``{key: value}``,
|
||||
#: representing the headers of the response.
|
||||
self.headers = {}
|
||||
self.formats = formats
|
||||
self.cookies: SimpleCookie = SimpleCookie() #: The cookies set in the Response
|
||||
self.session = (
|
||||
req.session
|
||||
) #: The cookie-based session data, in dict form, to add to the Response.
|
||||
self.cookies: SimpleCookie = SimpleCookie()
|
||||
self.session = req.session
|
||||
|
||||
def stream(self, func, *args, **kwargs):
|
||||
"""Set up a streaming response from an async generator function.
|
||||
|
||||
The generator yields chunks of bytes that are sent to the client
|
||||
as they are produced, without buffering the full response in memory.
|
||||
|
||||
Usage::
|
||||
|
||||
@api.route("/stream")
|
||||
async def stream_data(req, resp):
|
||||
@resp.stream
|
||||
async def body():
|
||||
for i in range(10):
|
||||
yield f"chunk {i}\\n".encode()
|
||||
|
||||
:param func: An async generator function that yields response chunks.
|
||||
"""
|
||||
assert inspect.isasyncgenfunction(func)
|
||||
|
||||
self._stream = functools.partial(func, *args, **kwargs)
|
||||
@@ -451,6 +493,12 @@ class Response:
|
||||
self.mimetype = guessed or "application/octet-stream"
|
||||
|
||||
def redirect(self, location, *, set_text=True, status_code=HTTP_301):
|
||||
"""Redirect the client to a different URL.
|
||||
|
||||
:param location: The URL to redirect to.
|
||||
:param set_text: If ``True``, set a default redirect message as the body.
|
||||
:param status_code: The HTTP status code (default ``301``).
|
||||
"""
|
||||
self.status_code = status_code
|
||||
if set_text:
|
||||
self.text = f"Redirecting to: {location}"
|
||||
@@ -496,6 +544,25 @@ class Response:
|
||||
secure=False,
|
||||
httponly=True,
|
||||
):
|
||||
"""Set a cookie on the response with full control over directives.
|
||||
|
||||
:param key: The cookie name.
|
||||
:param value: The cookie value.
|
||||
:param expires: Expiration date string (e.g. ``"Thu, 01 Jan 2026 00:00:00 GMT"``).
|
||||
:param path: URL path the cookie applies to (default ``"/"``).
|
||||
:param domain: Domain the cookie is valid for.
|
||||
:param max_age: Maximum age in seconds before the cookie expires.
|
||||
:param secure: If ``True``, cookie is only sent over HTTPS.
|
||||
:param httponly: If ``True`` (default), cookie is inaccessible to JavaScript.
|
||||
|
||||
Usage::
|
||||
|
||||
resp.set_cookie(
|
||||
"token", value="abc123",
|
||||
max_age=3600, secure=True, httponly=True,
|
||||
)
|
||||
|
||||
"""
|
||||
self.cookies[key] = value
|
||||
morsel = self.cookies[key]
|
||||
if expires is not None:
|
||||
@@ -534,10 +601,12 @@ class Response:
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
"""``True`` if the status code is in the 2xx range (success)."""
|
||||
return 200 <= self.status_code_safe < 300
|
||||
|
||||
@property
|
||||
def status_code_safe(self) -> int:
|
||||
"""Return the status code, raising ``RuntimeError`` if it hasn't been set."""
|
||||
if self.status_code is None:
|
||||
raise RuntimeError("HTTP status code has not been defined")
|
||||
return self.status_code
|
||||
|
||||
+96
-57
@@ -1,14 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import re
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Union
|
||||
|
||||
__all__ = ["Route", "WebSocketRoute", "Router"]
|
||||
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.types import ASGIApp
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
from starlette.websockets import WebSocket, WebSocketClose
|
||||
|
||||
from . import status_codes
|
||||
@@ -28,9 +32,9 @@ _CONVERTORS = {
|
||||
PARAM_RE = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
|
||||
|
||||
|
||||
def compile_path(path):
|
||||
def compile_path(path: str) -> tuple[re.Pattern, dict[str, type]]:
|
||||
path_re = "^"
|
||||
param_convertors = {}
|
||||
param_convertors: dict[str, type] = {}
|
||||
idx = 0
|
||||
|
||||
for match in PARAM_RE.finditer(path):
|
||||
@@ -54,40 +58,55 @@ def compile_path(path):
|
||||
|
||||
|
||||
class BaseRoute:
|
||||
def matches(self, scope):
|
||||
def matches(self, scope: Scope) -> tuple[bool, dict]:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class Route(BaseRoute):
|
||||
def __init__(self, route, endpoint, *, before_request=False, methods=None):
|
||||
"""An HTTP route that maps a URL pattern to an endpoint.
|
||||
|
||||
Supports path parameters with type convertors (``{id:int}``, ``{slug:str}``,
|
||||
``{pk:uuid}``, ``{value:float}``, ``{rest:path}``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
route: str,
|
||||
endpoint: Callable,
|
||||
*,
|
||||
before_request: bool = False,
|
||||
methods: list[str] | None = None,
|
||||
) -> None:
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
self.endpoint = endpoint
|
||||
self.before_request = before_request
|
||||
self.methods = {m.upper() for m in methods} if methods else None
|
||||
self.methods: set[str] | None = {m.upper() for m in methods} if methods else None
|
||||
|
||||
self.path_re: re.Pattern
|
||||
self.param_convertors: dict[str, type]
|
||||
self.path_re, self.param_convertors = compile_path(route)
|
||||
# Strip type annotations for URL generation (e.g. {id:int} -> {id})
|
||||
self._url_template = PARAM_RE.sub(r"{\1}", route)
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return f"<Route {self.route!r}={self.endpoint!r}>"
|
||||
|
||||
def url(self, **params):
|
||||
def url(self, **params: Any) -> str:
|
||||
return self._url_template.format(**params)
|
||||
|
||||
@property
|
||||
def endpoint_name(self):
|
||||
def endpoint_name(self) -> str:
|
||||
return self.endpoint.__name__
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
def description(self) -> str | None:
|
||||
return self.endpoint.__doc__
|
||||
|
||||
def matches(self, scope):
|
||||
def matches(self, scope: Scope) -> tuple[bool, dict]:
|
||||
if scope["type"] != "http":
|
||||
return False, {}
|
||||
|
||||
@@ -106,7 +125,7 @@ class Route(BaseRoute):
|
||||
|
||||
return True, {"path_params": {**matched_params}}
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
request = Request(scope, receive, formats=get_formats())
|
||||
response = Response(req=request, formats=get_formats())
|
||||
|
||||
@@ -114,7 +133,7 @@ class Route(BaseRoute):
|
||||
before_requests = scope.get("before_requests", [])
|
||||
|
||||
for before_request in before_requests.get("http", []):
|
||||
if asyncio.iscoroutinefunction(before_request):
|
||||
if inspect.iscoroutinefunction(before_request):
|
||||
await before_request(request, response)
|
||||
else:
|
||||
await run_in_threadpool(before_request, request, response)
|
||||
@@ -160,7 +179,7 @@ class Route(BaseRoute):
|
||||
|
||||
for view in views:
|
||||
# Check __call__ for class-based views (e.g. GraphQL)
|
||||
if asyncio.iscoroutinefunction(view) or asyncio.iscoroutinefunction(
|
||||
if inspect.iscoroutinefunction(view) or inspect.iscoroutinefunction(
|
||||
view.__call__
|
||||
):
|
||||
await view(request, response, **path_params)
|
||||
@@ -173,13 +192,13 @@ class Route(BaseRoute):
|
||||
try:
|
||||
validated = resp_model(**response.media)
|
||||
response.media = validated.model_dump()
|
||||
except Exception:
|
||||
except (ValueError, TypeError):
|
||||
pass # Don't break the response if serialization fails
|
||||
|
||||
# Run after-request hooks
|
||||
after_requests = scope.get("after_requests", [])
|
||||
for after_request in after_requests:
|
||||
if asyncio.iscoroutinefunction(after_request):
|
||||
if inspect.iscoroutinefunction(after_request):
|
||||
await after_request(request, response)
|
||||
else:
|
||||
await run_in_threadpool(after_request, request, response)
|
||||
@@ -189,38 +208,46 @@ class Route(BaseRoute):
|
||||
|
||||
await response(scope, receive, send)
|
||||
|
||||
def __eq__(self, other):
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Route):
|
||||
return NotImplemented
|
||||
return self.route == other.route and self.endpoint == other.endpoint
|
||||
|
||||
def __hash__(self):
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.route) ^ hash(self.endpoint) ^ hash(self.before_request)
|
||||
|
||||
|
||||
class WebSocketRoute(BaseRoute):
|
||||
def __init__(self, route, endpoint, *, before_request=False):
|
||||
"""A WebSocket route that maps a URL pattern to a WebSocket handler."""
|
||||
|
||||
def __init__(
|
||||
self, route: str, endpoint: Callable, *, before_request: bool = False
|
||||
) -> None:
|
||||
assert route.startswith("/"), "Route path must start with '/'"
|
||||
self.route = route
|
||||
self.endpoint = endpoint
|
||||
self.before_request = before_request
|
||||
|
||||
self.path_re: re.Pattern
|
||||
self.param_convertors: dict[str, type]
|
||||
self.path_re, self.param_convertors = compile_path(route)
|
||||
self._url_template = PARAM_RE.sub(r"{\1}", route)
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return f"<Route {self.route!r}={self.endpoint!r}>"
|
||||
|
||||
def url(self, **params):
|
||||
def url(self, **params: Any) -> str:
|
||||
return self._url_template.format(**params)
|
||||
|
||||
@property
|
||||
def endpoint_name(self):
|
||||
def endpoint_name(self) -> str:
|
||||
return self.endpoint.__name__
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
def description(self) -> str | None:
|
||||
return self.endpoint.__doc__
|
||||
|
||||
def matches(self, scope):
|
||||
def matches(self, scope: Scope) -> tuple[bool, dict]:
|
||||
if scope["type"] != "websocket":
|
||||
return False, {}
|
||||
|
||||
@@ -236,7 +263,7 @@ class WebSocketRoute(BaseRoute):
|
||||
|
||||
return True, {"path_params": {**matched_params}}
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
ws = WebSocket(scope, receive, send)
|
||||
|
||||
before_requests = scope.get("before_requests", [])
|
||||
@@ -245,41 +272,53 @@ class WebSocketRoute(BaseRoute):
|
||||
|
||||
await self.endpoint(ws)
|
||||
|
||||
def __eq__(self, other):
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, WebSocketRoute):
|
||||
return NotImplemented
|
||||
return self.route == other.route and self.endpoint == other.endpoint
|
||||
|
||||
def __hash__(self):
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.route) ^ hash(self.endpoint) ^ hash(self.before_request)
|
||||
|
||||
|
||||
class Router:
|
||||
def __init__(
|
||||
self, routes=None, default_response=None, before_requests=None, lifespan=None
|
||||
):
|
||||
self.routes = [] if routes is None else list(routes)
|
||||
"""The core router that dispatches incoming requests to matching routes.
|
||||
|
||||
self.apps: dict[str, ASGIApp] = {}
|
||||
self.default_endpoint = (
|
||||
Handles route matching, before/after request hooks, lifespan events,
|
||||
and mounted sub-applications.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
routes: list[BaseRoute] | None = None,
|
||||
default_response: Callable | None = None,
|
||||
before_requests: dict[str, list[Callable]] | None = None,
|
||||
lifespan: Callable | None = None,
|
||||
) -> None:
|
||||
self.routes: list[BaseRoute] = [] if routes is None else list(routes)
|
||||
|
||||
self.apps: dict[str, Union[ASGIApp, Any]] = {}
|
||||
self.default_endpoint: Callable = (
|
||||
self.default_response if default_response is None else default_response
|
||||
)
|
||||
self.before_requests = (
|
||||
self.before_requests: dict[str, list[Callable]] = (
|
||||
{"http": [], "ws": []} if before_requests is None else before_requests
|
||||
)
|
||||
self.after_requests: list = []
|
||||
self.events = defaultdict(list)
|
||||
self.after_requests: list[Callable] = []
|
||||
self.events: defaultdict[str, list[Callable]] = defaultdict(list)
|
||||
self._lifespan_handler = lifespan
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
route=None,
|
||||
endpoint=None,
|
||||
route: str | None = None,
|
||||
endpoint: Callable | None = None,
|
||||
*,
|
||||
default=False,
|
||||
websocket=False,
|
||||
before_request=False,
|
||||
check_existing=False,
|
||||
methods=None,
|
||||
):
|
||||
default: bool = False,
|
||||
websocket: bool = False,
|
||||
before_request: bool = False,
|
||||
check_existing: bool = False,
|
||||
methods: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Adds a route to the router.
|
||||
:param route: A string representation of the route
|
||||
:param endpoint: The endpoint for the route -- can be callable, or class.
|
||||
@@ -308,40 +347,40 @@ class Router:
|
||||
|
||||
self.routes.append(route)
|
||||
|
||||
def mount(self, route, app):
|
||||
def mount(self, route: str, app: Any) -> None:
|
||||
"""Mounts ASGI / WSGI applications at a given route"""
|
||||
self.apps.update({route: app})
|
||||
|
||||
def add_event_handler(self, event_type, handler):
|
||||
def add_event_handler(self, event_type: str, handler: Callable) -> None:
|
||||
assert event_type in (
|
||||
"startup",
|
||||
"shutdown",
|
||||
), f"Only 'startup' and 'shutdown' events are supported, not {event_type}."
|
||||
self.events[event_type].append(handler)
|
||||
|
||||
async def trigger_event(self, event_type):
|
||||
async def trigger_event(self, event_type: str) -> None:
|
||||
for handler in self.events.get(event_type, []):
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
if inspect.iscoroutinefunction(handler):
|
||||
await handler()
|
||||
else:
|
||||
handler()
|
||||
|
||||
def before_request(self, endpoint, websocket=False):
|
||||
def before_request(self, endpoint: Callable, websocket: bool = False) -> None:
|
||||
if websocket:
|
||||
self.before_requests.setdefault("ws", []).append(endpoint)
|
||||
else:
|
||||
self.before_requests.setdefault("http", []).append(endpoint)
|
||||
|
||||
def after_request(self, endpoint):
|
||||
def after_request(self, endpoint: Callable) -> None:
|
||||
self.after_requests.append(endpoint)
|
||||
|
||||
def url_for(self, endpoint, **params):
|
||||
def url_for(self, endpoint: Callable | str, **params: Any) -> str | None:
|
||||
for route in self.routes:
|
||||
if endpoint in (route.endpoint, route.endpoint.__name__):
|
||||
return route.url(**params)
|
||||
return None
|
||||
|
||||
async def default_response(self, scope, receive, send):
|
||||
async def default_response(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] == "websocket":
|
||||
websocket_close = WebSocketClose()
|
||||
await websocket_close(scope, receive, send)
|
||||
@@ -352,7 +391,7 @@ class Router:
|
||||
|
||||
raise HTTPException(status_code=status_codes.HTTP_404) # type: ignore[attr-defined]
|
||||
|
||||
def _resolve_route(self, scope):
|
||||
def _resolve_route(self, scope: Scope) -> BaseRoute | None:
|
||||
for route in self.routes:
|
||||
matches, child_scope = route.matches(scope)
|
||||
if matches:
|
||||
@@ -360,7 +399,7 @@ class Router:
|
||||
return route
|
||||
return None
|
||||
|
||||
async def lifespan(self, scope, receive, send):
|
||||
async def lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
message = await receive()
|
||||
assert message["type"] == "lifespan.startup"
|
||||
|
||||
@@ -395,7 +434,7 @@ class Router:
|
||||
|
||||
await send({"type": "lifespan.shutdown.complete"})
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
assert scope["type"] in ("http", "websocket", "lifespan")
|
||||
|
||||
if scope["type"] == "lifespan":
|
||||
@@ -418,7 +457,7 @@ class Router:
|
||||
# Call into a submounted app, if one exists.
|
||||
for path_prefix, app in self.apps.items():
|
||||
if path.startswith(path_prefix):
|
||||
scope["path"] = path[len(path_prefix) :]
|
||||
scope["path"] = path[len(path_prefix) :] or "/"
|
||||
scope["root_path"] = root_path + path_prefix
|
||||
try:
|
||||
await app(scope, receive, send)
|
||||
|
||||
+63
-11
@@ -4,14 +4,14 @@ import time
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient as StarletteTestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
import responder
|
||||
from responder.background import BackgroundQueue
|
||||
from responder.models import CaseInsensitiveDict, QueryDict, Response
|
||||
from responder.models import QueryDict
|
||||
from responder.routes import Route, WebSocketRoute
|
||||
from responder.templates import Templates
|
||||
|
||||
|
||||
# --- api.py coverage ---
|
||||
|
||||
|
||||
@@ -78,7 +78,10 @@ def test_background_task_exception(capsys):
|
||||
raise ValueError("task failed")
|
||||
|
||||
future = failing_task()
|
||||
future.result # wait for completion
|
||||
try:
|
||||
future.result() # wait for completion
|
||||
except ValueError:
|
||||
pass
|
||||
time.sleep(0.2) # let the done callback fire
|
||||
|
||||
captured = capsys.readouterr()
|
||||
@@ -302,7 +305,7 @@ def test_yaml_content_negotiation(api):
|
||||
def test_websocket_404(api):
|
||||
"""Lines 308-310: WebSocket to unknown route gets closed."""
|
||||
client = StarletteTestClient(api)
|
||||
with pytest.raises(Exception):
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with client.websocket_connect("ws://;/nonexistent"):
|
||||
pass
|
||||
|
||||
@@ -325,9 +328,7 @@ def test_websocket_route_params():
|
||||
pass
|
||||
|
||||
route = WebSocketRoute("/ws/{room_id:int}", handler)
|
||||
matches, scope = route.matches(
|
||||
{"type": "websocket", "path": "/ws/42"}
|
||||
)
|
||||
matches, scope = route.matches({"type": "websocket", "path": "/ws/42"})
|
||||
assert matches is True
|
||||
assert scope["path_params"] == {"room_id": 42}
|
||||
|
||||
@@ -591,7 +592,10 @@ def test_pydantic_schema():
|
||||
from pydantic import BaseModel
|
||||
|
||||
api = responder.API(
|
||||
title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"],
|
||||
title="Test",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
allowed_hosts=[";"],
|
||||
)
|
||||
|
||||
@api.schema("Pet")
|
||||
@@ -611,7 +615,10 @@ def test_pydantic_request_response_models():
|
||||
from pydantic import BaseModel
|
||||
|
||||
api = responder.API(
|
||||
title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"],
|
||||
title="Test",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
allowed_hosts=[";"],
|
||||
)
|
||||
|
||||
class ItemIn(BaseModel):
|
||||
@@ -623,8 +630,7 @@ def test_pydantic_request_response_models():
|
||||
name: str
|
||||
price: float
|
||||
|
||||
@api.route("/items", methods=["POST"],
|
||||
request_model=ItemIn, response_model=ItemOut)
|
||||
@api.route("/items", methods=["POST"], request_model=ItemIn, response_model=ItemOut)
|
||||
async def create(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"id": 1, **data}
|
||||
@@ -660,3 +666,49 @@ def test_templates_context(tmp_path):
|
||||
result = templates.render("test.html")
|
||||
assert "hello" in result
|
||||
assert "world" in result
|
||||
|
||||
|
||||
def test_static_file_serving(tmp_path):
|
||||
"""Verify static files are served correctly from the static directory."""
|
||||
static_dir = tmp_path / "static"
|
||||
static_dir.mkdir()
|
||||
(static_dir / "style.css").write_text("body { color: red; }")
|
||||
(static_dir / "app.js").write_text("console.log('hello');")
|
||||
|
||||
api = responder.API(
|
||||
static_dir=str(static_dir),
|
||||
static_route="/static",
|
||||
allowed_hosts=[";"],
|
||||
)
|
||||
|
||||
# CSS file served with correct content
|
||||
r = api.requests.get("http://;/static/style.css")
|
||||
assert r.status_code == 200
|
||||
assert "body { color: red; }" in r.text
|
||||
assert "text/css" in r.headers.get("content-type", "")
|
||||
|
||||
# JS file served with correct content
|
||||
r = api.requests.get("http://;/static/app.js")
|
||||
assert r.status_code == 200
|
||||
assert "console.log" in r.text
|
||||
|
||||
# Missing file returns 404
|
||||
r = api.requests.get("http://;/static/missing.txt")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_static_index_fallback(tmp_path):
|
||||
"""Verify static index.html is served as default route."""
|
||||
static_dir = tmp_path / "static"
|
||||
static_dir.mkdir()
|
||||
(static_dir / "index.html").write_text("<h1>SPA</h1>")
|
||||
|
||||
api = responder.API(
|
||||
static_dir=str(static_dir),
|
||||
allowed_hosts=[";"],
|
||||
)
|
||||
api.add_route("/", static=True)
|
||||
|
||||
r = api.requests.get("http://;/")
|
||||
assert r.status_code == 200
|
||||
assert "<h1>SPA</h1>" in r.text
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# ruff: noqa: E402
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
graphene = pytest.importorskip("graphene")
|
||||
@@ -17,6 +19,45 @@ def schema():
|
||||
return graphene.Schema(query=Query)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mutation_schema():
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
class CreateUser(graphene.Mutation):
|
||||
class Arguments:
|
||||
name = graphene.String(required=True)
|
||||
|
||||
ok = graphene.Boolean()
|
||||
name = graphene.String()
|
||||
|
||||
def mutate(self, info, name):
|
||||
return CreateUser(ok=True, name=name)
|
||||
|
||||
class Mutation(graphene.ObjectType):
|
||||
create_user = CreateUser.Field()
|
||||
|
||||
return graphene.Schema(query=Query, mutation=Mutation)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multi_op_schema():
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
goodbye = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
def resolve_goodbye(self, info, name):
|
||||
return f"Goodbye {name}"
|
||||
|
||||
return graphene.Schema(query=Query)
|
||||
|
||||
|
||||
def test_graphql_schema_query_querying(api, schema):
|
||||
api.add_route("/", GraphQLView(schema=schema, api=api))
|
||||
r = api.requests.get("http://;/?q={ hello }", headers={"Accept": "json"})
|
||||
@@ -63,3 +104,133 @@ def test_graphql_error_response(api, schema):
|
||||
api.add_route("/", GraphQLView(schema=schema, api=api))
|
||||
r = api.requests.post("http://;/", json={"query": "{ nonexistent }"})
|
||||
assert "errors" in r.json()
|
||||
|
||||
|
||||
def test_graphql_variables_json(api, schema):
|
||||
"""Variables passed via JSON body."""
|
||||
api.add_route("/", GraphQLView(schema=schema, api=api))
|
||||
r = api.requests.post(
|
||||
"http://;/",
|
||||
json={
|
||||
"query": "query Hello($name: String!) { hello(name: $name) }",
|
||||
"variables": {"name": "Alice"},
|
||||
},
|
||||
)
|
||||
assert r.json() == {"data": {"hello": "Hello Alice"}}
|
||||
|
||||
|
||||
def test_graphql_variables_query_param(api, schema):
|
||||
"""Variables passed as JSON string in query parameter."""
|
||||
api.add_route("/", GraphQLView(schema=schema, api=api))
|
||||
variables = json.dumps({"name": "Bob"})
|
||||
r = api.requests.get(
|
||||
f"http://;/?query=query Hello($name: String!) "
|
||||
f"{{ hello(name: $name) }}&variables={variables}",
|
||||
headers={"Accept": "json"},
|
||||
)
|
||||
assert r.json() == {"data": {"hello": "Hello Bob"}}
|
||||
|
||||
|
||||
def test_graphql_operation_name_json(api, multi_op_schema):
|
||||
"""operationName selects which operation to run."""
|
||||
api.add_route("/", GraphQLView(schema=multi_op_schema, api=api))
|
||||
query = """
|
||||
query SayHello { hello }
|
||||
query SayGoodbye { goodbye }
|
||||
"""
|
||||
r = api.requests.post(
|
||||
"http://;/",
|
||||
json={
|
||||
"query": query,
|
||||
"operationName": "SayHello",
|
||||
},
|
||||
)
|
||||
data = r.json()
|
||||
assert data["data"]["hello"] == "Hello stranger"
|
||||
|
||||
|
||||
def test_graphql_operation_name_query_param(api, multi_op_schema):
|
||||
"""operationName via query parameter."""
|
||||
api.add_route("/", GraphQLView(schema=multi_op_schema, api=api))
|
||||
query = "query SayHello { hello } query SayGoodbye { goodbye }"
|
||||
r = api.requests.get(
|
||||
f"http://;/?query={query}&operationName=SayGoodbye",
|
||||
headers={"Accept": "json"},
|
||||
)
|
||||
data = r.json()
|
||||
assert data["data"]["goodbye"] == "Goodbye stranger"
|
||||
|
||||
|
||||
def test_graphql_mutation(api, mutation_schema):
|
||||
"""Mutations work via JSON body."""
|
||||
api.add_route("/", GraphQLView(schema=mutation_schema, api=api))
|
||||
r = api.requests.post(
|
||||
"http://;/",
|
||||
json={
|
||||
"query": 'mutation { createUser(name: "Eve") { ok name } }',
|
||||
},
|
||||
)
|
||||
data = r.json()
|
||||
assert data["data"]["createUser"]["ok"] is True
|
||||
assert data["data"]["createUser"]["name"] == "Eve"
|
||||
|
||||
|
||||
def test_graphql_mutation_with_variables(api, mutation_schema):
|
||||
"""Mutations with variables."""
|
||||
api.add_route("/", GraphQLView(schema=mutation_schema, api=api))
|
||||
r = api.requests.post(
|
||||
"http://;/",
|
||||
json={
|
||||
"query": "mutation CreateUser($name: String!) "
|
||||
"{ createUser(name: $name) { ok name } }",
|
||||
"variables": {"name": "Frank"},
|
||||
},
|
||||
)
|
||||
data = r.json()
|
||||
assert data["data"]["createUser"]["ok"] is True
|
||||
assert data["data"]["createUser"]["name"] == "Frank"
|
||||
|
||||
|
||||
def test_graphql_context_access(api):
|
||||
"""Resolvers can access request and response via info.context."""
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
method = graphene.String()
|
||||
|
||||
def resolve_method(self, info):
|
||||
return info.context["request"].method
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
api.add_route("/", GraphQLView(schema=schema, api=api))
|
||||
r = api.requests.post("http://;/", json={"query": "{ method }"})
|
||||
assert r.json() == {"data": {"method": "post"}}
|
||||
|
||||
|
||||
def test_graphql_malformed_query(api, schema):
|
||||
"""Malformed GraphQL syntax returns errors."""
|
||||
api.add_route("/", GraphQLView(schema=schema, api=api))
|
||||
r = api.requests.post("http://;/", json={"query": "{ this is not valid"})
|
||||
data = r.json()
|
||||
assert "errors" in data
|
||||
assert len(data["errors"]) > 0
|
||||
|
||||
|
||||
def test_graphql_raw_text_query(api, schema):
|
||||
"""Query sent as raw text body."""
|
||||
api.add_route("/", GraphQLView(schema=schema, api=api))
|
||||
r = api.requests.post(
|
||||
"http://;/",
|
||||
content=b"{ hello }",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
assert r.json() == {"data": {"hello": "Hello stranger"}}
|
||||
|
||||
|
||||
def test_graphql_invalid_variables_query_param(api, schema):
|
||||
"""Invalid JSON in variables query param is treated as None."""
|
||||
api.add_route("/", GraphQLView(schema=schema, api=api))
|
||||
r = api.requests.get(
|
||||
"http://;/?query={ hello }&variables=not-json",
|
||||
headers={"Accept": "json"},
|
||||
)
|
||||
assert r.json() == {"data": {"hello": "Hello stranger"}}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"""Tests for new features: validation, SSE, after_request, route groups, etc."""
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
from starlette.testclient import TestClient as StarletteTestClient
|
||||
|
||||
import responder
|
||||
from responder.ext.ratelimit import RateLimiter
|
||||
|
||||
|
||||
# --- Pydantic auto-validation ---
|
||||
|
||||
|
||||
@@ -42,7 +39,9 @@ def test_pydantic_request_validation():
|
||||
assert "errors" in r.json()
|
||||
|
||||
# Invalid request — wrong type
|
||||
r = api.requests.post("http://;/items", json={"name": "widget", "price": "not_a_number"})
|
||||
r = api.requests.post(
|
||||
"http://;/items", json={"name": "widget", "price": "not_a_number"}
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
@@ -50,8 +49,7 @@ def test_pydantic_response_serialization():
|
||||
"""Auto-serialize response through response_model."""
|
||||
api = responder.API(allowed_hosts=[";"])
|
||||
|
||||
@api.route("/items", methods=["POST"],
|
||||
request_model=ItemIn, response_model=ItemOut)
|
||||
@api.route("/items", methods=["POST"], request_model=ItemIn, response_model=ItemOut)
|
||||
async def create(req, resp):
|
||||
data = await req.media()
|
||||
# Include an extra field that should be stripped by the model
|
||||
@@ -257,7 +255,7 @@ def test_rate_limiter():
|
||||
def view(req, resp):
|
||||
resp.text = "ok"
|
||||
|
||||
for i in range(3):
|
||||
for _i in range(3):
|
||||
r = api.requests.get("http://;/")
|
||||
assert r.status_code == 200
|
||||
assert "X-RateLimit-Remaining" in r.headers
|
||||
|
||||
@@ -546,14 +546,17 @@ def test_documentation(needs_openapi):
|
||||
assert "html" in r.text
|
||||
|
||||
|
||||
def test_mount_wsgi_app(api, flask):
|
||||
def test_mount_wsgi_app(flask):
|
||||
# Use localhost so Werkzeug's trusted-host check accepts the request.
|
||||
api = responder.API(allowed_hosts=["localhost"])
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = "hello"
|
||||
|
||||
api.mount("/flask", flask)
|
||||
|
||||
r = api.requests.get("http://;/flask")
|
||||
r = api.requests.get("http://localhost/flask")
|
||||
assert r.status_code < 300
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user