patx/wred
Support callable ASGI responses from route handlers
Commit 06b90e4 · patx · 2026-10-13T03:41:04-04:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 5bd026a..8a0f783 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,11 @@ not a Wred-specific session property. Its cookies are signed, not encrypted.
Return a dict or list for JSON with HTTP 200. Return bytes for
`application/octet-stream`. No `json()` helper. No ceremony.
+Handlers can also return callable ASGI response objects. Wred awaits them with
+`(scope, receive, send)`, letting the response handle its own headers, status,
+and body. Response classes from ASGI libraries can work directly; Wred doesn't
+need a dependency or adapter for them.
+
For a custom response, return `(body, content_type, status)` or
`(body, content_type, status, headers)`. Headers are a list of string pairs,
so repeated headers such as `Set-Cookie` work too.
diff --git a/tests/test_notebook.py b/tests/test_notebook.py
index f006f5c..93d6c4d 100644
--- a/tests/test_notebook.py
+++ b/tests/test_notebook.py
@@ -9,9 +9,9 @@ from unittest.mock import patch
import orjson
from tests.test_wred import call_app
-from examples import auth, storage, rate_limit
+from example_app import auth, storage, rate_limit
-notebook = importlib.import_module('examples.app')
+notebook = importlib.import_module('example_app.app')
class NotebookTests(unittest.IsolatedAsyncioTestCase):
diff --git a/tests/test_wred.py b/tests/test_wred.py
index 399ca14..1b0979b 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -20,6 +20,43 @@ async def call_app(app, scope, receive):
class WredTests(unittest.IsolatedAsyncioTestCase):
+ async def test_callable_responses_control_asgi_delivery_on_fixed_and_dynamic_routes(self):
+ class Response:
+ def __init__(self, name):
+ self.name = name
+
+ async def __call__(self, scope, receive, send):
+ message = await receive()
+ await send({'type': 'http.response.start', 'status': 202,
+ 'headers': [(b'content-type', b'text/plain'), (b'x-response', b'asgi')]})
+ await send({'type': 'http.response.body',
+ 'body': f'{scope["path"]}:{self.name}:'.encode(), 'more_body': True})
+ await send({'type': 'http.response.body', 'body': message['body']})
+
+ app = App()
+
+ @app.route('/fixed', methods=['POST'])
+ async def fixed(request):
+ return Response('fixed')
+
+ @app.route('/dynamic/{name}', methods=['POST'])
+ async def dynamic(request, name):
+ return Response(name)
+
+ for path, prefix in [('/fixed', b'/fixed:fixed:'),
+ ('/dynamic/alice', b'/dynamic/alice:alice:')]:
+ with self.subTest(path=path):
+ async def receive():
+ return {'type': 'http.request', 'body': b'payload'}
+
+ messages = await call_app(app, {'type': 'http', 'path': path, 'method': 'POST'}, receive)
+ self.assertEqual(messages, [
+ {'type': 'http.response.start', 'status': 202,
+ 'headers': [(b'content-type', b'text/plain'), (b'x-response', b'asgi')]},
+ {'type': 'http.response.body', 'body': prefix, 'more_body': True},
+ {'type': 'http.response.body', 'body': b'payload'},
+ ])
+
async def test_file_delivery_and_root_confinement(self):
# Exercise the public helper through the actual ASGI response boundary.
# This guards MIME/status delivery and accidental exposure outside root.
diff --git a/wred.py b/wred.py
index 64b406b..7daa68d 100644
--- a/wred.py
+++ b/wred.py
@@ -87,7 +87,10 @@ class App:
if path in self.routes and method in self.routes[path]:
handler = self.routes[path][method]
response = await handler(request)
- await self.send_response(request.send, response)
+ if callable(response):
+ await response(request.scope, request.receive, request.send)
+ else:
+ await self.send_response(request.send, response)
return
# Check dynamic routes
@@ -96,7 +99,10 @@ class App:
if params is not None and method in method_handlers:
handler = method_handlers[method]
response = await handler(request, **params)
- await self.send_response(request.send, response)
+ if callable(response):
+ await response(request.scope, request.receive, request.send)
+ else:
+ await self.send_response(request.send, response)
return
# No match found