Respect ASGI root_path when resolving mounted routes

Commit 53355ae · patx · 2026-10-13T03:51:44-04:00

Changeset
53355aee335c789e64c96fc7226330ec7b05e557
Parents
06b90e455f19a43100e3848465c059112ecf9d37

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 8a0f783..5b4cde5 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,10 @@ over dynamic routes for the requested method; dynamic routes are checked in
 registration order. Registering the same path and method again replaces its handler.
 Unmatched paths or methods return JSON 404. No automatic HEAD, OPTIONS, or 405.
 
+Mounted apps resolve routes relative to the ASGI `root_path`: a Wred route
+`/users` mounted at `/api` handles `/api/users`. `request.path` and the original
+ASGI scope keep the full incoming path for middleware and response objects.
+
 ## Requests
 
 | API | What you get |
diff --git a/tests/test_wred.py b/tests/test_wred.py
index 1b0979b..9ec2228 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -43,13 +43,19 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
         async def dynamic(request, name):
             return Response(name)
 
-        for path, prefix in [('/fixed', b'/fixed:fixed:'),
-                             ('/dynamic/alice', b'/dynamic/alice:alice:')]:
+        for path, root_path, prefix in [
+            ('/fixed', '', b'/fixed:fixed:'),
+            ('/dynamic/alice', '', b'/dynamic/alice:alice:'),
+            ('/api/fixed', '/api', b'/api/fixed:fixed:'),
+            ('/api/dynamic/alice', '/api', b'/api/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)
+                messages = await call_app(app, {
+                    'type': 'http', 'path': path, 'root_path': root_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')]},
@@ -57,6 +63,47 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
                     {'type': 'http.response.body', 'body': b'payload'},
                 ])
 
+    async def test_mounted_routing_uses_complete_root_path_without_changing_scope(self):
+        app = App()
+
+        @app.route('/items/{name}')
+        async def item(request, name):
+            return {'route': name, 'path': request.path, 'scope_path': request.scope['path']}
+
+        @app.route('/items/special')
+        @app.route('/')
+        @app.route('/apiary/items')
+        async def fixed(request):
+            return {'route': 'fixed', 'path': request.path, 'scope_path': request.scope['path']}
+
+        cases = [
+            ('/items/alice', None, 'alice'),
+            ('/items/alice', '', 'alice'),
+            ('/api/items/alice', '/api', 'alice'),
+            ('/api/items/alice', '/api/', 'alice'),
+            ('/api/items/special', '/api', 'fixed'),
+            ('/proxy/api/items/alice', '/proxy/api', 'alice'),
+            ('/api', '/api', 'fixed'),
+            ('/api/', '/api', 'fixed'),
+            ('/items/alice', '/api', 'alice'),
+            ('/apiary/items', '/api', 'fixed'),
+        ]
+        for path, root_path, route in cases:
+            with self.subTest(path=path, root_path=root_path):
+                async def receive():
+                    raise AssertionError('GET handlers should not read a body')
+
+                scope = {'type': 'http', 'path': path, 'method': 'GET'}
+                if root_path is not None:
+                    scope['root_path'] = root_path
+                original = scope.copy()
+                messages = await call_app(app, scope, receive)
+                self.assertEqual(messages[0]['status'], 200)
+                self.assertEqual(orjson.loads(messages[1]['body']), {
+                    'route': route, 'path': path, 'scope_path': path,
+                })
+                self.assertEqual(scope, original)
+
     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 7daa68d..2bbd0d7 100644
--- a/wred.py
+++ b/wred.py
@@ -81,6 +81,9 @@ class App:
     async def handle_http(self, request: Request) -> None:
         """Handle HTTP requests with routing and JSON response support."""
         path = request.path
+        root_path = request.scope.get('root_path', '').rstrip('/')
+        if root_path and (path == root_path or path.startswith(root_path + '/')):
+            path = path[len(root_path):] or '/'
         method = request.method
         try:
             # Check exact matches first