diff --git a/docs/source/_templates/sidebarintro.html b/docs/source/_templates/sidebarintro.html
index 9d6a9dc..3fe9e47 100644
--- a/docs/source/_templates/sidebarintro.html
+++ b/docs/source/_templates/sidebarintro.html
@@ -1,8 +1,14 @@
+
+
+
+
+
Responder — a familiar HTTP service framework for Python.
+Useful Links
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 85d7a6a..039d09f 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -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
diff --git a/docs/source/deployment.rst b/docs/source/deployment.rst
index d750326..f30b59c 100644
--- a/docs/source/deployment.rst
+++ b/docs/source/deployment.rst
@@ -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 `_ 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.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index d277288..ab24d6a 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -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/
diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst
index 92405ee..f675fc3 100644
--- a/docs/source/quickstart.rst
+++ b/docs/source/quickstart.rst
@@ -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 = "hello
"
+ resp.text = "plain text response"
+ resp.html = "HTML response
"
- # 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 `_
+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.
diff --git a/docs/source/testing.rst b/docs/source/testing.rst
index 01756ab..3ae625a 100644
--- a/docs/source/testing.rst
+++ b/docs/source/testing.rst
@@ -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}
diff --git a/docs/source/tour.rst b/docs/source/tour.rst
index cad2bc9..12d67d2 100644
--- a/docs/source/tour.rst
+++ b/docs/source/tour.rst
@@ -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 `_::
+Responder includes built-in GraphQL support via
+`Graphene `_. 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.
diff --git a/pyproject.toml b/pyproject.toml
index 3f4febd..de80b7c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]",
diff --git a/responder/api.py b/responder/api.py
index a759d46..b2a3f7d 100644
--- a/responder/api.py
+++ b/responder/api.py
@@ -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
diff --git a/responder/ext/openapi/__init__.py b/responder/ext/openapi/__init__.py
index a2caa31..c6746ea 100644
--- a/responder/ext/openapi/__init__.py
+++ b/responder/ext/openapi/__init__.py
@@ -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):
diff --git a/tests/test_coverage.py b/tests/test_coverage.py
index b9b9eab..dbf5a5a 100644
--- a/tests/test_coverage.py
+++ b/tests/test_coverage.py
@@ -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"