Update response documentation and format example app

Commit 1758426 · patx · 2026-10-24T22:28:13-04:00

Changeset
175842621a8aa8af36f9a74377cdbe89f54aae0d
Parents
7c4002c0cafd911a309bcd6bdc73e30acfc8897e

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/.gitignore b/.gitignore
index 65c99e5..fe86de6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ venv/
 build/
 dist/
 *.egg-info/
+.ruff_cache/
 
 # Test and coverage output
 .pytest_cache/
diff --git a/README.md b/README.md
index 1d2e171..3d14a1c 100644
--- a/README.md
+++ b/README.md
@@ -146,20 +146,19 @@ JSON-encoded. Headers are string pairs: names are lowercased and ASCII-encoded,
 and values are Latin-1-encoded. Order and repeated headers such as `Set-Cookie`
 are preserved. Use `[]` to send no headers.
 
-| Handler return | Response |
-| --- | --- |
-| `{'hello': 'world'}` | JSON object, 200 |
-| `[{'id': 1}, {'id': 2}]` | JSON array, 200 |
-| `42` | JSON number `42`, 200 |
-| `'hello'` | JSON string `"hello"`, 200 |
-| `True` | JSON boolean `true`, 200 |
-| `None` | JSON `null`, 200 |
-| `b'hello'` | Raw bytes, `application/octet-stream`, 200 |
-| `(b'hello', [('Content-Type', 'text/plain')], 200)` | Raw text, 200 |
-| `({'id': 3}, [('Content-Type', 'application/json'), ('Location', '/items/3')], 201)` | JSON with a location header, 201 |
-| `({'error': 'Invalid'}, [('Content-Type', 'application/problem+json')], 400)` | JSON with a custom media type, 400 |
-| `(b'', [('Location', '/')], 303)` | Redirect with an empty body |
-| `(b'', [], 204)` | No content, no headers |
+
+`{'hello': 'world'}` -> JSON object, 200 |
+`[{'id': 1}, {'id': 2}]` -> JSON array, 200 |
+`42` -> JSON number `42`, 200 |
+`'hello'` -> JSON string `"hello"`, 200 |
+`True` -> JSON boolean `true`, 200 |
+`None` -> JSON `null`, 200 |
+`b'hello'` -> Raw bytes, `application/octet-stream`, 200 
+`(b'hello', [('Content-Type', 'text/plain')], 200)` -> Raw text, 200
+`({'id': 3}, [('Content-Type', 'application/json'), ('Location', '/items/3')], 201)` -> JSON with a location header, 201
+`({'error': 'Invalid'}, [('Content-Type', 'application/problem+json')], 400)` -> JSON with a custom media type, 400
+`(b'', [('Location', '/')], 303)` -> Redirect with an empty body
+`(b'', [], 204)` -> No content, no headers
 
 Tuples are always interpreted as explicit responses. To return a tuple as JSON
 array data, use a list or place it inside a response tuple, such as
diff --git a/example_app.py b/example_app.py
index e5c4b1c..2849a45 100644
--- a/example_app.py
+++ b/example_app.py
@@ -1,37 +1,38 @@
 """
-    example_app
-    ~~~~~~~~~~~
+example_app
+~~~~~~~~~~~
 
-    Validation, JWT authentication, SSE, and frontend serving with Wred.
+Validation, JWT authentication, SSE, and frontend serving with Wred.
 
-    Install the example dependencies and run from the repository root::
+Install the example dependencies and run from the repository root::
 
-        python -m pip install -e '.[server]' PyJWT msgspec sse-starlette servestatic
-        export JWT_SECRET="$(python -c 'import secrets; print(secrets.token_hex(32))')"
-        mkdir -p frontend
-        echo '<h1>Wred example</h1>' > frontend/index.html
-        weft example_app:app
+    python -m pip install -e '.[server]' PyJWT msgspec sse-starlette servestatic
+    export JWT_SECRET="$(python -c 'import secrets; print(secrets.token_hex(32))')"
+    mkdir -p frontend
+    echo '<h1>Wred example</h1>' > frontend/index.html
+    weft example_app:app
 
-    ServeStatic serves frontend/index.html at http://127.0.0.1:8000/.
-    Replace the placeholder HTML with your frontend build. Existing files take
-    priority over Wred routes; keep greet, me, and events out of that directory.
+ServeStatic serves frontend/index.html at http://127.0.0.1:8000/.
+Replace the placeholder HTML with your frontend build. Existing files take
+priority over Wred routes; keep greet, me, and events out of that directory.
 
-    POST /greet accepts a JSON object with a name of 1–40 characters.
-    GET /me requires a Bearer token signed with JWT_SECRET using HS256,
-    with issuer "demo", audience "greetings", a nonempty subject, and an
-    expiration time.
+POST /greet accepts a JSON object with a name of 1–40 characters.
+GET /me requires a Bearer token signed with JWT_SECRET using HS256,
+with issuer "demo", audience "greetings", a nonempty subject, and an
+expiration time.
 
-    GET /events streams five messages using sse-starlette. Watch them arrive::
+GET /events streams five messages using sse-starlette. Watch them arrive::
 
-        curl -N http://127.0.0.1:8000/events
+    curl -N http://127.0.0.1:8000/events
 
-    EventSourceResponse is returned directly from a Wred handler, while
-    ServeStatic wraps the app. If you use a reverse proxy, configure it to pass
-    SSE events through without buffering.
+EventSourceResponse is returned directly from a Wred handler, while
+ServeStatic wraps the app. If you use a reverse proxy, configure it to pass
+SSE events through without buffering.
 
-    :copyright: (c) 2026 by Harrison Erd.
-    :license: BSD-3-Clause, see LICENSE for more details.
+:copyright: (c) 2026 by Harrison Erd.
+:license: BSD-3-Clause, see LICENSE for more details.
 """
+
 import asyncio
 import os
 from pathlib import Path
@@ -44,7 +45,7 @@ from sse_starlette import EventSourceResponse
 from wred import App
 
 
-SECRET = os.environ['JWT_SECRET']
+SECRET = os.environ["JWT_SECRET"]
 api = App()
 
 
@@ -53,53 +54,64 @@ class Greeting(msgspec.Struct, forbid_unknown_fields=True):
 
 
 def verified_user(authorization):
-    scheme, _, token = (authorization or '').partition(' ')
+    scheme, _, token = (authorization or "").partition(" ")
 
-    if scheme.lower() != 'bearer' or not token:
-        raise jwt.InvalidTokenError('Bearer token required')
+    if scheme.lower() != "bearer" or not token:
+        raise jwt.InvalidTokenError("Bearer token required")
 
     claims = jwt.decode(
-        token, SECRET, algorithms=['HS256'],
-        issuer='demo', audience='greetings',
-        options={'require': ['sub', 'exp', 'iss', 'aud']},
+        token,
+        SECRET,
+        algorithms=["HS256"],
+        issuer="demo",
+        audience="greetings",
+        options={"require": ["sub", "exp", "iss", "aud"]},
     )
 
-    if not claims['sub']:
-        raise jwt.InvalidTokenError('User required')
+    if not claims["sub"]:
+        raise jwt.InvalidTokenError("User required")
 
-    return claims['sub']
+    return claims["sub"]
 
 
[email protected]('/greet', methods=['POST'])
[email protected]("/greet", methods=["POST"])
 async def greet(request):
     try:
         data = msgspec.json.decode(await request.body(), type=Greeting)
 
     except msgspec.DecodeError:
-        return {'detail': 'Invalid greeting'}, [('Content-Type', 'application/json')], 422
+        return (
+            {"detail": "Invalid greeting"},
+            [("Content-Type", "application/json")],
+            422,
+        )
 
-    return {'message': f'Hello, {data.name}!'}
+    return {"message": f"Hello, {data.name}!"}
 
 
[email protected]('/me')
[email protected]("/me")
 async def me(request):
     try:
-        user = verified_user(request.headers.get('authorization'))
+        user = verified_user(request.headers.get("authorization"))
 
     except jwt.InvalidTokenError:
-        return {'detail': 'Not authenticated'}, [
-            ('Content-Type', 'application/json'),
-            ('WWW-Authenticate', 'Bearer'),
-        ], 401
+        return (
+            {"detail": "Not authenticated"},
+            [
+                ("Content-Type", "application/json"),
+                ("WWW-Authenticate", "Bearer"),
+            ],
+            401,
+        )
 
-    return {'user': user}
+    return {"user": user}
 
 
[email protected]('/events')
[email protected]("/events")
 async def events(request):
     async def messages():
         for number in range(5):
-            yield {'data': f'Event {number}'}
+            yield {"data": f"Event {number}"}
             await asyncio.sleep(1)
 
     return EventSourceResponse(messages())
@@ -107,6 +119,6 @@ async def events(request):
 
 app = ServeStaticASGI(
     api,
-    root=Path(__file__).resolve().parent / 'frontend',
+    root=Path(__file__).resolve().parent / "frontend",
     index_file=True,
 )
diff --git a/wred.py b/wred.py
index 013ffba..055a531 100644
--- a/wred.py
+++ b/wred.py
@@ -117,4 +117,3 @@ class App:
             target.setdefault(key, {}).update({m.upper(): handler for m in methods})
             return handler
         return decorator
-