Simplify explicit responses to body, headers, status tuples

Commit f6a8e14 · patx · 2026-10-24T18:35:42-04:00

Changeset
f6a8e146e605a430d34572d3ac2c304b94e135fa
Parents
2920985cb1e22b84faaa6e3c6be8f73e2cabb7bc

View source at this commit

Comments

No comments yet.

Log in to comment

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."""