diff --git a/docs/source/api.rst b/docs/source/api.rst
index 7bc0506..72e5ee5 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -12,6 +12,20 @@ The central object of every Responder application. It holds your routes,
middleware, templates, and configuration. Create one at the top of your
module and use it to define your entire web service.
+Quick example::
+
+ import responder
+
+ api = responder.API(
+ title="My Service", # OpenAPI title
+ version="1.0", # OpenAPI version
+ openapi="3.0.2", # enable OpenAPI
+ docs_route="/docs", # Swagger UI at /docs
+ cors=True, # enable CORS
+ secret_key="change-me", # session signing key
+ allowed_hosts=["example.com"],
+ )
+
.. module:: responder
.. autoclass:: API
@@ -28,6 +42,27 @@ parameters, the request body, cookies, and more.
Most properties are synchronous, but reading the body requires ``await``
because it involves I/O.
+Common patterns::
+
+ # Headers (case-insensitive)
+ token = req.headers.get("Authorization")
+
+ # Query parameters: /search?q=python&page=2
+ query = req.params["q"]
+
+ # JSON body
+ data = await req.media()
+
+ # Form data
+ form = await req.media("form")
+
+ # File uploads
+ files = await req.media("files")
+
+ # Client info
+ ip, port = req.client
+ is_https = req.is_secure
+
.. autoclass:: Request
:inherited-members:
@@ -39,6 +74,19 @@ The response object is passed into every view as the second argument.
Mutate it to control what gets sent back to the client — the body,
status code, headers, and cookies.
+Common patterns::
+
+ resp.text = "plain text" # text/plain
+ resp.html = "
Hello
" # text/html
+ resp.media = {"key": "value"} # application/json
+ resp.content = b"raw bytes" # application/octet-stream
+ resp.file("path/to/file.pdf") # auto content-type
+ resp.stream_file("large/export.csv") # streamed
+
+ resp.status_code = 201
+ resp.headers["X-Custom"] = "value"
+ resp.cookies["session"] = "abc123"
+
.. autoclass:: Response
:inherited-members:
@@ -47,7 +95,13 @@ Route Groups
------------
Group related routes under a shared URL prefix — useful for API versioning
-and organizing large applications.
+and organizing large applications::
+
+ v1 = api.group("/v1")
+
+ @v1.route("/users")
+ def list_users(req, resp):
+ resp.media = []
.. autoclass:: responder.api.RouteGroup
:members:
@@ -57,7 +111,19 @@ Background Queue
----------------
Run tasks in background threads without blocking the response. Available
-as ``api.background``.
+as ``api.background``::
+
+ @api.route("/submit")
+ async def submit(req, resp):
+ data = await req.media()
+
+ @api.background.task
+ def process(data):
+ # runs in a thread pool
+ ...
+
+ process(data)
+ resp.media = {"status": "accepted"}
.. autoclass:: responder.background.BackgroundQueue
:members:
@@ -67,6 +133,8 @@ Query Dict
----------
A dictionary subclass for query string parameters with multi-value support.
+Behaves like a normal dict for single values, but supports ``getlist()``
+for parameters that appear multiple times (e.g. ``?tag=a&tag=b``).
.. autoclass:: responder.models.QueryDict
:members:
@@ -76,7 +144,15 @@ Rate Limiter
------------
In-memory token bucket rate limiter. Limits requests per client IP address
-and returns ``429 Too Many Requests`` when exceeded.
+and returns ``429 Too Many Requests`` when exceeded::
+
+ from responder.ext.ratelimit import RateLimiter
+
+ limiter = RateLimiter(requests=100, period=60) # 100 req/min
+ limiter.install(api)
+
+Response headers: ``X-RateLimit-Limit``, ``X-RateLimit-Remaining``,
+and ``Retry-After`` (when limited).
.. autoclass:: responder.ext.ratelimit.RateLimiter
:members:
@@ -86,7 +162,14 @@ Status Code Helpers
-------------------
Convenience functions for checking which category a status code falls
-into. Useful in middleware and after-request hooks.
+into. Useful in middleware and after-request hooks::
+
+ from responder.status_codes import is_200, is_400, is_500
+
+ @api.after_request()
+ def log_errors(req, resp):
+ if is_400(resp.status_code) or is_500(resp.status_code):
+ print(f"Error: {req.method} {req.url.path} -> {resp.status_code}")
.. autofunction:: responder.status_codes.is_100
diff --git a/docs/source/backlog.md b/docs/source/backlog.md
index 73f717c..7dfd145 100644
--- a/docs/source/backlog.md
+++ b/docs/source/backlog.md
@@ -1,7 +1,8 @@
# Backlog
## Future Ideas
-- Consider adding `after_request` hooks (complement to `before_request`)
-- Explore WebSocket before_request short-circuit support
-- Add rate limiting middleware
-- Consider async template rendering by default
+- WebSocket before_request short-circuit support (reject before accept)
+- Per-route rate limiting (different limits for different endpoints)
+- Built-in structured logging with request context
+- OpenAPI 3.1 support
+- HTTP/2 server push
diff --git a/docs/source/cli.rst b/docs/source/cli.rst
index d7a0447..e465912 100644
--- a/docs/source/cli.rst
+++ b/docs/source/cli.rst
@@ -71,6 +71,25 @@ For URLs, use a fragment::
$ responder run https://example.com/app.py#service
+Environment Variables
+---------------------
+
+Responder automatically reads the ``PORT`` environment variable at
+runtime:
+
+- ``PORT`` — bind to ``0.0.0.0`` on this port (cloud platform convention)
+
+When ``PORT`` is set, the server binds to all interfaces automatically.
+This is how cloud platforms like Fly.io, Railway, and Heroku inject the
+listen port.
+
+For other settings like ``SECRET_KEY``, read them in your application
+code and pass them to ``responder.API()``::
+
+ import os
+ api = responder.API(secret_key=os.environ["SECRET_KEY"])
+
+
Building Frontend Assets
-------------------------
diff --git a/docs/source/deployment.rst b/docs/source/deployment.rst
index 7644ceb..6eb7b33 100644
--- a/docs/source/deployment.rst
+++ b/docs/source/deployment.rst
@@ -67,6 +67,27 @@ The pattern is always the same: deploy your code, set the start command
to ``python api.py``, and the platform handles the rest.
+Health Check Endpoint
+---------------------
+
+Every production deployment needs a health check — a lightweight endpoint
+that monitoring tools, load balancers, and orchestrators can poll to verify
+your service is running::
+
+ @api.route("/health")
+ def health(req, resp):
+ resp.media = {"status": "healthy"}
+
+Keep it simple. Don't query the database or do expensive work — the health
+check should return instantly. Cloud platforms, Docker, and Kubernetes all
+look for an HTTP 200 to confirm your service is alive.
+
+For Docker, add a ``HEALTHCHECK`` instruction::
+
+ HEALTHCHECK --interval=30s --timeout=3s \
+ CMD curl -f http://localhost/health || exit 1
+
+
Uvicorn Directly
----------------
@@ -82,6 +103,45 @@ Uvicorn supports many options — SSL certificates, access logging, graceful
shutdown timeouts, and more. See the
`uvicorn documentation `_ for details.
+For platforms like Heroku or Railway that use a ``Procfile``::
+
+ web: uvicorn api:api --host 0.0.0.0 --port $PORT --workers 4
+
+
+Docker Compose
+--------------
+
+For local development with databases and other services, Docker Compose
+ties everything together::
+
+ # docker-compose.yml
+ services:
+ api:
+ build: .
+ ports:
+ - "5042:80"
+ environment:
+ - PORT=80
+ - DATABASE_URL=postgresql+asyncpg://user:pass@db/myapp
+ - SECRET_KEY=dev-secret
+ depends_on:
+ - db
+
+ db:
+ image: docker.io/postgres:16-alpine
+ environment:
+ POSTGRES_USER: user
+ POSTGRES_PASSWORD: pass
+ POSTGRES_DB: myapp
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+ volumes:
+ pgdata:
+
+Run with ``docker compose up``. The API waits for ``db`` to start, then
+connects using the ``DATABASE_URL`` environment variable.
+
Reverse Proxy
-------------
@@ -95,9 +155,31 @@ front of your application for:
- **Static asset serving** — offload static files to the proxy
- **Rate limiting** — at the infrastructure level
+A minimal Caddy config that handles HTTPS automatically::
+
+ # Caddyfile
+ example.com {
+ reverse_proxy localhost:5042
+ }
+
Responder's ``TrustedHostMiddleware`` and ``HTTPSRedirectMiddleware`` work
correctly behind proxies that set standard forwarding headers
(``X-Forwarded-For``, ``X-Forwarded-Proto``).
That said, uvicorn is production-ready on its own. Many applications run
uvicorn directly without a reverse proxy and do just fine.
+
+
+Production Checklist
+--------------------
+
+Before going live:
+
+- **Set a secret key** — ``SECRET_KEY`` env var, never the default
+- **Disable debug mode** — ``DEBUG=false`` or omit it entirely
+- **Set allowed hosts** — restrict to your actual domain names
+- **Use multiple workers** — ``--workers 4`` or more, depending on CPU cores
+- **Add a health check** — ``/health`` endpoint for monitoring
+- **Enable HTTPS** — via your proxy, cloud platform, or uvicorn's ``--ssl-*`` flags
+- **Set up logging** — uvicorn logs requests by default; pipe them to your log aggregator
+- **Pin your dependencies** — commit ``uv.lock`` for reproducible deploys
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 6b9ba39..f48987b 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -59,21 +59,25 @@ What You Get
One ``pip install``, batteries included:
+- Pydantic request validation and response serialization.
- 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.
+- Before-request and after-request hooks for auth and logging.
- 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.
+- Server-Sent Events for real-time streaming.
- 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``.
+- Built-in rate limiting with ``X-RateLimit`` headers.
+- Content negotiation: JSON, YAML, and MessagePack.
- 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.
+- Route groups for API versioning.
- Signed cookie-based sessions.
- Background tasks in a thread pool.
- WebSocket support.
diff --git a/docs/source/testing.rst b/docs/source/testing.rst
index 6271d2d..fe3ba0f 100644
--- a/docs/source/testing.rst
+++ b/docs/source/testing.rst
@@ -269,6 +269,57 @@ just like in production. You can verify their effects on the response::
assert r.headers["X-Served-By"] == "responder"
+Testing Rate Limiting
+---------------------
+
+Rate limiters are just hooks — they run automatically during tests.
+Verify the headers and the 429 response::
+
+ from responder.ext.ratelimit import RateLimiter
+
+ def test_rate_limiting():
+ api = responder.API(allowed_hosts=["localhost"])
+ limiter = RateLimiter(requests=2, period=60)
+ limiter.install(api)
+
+ @api.route("/")
+ def view(req, resp):
+ resp.text = "ok"
+
+ # First two requests succeed
+ for _ in range(2):
+ r = api.requests.get("http://localhost/")
+ assert r.status_code == 200
+ assert "X-RateLimit-Remaining" in r.headers
+
+ # Third request is rate limited
+ r = api.requests.get("http://localhost/")
+ assert r.status_code == 429
+
+
+Testing Mounted Apps
+--------------------
+
+When testing WSGI apps mounted at a subroute, use ``localhost`` as the
+host to avoid Werkzeug's trusted host validation::
+
+ from flask import Flask
+
+ def test_flask_mount():
+ api = responder.API(allowed_hosts=["localhost"])
+
+ flask_app = Flask(__name__)
+ @flask_app.route("/")
+ def hello():
+ return "Hello from Flask!"
+
+ api.mount("/flask", flask_app)
+
+ r = api.requests.get("http://localhost/flask")
+ assert r.status_code == 200
+ assert "Hello from Flask" in r.text
+
+
Tips
----
@@ -284,3 +335,6 @@ Tips
- **Test the contract, not the implementation.** Assert on status codes,
response bodies, and headers — not on internal state.
+
+- **Use ``localhost`` for mounted WSGI apps.** Werkzeug 3.1.7+ validates
+ the ``Host`` header, so avoid synthetic hosts like ``;`` in tests.
diff --git a/docs/source/tour.rst b/docs/source/tour.rst
index f041e3f..6063fa8 100644
--- a/docs/source/tour.rst
+++ b/docs/source/tour.rst
@@ -596,6 +596,73 @@ can pace themselves.
The rate limiter is per-client, keyed by IP address.
+Pydantic Validation
+-------------------
+
+`Pydantic `_ models integrate directly with
+Responder's routing. Set ``request_model`` to validate incoming data and
+``response_model`` to control the shape of outgoing data::
+
+ 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}
+
+When ``request_model`` is set:
+
+- Valid requests are parsed and the data is available via ``await req.media()``
+- Invalid requests get an automatic ``422 Unprocessable Entity`` response
+ with detailed error messages — you don't write any validation code
+
+When ``response_model`` is set:
+
+- The response is serialized through the model before being sent
+- Extra fields are stripped automatically
+- Type coercion happens at the boundary
+
+This is the recommended way to build validated REST APIs with Responder.
+See the :doc:`tutorial-rest` for a complete walkthrough.
+
+
+Content Negotiation
+-------------------
+
+Responder automatically negotiates the response format based on the
+client's ``Accept`` header. Set ``resp.media`` to a Python object and
+the right thing happens:
+
+- ``Accept: application/json`` (default) → JSON
+- ``Accept: application/x-yaml`` → YAML
+- ``Accept: application/x-msgpack`` → MessagePack
+
+This means a single endpoint serves multiple formats without any
+conditional logic in your code::
+
+ @api.route("/data")
+ def data(req, resp):
+ resp.media = {"key": "value"}
+
+Clients get the format they ask for::
+
+ $ curl http://localhost:5042/data
+ {"key": "value"}
+
+ $ curl -H "Accept: application/x-yaml" http://localhost:5042/data
+ key: value
+
+
MessagePack
-----------
@@ -608,6 +675,10 @@ Responder supports MessagePack alongside JSON and YAML::
# Decode a MessagePack request body
data = await req.media("msgpack")
-Content negotiation works too — clients can send
+ # Respond with MessagePack
+ resp.media = {"result": [1, 2, 3]}
+
+Content negotiation works automatically — clients can send
``Accept: application/x-msgpack`` to receive MessagePack responses
-instead of JSON.
+instead of JSON. You can also explicitly decode MessagePack request
+bodies by passing ``"msgpack"`` to ``req.media()``.
diff --git a/docs/source/tutorial-middleware.rst b/docs/source/tutorial-middleware.rst
index 5679d35..63032d6 100644
--- a/docs/source/tutorial-middleware.rst
+++ b/docs/source/tutorial-middleware.rst
@@ -117,6 +117,47 @@ the existing stack. Keep this in mind for ordering dependencies — if
middleware A depends on middleware B having run first, add B before A.
+Writing Pure ASGI Middleware
+----------------------------
+
+For maximum performance and control, you can write middleware as a plain
+ASGI application. This bypasses Starlette's ``BaseHTTPMiddleware``
+abstraction — it's faster and gives you direct access to the ASGI
+protocol::
+
+ class SecurityHeadersMiddleware:
+ def __init__(self, app):
+ self.app = app
+
+ async def __call__(self, scope, receive, send):
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+
+ async def send_with_headers(message):
+ if message["type"] == "http.response.start":
+ extra = [
+ (b"x-content-type-options", b"nosniff"),
+ (b"x-frame-options", b"DENY"),
+ (b"referrer-policy", b"strict-origin-when-cross-origin"),
+ ]
+ message["headers"] = list(message["headers"]) + extra
+ await send(message)
+
+ await self.app(scope, receive, send_with_headers)
+
+ api.add_middleware(SecurityHeadersMiddleware)
+
+This is the same pattern used internally by Starlette and uvicorn. The
+middleware receives the ASGI ``scope``, ``receive``, and ``send`` callables,
+and wraps ``send`` to inject headers into the response.
+
+For most cases, ``BaseHTTPMiddleware`` is simpler and perfectly fine.
+Use the pure ASGI approach when you need to handle WebSocket connections,
+streaming responses, or want to avoid the overhead of request/response
+object creation.
+
+
When to Use What
-----------------