Skip to content

Commit

Permalink
✨ Add runtime isolation for nested Flama applications
Browse files Browse the repository at this point in the history
  • Loading branch information
perdy authored and migduroli committed Sep 27, 2023
1 parent a1d4d2b commit 4ffc05b
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 56 deletions.
5 changes: 4 additions & 1 deletion flama/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import typing as t

from flama import asgi, http, injection, types, url, validation, websockets
from flama.ddd.components import WorkerComponent
from flama.events import Events
from flama.middleware import MiddlewareStack
from flama.models.modules import ModelsModule
Expand All @@ -10,7 +11,6 @@
from flama.resources import ResourcesModule
from flama.routing import BaseRoute, Router
from flama.schemas.modules import SchemaModule
from flama.ddd.components import WorkerComponent

if t.TYPE_CHECKING:
from flama.middleware import Middleware
Expand Down Expand Up @@ -109,6 +109,9 @@ def __init__(
# Reference to paginator from within app
self.paginator = paginator

# Build router to propagate root application
self.router.build(self)

def __getattr__(self, item: str) -> t.Any:
"""Retrieve a module by its name.
Expand Down
31 changes: 13 additions & 18 deletions flama/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,9 +480,14 @@ def build(self, app: t.Optional["Flama"] = None) -> None:
:param app: Flama app.
"""
if app:
from flama import Flama

if app and isinstance(self.app, Flama):
self.app.router.components = Components(self.app.router.components + app.components)

if root := (self.app if isinstance(self.app, Flama) else app):
for route in self.routes:
route.build(app)
route.build(root)

def match(self, scope: types.Scope) -> Match:
"""Check if this route matches with given scope.
Expand Down Expand Up @@ -510,13 +515,17 @@ def route_scope(self, scope: types.Scope) -> types.Scope:
:param scope: ASGI scope.
:return: Route scope.
"""
from flama import Flama

app = self.app if isinstance(self.app, Flama) else scope["app"]
path = scope["path"]
root_path = scope.get("root_path", "")
matched_params = self.path.values(path)
remaining_path = matched_params.pop("path")
matched_path = path[: -len(remaining_path)]
return types.Scope(
{
"app": app,
"path_params": {**dict(scope.get("path_params", {})), **matched_params},
"endpoint": self.endpoint,
"root_path": root_path + matched_path,
Expand Down Expand Up @@ -575,7 +584,7 @@ def __init__(
:param root: Flama application.
"""
self.routes = [] if routes is None else list(routes)
self._components = Components(components if components else set())
self.components = Components(components if components else set())
self.lifespan = Lifespan(lifespan)

if root:
Expand Down Expand Up @@ -611,26 +620,12 @@ def build(self, app: "Flama") -> None:
for route in self.routes:
route.build(app)

@property
def components(self) -> Components:
return Components(
self._components
+ Components(
[
component
for route in self.routes
if hasattr(route, "app") and hasattr(route.app, "components")
for component in getattr(route.app, "components", [])
]
)
)

def add_component(self, component: Component):
"""Register a new component.
:param component: Component to register.
"""
self._components = Components(self._components + Components([component]))
self.components = Components(self.components + Components([component]))

def add_route(
self,
Expand Down
46 changes: 25 additions & 21 deletions tests/test_applications.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch
from unittest.mock import AsyncMock, MagicMock, call, patch

import pytest

Expand Down Expand Up @@ -94,14 +94,12 @@ async def test_call(self, app, asgi_scope, asgi_receive, asgi_send):
assert app.middleware.call_args_list == [call(asgi_scope, asgi_receive, asgi_send)]

def test_components(self, app):
expected_components = [MagicMock()]
components_mock = PropertyMock(return_value=expected_components)
component = MagicMock(spec=Component)

with patch.object(Router, "components", new=components_mock):
components = app.components
app.router.add_component(component)

assert components_mock.call_args_list == [call()]
assert components == expected_components
default_components = app.components[:1]
assert app.components == [*default_components, component]

def test_add_component(self, app, component):
component_obj = component()
Expand Down Expand Up @@ -258,21 +256,23 @@ def custom(self):
with exception:
assert app.resolve_url(resolve, **path_params) == resolution

def test_end_to_end(self, component, module):
"""Test"""
root_app = Flama(schema=None, docs=None)
def test_end_to_end(self, module):
root_component = MagicMock(spec=Component)
root_app = Flama(schema=None, docs=None, components=[root_component])
root_app.add_get("/foo", lambda: {})

assert len(root_app.router.routes) == 1
default_components = root_app.components[:1]
assert root_app.components == default_components
root_default_components = root_app.components[:1]
assert root_app.components == [*root_default_components, root_component]
assert root_app.modules == DEFAULT_MODULES

leaf_app = Flama(schema=None, docs=None, components=[component], modules={module()})
leaf_component = MagicMock(spec=Component)
leaf_app = Flama(schema=None, docs=None, components=[leaf_component], modules={module()})
leaf_app.add_get("/bar", lambda: {})

assert len(leaf_app.router.routes) == 1
assert leaf_app.components == [component]
leaf_default_components = leaf_app.components[:1]
assert leaf_app.components == [*leaf_default_components, leaf_component]
assert leaf_app.modules == [*DEFAULT_MODULES, module]

root_app.mount("/app", app=leaf_app)
Expand All @@ -288,17 +288,19 @@ def test_end_to_end(self, component, module):
assert isinstance(mount_app.app, Router)
mount_router = mount_app.app
# Check components are collected across the entire tree
assert mount_router.components == [component]
assert root_app.components == [component]
assert root_app.components == [*root_default_components, root_component]
assert mount_router.components == [*leaf_default_components, leaf_component, *root_app.components]
# Check modules are isolated for each app
assert mount_app.modules == [*DEFAULT_MODULES, module]
assert root_app.modules == DEFAULT_MODULES

def test_end_to_end_declarative(self, component, module):
def test_end_to_end_declarative(self, module):
leaf_component = MagicMock(spec=Component)
leaf_routes = [Route("/bar", lambda: {})]
leaf_app = Flama(routes=leaf_routes, schema=None, docs=None, components=[component], modules={module()})
leaf_app = Flama(routes=leaf_routes, schema=None, docs=None, components=[leaf_component], modules={module()})
root_component = MagicMock(spec=Component)
root_routes = [Route("/foo", lambda: {}), Mount("/app", app=leaf_app)]
root_app = Flama(routes=root_routes, schema=None, docs=None)
root_app = Flama(routes=root_routes, schema=None, docs=None, components=[root_component])

assert len(root_app.router.routes) == 2
# Check mount is initialized
Expand All @@ -311,8 +313,10 @@ def test_end_to_end_declarative(self, component, module):
assert isinstance(mount_app.app, Router)
mount_router = mount_app.app
# Check components are collected across the entire tree
assert mount_router.components == [component]
assert root_app.components == [component]
root_default_components = root_app.components[:1]
assert root_app.components == [*root_default_components, root_component]
leaf_default_components = leaf_app.components[:1]
assert mount_router.components == [*leaf_default_components, leaf_component, *root_app.components]
# Check modules are isolated for each app
assert mount_app.modules == [*DEFAULT_MODULES, module]
assert root_app.modules == DEFAULT_MODULES
61 changes: 45 additions & 16 deletions tests/test_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,14 +351,24 @@ def test_eq(self, app):
assert Mount("/", app, name="app_mock") == Mount("/", app, name="app_mock")
assert Mount("/", app, name="app_mock") != Mount("/", app, name="bar")

def test_build(self, mount, app):
@pytest.mark.parametrize(
["app", "used"],
(
pytest.param(MagicMock(spec=Router), False, id="router"),
pytest.param(MagicMock(spec=Flama, router=MagicMock(spec=Router, components=[])), True, id="app"),
),
)
def test_build(self, mount, app, used):
root_app = MagicMock(spec=Flama)
expected_calls = [call(app)] if used else [call(root_app)]

route = MagicMock(spec=Route)
mount.app = MagicMock(spec=Flama)
mount.app = app
mount.app.routes = [route]

mount.build(app)
mount.build(root_app)

assert route.build.call_args_list == [call(app)]
assert route.build.call_args_list == expected_calls

@pytest.mark.parametrize(
["scope_type", "path_match_return", "result"],
Expand All @@ -383,16 +393,30 @@ async def test_handle(self, mount, asgi_scope, asgi_receive, asgi_send):

assert mount.app.call_args_list == [call(asgi_scope, asgi_receive, asgi_send)]

def test_route_scope(self, mount, asgi_scope):
@pytest.mark.parametrize(
["app", "used"],
(
pytest.param(Router(), False, id="router"),
pytest.param(Flama(docs=None, schema=None), True, id="app"),
),
)
def test_route_scope(self, mount, asgi_scope, app, used):
def bar():
...

mount.app = app
mount.app.add_route("/bar", bar)

asgi_scope["path"] = "/foo/1/bar"
route_scope = mount.route_scope(asgi_scope)

assert route_scope == {"endpoint": mount.app, "path": "/bar", "path_params": {"x": 1}, "root_path": "/foo/1"}
assert route_scope == {
"app": app if used else asgi_scope["app"],
"endpoint": mount.app,
"path": "/bar",
"path_params": {"x": 1},
"root_path": "/foo/1",
}

@pytest.mark.parametrize(
["name", "params", "expected_url", "exception"],
Expand Down Expand Up @@ -452,7 +476,7 @@ def router(self, app):

@pytest.fixture(scope="function")
def app_mock(self):
return MagicMock(spec=Flama)
return MagicMock(spec=Flama, router=MagicMock(spec=Router, components=Components([])))

@pytest.fixture(scope="function")
def component_mock(self):
Expand Down Expand Up @@ -527,9 +551,14 @@ def test_build(self, router):
def test_components(self, router, component_mock):
assert router.components == []

router.mount("/app/", app=Router(components=[component_mock]))
router.add_component(component_mock)
leaf_component = MagicMock(spec=Component)
leaf_router = Router(components=[leaf_component])
router.mount("/app/", app=leaf_router)

assert router.components == [component_mock]
# Components are not propagated to leaf because mounted was done through a router instead of app
assert leaf_router.components == [leaf_component]

def test_add_component(self, router, component_mock):
assert router.components == []
Expand Down Expand Up @@ -693,8 +722,9 @@ def test_mount_router(self, app, component_mock, tags):
# Check router is created and initialized, also shares components and modules with main app
assert isinstance(mount_route.app, Router)
mount_router = mount_route.app
assert mount_router.components == Components([component_mock])
assert app.components == Components([component_mock])
default_components = app.components[:1]
assert mount_router.components == [component_mock]
assert app.components == default_components

def test_mount_declarative(self, component_mock, tags):
def root():
Expand Down Expand Up @@ -742,9 +772,9 @@ def foo_view():
# Check router is created and initialized, also shares components and modules with main app
assert isinstance(mount_with_routes_route.app, Router)
mount_with_routes_router = mount_with_routes_route.app
assert mount_with_routes_router.components == Components([component_mock])
# As the component is repeated, it should appear twice
assert app.components == Components([component_mock, component_mock])
assert mount_with_routes_router.components == [component_mock]
default_components = app.components[:1]
assert app.components == default_components
# Check second-level routes are created an initialized
assert len(mount_with_routes_route.routes) == 2
assert mount_with_routes_route.routes[0].path == "/"
Expand All @@ -758,9 +788,8 @@ def foo_view():
# Check router is created and initialized, also shares components and modules with main app
assert isinstance(mount_with_app_route.app, Router)
mount_with_app_router = mount_with_app_route.app
assert mount_with_app_router.components == Components([component_mock])
# As the component is repeated, it should appear twice
assert app.components == Components([component_mock, component_mock])
assert mount_with_app_router.components == [component_mock]
assert app.components == default_components
# Check second-level routes are created an initialized
assert len(mount_with_app_route.routes) == 2
assert mount_with_app_route.routes[0].path == "/"
Expand Down

0 comments on commit 4ffc05b

Please sign in to comment.