patx/wred
Respect ASGI root_path when resolving mounted routes
Commit 53355ae · patx · 2026-10-13T03:51:44-04:00
Comments
No comments yet.
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