diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 81b8e625e2d..70b099269db 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1467,7 +1467,8 @@ def __call__(self, app: ApiGatewayResolver) -> dict | tuple | Response: logger.debug("MiddlewareFrame: %s", self) app._push_processed_stack_frame(str(self)) - return self.current_middleware(app, self.next_middleware) + extra_kwargs = _resolve_extra_middleware_kwargs(app, self.current_middleware) + return self.current_middleware(app, self.next_middleware, **extra_kwargs) def _find_request_param_name(func: Callable) -> str | None: @@ -1488,6 +1489,61 @@ def _find_request_param_name(func: Callable) -> str | None: return None +_DEPENDS_CACHE_KEY = "_dependency_cache" + + +def _inspect_middleware(middleware: Callable[..., Any]) -> tuple: + """Inspect a middleware's signature once and cache the result on the callable. + + Returns a tuple of the ``Request``-typed parameter name (or ``None``) and the + middleware's dependency tree. Results are cached on the callable itself, mirroring + how ``Route`` caches its dependant, to avoid repeated signature analysis per request. + """ + from aws_lambda_powertools.event_handler.depends import build_dependency_tree + + cached = getattr(middleware, "_powertools_middleware_inspection", None) + if cached is not None: + return cached + + inspection = (_find_request_param_name(middleware), build_dependency_tree(middleware)) + try: + middleware._powertools_middleware_inspection = inspection # type: ignore[attr-defined] + except (AttributeError, TypeError): + pass # callables without attribute support are re-inspected per request + + return inspection + + +def _resolve_extra_middleware_kwargs(app: ApiGatewayResolver, middleware: Callable[..., Any]) -> dict: + """Resolve extra keyword arguments for a function-based middleware. + + Supports ``Request`` injection and ``Depends()`` parameters, sharing the per-request + dependency cache (stored in ``app.context``) with route handlers so the same + dependency is resolved only once across middleware and routes. Class-based + middleware is intentionally out of scope. + """ + from aws_lambda_powertools.event_handler.depends import solve_dependencies + + request_param, dependency_tree = _inspect_middleware(middleware) + + kwargs: dict = {} + if request_param: + kwargs[request_param] = app.request + + if dependency_tree.dependencies: + dependency_cache = app.context.setdefault(_DEPENDS_CACHE_KEY, {}) + kwargs.update( + solve_dependencies( + dependant=dependency_tree, + request=app.request, + dependency_overrides=app.dependency_overrides or None, + dependency_cache=dependency_cache, + ), + ) + + return kwargs + + def _registered_api_adapter( app: ApiGatewayResolver, next_middleware: Callable[..., Any], @@ -1535,6 +1591,7 @@ def _registered_api_adapter( dependant=build_dependency_tree(route.func), request=app.request, dependency_overrides=app.dependency_overrides or None, + dependency_cache=app.context.get(_DEPENDS_CACHE_KEY), ) route_args.update(dep_values) diff --git a/aws_lambda_powertools/event_handler/middlewares/async_utils.py b/aws_lambda_powertools/event_handler/middlewares/async_utils.py index 4f375bc9b0b..43e4fc92e30 100644 --- a/aws_lambda_powertools/event_handler/middlewares/async_utils.py +++ b/aws_lambda_powertools/event_handler/middlewares/async_utils.py @@ -155,8 +155,12 @@ async def __call__(self, app: ApiGatewayResolver) -> dict | tuple | Response: logger.debug("AsyncMiddlewareFrame: %s", self) app._push_processed_stack_frame(str(self)) + from aws_lambda_powertools.event_handler.api_gateway import _resolve_extra_middleware_kwargs + + extra_kwargs = _resolve_extra_middleware_kwargs(app, self.current_middleware) + if inspect.iscoroutinefunction(self.current_middleware): - return await self.current_middleware(app, self.next_middleware) + return await self.current_middleware(app, self.next_middleware, **extra_kwargs) loop = asyncio.get_running_loop() @@ -164,7 +168,7 @@ def sync_next(app: ApiGatewayResolver) -> Any: future = asyncio.run_coroutine_threadsafe(self.next_middleware(app), loop) return future.result() - return await asyncio.to_thread(self.current_middleware, app, sync_next) + return await asyncio.to_thread(self.current_middleware, app, sync_next, **extra_kwargs) async def _registered_api_adapter_async( @@ -207,12 +211,14 @@ async def _registered_api_adapter_async( route_args = {**route_args, route.request_param_name: app.request} if route.has_dependencies: + from aws_lambda_powertools.event_handler.api_gateway import _DEPENDS_CACHE_KEY from aws_lambda_powertools.event_handler.depends import build_dependency_tree, solve_dependencies dep_values = solve_dependencies( dependant=build_dependency_tree(route.func), request=app.request, dependency_overrides=app.dependency_overrides or None, + dependency_cache=app.context.get(_DEPENDS_CACHE_KEY), ) route_args.update(dep_values) diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index 711ddf6a68f..875caef1a0e 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -1457,6 +1457,14 @@ The bridge between them is `request.context`: middleware writes to `app.context` --8<-- "examples/event_handler_rest/src/dependency_injection_with_middleware.py" ``` +Middleware can also declare `Depends()` parameters directly, the same way route handlers do. This is useful when the middleware itself needs typed data access - for example, looking up a user via a DAO before deciding whether to continue: + +```python hl_lines="22 25" +--8<-- "examples/event_handler_rest/src/dependency_injection_middleware_depends.py" +``` + +Dependencies declared by middleware share the per-invocation cache with route dependencies, support `dependency_overrides` in tests, and can declare a `Request` parameter. This works for global (`app.use()`), router-level, and route-level (`@app.get(..., middlewares=[...])`) middleware. + ???+ tip "When to use middleware vs Depends()" | Use case | Middleware | Depends() | | --- | --- | --- | diff --git a/examples/event_handler_rest/src/dependency_injection_middleware_depends.py b/examples/event_handler_rest/src/dependency_injection_middleware_depends.py new file mode 100644 index 00000000000..f53328ae6aa --- /dev/null +++ b/examples/event_handler_rest/src/dependency_injection_middleware_depends.py @@ -0,0 +1,46 @@ +from typing import Annotated + +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver +from aws_lambda_powertools.event_handler.depends import Depends +from aws_lambda_powertools.event_handler.middlewares import NextMiddleware +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = APIGatewayHttpResolver() + + +class UserDao: + def get(self, sub: str) -> dict | None: + # fetch user from a database + ... + + +def get_user_dao() -> UserDao: + return UserDao() + + +# Middleware can declare Depends() parameters, just like route handlers +def user_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + user_dao: Annotated[UserDao, Depends(get_user_dao)], +): + authorizer = app.current_event.request_context.authorizer + if authorizer: + sub: str | None = authorizer.jwt_claim.get("sub") + if sub: + user = user_dao.get(sub) + if user: + app.append_context(user=user) + return next_middleware(app) + + +app.use(middlewares=[user_middleware]) + + +@app.get("/profile") +def get_profile(): + return {"user": app.context.get("user")} + + +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/tests/functional/event_handler/required_dependencies/test_middleware_depends.py b/tests/functional/event_handler/required_dependencies/test_middleware_depends.py new file mode 100644 index 00000000000..215b65f3103 --- /dev/null +++ b/tests/functional/event_handler/required_dependencies/test_middleware_depends.py @@ -0,0 +1,347 @@ +"""Tests for Depends() dependency injection in function-based middleware. + +Covers issue #8348: middleware should be able to declare Depends() parameters +the same way route handlers do, sharing the per-request dependency cache with +routes so a dependency used by both is resolved only once. +""" + +import asyncio +import json +from typing import Annotated + +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver +from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.depends import Depends +from aws_lambda_powertools.event_handler.middlewares import NextMiddleware +from aws_lambda_powertools.event_handler.request import Request +from tests.functional.utils import load_event + +API_GW_V2_EVENT = load_event("apiGatewayProxyV2Event.json") + + +def test_middleware_depends_resolved(): + """A dependency declared by middleware is resolved and injected (issue example).""" + app = APIGatewayHttpResolver() + + class UserDao: + def get(self, sub: str) -> dict: + return {"sub": sub, "name": "Jane"} + + def get_user_dao() -> UserDao: + return UserDao() + + def user_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + user_dao: Annotated[UserDao, Depends(get_user_dao)], + ): + app.append_context(user={"source": "middleware", "dao": type(user_dao).__name__}) + return next_middleware(app) + + app.use([user_middleware]) + + @app.post("/my/path") + def get_user(): + return {"user": app.context.get("user")} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"user": {"source": "middleware", "dao": "UserDao"}} + + +def test_middleware_depends_shared_cache_with_route(): + """The same dependency used by middleware and route is resolved only once per request.""" + app = APIGatewayHttpResolver() + call_count = 0 + + def get_config() -> dict: + nonlocal call_count + call_count += 1 + return {"key": "value"} + + def config_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + config: Annotated[dict, Depends(get_config)], + ): + assert config == {"key": "value"} + return next_middleware(app) + + app.use([config_middleware]) + + @app.post("/my/path") + def get_config_route(config: Annotated[dict, Depends(get_config)]): + return {"config": config} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"config": {"key": "value"}} + assert call_count == 1 + + +def test_middleware_depends_overrides(): + """dependency_overrides applies to middleware dependencies too.""" + app = APIGatewayHttpResolver() + + def get_greeting() -> str: + return "hello" + + def get_override() -> str: + return "overridden" + + app.dependency_overrides = {get_greeting: get_override} + + def greeting_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + greeting: Annotated[str, Depends(get_greeting)], + ): + app.append_context(greeting=greeting) + return next_middleware(app) + + app.use([greeting_middleware]) + + @app.post("/my/path") + def greet(): + return {"greeting": app.context.get("greeting")} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"greeting": "overridden"} + + +def test_middleware_short_circuit_skips_route_dependencies(): + """A middleware that short-circuits must not trigger route dependency resolution.""" + app = APIGatewayHttpResolver() + route_dep_called = 0 + + def get_route_value() -> str: + nonlocal route_dep_called + route_dep_called += 1 + return "route-value" + + def auth_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + ): + # short-circuit: never calls next_middleware + return {"statusCode": 401, "body": json.dumps({"message": "denied"})} + + app.use([auth_middleware]) + + @app.post("/my/path") + def protected(value: Annotated[str, Depends(get_route_value)]): + return {"value": value} + + app(API_GW_V2_EVENT, {}) + assert route_dep_called == 0 + + +def test_middleware_request_injection(): + """Middleware can declare a parameter typed as Request, like route handlers.""" + app = APIGatewayHttpResolver() + seen_path = {} + + def request_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + request: Request, + ): + seen_path["method"] = request.method + return next_middleware(app) + + app.use([request_middleware]) + + @app.post("/my/path") + def check(): + return {"ok": True} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert seen_path.get("method") + + +def test_middleware_dependency_receives_request(): + """A dependency that declares a Request parameter receives the current request.""" + app = APIGatewayHttpResolver() + + def get_tenant(request: Request) -> str: + return (request.headers or {}).get("x-tenant", "none") + + def tenant_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + tenant: Annotated[str, Depends(get_tenant)], + ): + app.append_context(tenant=tenant) + return next_middleware(app) + + app.use([tenant_middleware]) + + @app.post("/my/path") + def tenant(): + return {"tenant": app.context.get("tenant")} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert "tenant" in json.loads(result["body"]) + + +def test_plain_middleware_unaffected(): + """Middleware without Depends()/Request params behaves exactly as before.""" + app = APIGatewayHttpResolver() + called = [] + + def plain_middleware(app: APIGatewayHttpResolver, next_middleware: NextMiddleware): + called.append("middleware") + return next_middleware(app) + + app.use([plain_middleware]) + + @app.post("/my/path") + def plain(): + return {"ok": True} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert called == ["middleware"] + + +def test_async_middleware_depends_resolved(): + """Async middleware with Depends() is resolved in the async chain.""" + app = APIGatewayHttpResolver() + + def get_greeting() -> str: + return "async-hello" + + async def greeting_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + greeting: Annotated[str, Depends(get_greeting)], + ): + app.append_context(greeting=greeting) + return await next_middleware(app) + + app.use([greeting_middleware]) + + @app.post("/my/path") + async def greet(): + return {"greeting": app.context.get("greeting")} + + result = asyncio.run(app.resolve_async(API_GW_V2_EVENT, {})) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"greeting": "async-hello"} + + +def test_async_sync_middleware_depends_resolved(): + """Sync middleware with Depends() works in the async chain (thread path).""" + app = APIGatewayHttpResolver() + call_count = 0 + + def get_config() -> dict: + nonlocal call_count + call_count += 1 + return {"key": "value"} + + def config_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + config: Annotated[dict, Depends(get_config)], + ): + assert config == {"key": "value"} + return next_middleware(app) + + app.use([config_middleware]) + + @app.post("/my/path") + async def get_config_route(config: Annotated[dict, Depends(get_config)]): + return {"config": config} + + result = asyncio.run(app.resolve_async(API_GW_V2_EVENT, {})) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"config": {"key": "value"}} + assert call_count == 1 + + +def test_router_level_middleware_depends_resolved(): + """Middleware registered on a Router resolves Depends() params after include_router.""" + app = APIGatewayHttpResolver() + router = Router() + + def get_settings() -> dict: + return {"region": "us-east-1"} + + def router_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + settings: Annotated[dict, Depends(get_settings)], + ): + app.append_context(router_mw=settings["region"]) + return next_middleware(app) + + router.use([router_middleware]) + + @router.post("/my/path") + def handler(): + return {"router_mw": app.context.get("router_mw")} + + app.include_router(router) + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"router_mw": "us-east-1"} + + +def test_route_level_middleware_depends_resolved(): + """Middleware attached to a single route resolves Depends() params.""" + app = APIGatewayHttpResolver() + + def get_greeting() -> str: + return "hello" + + def route_middleware( + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + greeting: Annotated[str, Depends(get_greeting)], + ): + app.append_context(route_mw=greeting) + return next_middleware(app) + + @app.post("/my/path", middlewares=[route_middleware]) + def handler(): + return {"route_mw": app.context.get("route_mw")} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"route_mw": "hello"} + + +def test_bound_method_middleware_depends_resolved(): + """A bound method cannot cache its inspection as an attribute; it is re-inspected on every request.""" + app = APIGatewayHttpResolver() + + def get_region() -> str: + return "eu-west-1" + + class Middlewares: + def with_region( + self, + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + region: Annotated[str, Depends(get_region)], + ): + app.append_context(region=region) + return next_middleware(app) + + middleware = Middlewares().with_region + + @app.post("/my/path", middlewares=[middleware]) + def handler(): + return {"region": app.context.get("region")} + + for _ in range(2): + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"region": "eu-west-1"} + + assert not hasattr(middleware, "_powertools_middleware_inspection")