mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 06:46:14 +00:00
Add Pydantic support for OpenAPI schema generation
Define your API schemas with Pydantic models instead of (or alongside)
YAML docstrings and marshmallow:
from pydantic import BaseModel
class PetIn(BaseModel):
name: str
age: int = 0
class PetOut(BaseModel):
id: int
name: str
age: int
@api.route("/pets", methods=["POST"],
request_model=PetIn, response_model=PetOut)
async def create_pet(req, resp):
data = await req.media()
resp.media = {"id": 1, **data}
Also works with @api.schema("Name") decorator for registering
standalone schema components.
Pydantic models, marshmallow schemas, and YAML docstrings can all
be used together in the same API.
Also: rewrite docs with more prose, restore sidebar logo and links,
add FastAPI acknowledgment, update homepage copy.
161 tests, 95% coverage.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
<p class="logo">
|
||||
<a href="{{ pathto(master_doc) }}">
|
||||
<img class="logo" src="{{ pathto('_static/responder.png', 1) }}" />
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Responder</strong> — a familiar HTTP service framework for Python.
|
||||
</p>
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
<li><a href="https://github.com/kennethreitz/responder">GitHub</a></li>
|
||||
<li><a href="https://pypi.org/project/responder/">PyPI</a></li>
|
||||
<li><a href="https://github.com/kennethreitz/responder/issues">Issues</a></li>
|
||||
<li><a href="https://github.com/kennethreitz/responder">Responder @ GitHub</a></li>
|
||||
<li><a href="https://pypi.org/project/responder/">Responder @ PyPI</a></li>
|
||||
<li><a href="https://github.com/kennethreitz/responder/issues">Issue Tracker</a></li>
|
||||
</ul>
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@ html_theme_options = {
|
||||
}
|
||||
html_static_path = ["_static"]
|
||||
html_sidebars = {
|
||||
"**": ["localtoc.html", "searchbox.html"],
|
||||
"index": ["sidebarintro.html", "searchbox.html"],
|
||||
"**": ["sidebarintro.html", "localtoc.html", "searchbox.html"],
|
||||
}
|
||||
|
||||
# MyST
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
Deployment
|
||||
==========
|
||||
|
||||
Responder applications are standard ASGI apps. You can deploy them anywhere
|
||||
you'd deploy a Python web service.
|
||||
|
||||
|
||||
Running Locally
|
||||
---------------
|
||||
|
||||
The simplest way to run your application::
|
||||
|
||||
# api.py
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
This starts a production uvicorn server on ``127.0.0.1:5042``.
|
||||
|
||||
|
||||
Docker
|
||||
------
|
||||
|
||||
::
|
||||
A minimal Dockerfile for deploying a Responder application::
|
||||
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
@@ -14,18 +38,49 @@ Docker
|
||||
EXPOSE 80
|
||||
CMD ["python", "api.py"]
|
||||
|
||||
Build and run::
|
||||
|
||||
$ docker build -t myapi .
|
||||
$ docker run -p 8000:80 myapi
|
||||
|
||||
|
||||
Cloud Platforms
|
||||
---------------
|
||||
|
||||
Responder honors the ``PORT`` environment variable automatically.
|
||||
It works with any platform that sets ``PORT``: Fly.io, Railway, Render,
|
||||
Google Cloud Run, etc.
|
||||
Responder automatically honors the ``PORT`` environment variable, which is
|
||||
set by most cloud platforms. When ``PORT`` is set, Responder binds to
|
||||
``0.0.0.0`` on that port automatically.
|
||||
|
||||
This works out of the box with:
|
||||
|
||||
- **Fly.io**
|
||||
- **Railway**
|
||||
- **Render**
|
||||
- **Google Cloud Run**
|
||||
- **Azure Container Apps**
|
||||
- **AWS App Runner**
|
||||
|
||||
Just deploy your code and set the start command to ``python api.py``.
|
||||
|
||||
|
||||
Uvicorn Directly
|
||||
----------------
|
||||
|
||||
For more control, run with uvicorn::
|
||||
For more control over the production server, you can bypass ``api.run()``
|
||||
and use uvicorn directly::
|
||||
|
||||
uvicorn api:api --host 0.0.0.0 --port 8000 --workers 4
|
||||
$ uvicorn api:api --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
This gives you access to all of uvicorn's options: worker count, SSL
|
||||
certificates, access logging, and more. See the
|
||||
`uvicorn documentation <https://www.uvicorn.org/>`_ for details.
|
||||
|
||||
|
||||
Reverse Proxy
|
||||
-------------
|
||||
|
||||
In production, you may want to place Responder behind a reverse proxy like
|
||||
nginx or Caddy for SSL termination, load balancing, or serving static assets.
|
||||
|
||||
Responder's ``TrustedHostMiddleware`` and ``HTTPSRedirectMiddleware`` work
|
||||
correctly behind proxies that set standard forwarding headers.
|
||||
|
||||
+71
-4
@@ -1,7 +1,7 @@
|
||||
Responder
|
||||
=========
|
||||
|
||||
A familiar HTTP Service Framework for Python, powered by `Starlette`_.
|
||||
A familiar HTTP Service Framework for Python.
|
||||
|
||||
.. code:: python
|
||||
|
||||
@@ -16,14 +16,76 @@ A familiar HTTP Service Framework for Python, powered by `Starlette`_.
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
|
||||
Install it::
|
||||
Powered by `Starlette`_ and `uvicorn`_. The ``async`` is optional.
|
||||
|
||||
pip install responder
|
||||
|
||||
Python 3.9+.
|
||||
The Idea
|
||||
--------
|
||||
|
||||
Responder takes the best ideas from `Flask`_ and `Falcon`_ and brings them
|
||||
together into one clean framework.
|
||||
|
||||
The request and response objects are passed into every view and mutated
|
||||
directly — no return values, no boilerplate. If you've used Requests,
|
||||
you'll feel right at home. If you've used Flask, the routing will look
|
||||
familiar. If you've used Falcon, the ``req`` / ``resp`` pattern will
|
||||
click immediately.
|
||||
|
||||
- ``resp.text`` sends back text. ``resp.html`` sends back HTML.
|
||||
- ``resp.media`` sends back JSON — or YAML, if the client asks for it.
|
||||
- ``resp.file("path")`` serves a file. ``resp.content`` sends raw bytes.
|
||||
- ``req.headers`` is case-insensitive. ``req.params`` holds query parameters.
|
||||
- ``resp.status_code``, ``req.method``, ``req.url`` — the usual suspects.
|
||||
|
||||
Content negotiation happens automatically. Set ``resp.media`` to a dict
|
||||
and Responder figures out the rest.
|
||||
|
||||
Responder and `FastAPI`_ share DNA — both are built on Starlette, both
|
||||
appeared around the same time, and both pushed Python's ASGI ecosystem
|
||||
forward. FastAPI went deep on type annotations and automatic validation.
|
||||
Responder went for a mutable request/response pattern and a simpler,
|
||||
more familiar API. Both projects are better for the other existing, and
|
||||
you should use whichever feels right for what you're building.
|
||||
|
||||
|
||||
What You Get
|
||||
------------
|
||||
|
||||
One ``pip install``, batteries included:
|
||||
|
||||
- Mount Flask, Django, or any WSGI/ASGI app at a subroute.
|
||||
- Gzip compression, HSTS, CORS, and trusted host validation.
|
||||
- Before-request hooks that can short-circuit for auth guards.
|
||||
- A test client for fast, in-process testing with pytest.
|
||||
- Route parameters with f-string syntax and type convertors.
|
||||
- Lifespan context managers for startup and shutdown logic.
|
||||
- Custom exception handlers for clean error responses.
|
||||
- `GraphQL`_ with Graphene and a built-in GraphiQL IDE.
|
||||
- File serving with automatic content-type detection.
|
||||
- Sync and async views — ``async`` is always optional.
|
||||
- Class-based views with ``on_get``, ``on_post``, ``on_request``.
|
||||
- A pleasant API with a single import statement.
|
||||
- OpenAPI schema generation with Swagger UI.
|
||||
- A production `uvicorn`_ server, ready to deploy.
|
||||
- HTTP method filtering for REST APIs.
|
||||
- Signed cookie-based sessions.
|
||||
- Background tasks in a thread pool.
|
||||
- WebSocket support.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$ uv pip install responder
|
||||
|
||||
Python 3.9 and above. That's it.
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: User Guide
|
||||
|
||||
quickstart
|
||||
tour
|
||||
@@ -42,3 +104,8 @@ Python 3.9+.
|
||||
|
||||
|
||||
.. _Starlette: https://www.starlette.io/
|
||||
.. _uvicorn: https://www.uvicorn.org/
|
||||
.. _Flask: https://flask.palletsprojects.com/
|
||||
.. _Falcon: https://falconframework.org/
|
||||
.. _FastAPI: https://fastapi.tiangolo.com/
|
||||
.. _GraphQL: https://graphql.org/
|
||||
|
||||
+151
-60
@@ -1,143 +1,234 @@
|
||||
Quick Start
|
||||
===========
|
||||
|
||||
Create an API
|
||||
-------------
|
||||
This guide will walk you through the basics of building a web service with
|
||||
Responder. By the end, you'll know how to declare routes, handle requests,
|
||||
send responses, render templates, and process background tasks.
|
||||
|
||||
::
|
||||
|
||||
Create a Web Service
|
||||
--------------------
|
||||
|
||||
The first thing you need to do is declare a web service. This is the central
|
||||
object that holds all your routes, middleware, and configuration::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
Add a route
|
||||
|
||||
Hello World
|
||||
-----------
|
||||
|
||||
::
|
||||
Next, add a route. Here, we'll make the root URL say "hello, world!"::
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
def hello_world(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
Run it
|
||||
------
|
||||
Every view receives a ``req`` (request) and ``resp`` (response) object. You
|
||||
don't need to return anything — just mutate the response directly.
|
||||
|
||||
::
|
||||
|
||||
Run the Server
|
||||
--------------
|
||||
|
||||
Start your web service with ``api.run()``::
|
||||
|
||||
api.run()
|
||||
|
||||
This starts a production uvicorn server on port ``5042``. Customize with
|
||||
``api.run(port=8000)`` or set the ``PORT`` environment variable.
|
||||
This spins up a production-grade uvicorn server on port ``5042``, ready for
|
||||
incoming HTTP requests.
|
||||
|
||||
You can customize the port with ``api.run(port=8000)``. The ``PORT``
|
||||
environment variable is also honored automatically — when set, Responder
|
||||
binds to ``0.0.0.0`` on that port, which is what cloud platforms like
|
||||
Fly.io, Railway, and Google Cloud Run expect.
|
||||
|
||||
.. note::
|
||||
|
||||
Both sync and async views are supported. The ``async`` keyword is always
|
||||
optional — use it when you need to ``await`` something.
|
||||
|
||||
|
||||
Route Parameters
|
||||
----------------
|
||||
|
||||
Use f-string syntax for dynamic URLs::
|
||||
If you want dynamic URLs, use Python's familiar f-string syntax to declare
|
||||
variables in your routes::
|
||||
|
||||
@api.route("/hello/{who}")
|
||||
def hello_to(req, resp, *, who):
|
||||
resp.text = f"hello, {who}!"
|
||||
|
||||
Type convertors are available::
|
||||
A ``GET`` request to ``/hello/world`` will respond with ``hello, world!``.
|
||||
|
||||
Route parameters are passed as keyword-only arguments (after the ``*``).
|
||||
|
||||
|
||||
Type Convertors
|
||||
^^^^^^^^^^^^^^^
|
||||
|
||||
You can constrain route parameters to specific types. The parameter will be
|
||||
automatically converted before it reaches your view::
|
||||
|
||||
@api.route("/add/{a:int}/{b:int}")
|
||||
async def add(req, resp, *, a, b):
|
||||
resp.text = f"{a} + {b} = {a + b}"
|
||||
|
||||
Supported types: ``str``, ``int``, ``float``, ``uuid``, ``path``.
|
||||
Supported types:
|
||||
|
||||
- ``str`` — matches any string without slashes (default)
|
||||
- ``int`` — matches digits, converts to ``int``
|
||||
- ``float`` — matches decimal numbers, converts to ``float``
|
||||
- ``uuid`` — matches UUID strings like ``550e8400-e29b-41d4-a716-446655440000``
|
||||
- ``path`` — matches any string *including* slashes, useful for file paths
|
||||
|
||||
|
||||
Responses
|
||||
---------
|
||||
Sending Responses
|
||||
-----------------
|
||||
|
||||
::
|
||||
Responder gives you several ways to send data back to the client. Just set
|
||||
the appropriate property on the response object.
|
||||
|
||||
# Text
|
||||
resp.text = "hello"
|
||||
**Text and HTML**::
|
||||
|
||||
# HTML
|
||||
resp.html = "<h1>hello</h1>"
|
||||
resp.text = "plain text response"
|
||||
resp.html = "<h1>HTML response</h1>"
|
||||
|
||||
# JSON (default)
|
||||
resp.media = {"hello": "world"}
|
||||
**JSON** — the most common pattern for APIs. Set ``resp.media`` to any
|
||||
JSON-serializable Python object::
|
||||
|
||||
# Bytes
|
||||
resp.content = b"\x00\x01\x02"
|
||||
@api.route("/hello/{who}/json")
|
||||
def hello_json(req, resp, *, who):
|
||||
resp.media = {"hello": who}
|
||||
|
||||
# File
|
||||
resp.file("report.pdf")
|
||||
If the client sends an ``Accept: application/x-yaml`` header, the same data
|
||||
will be returned as YAML instead. Content negotiation is automatic.
|
||||
|
||||
**Files** — serve a file from disk with automatic content-type detection::
|
||||
|
||||
resp.file("reports/annual.pdf")
|
||||
|
||||
**Raw bytes**::
|
||||
|
||||
resp.content = b"\x89PNG\r\n..."
|
||||
|
||||
**Status codes and headers**::
|
||||
|
||||
# Status code
|
||||
resp.status_code = 201
|
||||
|
||||
# Headers
|
||||
resp.headers["X-Custom"] = "value"
|
||||
|
||||
# Redirect
|
||||
api.redirect(resp, location="/other")
|
||||
**Redirects**::
|
||||
|
||||
api.redirect(resp, location="/new-url")
|
||||
|
||||
|
||||
Requests
|
||||
--------
|
||||
Reading Requests
|
||||
----------------
|
||||
|
||||
::
|
||||
The request object gives you access to everything the client sent.
|
||||
|
||||
# Method (lowercase)
|
||||
req.method # "get", "post", etc.
|
||||
**Method and URL**::
|
||||
|
||||
req.method # "get", "post", etc. (lowercase)
|
||||
req.full_url # "http://example.com/path?q=1"
|
||||
req.url # parsed URL object
|
||||
|
||||
**Headers** — case-insensitive, just like you'd expect::
|
||||
|
||||
# Headers (case-insensitive)
|
||||
req.headers["Content-Type"]
|
||||
req.headers["content-type"] # same thing
|
||||
|
||||
# Query parameters
|
||||
req.params["q"]
|
||||
**Query parameters**::
|
||||
|
||||
# Path parameters
|
||||
req.path_params["user_id"]
|
||||
# GET /search?q=python&page=2
|
||||
req.params["q"] # "python"
|
||||
req.params["page"] # "2"
|
||||
|
||||
# JSON body (must await)
|
||||
**Path parameters** — also available on the request object::
|
||||
|
||||
req.path_params["user_id"] # same as the keyword argument
|
||||
|
||||
**Request body** — for POST/PUT/PATCH requests, you need to ``await`` the
|
||||
body content::
|
||||
|
||||
# JSON body
|
||||
data = await req.media()
|
||||
|
||||
# Raw body
|
||||
# Form data
|
||||
data = await req.media("form")
|
||||
|
||||
# File uploads
|
||||
files = await req.media("files")
|
||||
|
||||
# Raw bytes
|
||||
body = await req.content
|
||||
|
||||
# Check content type
|
||||
req.is_json # True/False
|
||||
# Raw text
|
||||
text = await req.text
|
||||
|
||||
# Client address
|
||||
req.client # (host, port)
|
||||
**Other useful properties**::
|
||||
|
||||
req.is_json # True if content type is JSON
|
||||
req.cookies # dict of cookies
|
||||
req.session # session data (dict)
|
||||
req.client # (host, port) tuple
|
||||
req.is_secure # True if HTTPS
|
||||
|
||||
|
||||
Templates
|
||||
---------
|
||||
Rendering Templates
|
||||
-------------------
|
||||
|
||||
Responder includes Jinja2 templating::
|
||||
Responder includes built-in `Jinja2 <https://jinja.palletsprojects.com/>`_
|
||||
support. Templates are loaded from the ``templates/`` directory by default.
|
||||
|
||||
The simplest way is to use ``api.template()``::
|
||||
|
||||
@api.route("/hello/{name}/html")
|
||||
def hello_html(req, resp, *, name):
|
||||
resp.html = api.template("hello.html", name=name)
|
||||
|
||||
Or use the ``Templates`` class directly::
|
||||
You can also use the ``Templates`` class directly for more control::
|
||||
|
||||
from responder.templates import Templates
|
||||
|
||||
templates = Templates(directory="templates")
|
||||
resp.html = templates.render("page.html", title="Hello")
|
||||
|
||||
@api.route("/page")
|
||||
def page(req, resp):
|
||||
resp.html = templates.render("page.html", title="Hello")
|
||||
|
||||
Async rendering is supported too::
|
||||
|
||||
templates = Templates(directory="templates", enable_async=True)
|
||||
resp.html = await templates.render_async("page.html", title="Hello")
|
||||
|
||||
You can render template strings without a file::
|
||||
|
||||
resp.html = api.template_string("Hello, {{ name }}!", name="world")
|
||||
|
||||
|
||||
Background Tasks
|
||||
----------------
|
||||
|
||||
Process work in the background while responding immediately::
|
||||
Sometimes you want to accept a request, respond immediately, and do the
|
||||
actual processing later. Responder makes this easy with background tasks::
|
||||
|
||||
@api.route("/work")
|
||||
async def work(req, resp):
|
||||
@api.route("/incoming")
|
||||
async def receive_incoming(req, resp):
|
||||
data = await req.media()
|
||||
|
||||
@api.background.task
|
||||
def process(data):
|
||||
def process_data(data):
|
||||
"""This runs in a background thread."""
|
||||
import time
|
||||
time.sleep(10)
|
||||
time.sleep(10) # simulate heavy work
|
||||
|
||||
process(data)
|
||||
resp.media = {"status": "processing"}
|
||||
process_data(data)
|
||||
|
||||
# Respond immediately — processing continues in the background
|
||||
resp.media = {"status": "accepted"}
|
||||
|
||||
The ``@api.background.task`` decorator wraps any function to run in a thread
|
||||
pool. The client gets an immediate response while the work continues.
|
||||
|
||||
+84
-19
@@ -1,12 +1,15 @@
|
||||
Testing
|
||||
=======
|
||||
|
||||
Responder includes a built-in test client powered by Starlette's TestClient.
|
||||
Responder includes a built-in test client powered by Starlette's
|
||||
``TestClient``. You don't need to start a server — tests run in-process,
|
||||
making them fast and reliable.
|
||||
|
||||
Basic Test
|
||||
----------
|
||||
|
||||
``api.py``::
|
||||
Getting Started
|
||||
---------------
|
||||
|
||||
Given a simple application in ``api.py``::
|
||||
|
||||
import responder
|
||||
|
||||
@@ -19,15 +22,16 @@ Basic Test
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
``test_api.py``::
|
||||
You can test it with pytest::
|
||||
|
||||
# test_api.py
|
||||
import api as service
|
||||
|
||||
def test_hello():
|
||||
r = service.api.requests.get("/")
|
||||
assert r.text == "hello, world!"
|
||||
|
||||
Run with pytest::
|
||||
Run your tests::
|
||||
|
||||
$ pytest
|
||||
|
||||
@@ -35,7 +39,8 @@ Run with pytest::
|
||||
Using Fixtures
|
||||
--------------
|
||||
|
||||
::
|
||||
For larger test suites, use pytest fixtures to share the API instance
|
||||
across tests::
|
||||
|
||||
import pytest
|
||||
import api as service
|
||||
@@ -56,11 +61,47 @@ Using Fixtures
|
||||
r = api.requests.get(api.url_for(data))
|
||||
assert r.json() == {"key": "value"}
|
||||
|
||||
The ``api.url_for()`` method generates a URL for a given route endpoint,
|
||||
so you don't have to hard-code paths in your tests.
|
||||
|
||||
|
||||
Testing JSON APIs
|
||||
-----------------
|
||||
|
||||
Send JSON data and check the response::
|
||||
|
||||
def test_create_item(api):
|
||||
@api.route("/items")
|
||||
async def create(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"created": data}
|
||||
resp.status_code = 201
|
||||
|
||||
r = api.requests.post(api.url_for(create), json={"name": "widget"})
|
||||
assert r.status_code == 201
|
||||
assert r.json() == {"created": {"name": "widget"}}
|
||||
|
||||
|
||||
Testing File Uploads
|
||||
--------------------
|
||||
|
||||
Send files using the ``files`` parameter::
|
||||
|
||||
def test_upload(api):
|
||||
@api.route("/upload")
|
||||
async def upload(req, resp):
|
||||
files = await req.media("files")
|
||||
resp.media = {"received": list(files.keys())}
|
||||
|
||||
files = {"doc": ("report.pdf", b"content", "application/pdf")}
|
||||
r = api.requests.post(api.url_for(upload), files=files)
|
||||
assert r.json() == {"received": ["doc"]}
|
||||
|
||||
|
||||
Testing WebSockets
|
||||
------------------
|
||||
|
||||
::
|
||||
Use Starlette's ``TestClient`` directly for WebSocket connections::
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
@@ -76,17 +117,41 @@ Testing WebSockets
|
||||
assert ws.receive_text() == "hello"
|
||||
|
||||
|
||||
Testing File Uploads
|
||||
--------------------
|
||||
Testing Error Handling
|
||||
----------------------
|
||||
|
||||
::
|
||||
To test error responses without pytest raising the exception, disable
|
||||
server exception propagation::
|
||||
|
||||
def test_upload(api):
|
||||
@api.route("/upload")
|
||||
async def upload(req, resp):
|
||||
files = await req.media("files")
|
||||
resp.media = {"name": list(files.keys())[0]}
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
files = {"doc": ("test.txt", b"content", "text/plain")}
|
||||
r = api.requests.post(api.url_for(upload), files=files)
|
||||
assert r.json() == {"name": "doc"}
|
||||
def test_500(api):
|
||||
@api.route("/fail")
|
||||
def fail(req, resp):
|
||||
raise ValueError("something broke")
|
||||
|
||||
client = TestClient(api, raise_server_exceptions=False)
|
||||
r = client.get(api.url_for(fail))
|
||||
assert r.status_code == 500
|
||||
|
||||
|
||||
Testing Lifespan Events
|
||||
-----------------------
|
||||
|
||||
The test client supports lifespan events. Use ``with`` to ensure startup
|
||||
and shutdown hooks run::
|
||||
|
||||
def test_with_lifespan(api):
|
||||
started = {"value": False}
|
||||
|
||||
@api.on_event("startup")
|
||||
async def on_startup():
|
||||
started["value"] = True
|
||||
|
||||
@api.route("/")
|
||||
def check(req, resp):
|
||||
resp.media = {"started": started["value"]}
|
||||
|
||||
with api.requests as session:
|
||||
r = session.get("http://;/")
|
||||
assert r.json() == {"started": True}
|
||||
|
||||
+162
-45
@@ -1,11 +1,15 @@
|
||||
Feature Tour
|
||||
============
|
||||
|
||||
This section walks through Responder's features in detail. Each section
|
||||
includes working code examples you can copy into your application.
|
||||
|
||||
|
||||
Method Filtering
|
||||
----------------
|
||||
|
||||
Restrict routes to specific HTTP methods::
|
||||
By default, a route matches all HTTP methods. If you want to restrict a
|
||||
route to specific methods, pass the ``methods`` parameter::
|
||||
|
||||
@api.route("/items", methods=["GET"])
|
||||
def list_items(req, resp):
|
||||
@@ -16,11 +20,15 @@ Restrict routes to specific HTTP methods::
|
||||
data = await req.media()
|
||||
resp.media = {"created": data}
|
||||
|
||||
Note the ``check_existing=False`` — this allows you to register multiple
|
||||
handlers for the same path with different methods.
|
||||
|
||||
|
||||
Class-Based Views
|
||||
-----------------
|
||||
|
||||
::
|
||||
For more complex resources, you can use class-based views. Responder will
|
||||
dispatch to the appropriate method handler based on the HTTP method::
|
||||
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
@@ -31,28 +39,36 @@ Class-Based Views
|
||||
resp.media = {"received": greeting}
|
||||
|
||||
def on_request(self, req, resp, *, greeting):
|
||||
"""Called on every request method."""
|
||||
"""Called on EVERY request, before the method-specific handler."""
|
||||
resp.headers["X-Greeting"] = greeting
|
||||
|
||||
The ``on_request`` method is called for all HTTP methods, much like
|
||||
middleware scoped to a single route. Method-specific handlers (``on_get``,
|
||||
``on_post``, ``on_put``, ``on_delete``, etc.) are called after.
|
||||
|
||||
No inheritance required — just define a class with the right method names.
|
||||
|
||||
|
||||
Lifespan Events
|
||||
---------------
|
||||
|
||||
Use a context manager for startup and shutdown::
|
||||
Modern applications often need to set up resources on startup (database
|
||||
connections, caches, ML models) and tear them down on shutdown. Responder
|
||||
supports the lifespan context manager pattern::
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup
|
||||
# Startup — runs before the first request
|
||||
print("connecting to database...")
|
||||
yield
|
||||
# Shutdown
|
||||
# Shutdown — runs after the server stops
|
||||
print("closing connections...")
|
||||
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
Or use event decorators::
|
||||
You can also use the traditional event decorator style::
|
||||
|
||||
@api.on_event("startup")
|
||||
async def startup():
|
||||
@@ -62,42 +78,57 @@ Or use event decorators::
|
||||
async def shutdown():
|
||||
print("shutting down")
|
||||
|
||||
The context manager approach is preferred for new code — it makes the
|
||||
startup/shutdown relationship explicit and keeps related code together.
|
||||
|
||||
File Serving
|
||||
------------
|
||||
|
||||
Serve files with automatic content-type detection::
|
||||
Serving Files
|
||||
-------------
|
||||
|
||||
Serve files from disk with automatic content-type detection. Responder
|
||||
uses Python's ``mimetypes`` module to figure out the right ``Content-Type``
|
||||
header for you::
|
||||
|
||||
@api.route("/download")
|
||||
def download(req, resp):
|
||||
resp.file("reports/annual.pdf")
|
||||
|
||||
You can override the content type if needed::
|
||||
|
||||
@api.route("/image")
|
||||
def image(req, resp):
|
||||
resp.file("photos/cat.jpg", content_type="image/jpeg")
|
||||
|
||||
|
||||
Error Handling
|
||||
--------------
|
||||
Custom Error Handling
|
||||
---------------------
|
||||
|
||||
Register handlers for specific exception types::
|
||||
By default, unhandled exceptions result in a 500 Internal Server Error.
|
||||
You can register custom handlers for specific exception types to return
|
||||
structured error responses::
|
||||
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle_value_error(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
Now, any route that raises a ``ValueError`` will return a clean 400 response
|
||||
with a JSON error message instead of a generic 500 page.
|
||||
|
||||
|
||||
Before-Request Hooks
|
||||
--------------------
|
||||
|
||||
Run code before every request::
|
||||
Run code before every request. This is useful for logging, adding common
|
||||
headers, or setting up per-request state::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def add_headers(req, resp):
|
||||
resp.headers["X-API-Version"] = "3.1"
|
||||
|
||||
Short-circuit by setting a status code — the route handler will be skipped::
|
||||
**Short-circuiting:** If your hook sets ``resp.status_code``, the route
|
||||
handler will be skipped entirely and the response will be sent immediately.
|
||||
This is the pattern for authentication guards::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def auth_check(req, resp):
|
||||
@@ -105,11 +136,20 @@ Short-circuit by setting a status code — the route handler will be skipped::
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "unauthorized"}
|
||||
|
||||
If the ``Authorization`` header is missing, the client gets a 401 response
|
||||
and the actual route handler never runs.
|
||||
|
||||
WebSockets
|
||||
----------
|
||||
WebSocket hooks work the same way::
|
||||
|
||||
::
|
||||
@api.before_request(websocket=True)
|
||||
async def ws_auth(ws):
|
||||
await ws.accept()
|
||||
|
||||
|
||||
WebSocket Support
|
||||
-----------------
|
||||
|
||||
Responder supports WebSockets for real-time, bidirectional communication::
|
||||
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
@@ -119,46 +159,79 @@ WebSockets
|
||||
await ws.send_text(f"Hello {name}!")
|
||||
await ws.close()
|
||||
|
||||
Supported formats: ``send_text``, ``send_json``, ``send_bytes``.
|
||||
You can send and receive in multiple formats:
|
||||
|
||||
- ``send_text`` / ``receive_text`` — plain text
|
||||
- ``send_json`` / ``receive_json`` — JSON objects
|
||||
- ``send_bytes`` / ``receive_bytes`` — raw binary data
|
||||
|
||||
|
||||
GraphQL
|
||||
-------
|
||||
|
||||
One-liner setup with `Graphene <https://graphene-python.org/>`_::
|
||||
Responder includes built-in GraphQL support via
|
||||
`Graphene <https://graphene-python.org/>`_. Set up a full GraphQL endpoint
|
||||
with a single method call::
|
||||
|
||||
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))
|
||||
|
||||
Visiting ``/graphql`` in a browser renders the GraphiQL IDE.
|
||||
Visiting ``/graphql`` in a browser renders the GraphiQL interactive IDE,
|
||||
where you can explore your schema and test queries. Programmatic clients
|
||||
can POST JSON queries to the same endpoint.
|
||||
|
||||
You can access the Responder request and response objects in your resolvers
|
||||
through ``info.context["request"]`` and ``info.context["response"]``.
|
||||
|
||||
|
||||
OpenAPI
|
||||
-------
|
||||
OpenAPI Documentation
|
||||
---------------------
|
||||
|
||||
::
|
||||
Responder can generate an OpenAPI schema and serve interactive API
|
||||
documentation automatically::
|
||||
|
||||
api = responder.API(
|
||||
title="My API",
|
||||
title="Pet Store",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
docs_route="/docs",
|
||||
)
|
||||
|
||||
Visit ``/docs`` for interactive Swagger UI documentation.
|
||||
The schema is served at ``/schema.yml``.
|
||||
This gives you:
|
||||
|
||||
- An OpenAPI schema at ``/schema.yml``
|
||||
- Interactive Swagger UI documentation at ``/docs``
|
||||
|
||||
Document your endpoints using YAML in docstrings::
|
||||
|
||||
@api.route("/pets")
|
||||
def list_pets(req, resp):
|
||||
"""A list of pets.
|
||||
---
|
||||
get:
|
||||
description: Get all pets
|
||||
responses:
|
||||
200:
|
||||
description: A list of pets
|
||||
"""
|
||||
resp.media = [{"name": "Fido"}]
|
||||
|
||||
You can choose from multiple documentation themes:
|
||||
``swagger_ui`` (default), ``redoc``, ``rapidoc``, or ``elements``.
|
||||
|
||||
|
||||
Mounting Apps
|
||||
-------------
|
||||
Mounting Other Apps
|
||||
-------------------
|
||||
|
||||
Mount any WSGI or ASGI application at a subroute::
|
||||
Responder can mount any WSGI or ASGI application at a subroute. This means
|
||||
you can gradually migrate from Flask, or run multiple frameworks side by side::
|
||||
|
||||
from flask import Flask
|
||||
|
||||
@@ -170,26 +243,42 @@ Mount any WSGI or ASGI application at a subroute::
|
||||
|
||||
api.mount("/flask", flask_app)
|
||||
|
||||
Requests to ``/flask/`` will be handled by Flask. Everything else goes
|
||||
through Responder. Both WSGI and ASGI apps are supported — Responder
|
||||
wraps WSGI apps automatically.
|
||||
|
||||
|
||||
Cookies
|
||||
-------
|
||||
|
||||
::
|
||||
Reading and writing cookies is straightforward::
|
||||
|
||||
# Read cookies
|
||||
req.cookies["session_id"]
|
||||
# Read cookies from the request
|
||||
session_id = req.cookies.get("session_id")
|
||||
|
||||
# Set cookies
|
||||
# Set a cookie on the response
|
||||
resp.cookies["hello"] = "world"
|
||||
|
||||
# With directives
|
||||
resp.set_cookie("token", value="abc", max_age=3600, secure=True)
|
||||
For more control over cookie directives, use ``set_cookie``::
|
||||
|
||||
resp.set_cookie(
|
||||
"token",
|
||||
value="abc123",
|
||||
max_age=3600,
|
||||
secure=True,
|
||||
httponly=True,
|
||||
path="/",
|
||||
)
|
||||
|
||||
Supported directives: ``key``, ``value``, ``expires``, ``max_age``,
|
||||
``domain``, ``path``, ``secure``, ``httponly``.
|
||||
|
||||
|
||||
Sessions
|
||||
--------
|
||||
Cookie-Based Sessions
|
||||
---------------------
|
||||
|
||||
Built-in cookie-based sessions::
|
||||
Responder has built-in support for signed, cookie-based sessions. Just
|
||||
read from and write to the ``session`` dictionary::
|
||||
|
||||
@api.route("/login")
|
||||
def login(req, resp):
|
||||
@@ -199,9 +288,15 @@ Built-in cookie-based sessions::
|
||||
def profile(req, resp):
|
||||
resp.media = {"user": req.session.get("username")}
|
||||
|
||||
Set a secret key for production::
|
||||
The session data is stored in a cookie called ``Responder-Session``. It's
|
||||
signed for tamper protection, so you can trust that the data originated
|
||||
from your server.
|
||||
|
||||
api = responder.API(secret_key="your-secret-key")
|
||||
.. warning::
|
||||
|
||||
For production use, always set a secret key::
|
||||
|
||||
api = responder.API(secret_key="your-secret-key-here")
|
||||
|
||||
|
||||
Static Files
|
||||
@@ -211,34 +306,56 @@ Static files are served from the ``static/`` directory by default::
|
||||
|
||||
api = responder.API(static_dir="static", static_route="/static")
|
||||
|
||||
For single-page apps, serve ``index.html`` as the default::
|
||||
Place your CSS, JavaScript, images, and other assets in the ``static/``
|
||||
directory and they'll be served automatically.
|
||||
|
||||
For single-page applications, you can serve ``index.html`` as the default
|
||||
response for all unmatched routes::
|
||||
|
||||
api.add_route("/", static=True)
|
||||
|
||||
You can add additional static directories at runtime::
|
||||
|
||||
api.static_app.add_directory("extra_assets")
|
||||
|
||||
|
||||
CORS
|
||||
----
|
||||
|
||||
::
|
||||
Enable Cross-Origin Resource Sharing for your API::
|
||||
|
||||
api = responder.API(cors=True, cors_params={
|
||||
"allow_origins": ["https://example.com"],
|
||||
"allow_methods": ["GET", "POST"],
|
||||
"allow_headers": ["*"],
|
||||
"allow_credentials": True,
|
||||
"max_age": 600,
|
||||
})
|
||||
|
||||
The default CORS policy is restrictive — you must explicitly enable the
|
||||
origins, methods, and headers your frontend needs.
|
||||
|
||||
|
||||
HSTS
|
||||
----
|
||||
|
||||
Redirect all traffic to HTTPS::
|
||||
Force all traffic to HTTPS with a single flag::
|
||||
|
||||
api = responder.API(enable_hsts=True)
|
||||
|
||||
This adds the ``Strict-Transport-Security`` header and redirects HTTP
|
||||
requests to HTTPS.
|
||||
|
||||
|
||||
Trusted Hosts
|
||||
-------------
|
||||
|
||||
::
|
||||
Protect against HTTP Host header attacks by restricting which hostnames
|
||||
your application will respond to::
|
||||
|
||||
api = responder.API(allowed_hosts=["example.com", "*.example.com"])
|
||||
|
||||
Requests with a ``Host`` header that doesn't match any of the patterns
|
||||
will receive a 400 Bad Request response. Wildcard domains are supported.
|
||||
|
||||
By default, all hostnames are allowed.
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies = [
|
||||
"graphql-core>=3.1",
|
||||
"marshmallow",
|
||||
"pueblo[sfa-full]>=0.0.11",
|
||||
"pydantic>=2",
|
||||
"python-multipart",
|
||||
"starlette[full]>=0.40",
|
||||
"uvicorn[standard]",
|
||||
|
||||
+32
-1
@@ -327,7 +327,7 @@ class API:
|
||||
|
||||
self.router.add_event_handler(event_type, handler)
|
||||
|
||||
def route(self, route=None, **options):
|
||||
def route(self, route=None, *, request_model=None, response_model=None, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
@@ -336,9 +336,40 @@ class API:
|
||||
def hello(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
With Pydantic models for 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"],
|
||||
request_model=ItemIn, response_model=ItemOut)
|
||||
async def create_item(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"id": 1, **data}
|
||||
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
if request_model is not None:
|
||||
f._request_model = request_model
|
||||
if hasattr(self, "openapi"):
|
||||
self.openapi.add_schema(
|
||||
request_model.__name__, request_model, check_existing=False
|
||||
)
|
||||
if response_model is not None:
|
||||
f._response_model = response_model
|
||||
if hasattr(self, "openapi"):
|
||||
self.openapi.add_schema(
|
||||
response_model.__name__, response_model, check_existing=False
|
||||
)
|
||||
self.add_route(route, f, **options)
|
||||
return f
|
||||
|
||||
|
||||
@@ -8,6 +8,38 @@ from responder.statics import API_THEMES, DEFAULT_OPENAPI_THEME
|
||||
from responder.templates import Templates
|
||||
|
||||
|
||||
def _is_pydantic_model(obj):
|
||||
"""Check if obj is a Pydantic model class."""
|
||||
try:
|
||||
from pydantic import BaseModel
|
||||
|
||||
return isinstance(obj, type) and issubclass(obj, BaseModel)
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
class PydanticPlugin:
|
||||
"""APISpec plugin that resolves Pydantic models to JSON Schema."""
|
||||
|
||||
def __init__(self):
|
||||
self._schemas = {}
|
||||
|
||||
def definition_helper(self, name, definition, **kwargs):
|
||||
schema = kwargs.get("schema")
|
||||
if schema is not None and _is_pydantic_model(schema):
|
||||
return schema.model_json_schema()
|
||||
return None
|
||||
|
||||
def resolve_schemas(self, spec):
|
||||
pass
|
||||
|
||||
def init_spec(self, spec):
|
||||
pass
|
||||
|
||||
def operation_helper(self, **kwargs):
|
||||
return {}
|
||||
|
||||
|
||||
class OpenAPISchema:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -27,6 +59,7 @@ class OpenAPISchema:
|
||||
):
|
||||
self.app = app
|
||||
self.schemas = {}
|
||||
self.pydantic_schemas = {}
|
||||
self.title = title
|
||||
self.version = version
|
||||
self.description = description
|
||||
@@ -80,9 +113,56 @@ class OpenAPISchema:
|
||||
operations = yaml_utils.load_operations_from_docstring(route.description)
|
||||
spec.path(path=route.route, operations=operations)
|
||||
|
||||
# Check for Pydantic-annotated routes
|
||||
endpoint = route.endpoint
|
||||
req_model = getattr(endpoint, "_request_model", None)
|
||||
resp_model = getattr(endpoint, "_response_model", None)
|
||||
|
||||
if req_model or resp_model:
|
||||
operations = {}
|
||||
methods = getattr(route, "methods", None) or ["get"]
|
||||
|
||||
for method in [m.lower() for m in methods]:
|
||||
op = {}
|
||||
if req_model and method in ("post", "put", "patch"):
|
||||
model_name = req_model.__name__
|
||||
op["requestBody"] = {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": f"#/components/schemas/{model_name}"}
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp_model:
|
||||
model_name = resp_model.__name__
|
||||
op["responses"] = {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": f"#/components/schemas/{model_name}"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if op:
|
||||
operations[method] = op
|
||||
|
||||
if operations and not route.description:
|
||||
spec.path(path=route.route, operations=operations)
|
||||
|
||||
# Register marshmallow schemas
|
||||
for name, schema in self.schemas.items():
|
||||
spec.components.schema(name, schema=schema)
|
||||
|
||||
# Register Pydantic schemas
|
||||
for name, model in self.pydantic_schemas.items():
|
||||
json_schema = model.model_json_schema()
|
||||
json_schema.pop("title", None)
|
||||
spec.components.schema(name, component=json_schema)
|
||||
|
||||
return spec
|
||||
|
||||
@property
|
||||
@@ -90,14 +170,18 @@ class OpenAPISchema:
|
||||
return self._apispec.to_yaml()
|
||||
|
||||
def add_schema(self, name, schema, check_existing=True):
|
||||
"""Adds a marshmallow schema to the API specification."""
|
||||
"""Adds a marshmallow or Pydantic schema to the API specification."""
|
||||
if check_existing:
|
||||
assert name not in self.schemas
|
||||
assert name not in self.pydantic_schemas
|
||||
|
||||
self.schemas[name] = schema
|
||||
if _is_pydantic_model(schema):
|
||||
self.pydantic_schemas[name] = schema
|
||||
else:
|
||||
self.schemas[name] = schema
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
"""Decorator for registering schemas (marshmallow or Pydantic).
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -107,6 +191,15 @@ class OpenAPISchema:
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
Or with Pydantic::
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@api.schema("Pet")
|
||||
class Pet(BaseModel):
|
||||
name: str
|
||||
age: int = 0
|
||||
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
|
||||
@@ -586,6 +586,61 @@ def test_openapi_static_url():
|
||||
assert url == "/static/swagger-ui.css"
|
||||
|
||||
|
||||
def test_pydantic_schema():
|
||||
"""Pydantic models registered via @api.schema."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
api = responder.API(
|
||||
title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"],
|
||||
)
|
||||
|
||||
@api.schema("Pet")
|
||||
class Pet(BaseModel):
|
||||
name: str
|
||||
age: int = 0
|
||||
|
||||
r = api.requests.get("http://;/schema.yml")
|
||||
assert r.status_code == 200
|
||||
assert "Pet" in r.text
|
||||
assert "name" in r.text
|
||||
assert "type: string" in r.text
|
||||
|
||||
|
||||
def test_pydantic_request_response_models():
|
||||
"""request_model and response_model generate OpenAPI schemas."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
api = responder.API(
|
||||
title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"],
|
||||
)
|
||||
|
||||
class ItemIn(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
class ItemOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
price: float
|
||||
|
||||
@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}
|
||||
|
||||
# Check schema generation
|
||||
r = api.requests.get("http://;/schema.yml")
|
||||
assert "ItemIn" in r.text
|
||||
assert "ItemOut" in r.text
|
||||
assert "$ref" in r.text
|
||||
assert "requestBody" in r.text
|
||||
|
||||
# Check the endpoint still works
|
||||
r = api.requests.post("http://;/items", json={"name": "widget", "price": 9.99})
|
||||
assert r.json() == {"id": 1, "name": "widget", "price": 9.99}
|
||||
|
||||
|
||||
def test_templates_context(tmp_path):
|
||||
"""Lines 23, 27: Templates.context getter and setter."""
|
||||
template_dir = tmp_path / "templates"
|
||||
|
||||
Reference in New Issue
Block a user