patx/veela

Add HTTP ASGI application mounting

Commit 4e0f3d0 · patx · 2026-10-14T20:09:49-04:00

Changeset
4e0f3d0318424ad832994351d6ba432c58f1b22a
Parents
2fc469ec7feea03e39556ed2aceeb3bac1518198

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 2b307ea..b764188 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,7 @@ to a matter of trust.
 - **Async routes.** Your function can wait for things without making it everyone's problem.
 - **JSON responses.** Return a dict. Receive the full enterprise experience of a dict.
 - **Path parameters.** Put a variable in a URL. The future has arrived at `/users/3`.
+- **Mount ASGI apps.** Put another app inside your app. Architecture has discovered nesting.
 - **A built-in server.** Call `app.run()`. Accept the miracle. Move on.
 - **No runtime downloads.** Everything required has already been thoughtfully accounted for. Somewhere.
 
@@ -97,6 +98,45 @@ Fixed routes take priority over parameterized routes. Unmatched paths or methods
 return JSON 404. Automatic HEAD, OPTIONS, and 405 handling have been left out to
 preserve the thrill of discovery.
 
+### An app inside your app
+
+Already have an ASGI application? Give it a prefix. We have invented sharing.
+
+```python
+app = App()
+admin = App()
+
[email protected]('/')
+async def admin_index(request):
+    return {'department': 'administration'}
+
+app.mount('/admin', admin)
+```
+
+Put this setup above `app.run()`. `/admin` and `/admin/` now reach the child’s
+`/` route. Other paths under `/admin/` belong to that child too. The second
+argument can be any HTTP ASGI callable, including an app from another framework.
+Interoperability. In this economy.
+
+Fixed and parameterized routes on the parent take priority when their method
+matches. Mounts come next, with the longest matching prefix winning regardless
+of registration order. `/admin` matches `/admin/users`, but not `/administrator`.
+Even our ambition has boundaries.
+
+Prefixes are literal absolute paths; path parameters are for `route()`.
+Trailing slashes are ignored when registering a mount, and mounting the same
+prefix again replaces its app. Mount at `/` for a fallback covering every path
+that parent routes and more specific mounts leave behind.
+
+The child controls its responses, including streaming and 404s. A child’s 404
+stays a 404; the parent does not shop around for a more encouraging answer.
+Mounts can nest: each child receives a copy of the scope with its prefix added
+to `root_path`, while `path`, `raw_path`, and the request streams pass through.
+
+Mounting supports HTTP only. WebSockets and startup/shutdown lifespan events
+are not dispatched to mounted apps, so arrange any required child lifecycle
+separately. We have achieved composition. Immortality remains on the roadmap.
+
 ### Things you can ask a request
 
 | API | What you get |
diff --git a/source/veela.py b/source/veela.py
index b77ef52..365a4c3 100644
--- a/source/veela.py
+++ b/source/veela.py
@@ -107,6 +107,7 @@ class App:
     def __init__(self):
         self.routes: dict[str, dict[str, Callable]] = {}
         self.pattern_routes: dict[str, dict[str, Callable]] = {}
+        self.mounts: dict[str, Callable] = {}
 
     def run(self, host="127.0.0.1", port=8000, workers=1):
         """Serve HTTP until interrupted. Workers need a module-level app, no main guard."""
@@ -220,12 +221,20 @@ class App:
                     await self.dispatch_response(request, response)
                     return
 
-            # No match found
-            await self.send_response(request.send, {"error": "Not Found"}, status=404)
-
         except (VeelaResponseError, VeelaRequestError) as e:
             status = 400 if isinstance(e, VeelaRequestError) else 500
             await self.send_response(request.send, {"error": str(e)}, status=status)
+            return
+
+        # Delegate outside the response-error handler: the child owns its stream.
+        for prefix, application in self.mounts.items():
+            if not prefix or path == prefix or path.startswith(prefix + '/'):
+                scope = request.scope.copy()
+                scope['root_path'] = root_path + prefix
+                await application(scope, request.receive, request.send)
+                return
+
+        await self.send_response(request.send, {"error": "Not Found"}, status=404)
 
     async def dispatch_response(self, request: Request, response: Any) -> None:
         """Invoke an ASGI response or serialize an ordinary handler result."""
@@ -286,6 +295,15 @@ class App:
 
         return await to_thread(read)
 
+    def mount(self, path: str, application: Callable) -> None:
+        """Delegate an HTTP subtree to an ASGI app, after ordinary routes."""
+        if not isinstance(path, str) or not path.startswith('/') or '{' in path or '}' in path:
+            raise ValueError('Mount path must be an absolute literal path')
+        if not callable(application):
+            raise TypeError('Mounted application must be callable')
+        self.mounts[path.rstrip('/')] = application
+        self.mounts = dict(sorted(self.mounts.items(), key=lambda item: len(item[0]), reverse=True))
+
     def route(self, path: str, methods: list[str] = ['GET']) -> Callable:
         """Decorator to register routes with specific HTTP methods."""
         def decorator(handler: Callable) -> Callable:
diff --git a/tests/test_veela.py b/tests/test_veela.py
index 2385a99..e2dc281 100644
--- a/tests/test_veela.py
+++ b/tests/test_veela.py
@@ -5,7 +5,7 @@ from unittest.mock import patch
 
 import json
 
-from veela import App
+from veela import App, VeelaResponseError
 
 
 async def call_app(app, scope, receive):
@@ -20,6 +20,157 @@ async def call_app(app, scope, receive):
 
 
 class VeelaTests(unittest.IsolatedAsyncioTestCase):
+    async def test_mounts_match_segments_and_choose_longest_prefix(self):
+        async def child(scope, receive, send):
+            await send({'type': 'http.response.start', 'status': 200, 'headers': []})
+            await send({'type': 'http.response.body', 'body': scope['root_path'].encode()})
+
+        app = App()
+        app.mount('/api/internal', child)
+        app.mount('/api/', child)
+        app.mount('/', child)
+        for path, root_path, expected in [
+            ('/api', '', '/api'),
+            ('/api/', '', '/api'),
+            ('/api/items', '', '/api'),
+            ('/api/internal', '', '/api/internal'),
+            ('/api/internal/items', '', '/api/internal'),
+            ('/api/internalize', '', '/api'),
+            ('/apiary', '', ''),
+            ('/', '', ''),
+            ('/elsewhere', '', ''),
+            ('/proxy/api/internal/items', '/proxy/', '/proxy/api/internal'),
+        ]:
+            with self.subTest(path=path):
+                async def receive():
+                    raise AssertionError('Mount dispatch should not consume the body')
+
+                messages = await call_app(app, {
+                    'type': 'http', 'path': path, 'root_path': root_path, 'method': 'GET',
+                }, receive)
+                self.assertEqual(messages, [
+                    {'type': 'http.response.start', 'status': 200, 'headers': []},
+                    {'type': 'http.response.body', 'body': expected.encode()},
+                ])
+
+    async def test_parent_routes_win_and_child_404_does_not_fall_back(self):
+        app, child, fallback = App(), App(), App()
+
+        @app.route('/api/fixed')
+        async def fixed(request):
+            return {'route': 'fixed'}
+
+        @app.route('/api/{name}')
+        async def dynamic(request, name):
+            return {'route': name}
+
+        @child.route('/fixed', methods=['POST'])
+        @child.route('/internal/missing')
+        @fallback.route('/api/internal/missing')
+        async def mounted(request):
+            return {'route': 'child'}
+
+        app.mount('/api', child)
+        app.mount('/api/internal', App())
+        app.mount('/', fallback)
+        for path, method, status, expected in [
+            ('/api/fixed', 'GET', 200, {'route': 'fixed'}),
+            ('/api/alice', 'GET', 200, {'route': 'alice'}),
+            ('/api/fixed', 'POST', 200, {'route': 'child'}),
+            ('/api/internal/missing', 'GET', 404, {'error': 'Not Found'}),
+        ]:
+            with self.subTest(path=path, method=method):
+                async def receive():
+                    raise AssertionError('Handlers should not read a body')
+
+                messages = await call_app(app, {
+                    'type': 'http', 'path': path, 'method': method,
+                }, receive)
+                self.assertEqual(messages[0]['status'], status)
+                self.assertEqual(json.loads(messages[1]['body']), expected)
+
+    async def test_nested_mounts_preserve_scope_and_streams(self):
+        app, child = App(), App()
+        scope = {
+            'type': 'http', 'path': '/proxy/api/files/café', 'root_path': '/proxy',
+            'raw_path': b'/proxy/api/files/caf%C3%A9', 'method': 'POST',
+            'query_string': b'name=a%2Fb', 'headers': [(b'x-test', b'yes')],
+        }
+        original = scope.copy()
+        incoming = iter([
+            {'type': 'http.request', 'body': b'first', 'more_body': True},
+            {'type': 'http.request', 'body': b'second'},
+            {'type': 'http.disconnect'},
+        ])
+
+        async def receive():
+            return next(incoming)
+
+        async def streaming(scope, child_receive, send):
+            self.assertEqual(scope, {**original, 'root_path': '/proxy/api/files'})
+            self.assertIs(child_receive, receive)
+            scope['child_only'] = True
+            await send({'type': 'http.response.start', 'status': 202,
+                        'headers': [(b'x-child', b'yes'), (b'set-cookie', b'a=1'),
+                                    (b'set-cookie', b'b=2')]})
+            for _ in range(2):
+                message = await child_receive()
+                await send({'type': 'http.response.body', 'body': message['body'],
+                            'more_body': message.get('more_body', False)})
+            self.assertEqual(await child_receive(), {'type': 'http.disconnect'})
+
+        child.mount('/files', streaming)
+        app.mount('/api', child)
+        messages = await call_app(app, scope, receive)
+        self.assertEqual(messages, [
+            {'type': 'http.response.start', 'status': 202,
+             'headers': [(b'x-child', b'yes'), (b'set-cookie', b'a=1'), (b'set-cookie', b'b=2')]},
+            {'type': 'http.response.body', 'body': b'first', 'more_body': True},
+            {'type': 'http.response.body', 'body': b'second', 'more_body': False},
+        ])
+        self.assertEqual(scope, original)
+
+    async def test_mounted_app_error_after_response_start_is_not_serialized(self):
+        async def child(scope, receive, send):
+            await send({'type': 'http.response.start', 'status': 200, 'headers': []})
+            raise VeelaResponseError('Child stream failed')
+
+        app = App()
+        app.mount('/child', child)
+        messages = []
+
+        async def receive():
+            raise AssertionError('Mount dispatch should not consume the body')
+
+        async def send(message):
+            messages.append(message)
+
+        with self.assertRaisesRegex(VeelaResponseError, 'Child stream failed'):
+            await app({'type': 'http', 'path': '/child', 'method': 'GET'}, receive, send)
+        self.assertEqual(messages, [{'type': 'http.response.start', 'status': 200, 'headers': []}])
+
+    async def test_mount_replacement_and_invalid_registrations(self):
+        app, old_child, new_child = App(), App(), App()
+
+        @new_child.route('/')
+        async def home(request):
+            return {'replacement': True}
+
+        app.mount('/child', old_child)
+        app.mount('/child/', new_child)
+
+        async def receive():
+            raise AssertionError('Mount dispatch should not consume the body')
+
+        messages = await call_app(app, {'type': 'http', 'path': '/child', 'method': 'GET'}, receive)
+        self.assertEqual(messages[0]['status'], 200)
+        self.assertEqual(json.loads(messages[1]['body']), {'replacement': True})
+        for path in ['', 'relative', '/{name}', '/broken}', None]:
+            with self.subTest(path=path), self.assertRaises(ValueError):
+                app.mount(path, new_child)
+        with self.assertRaises(TypeError):
+            app.mount('/invalid', object())
+
     async def test_callable_responses_control_asgi_delivery_on_fixed_and_dynamic_routes(self):
         class Response:
             def __init__(self, name):
diff --git a/veela.py b/veela.py
index 9f1d8ac..405e5a1 100644
--- a/veela.py
+++ b/veela.py
@@ -107,6 +107,7 @@ class App:
     def __init__(self):
         self.routes: dict[str, dict[str, Callable]] = {}
         self.pattern_routes: dict[str, dict[str, Callable]] = {}
+        self.mounts: dict[str, Callable] = {}
 
     def run(self, host="127.0.0.1", port=8000, workers=1):
         """Serve HTTP until interrupted. Workers need a module-level app, no main guard."""
@@ -220,12 +221,20 @@ class App:
                     await self.dispatch_response(request, response)
                     return
 
-            # No match found
-            await self.send_response(request.send, {"error": "Not Found"}, status=404)
-
         except (VeelaResponseError, VeelaRequestError) as e:
             status = 400 if isinstance(e, VeelaRequestError) else 500
             await self.send_response(request.send, {"error": str(e)}, status=status)
+            return
+
+        # Delegate outside the response-error handler: the child owns its stream.
+        for prefix, application in self.mounts.items():
+            if not prefix or path == prefix or path.startswith(prefix + '/'):
+                scope = request.scope.copy()
+                scope['root_path'] = root_path + prefix
+                await application(scope, request.receive, request.send)
+                return
+
+        await self.send_response(request.send, {"error": "Not Found"}, status=404)
 
     async def dispatch_response(self, request: Request, response: Any) -> None:
         """Invoke an ASGI response or serialize an ordinary handler result."""
@@ -286,6 +295,15 @@ class App:
 
         return await to_thread(read)
 
+    def mount(self, path: str, application: Callable) -> None:
+        """Delegate an HTTP subtree to an ASGI app, after ordinary routes."""
+        if not isinstance(path, str) or not path.startswith('/') or '{' in path or '}' in path:
+            raise ValueError('Mount path must be an absolute literal path')
+        if not callable(application):
+            raise TypeError('Mounted application must be callable')
+        self.mounts[path.rstrip('/')] = application
+        self.mounts = dict(sorted(self.mounts.items(), key=lambda item: len(item[0]), reverse=True))
+
     def route(self, path: str, methods: list[str] = ['GET']) -> Callable:
         """Decorator to register routes with specific HTTP methods."""
         def decorator(handler: Callable) -> Callable: