diff --git a/README.md b/README.md index a39b779..62d25d1 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ app.router.add_view("/users", Users) An HTTP request opens a `Scope.REQUEST` child container; a WebSocket connection opens a `Scope.SESSION` one. The connection `aiohttp.web.Request` is resolvable within DI: HTTP handlers and `REQUEST`-scoped factories inject it by type via `aiohttp_request_provider`, while WebSocket handlers read it via `FromDI(aiohttp_websocket_provider)`. For per-message work inside a WebSocket handler, open a nested `Scope.REQUEST` child of the session container fetched with `fetch_request_container`. +Sub-apps added with `app.add_subapp` work with or without their own `setup_di`. When both the parent and the sub-app call `setup_di`, a sub-app route resolves from a child of the sub-app's root container, and the parent's middleware still opens and closes its own child container around it. When only the parent calls `setup_di`, a sub-app route resolves from a child of the parent's root container. + ## API | Symbol | Description | diff --git a/modern_di_aiohttp/main.py b/modern_di_aiohttp/main.py index 63863d9..8a06592 100644 --- a/modern_di_aiohttp/main.py +++ b/modern_di_aiohttp/main.py @@ -70,13 +70,17 @@ async def _di_middleware( async with fetch_di_container(request.app).build_child_container( scope=match.scope, context=match.context ) as child_container: + outer_container = request.get(_CONTAINER_REQUEST_KEY) request[_CONTAINER_REQUEST_KEY] = child_container try: return await handler(request) finally: # The container's context holds this request, so leaving the entry behind closes a # cycle and the request could only be reclaimed by the GC. - del request[_CONTAINER_REQUEST_KEY] + if outer_container is None: + del request[_CONTAINER_REQUEST_KEY] + else: + request[_CONTAINER_REQUEST_KEY] = outer_container def setup_di(app: web.Application, container: Container) -> Container: diff --git a/tests/test_subapps.py b/tests/test_subapps.py new file mode 100644 index 0000000..eb5f10f --- /dev/null +++ b/tests/test_subapps.py @@ -0,0 +1,90 @@ +import collections.abc +import dataclasses +import http +import typing + +from aiohttp import web +from aiohttp.test_utils import TestClient +from modern_di import Container, Group, Scope, providers +from modern_di.providers import CacheSettings + +from modern_di_aiohttp import FromDI, fetch_request_container, inject, setup_di + + +AiohttpClient = collections.abc.Callable[[web.Application], collections.abc.Awaitable[TestClient]] + + +@dataclasses.dataclass(kw_only=True, slots=True) +class Label: + name: str + + +def make_group(name: str, finalized: list[str]) -> type[Group]: + class AppDependencies(Group): + label = providers.Factory( + scope=Scope.REQUEST, + creator=lambda: Label(name=name), + bound_type=Label, + cache=CacheSettings(finalizer=lambda label: finalized.append(label.name)), + ) + + return AppDependencies + + +@inject +async def label_view( + request: web.Request, # noqa: ARG001 + label: typing.Annotated[Label, FromDI(Label)], +) -> web.Response: + return web.Response(text=label.name) + + +@web.middleware +async def resolve_label_middleware( + request: web.Request, + handler: typing.Callable[[web.Request], typing.Awaitable[web.StreamResponse]], +) -> web.StreamResponse: + fetch_request_container(request).resolve(Label) + return await handler(request) + + +def build_apps(*, sub_has_di: bool, finalized: list[str]) -> web.Application: + sub = web.Application() + sub.router.add_get("/x", label_view) + if sub_has_di: + setup_di(sub, Container(groups=[make_group("sub", finalized)])) + + parent = web.Application() + parent.router.add_get("/x", label_view) + setup_di(parent, Container(groups=[make_group("parent", finalized)])) + parent.middlewares.append(resolve_label_middleware) + parent.add_subapp("/sub", sub) + return parent + + +async def test_each_app_resolves_from_its_own_container(aiohttp_client: AiohttpClient) -> None: + client = await aiohttp_client(build_apps(sub_has_di=True, finalized=[])) + + parent_response = await client.get("/x") + assert parent_response.status == http.HTTPStatus.OK + assert await parent_response.text() == "parent" + + sub_response = await client.get("/sub/x") + assert sub_response.status == http.HTTPStatus.OK + assert await sub_response.text() == "sub" + + +async def test_sub_app_without_setup_di_resolves_from_parent(aiohttp_client: AiohttpClient) -> None: + client = await aiohttp_client(build_apps(sub_has_di=False, finalized=[])) + + response = await client.get("/sub/x") + assert response.status == http.HTTPStatus.OK + assert await response.text() == "parent" + + +async def test_both_child_containers_closed_after_sub_app_request(aiohttp_client: AiohttpClient) -> None: + finalized: list[str] = [] + client = await aiohttp_client(build_apps(sub_has_di=True, finalized=finalized)) + + assert (await client.get("/sub/x")).status == http.HTTPStatus.OK + assert sorted(finalized) == ["parent", "sub"]