diff --git a/README.md b/README.md index 22e9d21..074f8c6 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ The current `StreamMessage` is resolvable within DI via the pre-built `faststrea ## API - `setup_di(app, container)` — stores the container in the app context and registers startup/shutdown lifecycle hooks: on startup it reopens the container and adds the DI middleware to every broker of the app (including one added via `app.add_broker` after `setup_di`), after shutdown it closes the container -- `FromDI(dependency, *, use_cache=True, cast=False)` — FastStream `Depends` that resolves a provider (or type) from the request container +- `FromDI(dependency, *, use_cache=True, cast=False)` — FastStream `Depends` that resolves a provider (or type) from the request container. Raises `RuntimeError` naming `setup_di` when a message reaches it without the middleware installed - `fetch_di_container(app)` — returns the root container from the app context - `faststream_message_provider` — `ContextProvider` for the current `faststream.StreamMessage` diff --git a/modern_di_faststream/main.py b/modern_di_faststream/main.py index cab0863..3696394 100644 --- a/modern_di_faststream/main.py +++ b/modern_di_faststream/main.py @@ -19,6 +19,12 @@ _ROOT_CONTAINER_KEY = "di_container" _REQUEST_CONTAINER_KEY = "request_container" +_MISSING_REQUEST_CONTAINER = ( + "No request container for this message, so the DI middleware did not run for it. " + "Call setup_di(app, container) on the app that owns this broker and start the app; " + "in tests, pair the test broker with TestApp(app) in the same `async with`." +) + class _DIMiddlewareFactory: __slots__ = ("di_container",) @@ -89,7 +95,9 @@ class Dependency(typing.Generic[T_co]): marker: integrations.Marker[T_co] async def __call__(self, context: faststream.ContextRepo) -> T_co: - request_container: Container = context.get(_REQUEST_CONTAINER_KEY) + request_container: Container | None = context.get(_REQUEST_CONTAINER_KEY) + if request_container is None: + raise RuntimeError(_MISSING_REQUEST_CONTAINER) return self.marker.resolve(request_container) diff --git a/tests/test_faststream_di.py b/tests/test_faststream_di.py index 1de1797..49ff99a 100644 --- a/tests/test_faststream_di.py +++ b/tests/test_faststream_di.py @@ -111,3 +111,35 @@ async def test_middleware_is_installed_once_per_broker_across_restarts() -> None for broker in (first, second): installed = [m for m in broker.config.broker_middlewares if isinstance(m, _DIMiddlewareFactory)] assert len(installed) == 1 + + +def _subscribe_with_from_di(broker: NatsBroker, subject: str) -> None: + @broker.subscriber(subject) + async def subscriber(instance: typing.Annotated[SimpleCreator, FromDI(Dependencies.app_factory)]) -> None: + """Never reached: resolving ``instance`` fails first.""" + + +async def test_from_di_without_setup_di_names_the_missing_step() -> None: + """INVARIANT: ``FromDI`` on a message the DI middleware never saw fails by naming ``setup_di``. + + Broken by handing ``ContextRepo.get``'s ``None`` straight to the marker, which surfaces as + modern-di's ``AttributeError: 'NoneType' object has no attribute 'resolve_dependency'`` and + points at nothing in this package. + """ + broker = NatsBroker() + app_ = faststream.FastStream(broker) + _subscribe_with_from_di(broker, TEST_SUBJECT) + + async with TestNatsBroker(broker) as br, TestApp(app_): + with pytest.raises(RuntimeError, match="setup_di"): + await br.publish(None, TEST_SUBJECT) + + +async def test_from_di_on_an_app_never_started_names_the_missing_step(app: faststream.FastStream) -> None: + """The middleware is installed on startup, so a test broker entered without ``TestApp`` has none.""" + broker = typing.cast(NatsBroker, app.broker) + _subscribe_with_from_di(broker, TEST_SUBJECT) + + async with TestNatsBroker(broker) as br: + with pytest.raises(RuntimeError, match="TestApp"): + await br.publish(None, TEST_SUBJECT)