patx/wred
Update response documentation and format example app
Commit 1758426 · patx · 2026-10-24T22:28:13-04:00
Comments
No comments yet.
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
-