patx/wred
Support arbitrary orjson-serializable responses in v0.13
Commit ac21b3e · patx · 2026-10-24T20:08:41-04:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index f853a70..1d2e171 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,8 @@
[Wred](https://harrisonerd.com/wred) is a Python ASGI router for JSON APIs.
It uses `orjson` to read and write JSON. Pronounced "red".
-Handlers are async functions. They receive a request and return a dict, list,
-bytes, response tuple, or callable ASGI response. Routes support path parameters
+Handlers are async functions. They receive a request and return a JSON-serializable
+value, bytes, response tuple, or callable ASGI response. Routes support path parameters
and explicit HTTP methods. The request object provides headers, query parameters,
cached body reads, and JSON parsing. Response tuples let you set the status,
content type, and headers; callable ASGI responses can handle streaming.
@@ -106,7 +106,7 @@ Wred 0.9 uses `request.query()`; remove the parentheses when upgrading.
* `request.path` -> Path without the query string
* `request.method` -> HTTP method
-* `request.headers` -> Header dict decoded and cached on first access, with lowercase names and Latin-1 values; repeated Cookie fields join with `; `, other repeated names keep their last value
+* `request.headers` -> Header dict decoded and cached on first access, with lowercase ASCII names and Latin-1 values; repeated Cookie fields join with `; `, other repeated names keep their last value
* `request.scope` -> Original ASGI scope, including client, scheme, and middleware state
* `request.query` -> Dict of string keys and values, parsed and cached on first access.
* `await request.body()` -> Cached body raw bytes
@@ -127,30 +127,50 @@ 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 exactly `(body, headers, status)`.
-Tuple bodies must be bytes, dicts, or lists. Dicts and lists are JSON-encoded.
+Wred handles return values in three ways:
+
+* A callable is awaited with `(scope, receive, send)` as an ASGI response.
+* A tuple sets the body, headers, and status: `(body, headers, status)`.
+* Any other value gets HTTP 200. Bytes are sent unchanged with
+ `application/octet-stream`; other values are serialized with `orjson.dumps`
+ and get `application/json`.
+
+Since Wred 0.13, both plain returns and tuple bodies accept any value supported
+by `orjson.dumps`, including strings, numbers, booleans, and `None`. Strings
+become quoted JSON strings; use bytes for raw text. Returning `None`, including
+by falling off the end of a handler, sends JSON `null` with HTTP 200.
+
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.
-
-Wred 0.12 replaces the old `(body, content_type, status[, headers])` format with
-`(body, headers, status)`. 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', [('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,
-downloads, or SSE.
+for JSON bodies. Bytes pass through unchanged; all other tuple bodies are
+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 |
+
+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
+`((1, 2), [('Content-Type', 'application/json')], 200)`.
+
+Wred 0.12 replaced `(body, content_type, status[, headers])` with
+`(body, headers, status)`. When upgrading, move explicit content types into the
+headers list; four-item response tuples are no longer accepted.
+
+Callable responses let you use ASGI response classes from other libraries for
+streaming, downloads, or SSE. They control their own headers and body messages.
## Serving the frontend
@@ -166,11 +186,21 @@ browser restrictions.
## Errors
-Exceptions propagate to your ASGI server or middleware. Malformed JSON raises
-`orjson.JSONDecodeError`, invalid UTF-8 query bytes (raw or percent-encoded) raise `UnicodeDecodeError`,
-client disconnects during body reads raise `ConnectionError`, and unsupported
-response types raise `TypeError`. Wred does not turn exceptions into responses.
-Malformed response tuples can raise `ValueError` during unpacking.
+Exceptions propagate to your ASGI server or middleware. Wred does not turn
+exceptions into responses.
+
+* Malformed JSON raises `orjson.JSONDecodeError`.
+* Invalid UTF-8 query bytes (raw or percent-encoded) and non-ASCII request
+ header names raise `UnicodeDecodeError` when those properties are accessed.
+* Client disconnects during body reads raise `ConnectionError`.
+* Bodies that `orjson.dumps` cannot serialize raise `orjson.JSONEncodeError`
+ (a `TypeError` subclass).
+* Response tuples with fewer or more than three items raise `ValueError`.
+* Non-ASCII response header names or values outside Latin-1 raise
+ `UnicodeEncodeError`.
+
+For ordinary and tuple responses, serialization and header encoding finish
+before Wred sends the response start message.
Return response tuples for expected application errors. Catch parsing errors
in your handler when you want a JSON 400 response:
diff --git a/tests.py b/tests.py
index 1f33def..174b4ec 100644
--- a/tests.py
+++ b/tests.py
@@ -159,7 +159,7 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(scope, original)
- async def test_dictionary_and_list_responses_are_json(self):
+ async def test_serializable_responses_are_json(self):
app = App()
@app.route('/')
@@ -173,6 +173,11 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
({'hello': 'world'}, b'{"hello":"world"}'),
([{'id': 1}, {'id': 2}], b'[{"id":1},{"id":2}]'),
([], b'[]'),
+ (42, b'42'),
+ (1.5, b'1.5'),
+ ('hello', b'"hello"'),
+ (True, b'true'),
+ (None, b'null'),
]
for value, body in cases:
with self.subTest(value=value):
@@ -262,7 +267,8 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
('GET', '/search', b'', b'\xff', None, UnicodeDecodeError),
('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'', object(), TypeError),
+ ('GET', '/invalid', b'', b'', (object(), [], 200), TypeError),
('GET', '/invalid', b'', b'', (b'bad', []), ValueError),
('GET', '/invalid', b'', b'', (b'bad', [], 200, []), ValueError),
]
@@ -620,6 +626,11 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
cases = [
(b'\x00\xff', 200, b'application/octet-stream', b'\x00\xff', []),
((b'', [], 204), 204, None, b'', []),
+ ((42, [('Content-Type', 'application/json')], 201), 201, b'application/json', b'42', []),
+ (('hello', [], 200), 200, None, b'"hello"', []),
+ ((True, [], 200), 200, None, b'true', []),
+ ((None, [], 200), 200, None, b'null', []),
+ (((1, 2), [], 200), 200, None, b'[1,2]', []),
(
({'id': 3}, [('Location', '/items/3')], 201),
201,
diff --git a/wred.py b/wred.py
index 1147920..7c47992 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.12'
+__version__ = '0.13'
from collections.abc import Awaitable, Callable
from functools import cached_property
from typing import Any
@@ -92,12 +92,10 @@ class App:
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'
+ content_type = b'application/octet-stream' if isinstance(response, bytes) else b'application/json'
headers = [(b'content-type', content_type)]
- if isinstance(response, (dict, list)):
+ if not isinstance(response, bytes):
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': headers})
await send({'type': 'http.response.body', 'body': response})