diff --git a/docs/pages/integrations/fastapi.md b/docs/pages/integrations/fastapi.md index bb32850..f3a74c7 100644 --- a/docs/pages/integrations/fastapi.md +++ b/docs/pages/integrations/fastapi.md @@ -7,6 +7,7 @@ Dependency injection for FastAPI is available in the `wireup.integration.fastapi - [x] Expose `fastapi.Request` as a wireup dependency. * Available as a `TRANSIENT` scoped dependency, your services can ask for a fastapi request object. - [x] Can: Mix Wireup and FastAPI dependencies in routes. +- [x] Can: Use Wireup in FastAPI dependencies. - [ ] Cannot: Use FastAPI dependencies in Wireup service objects. ## Getting Started @@ -39,9 +40,14 @@ container = wireup.create_container( wireup.integration.fastapi.setup(container, app) ``` -Wireup integration performs injection only in fastapi routes. If you're not storing the container in a global variable, -you can always get a reference to it wherever you have a fastapi application reference -by using `wireup.integration.fastapi.get_container`. +## Use Wireup in FastAPI Depends + +To help with migration, it is possible to use Wireup in FastAPI depends +or anywhere you have a reference to the FastAPI application instance. + + +If you're not storing the container in a variable somewhere, you can get a reference +to it by using the `wireup.integration.fastapi.get_container` function. ```python title="example_middleware.py" from wireup.integration.fastapi import get_container @@ -58,11 +64,23 @@ In the same way, you can get a reference to it in a fastapi dependency. ```python from wireup.integration.fastapi import get_container -async def example_dependency(request: Request, other_dependency: Depends(...)): +async def example_dependency(request: Request, other_dependency: Annotated[X, Depends(...)]): container = get_container(request.app) ... ``` +The integration also provides some FastAPI Depends functions you can use directly that perform some operation +with the container. + +```python +async def some_fastapi_dependency( + greeter: Annotated[GreeterService, WireupService(GreeterService)], + foo_param: Annotated[str, WireupParameter("foo")], + foo_foo: Annotated[str, WireupExpr("${foo}-${foo}")], + container: Annotated[DependencyContainer, WireupContainer()], +): ... +``` + ### FastAPI request A key feature of the integration is to expose `fastapi.Request` and `starlette.requests.Request` objects in wireup. diff --git a/pyproject.toml b/pyproject.toml index 7727d35..b254dfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,7 @@ lint.ignore = [ "D213", # Disable "Summary must go into next line" "D107", # Disable required docs for __init. Can be redundant if class also has them. "A003", # Disable "shadows builtin". OverrideManager.set was flagged by this + "N802", # Disable due to various functions used in Annotated[]. "FA100", # Don't recomment __future__ annotations. # Disable as they may cause conflicts with ruff formatter "COM812", diff --git a/test/integration/test_fastapi_integration.py b/test/integration/test_fastapi_integration.py index 2035f19..6fab678 100644 --- a/test/integration/test_fastapi_integration.py +++ b/test/integration/test_fastapi_integration.py @@ -13,7 +13,8 @@ from typing_extensions import Annotated from wireup import Inject from wireup.errors import UnknownServiceRequestedError, WireupError -from wireup.integration.fastapi import get_container +from wireup.integration.fastapi import WireupContainer, WireupExpr, WireupParameter, WireupService, get_container +from wireup.ioc.dependency_container import DependencyContainer from wireup.ioc.types import ServiceLifetime from test.unit.services.no_annotations.random.random_service import RandomService @@ -40,6 +41,13 @@ def greet(self, name: str) -> str: return f"Hello {name}" +def get_greeting( + greeter: Annotated[GreeterService, WireupService(GreeterService)], + name: str, +) -> str: + return greeter.greet(name) + + def create_app() -> FastAPI: app = FastAPI() @@ -53,6 +61,19 @@ async def lucky_number_route( async def rng_route(random_service: Annotated[RandomService, Inject()]): return {"number": random_service.get_random()} + @app.get("/wireup-in-fastapi") + async def wireup_in_fastapi_depends( + greeting: Annotated[str, Depends(get_greeting)], + foo_param: Annotated[str, WireupParameter("foo")], + foo_foo: Annotated[str, WireupExpr("${foo}-${foo}")], + container: Annotated[DependencyContainer, WireupContainer()], + ): + assert foo_param == "bar" + assert foo_foo == "bar-bar" + assert isinstance(container, DependencyContainer) + + return {"greeting": greeting} + @app.get("/params") async def params_route( foo: Annotated[str, Inject(param="foo")], foo_foo: Annotated[str, Inject(expr="${foo}-${foo}")] @@ -99,6 +120,12 @@ def test_injects_service(client: TestClient): assert response.json() == {"number": 4, "lucky_number": 42} +def test_injects_wireup_in_fastapi_depends(client: TestClient): + response = client.get("/wireup-in-fastapi", params={"name": "World"}) + assert response.status_code == 200 + assert response.json() == {"greeting": "Hello World"} + + def test_override(app: FastAPI, client: TestClient): class RealRandom(RandomService): def get_random(self) -> int: diff --git a/wireup/annotation/__init__.py b/wireup/annotation/__init__.py index a2564ec..b92317e 100644 --- a/wireup/annotation/__init__.py +++ b/wireup/annotation/__init__.py @@ -21,7 +21,7 @@ from collections.abc import Callable -def Inject( # noqa: N802 +def Inject( *, param: str | None = None, expr: str | None = None, diff --git a/wireup/integration/fastapi.py b/wireup/integration/fastapi.py index 3588093..4478b48 100644 --- a/wireup/integration/fastapi.py +++ b/wireup/integration/fastapi.py @@ -1,16 +1,52 @@ +from __future__ import annotations + from contextvars import ContextVar -from typing import Awaitable, Callable +from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar -from fastapi import FastAPI, Request, Response +from fastapi import Depends, FastAPI, Request, Response from fastapi.routing import APIRoute, APIWebSocketRoute -from wireup import DependencyContainer from wireup.errors import WireupError from wireup.integration.util import is_view_using_container -from wireup.ioc.types import ServiceLifetime +from wireup.ioc.types import Qualifier, ServiceLifetime, TemplatedString + +if TYPE_CHECKING: + from wireup import DependencyContainer current_request: ContextVar[Request] = ContextVar("wireup_fastapi_request") +T = TypeVar("T") + + +def WireupContainer() -> Callable[[], DependencyContainer]: + """Inject the wireup container associated with the current FastAPI instance.""" + + def _depends(request: Request) -> DependencyContainer: + return get_container(request.app) + + return Depends(_depends) + + +def WireupService(service: type[T], qualifier: Qualifier | None = None) -> Callable[..., T]: # noqa: D103 + def _depends(request: Request) -> T: + return get_container(request.app).get(service, qualifier) + + return Depends(_depends) + + +def WireupParameter(param: str) -> Callable[..., Any]: # noqa: D103 + def _depends(request: Request) -> Any: + return get_container(request.app).params.get(param) + + return Depends(_depends) + + +def WireupExpr(expr: str) -> Callable[..., Any]: # noqa: D103 + def _depends(request: Request) -> Any: + return get_container(request.app).params.get(TemplatedString(expr)) + + return Depends(_depends) + async def _wireup_request_middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: token = current_request.set(request)