patx/veela
Add HTTP ASGI application mounting
Commit 4e0f3d0 · patx · 2026-10-14T20:09:49-04:00
Comments
No comments yet.
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: