Support callable ASGI responses from route handlers

Commit 06b90e4 · patx · 2026-10-13T03:41:04-04:00

Changeset
06b90e455f19a43100e3848465c059112ecf9d37
Parents
e638cd92786ba97f65a90abee4eec0b4929fbe68

View source at this commit

Comments

No comments yet.

Log in to comment

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