diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3cf2d0b..c485090 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,44 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## [Unreleased]
+## [v3.2.0] - 2026-03-22
+
+### Added
+
+- Pydantic auto-validation: `request_model` validates input, returns 422 on failure
+- Pydantic auto-serialization: `response_model` strips extra fields from responses
+- Server-Sent Events: `@resp.sse` for real-time streaming
+- `resp.stream_file()` for streaming large files without loading into memory
+- `@api.after_request()` hooks
+- `api.group("/prefix")` for route groups and API versioning
+- `api.graphql("/path", schema=schema)` one-liner GraphQL setup
+- `api = responder.API(request_id=True)` for automatic request ID generation
+- Built-in rate limiter: `RateLimiter(requests=100, period=60).install(api)`
+- MessagePack format support: `await req.media("msgpack")`
+- `req.is_json`, `req.path_params`, `req.client` properties
+- `api.exception_handler()` decorator for custom error handling
+- Lifespan context manager support
+- `uuid` and `path` route convertors
+- PEP 561 `py.typed` marker
+- Pydantic support for OpenAPI schema generation
+
+### Changed
+
+- Dependencies flattened: `pip install responder` gets everything
+- Core deps reduced to starlette + uvicorn
+- TestClient lazy-loaded (no httpx import in production)
+- Before-request hooks can short-circuit by setting status code
+- Removed poethepoet task runner
+
+### Fixed
+
+- Multipart parser losing headers when parts have multiple headers
+- `url_for()` with typed route params (`{id:int}`)
+- `resp.body` encoding crash on bytes content
+- GraphQL text query missing `await`
+- Streaming responses not sending Content-Type headers
+- Python 3.9 compatibility for union type syntax
+
## [v3.0.0] - 2026-03-22
### Added
diff --git a/docs/source/guide-config.rst b/docs/source/guide-config.rst
new file mode 100644
index 0000000..ce62f6e
--- /dev/null
+++ b/docs/source/guide-config.rst
@@ -0,0 +1,172 @@
+Configuration
+=============
+
+Every application needs different settings for different environments —
+debug mode in development, real secrets in production, different database
+URLs for testing. This guide covers how to manage configuration cleanly.
+
+
+Environment Variables
+---------------------
+
+The simplest and most universal approach. Environment variables work
+everywhere — locally, in Docker, on cloud platforms — and keep secrets
+out of your source code::
+
+ import os
+ import responder
+
+ api = responder.API(
+ debug=os.getenv("DEBUG", "false").lower() == "true",
+ secret_key=os.environ["SECRET_KEY"],
+ cors=os.getenv("CORS_ENABLED", "false").lower() == "true",
+ )
+
+Some variables Responder handles automatically:
+
+- ``PORT`` — when set, the server binds to ``0.0.0.0`` on this port
+
+Set variables in your shell::
+
+ $ export SECRET_KEY="your-secret-here"
+ $ export DEBUG=true
+ $ python app.py
+
+Or in a ``.env`` file (don't commit this to git)::
+
+ SECRET_KEY=your-secret-here
+ DEBUG=true
+
+
+Using .env Files
+----------------
+
+For local development, a ``.env`` file is convenient. Install
+``python-dotenv`` and load it at the top of your app::
+
+ $ uv pip install python-dotenv
+
+::
+
+ from dotenv import load_dotenv
+ load_dotenv()
+
+ import os
+ import responder
+
+ api = responder.API(
+ secret_key=os.environ["SECRET_KEY"],
+ )
+
+Add ``.env`` to your ``.gitignore`` — never commit secrets.
+
+
+Configuration Class Pattern
+----------------------------
+
+For larger applications, a configuration class keeps things organized::
+
+ import os
+
+ class Config:
+ SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret")
+ DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
+ DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///dev.db")
+ CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",")
+
+ config = Config()
+
+ api = responder.API(
+ debug=config.DEBUG,
+ secret_key=config.SECRET_KEY,
+ cors=bool(config.CORS_ORIGINS[0]),
+ cors_params={"allow_origins": config.CORS_ORIGINS},
+ )
+
+This makes it easy to see all your settings in one place.
+
+
+Secret Key
+----------
+
+The ``secret_key`` is used to sign session cookies. If someone knows your
+secret key, they can forge session data and impersonate any user.
+
+Rules:
+
+- **Never use the default** in production
+- **Generate a random key**: ``python -c "import secrets; print(secrets.token_hex(32))"``
+- **Store it in an environment variable**, not in code
+- **Rotate it** if it's ever compromised (this invalidates all sessions)
+
+::
+
+ api = responder.API(secret_key=os.environ["SECRET_KEY"])
+
+
+Debug Mode
+----------
+
+Debug mode controls error page behavior:
+
+- **On** (``debug=True``): detailed error pages with tracebacks. Never
+ use this in production — it exposes your source code.
+- **Off** (``debug=False``): generic error pages. This is the default.
+
+::
+
+ api = responder.API(debug=True) # development only
+
+A common pattern is to read it from the environment::
+
+ api = responder.API(debug=os.getenv("DEBUG") == "true")
+
+
+Allowed Hosts
+-------------
+
+In production, always set ``allowed_hosts`` to prevent Host header
+attacks. This should match the domain names your application serves::
+
+ api = responder.API(
+ allowed_hosts=["example.com", "www.example.com"],
+ )
+
+In development, you can use ``["*"]`` (the default) or specific local
+addresses::
+
+ api = responder.API(allowed_hosts=["localhost", "127.0.0.1"])
+
+
+Putting It All Together
+-----------------------
+
+A production-ready configuration setup::
+
+ import os
+ from dotenv import load_dotenv
+
+ load_dotenv()
+
+ import responder
+
+ api = responder.API(
+ debug=os.getenv("DEBUG", "false") == "true",
+ secret_key=os.environ["SECRET_KEY"],
+ allowed_hosts=os.getenv("ALLOWED_HOSTS", "*").split(","),
+ cors=bool(os.getenv("CORS_ORIGINS")),
+ cors_params={
+ "allow_origins": os.getenv("CORS_ORIGINS", "").split(","),
+ "allow_methods": ["GET", "POST", "PUT", "DELETE"],
+ },
+ )
+
+With a ``.env`` file for local development::
+
+ SECRET_KEY=dev-secret-do-not-use-in-prod
+ DEBUG=true
+ ALLOWED_HOSTS=localhost,127.0.0.1
+ CORS_ORIGINS=http://localhost:3000
+
+And environment variables set properly in production (via your cloud
+platform's dashboard, Docker secrets, or a secrets manager).
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 7455866..c11eba3 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -106,7 +106,11 @@ Python 3.9 and above. That's it.
tutorial-rest
tutorial-sqlalchemy
+ tutorial-auth
+ tutorial-websockets
+ tutorial-middleware
tutorial-flask
+ guide-config
.. toctree::
:maxdepth: 1
diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst
index 1781eed..a36cdc7 100644
--- a/docs/source/quickstart.rst
+++ b/docs/source/quickstart.rst
@@ -330,3 +330,49 @@ for lightweight use cases where you don't need a full message broker.
your application, which makes them fast to start but means CPU-intensive
work will block the event loop. For heavy computation, consider a proper
task queue.
+
+
+Putting It All Together
+-----------------------
+
+Here's a complete, working Responder application that combines everything
+from this guide::
+
+ import responder
+
+ api = responder.API()
+
+ @api.route("/")
+ def index(req, resp):
+ resp.text = "Welcome to the API"
+
+ @api.route("/hello/{name}")
+ def greet(req, resp, *, name):
+ resp.media = {"message": f"hello, {name}!"}
+
+ @api.route("/add/{a:int}/{b:int}")
+ def add(req, resp, *, a, b):
+ resp.media = {"result": a + b}
+
+ @api.route("/echo", methods=["POST"])
+ async def echo(req, resp):
+ data = await req.media()
+ resp.media = {"received": data}
+
+ if __name__ == "__main__":
+ api.run()
+
+Save this as ``app.py``, run it with ``python app.py``, and try::
+
+ $ curl http://localhost:5042/
+ $ curl http://localhost:5042/hello/world
+ $ curl http://localhost:5042/add/3/4
+ $ curl -X POST http://localhost:5042/echo \
+ -H "Content-Type: application/json" -d '{"key": "value"}'
+
+From here, explore the :doc:`tour` for the full range of features, or
+jump into the tutorials:
+
+- :doc:`tutorial-rest` — build a full CRUD API with validation
+- :doc:`tutorial-sqlalchemy` — connect to a database
+- :doc:`tutorial-auth` — add authentication
diff --git a/docs/source/tutorial-auth.rst b/docs/source/tutorial-auth.rst
new file mode 100644
index 0000000..5e40408
--- /dev/null
+++ b/docs/source/tutorial-auth.rst
@@ -0,0 +1,191 @@
+Authentication
+==============
+
+Every API that handles user data needs authentication — a way to verify
+who is making a request. This guide covers the most common patterns:
+API keys, JWT tokens, and how to build reusable auth guards with
+Responder's before-request hooks.
+
+
+API Key Authentication
+----------------------
+
+The simplest approach. The client sends a secret key in a header, and
+your server checks it against a known value. This is common for
+server-to-server communication and simple APIs::
+
+ API_KEYS = {"sk-abc123", "sk-def456"}
+
+ @api.route(before_request=True)
+ def check_api_key(req, resp):
+ key = req.headers.get("X-API-Key")
+ if key not in API_KEYS:
+ resp.status_code = 401
+ resp.media = {"error": "Invalid or missing API key"}
+
+Because the before-request hook sets ``resp.status_code``, the route
+handler is skipped entirely for unauthorized requests. The client never
+reaches your endpoint — the guard catches them first.
+
+The client sends the key like this::
+
+ $ curl -H "X-API-Key: sk-abc123" http://localhost:5042/protected
+
+
+Bearer Token Authentication
+----------------------------
+
+Bearer tokens are the standard for modern APIs. The client sends a token
+in the ``Authorization`` header, and the server validates it. The most
+common format is `JWT `_ (JSON Web Tokens).
+
+Install PyJWT::
+
+ $ uv pip install pyjwt
+
+Create a helper to encode and decode tokens::
+
+ import jwt
+ from datetime import datetime, timedelta
+
+ SECRET = "your-secret-key"
+
+ def create_token(user_id: int) -> str:
+ payload = {
+ "sub": user_id,
+ "exp": datetime.utcnow() + timedelta(hours=24),
+ }
+ return jwt.encode(payload, SECRET, algorithm="HS256")
+
+ def verify_token(token: str) -> dict | None:
+ try:
+ return jwt.decode(token, SECRET, algorithms=["HS256"])
+ except jwt.InvalidTokenError:
+ return None
+
+Add a login endpoint that issues tokens, and a before-request hook that
+verifies them::
+
+ @api.route("/login", methods=["POST"])
+ async def login(req, resp):
+ data = await req.media()
+ # In a real app, check credentials against a database
+ if data.get("username") == "admin" and data.get("password") == "secret":
+ token = create_token(user_id=1)
+ resp.media = {"token": token}
+ else:
+ resp.status_code = 401
+ resp.media = {"error": "Invalid credentials"}
+
+ @api.route(before_request=True)
+ def auth_guard(req, resp):
+ # Skip auth for the login endpoint itself
+ if req.url.path == "/login":
+ return
+
+ auth = req.headers.get("Authorization", "")
+ if not auth.startswith("Bearer "):
+ resp.status_code = 401
+ resp.media = {"error": "Missing bearer token"}
+ return
+
+ token = auth[7:] # Strip "Bearer "
+ payload = verify_token(token)
+ if payload is None:
+ resp.status_code = 401
+ resp.media = {"error": "Invalid or expired token"}
+ return
+
+ # Store the authenticated user on the request state
+ req.state.user_id = payload["sub"]
+
+Now any route can access the authenticated user::
+
+ @api.route("/me")
+ def get_me(req, resp):
+ resp.media = {"user_id": req.state.user_id}
+
+The client flow:
+
+1. ``POST /login`` with credentials → receive a token
+2. Include ``Authorization: Bearer `` on every subsequent request
+3. The token expires after 24 hours — the client must log in again
+
+
+Skipping Auth for Public Routes
+--------------------------------
+
+The example above skips auth for ``/login`` by checking the path. For
+more control, you can use a set of public paths::
+
+ PUBLIC_PATHS = {"/login", "/signup", "/health", "/docs", "/schema.yml"}
+
+ @api.route(before_request=True)
+ def auth_guard(req, resp):
+ if req.url.path in PUBLIC_PATHS:
+ return
+ # ... check token
+
+
+Custom Exception for Auth Errors
+---------------------------------
+
+For cleaner code, define a custom exception and register a handler::
+
+ class AuthError(Exception):
+ def __init__(self, message="Unauthorized", status_code=401):
+ self.message = message
+ self.status_code = status_code
+
+ @api.exception_handler(AuthError)
+ async def handle_auth_error(req, resp, exc):
+ resp.status_code = exc.status_code
+ resp.media = {"error": exc.message}
+
+Now your auth guard can simply raise::
+
+ @api.route(before_request=True)
+ def auth_guard(req, resp):
+ if req.url.path in PUBLIC_PATHS:
+ return
+ if "Authorization" not in req.headers:
+ raise AuthError("Missing authorization header")
+
+
+Using Sessions for Web Apps
+----------------------------
+
+For traditional web applications (with HTML pages and forms), cookie-based
+sessions are simpler than tokens. The browser handles cookies automatically
+— no client-side token management needed::
+
+ @api.route("/login", methods=["POST"])
+ async def login(req, resp):
+ data = await req.media("form")
+ if data["username"] == "admin" and data["password"] == "secret":
+ resp.session["user"] = data["username"]
+ api.redirect(resp, location="/dashboard")
+ else:
+ resp.status_code = 401
+ resp.html = "
Invalid credentials
"
+
+ @api.route("/dashboard")
+ def dashboard(req, resp):
+ user = req.session.get("user")
+ if not user:
+ api.redirect(resp, location="/login")
+ return
+ resp.html = f"
Welcome, {user}!
"
+
+ @api.route("/logout")
+ def logout(req, resp):
+ resp.session.clear()
+ api.redirect(resp, location="/login")
+
+Remember to set a proper secret key::
+
+ api = responder.API(secret_key="your-production-secret-key")
+
+The session data is signed (not encrypted) — users can read it but
+can't tamper with it. Don't store sensitive data like passwords in
+sessions.
diff --git a/docs/source/tutorial-middleware.rst b/docs/source/tutorial-middleware.rst
new file mode 100644
index 0000000..69dcf4d
--- /dev/null
+++ b/docs/source/tutorial-middleware.rst
@@ -0,0 +1,129 @@
+Writing Middleware
+=================
+
+Middleware sits between the server and your route handlers, processing
+every request and response that flows through your application. It's the
+right tool for cross-cutting concerns — things that apply to *all*
+requests, not just specific routes.
+
+Common middleware use cases:
+
+- Request logging and timing
+- Authentication and authorization
+- Adding security headers
+- Request ID generation
+- Rate limiting
+- Response compression (built-in)
+
+
+Hooks vs. Middleware
+--------------------
+
+Responder gives you two levels of request processing:
+
+**Hooks** (``before_request`` / ``after_request``) run inside Responder's
+routing layer. They receive Responder's ``req`` and ``resp`` objects and
+are the simplest way to add behavior::
+
+ @api.route(before_request=True)
+ def add_header(req, resp):
+ resp.headers["X-Powered-By"] = "Responder"
+
+ @api.after_request()
+ def log_request(req, resp):
+ print(f"{req.method} {req.url.path} -> {resp.status_code}")
+
+**Middleware** runs at the ASGI level, wrapping the entire application.
+It's more powerful but more complex — you work with raw ASGI scopes
+instead of Responder objects. Use middleware when you need to process
+requests *before* they reach Responder's routing, or when you need to
+integrate with Starlette middleware.
+
+
+Using Starlette Middleware
+--------------------------
+
+Responder is built on Starlette, so any Starlette middleware works
+out of the box::
+
+ from starlette.middleware.base import BaseHTTPMiddleware
+
+ class TimingMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request, call_next):
+ import time
+ start = time.time()
+ response = await call_next(request)
+ duration = time.time() - start
+ response.headers["X-Response-Time"] = f"{duration:.3f}s"
+ return response
+
+ api.add_middleware(TimingMiddleware)
+
+The ``dispatch`` method receives a Starlette ``Request`` and a
+``call_next`` function. Call ``call_next(request)`` to pass the request
+to the next middleware (or to your route handler). The return value is
+a Starlette ``Response`` that you can modify before it's sent.
+
+
+Built-in Middleware
+-------------------
+
+Responder configures several middleware components automatically:
+
+- **GZipMiddleware** — compresses responses larger than 500 bytes
+- **TrustedHostMiddleware** — validates the ``Host`` header
+- **ServerErrorMiddleware** — catches unhandled exceptions
+- **ExceptionMiddleware** — routes exceptions to your handlers
+- **SessionMiddleware** — manages signed cookie sessions
+
+Optional middleware you can enable:
+
+- **CORSMiddleware** — ``api = responder.API(cors=True)``
+- **HTTPSRedirectMiddleware** — ``api = responder.API(enable_hsts=True)``
+
+
+Adding Third-Party Middleware
+-----------------------------
+
+Any ASGI middleware can be added with ``api.add_middleware()``::
+
+ from some_package import SomeMiddleware
+
+ api.add_middleware(SomeMiddleware, option1="value", option2=True)
+
+Keyword arguments are passed to the middleware's constructor.
+
+
+Middleware Order
+----------------
+
+Middleware wraps your application like layers of an onion. The *last*
+middleware added is the *outermost* layer — it sees the request first
+and the response last.
+
+Responder's built-in middleware stack (from outermost to innermost):
+
+1. SessionMiddleware
+2. ServerErrorMiddleware
+3. CORSMiddleware (if enabled)
+4. TrustedHostMiddleware
+5. HTTPSRedirectMiddleware (if enabled)
+6. GZipMiddleware
+7. ExceptionMiddleware
+8. Your routes
+
+When you call ``api.add_middleware()``, your middleware is added *outside*
+the existing stack. Keep this in mind for ordering dependencies — if
+middleware A depends on middleware B having run first, add B before A.
+
+
+When to Use What
+-----------------
+
+- **Simple header additions, logging, auth checks** → use hooks
+- **Response transformation, timing, third-party integrations** → use middleware
+- **Rate limiting** → use the built-in ``RateLimiter`` (it uses hooks internally)
+- **Request ID** → use ``api = responder.API(request_id=True)``
+
+Start with hooks. They're simpler and cover most cases. Graduate to
+middleware when hooks aren't enough.
diff --git a/docs/source/tutorial-websockets.rst b/docs/source/tutorial-websockets.rst
new file mode 100644
index 0000000..62f17ae
--- /dev/null
+++ b/docs/source/tutorial-websockets.rst
@@ -0,0 +1,171 @@
+WebSocket Tutorial
+==================
+
+HTTP is request-response — the client asks, the server answers, and the
+connection closes. WebSockets upgrade that into a persistent, bidirectional
+channel where both sides can send messages at any time. This is what powers
+chat apps, live dashboards, multiplayer games, and collaborative editors.
+
+This tutorial builds a simple chat room to show how WebSockets work in
+Responder.
+
+
+How WebSockets Work
+-------------------
+
+1. The client sends a normal HTTP request with an ``Upgrade: websocket``
+ header.
+2. The server accepts the upgrade and the connection switches protocols.
+3. Both sides can now send messages freely — no more request/response.
+4. Either side can close the connection at any time.
+
+In Responder, WebSocket routes receive a ``ws`` object instead of
+``req`` and ``resp``. The ``ws`` object has methods for accepting the
+connection, sending and receiving data, and closing.
+
+
+Echo Server
+-----------
+
+The simplest WebSocket — echoes everything back::
+
+ @api.route("/ws", websocket=True)
+ async def echo(ws):
+ await ws.accept()
+ while True:
+ data = await ws.receive_text()
+ await ws.send_text(f"Echo: {data}")
+
+The ``await ws.accept()`` call completes the WebSocket handshake. After
+that, you're in a loop — receive a message, send a response.
+
+Test it with a WebSocket client::
+
+ $ pip install websocket-client
+ $ python -c "
+ import websocket
+ ws = websocket.create_connection('ws://localhost:5042/ws')
+ ws.send('hello')
+ print(ws.recv()) # Echo: hello
+ ws.close()
+ "
+
+
+Chat Room
+---------
+
+A chat room needs to broadcast messages to all connected clients. We keep
+a set of active connections and iterate through them when someone sends
+a message::
+
+ connected = set()
+
+ @api.route("/chat", websocket=True)
+ async def chat(ws):
+ await ws.accept()
+ connected.add(ws)
+ try:
+ while True:
+ message = await ws.receive_text()
+ # Broadcast to all connected clients
+ for client in connected:
+ await client.send_text(message)
+ except Exception:
+ pass
+ finally:
+ connected.discard(ws)
+
+The ``try/finally`` block ensures we remove disconnected clients from
+the set, even if the connection drops unexpectedly.
+
+
+Data Formats
+------------
+
+WebSockets support three data formats:
+
+**Text** — plain strings::
+
+ await ws.send_text("hello")
+ message = await ws.receive_text()
+
+**JSON** — auto-serialized Python objects::
+
+ await ws.send_json({"type": "update", "data": [1, 2, 3]})
+ message = await ws.receive_json()
+
+**Binary** — raw bytes, useful for images, audio, or custom protocols::
+
+ await ws.send_bytes(b"\x00\x01\x02")
+ data = await ws.receive_bytes()
+
+
+HTML Client
+-----------
+
+Here's a minimal HTML page that connects to the chat room. The browser's
+built-in ``WebSocket`` API handles everything — no libraries needed:
+
+.. code-block:: html
+
+
+
+
+
+
+
+
+
+
+Save this as ``static/index.html`` and serve it with Responder's
+built-in static file support.
+
+
+Before-Request Hooks for WebSockets
+------------------------------------
+
+You can run code before a WebSocket connection is established, just like
+HTTP before-request hooks. This is useful for authentication::
+
+ @api.before_request(websocket=True)
+ async def ws_auth(ws):
+ # Check for a token in the query string
+ # (WebSocket headers are limited in browsers)
+ await ws.accept()
+
+WebSocket before-request hooks receive the ``ws`` object and must call
+``await ws.accept()`` if they want the connection to proceed.
+
+
+Testing WebSockets
+------------------
+
+Use Starlette's ``TestClient`` for WebSocket tests::
+
+ from starlette.testclient import TestClient
+
+ def test_echo():
+ client = TestClient(api)
+ with client.websocket_connect("/ws") as ws:
+ ws.send_text("hello")
+ assert ws.receive_text() == "Echo: hello"
+
+The ``websocket_connect`` context manager handles the connection
+lifecycle — it connects on enter and disconnects on exit.
diff --git a/examples/rest_api.py b/examples/rest_api.py
new file mode 100644
index 0000000..848a066
--- /dev/null
+++ b/examples/rest_api.py
@@ -0,0 +1,70 @@
+# Complete REST API example with Pydantic validation.
+# https://responder.kennethreitz.org/tutorial-rest.html
+import responder
+from pydantic import BaseModel
+
+
+class BookIn(BaseModel):
+ title: str
+ author: str
+ year: int
+ isbn: str | None = None
+
+
+class BookOut(BaseModel):
+ id: int
+ title: str
+ author: str
+ year: int
+ isbn: str | None = None
+
+
+api = responder.API(
+ title="Book Catalog",
+ version="1.0",
+ openapi="3.0.2",
+ docs_route="/docs",
+)
+
+books_db: dict[int, dict] = {}
+next_id = 1
+
+
+@api.route("/books", methods=["GET"])
+def list_books(req, resp):
+ resp.media = list(books_db.values())
+
+
+@api.route("/books", methods=["POST"], check_existing=False,
+ request_model=BookIn, response_model=BookOut)
+async def create_book(req, resp):
+ global next_id
+ data = await req.media()
+ book = {"id": next_id, **data}
+ books_db[next_id] = book
+ next_id += 1
+ resp.media = book
+ resp.status_code = 201
+
+
+@api.route("/books/{book_id:int}", methods=["GET"])
+def get_book(req, resp, *, book_id):
+ if book_id not in books_db:
+ resp.status_code = 404
+ resp.media = {"error": f"Book {book_id} not found"}
+ return
+ resp.media = books_db[book_id]
+
+
+@api.route("/books/{book_id:int}", methods=["DELETE"], check_existing=False)
+def delete_book(req, resp, *, book_id):
+ if book_id not in books_db:
+ resp.status_code = 404
+ resp.media = {"error": f"Book {book_id} not found"}
+ return
+ del books_db[book_id]
+ resp.status_code = 204
+
+
+if __name__ == "__main__":
+ api.run()
diff --git a/examples/sse_stream.py b/examples/sse_stream.py
new file mode 100644
index 0000000..801eac5
--- /dev/null
+++ b/examples/sse_stream.py
@@ -0,0 +1,42 @@
+# Server-Sent Events streaming example.
+# https://responder.kennethreitz.org/tour.html#server-sent-events-sse
+import asyncio
+
+import responder
+
+api = responder.API()
+
+
+@api.route("/")
+def index(req, resp):
+ resp.html = """
+
+
+
+