patx/wred
Simplify explicit responses to body, headers, status tuples
Commit f6a8e14 · patx · 2026-10-24T18:35:42-04:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index cbaa0ed..efd6e68 100644
--- a/README.md
+++ b/README.md
@@ -76,7 +76,7 @@ async def get_user(request, user_id):
@app.route('/users', methods=['POST'])
async def create_user(request):
- return await request.json(), 'application/json', 201
+ return await request.json(), [('Content-Type', 'application/json')], 201
@app.route('/users/{user_id}', methods=['PUT', 'PATCH'])
async def update_user(request, user_id):
@@ -128,20 +128,24 @@ Authentication middleware can populate the scope for handlers to check.
## Responses
Dicts and lists become `application/json` with HTTP 200. Bytes default to
-`application/octet-stream`. Custom responses use `(body, content_type, status[, headers])`.
-Tuple bodies must be bytes, dicts, or lists. Dicts and lists are JSON-encoded while
-preserving the tuple's explicit content type. This is fixed in 0.11; 0.10
-replaced it with `application/json`. If you relied on that replacement, set
-`application/json` explicitly in your tuple.
-Headers are string pairs; repeated `Set-Cookie` headers are supported.
+`application/octet-stream`. Custom responses use exactly `(body, headers, status)`.
+Tuple bodies must be bytes, dicts, or lists. Dicts and lists are JSON-encoded.
+Explicit tuples send only the supplied headers; no content type is added, even
+for JSON bodies. Include `Content-Type` when wanted, or use an empty list to send
+no headers. Headers are string pairs, with names lowercased and encoded for ASGI;
+order and repeated headers such as `Set-Cookie` are preserved.
+
+This replaces the old `(body, content_type, status[, headers])` format. When
+upgrading, move explicit content types into the headers list; four-item tuples
+are no longer accepted.
* `{'hello': 'world'}` -> JSON object, 200
* `[{'id': 1}, {'id': 2}]` -> JSON array, 200
-* `b'hello', 'text/plain', 200` -> Text response
-* `{'id': 3}, 'application/json', 201, [('Location', '/items/3')]` -> Created resource with a custom header
-* `{'error': 'Invalid'}, 'application/problem+json', 400` -> JSON with a custom media type
-* `b'', 'text/plain', 303, [('Location', '/')]` -> Redirect
-* `b'', 'application/json', 204` -> No content
+* `b'hello', [('Content-Type', 'text/plain')], 200` -> Text response
+* `{'id': 3}, [('Content-Type', 'application/json'), ('Location', '/items/3')], 201` -> Created resource with a custom header
+* `{'error': 'Invalid'}, [('Content-Type', 'application/problem+json')], 400` -> JSON with a custom media type
+* `b'', [('Location', '/')], 303` -> Redirect
+* `b'', [], 204` -> No content
If a handler returns a callable, Wred calls it with `(scope, receive, send)`.
This lets you use ASGI response classes from other libraries for streaming,
@@ -178,10 +182,10 @@ async def create_item(request):
try:
data = await request.json()
except orjson.JSONDecodeError:
- return {'error': 'Invalid JSON body'}, 'application/json', 400
+ return {'error': 'Invalid JSON body'}, [('Content-Type', 'application/json')], 400
if not isinstance(data, dict) or not data.get('title'):
- return {'error': 'A title is required'}, 'application/json', 400
- return data, 'application/json', 201
+ return {'error': 'A title is required'}, [('Content-Type', 'application/json')], 400
+ return data, [('Content-Type', 'application/json')], 201
```
Validate inputs before returning a streaming response. Once streaming starts,
diff --git a/example_app.py b/example_app.py
index 8c7d9a2..e5c4b1c 100644
--- a/example_app.py
+++ b/example_app.py
@@ -76,7 +76,7 @@ async def greet(request):
data = msgspec.json.decode(await request.body(), type=Greeting)
except msgspec.DecodeError:
- return {'detail': 'Invalid greeting'}, 'application/json', 422
+ return {'detail': 'Invalid greeting'}, [('Content-Type', 'application/json')], 422
return {'message': f'Hello, {data.name}!'}
@@ -87,9 +87,10 @@ async def me(request):
user = verified_user(request.headers.get('authorization'))
except jwt.InvalidTokenError:
- return {'detail': 'Not authenticated'}, 'application/json', 401, [
+ return {'detail': 'Not authenticated'}, [
+ ('Content-Type', 'application/json'),
('WWW-Authenticate', 'Bearer'),
- ]
+ ], 401
return {'user': user}
diff --git a/tests.py b/tests.py
index e0b4762..1f33def 100644
--- a/tests.py
+++ b/tests.py
@@ -200,14 +200,14 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
@app.route('/{name}/created')
async def created(request, name):
- return {'name': name}, 'application/json', 201
+ return {'name': name}, [('Content-Type', 'application/json')], 201
@app.route('/input', methods=['POST'])
async def input_data(request):
try:
return await request.json()
except orjson.JSONDecodeError:
- return {'error': 'Invalid JSON body'}, 'application/json', 400
+ return {'error': 'Invalid JSON body'}, [('Content-Type', 'application/json')], 400
cases = [
('GET', '/alice/created', b'', 201, b'{"name":"alice"}'),
@@ -263,7 +263,8 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
('GET', '/search', b'', b'q=%FF', None, UnicodeDecodeError),
('GET', '/search', b'', b'%FF=value', None, UnicodeDecodeError),
('GET', '/invalid', b'', b'', 'unsupported response', TypeError),
- ('GET', '/invalid', b'', b'', (b'bad', 'text/plain'), ValueError),
+ ('GET', '/invalid', b'', b'', (b'bad', []), ValueError),
+ ('GET', '/invalid', b'', b'', (b'bad', [], 200, []), ValueError),
]
for method, path, body, query_string, response, error in cases:
with self.subTest(
@@ -618,15 +619,23 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
cases = [
(b'\x00\xff', 200, b'application/octet-stream', b'\x00\xff', []),
+ ((b'', [], 204), 204, None, b'', []),
(
- (b'<p>Hello</p>', 'text/html; charset=utf-8', 202),
+ ({'id': 3}, [('Location', '/items/3')], 201),
+ 201,
+ None,
+ b'{"id":3}',
+ [(b'location', b'/items/3')],
+ ),
+ (
+ (b'<p>Hello</p>', [('Content-Type', 'text/html; charset=utf-8')], 202),
202,
b'text/html; charset=utf-8',
b'<p>Hello</p>',
[],
),
(
- ([], 'text/plain', 201),
+ ([], [('content-type', 'text/plain')], 201),
201,
b'text/plain',
b'[]',
@@ -635,12 +644,12 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
(
(
{'error': 'Unauthorized'},
- 'application/problem+json',
- 401,
[
+ ('CONTENT-TYPE', 'application/problem+json'),
('WWW-Authenticate', 'Bearer'),
('Cache-Control', 'no-store'),
],
+ 401,
),
401,
b'application/problem+json',
@@ -653,14 +662,14 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
(
(
b'',
- 'text/plain',
- 303,
[
+ ('Content-Type', 'text/plain'),
('Location', '/ui'),
('X-Label', 'caf\u00e9'),
('Set-Cookie', 'a=1; Secure'),
('Set-Cookie', 'b=2; Secure'),
],
+ 303,
),
303,
b'text/plain',
@@ -673,9 +682,9 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
],
),
(
- ([], 'application/json', 200, []),
+ ([], [], 200),
200,
- b'application/json',
+ None,
b'[]',
[],
),
@@ -699,8 +708,8 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
{
'type': 'http.response.start',
'status': status,
- 'headers': [(b'content-type', content_type)]
- + headers,
+ 'headers': ([(b'content-type', content_type)]
+ if content_type is not None else []) + headers,
},
{'type': 'http.response.body', 'body': body},
],
diff --git a/wred.py b/wred.py
index 1617e3e..60e55c3 100644
--- a/wred.py
+++ b/wred.py
@@ -5,7 +5,7 @@ ASGI-compatible web services. It prioritizes simplicity and performance over all
Copyright (c) 2026, Harrison Erd.
"""
__author__, __email__, __license__ = 'Harrison Erd', '[email protected]', 'BSD-3-Clause'
-__version__ = '0.11'
+__version__ = '0.12'
from collections.abc import Awaitable, Callable
from functools import cached_property
from typing import Any
@@ -87,20 +87,19 @@ class App:
await self.send_response(send, response)
async def send_response(self, send: Callable[[dict[str, Any]], Awaitable[None]], response: Any, status: int = 200) -> None:
- """Send JSON or (body, content_type, status[, headers]) with string header pairs."""
- body, content_type, headers = response, 'application/octet-stream', []
+ """Send JSON or (body, headers, status) with string header pairs."""
if isinstance(response, tuple):
- body, content_type, status = response[:3]
- if len(response) == 4:
- headers = [(n.lower().encode('ascii'), v.encode('latin-1')) for n, v in response[3]]
- elif isinstance(body, (dict, list)):
- content_type = 'application/json'
- if isinstance(body, (dict, list)):
- body = orjson.dumps(body)
- elif not isinstance(body, bytes):
+ response, headers, status = response
+ headers = [(n.lower().encode('ascii'), v.encode('latin-1')) for n, v in headers]
+ else:
+ content_type = b'application/json' if isinstance(response, (dict, list)) else b'application/octet-stream'
+ headers = [(b'content-type', content_type)]
+ if isinstance(response, (dict, list)):
+ response = orjson.dumps(response)
+ elif not isinstance(response, bytes):
raise TypeError("Response must be dict, list, bytes, or tuple")
- await send({'type': 'http.response.start', 'status': status, 'headers': [(b'content-type', content_type.encode('latin-1'))] + headers})
- await send({'type': 'http.response.body', 'body': body})
+ await send({'type': 'http.response.start', 'status': status, 'headers': headers})
+ await send({'type': 'http.response.body', 'body': response})
def match_path(self, pattern: tuple[str, ...], parts: list[str]) -> dict[str, str] | None:
"""Match normalized paths and extract parameters."""