From 58d4dfdeec13e4afa88fd739a02c5a0f142ce892 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 14 Apr 2026 11:46:14 +0200 Subject: [PATCH] feat(event_handler): enrich request object --- .../event_handler/api_gateway.py | 1 + .../event_handler/request.py | 87 +++++++++++++---- docs/core/event_handler/api_gateway.md | 90 +++++++++++------- .../dependency_injection_with_middleware.py | 36 +++++++ .../src/dependency_injection_with_request.py | 3 +- .../required_dependencies/test_depends.py | 93 +++++++++++++++++++ .../required_dependencies/test_request.py | 81 ++++++++++++++++ 7 files changed, 342 insertions(+), 49 deletions(-) create mode 100644 examples/event_handler_rest/src/dependency_injection_with_middleware.py diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index f54d486080f..041f6f7abf3 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1327,6 +1327,7 @@ def my_middleware(app, next_middleware): route_path=route.openapi_path, path_parameters=self.context.get("_route_args", {}), current_event=self.current_event, + context=self.context, ) self.context["_request"] = request return request diff --git a/aws_lambda_powertools/event_handler/request.py b/aws_lambda_powertools/event_handler/request.py index e402c094ded..59d1f84d962 100644 --- a/aws_lambda_powertools/event_handler/request.py +++ b/aws_lambda_powertools/event_handler/request.py @@ -12,11 +12,35 @@ class Request: """Represents the resolved HTTP request. Provides structured access to the matched route pattern, extracted path parameters, - HTTP method, headers, query parameters, and body. Available via ``app.request`` - inside middleware and, when added as a type-annotated parameter, inside route handlers. + HTTP method, headers, query parameters, body, the full Powertools proxy event + (``resolved_event``), and the shared resolver context (``context``). + + Available via ``app.request`` inside middleware and, when added as a type-annotated + parameter, inside ``Depends()`` dependency functions and route handlers. Examples -------- + **Dependency injection with Depends()** + + ```python + from typing import Annotated + from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Request, Depends + + app = APIGatewayRestResolver() + + def get_auth_user(request: Request) -> str: + # Full event access via resolved_event + token = request.resolved_event.get_header_value("authorization", default_value="") + user = validate_token(token) + # Bridge with middleware via shared context + request.context["user"] = user + return user + + @app.get("/orders") + def list_orders(user: Annotated[str, Depends(get_auth_user)]): + return {"user": user} + ``` + **Middleware usage** ```python @@ -39,32 +63,21 @@ def auth_middleware(app: APIGatewayRestResolver, next_middleware: NextMiddleware app.use(middlewares=[auth_middleware]) ``` - - **Route handler injection (type-annotated)** - - ```python - from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Request - - app = APIGatewayRestResolver() - - @app.get("/applications/") - def get_application(application_id: str, request: Request): - user_agent = request.headers.get("user-agent") - return {"id": application_id, "user_agent": user_agent} - ``` """ - __slots__ = ("_current_event", "_path_parameters", "_route_path") + __slots__ = ("_context", "_current_event", "_path_parameters", "_route_path") def __init__( self, route_path: str, path_parameters: dict[str, Any], current_event: BaseProxyEvent, + context: dict[str, Any] | None = None, ) -> None: self._route_path = route_path self._path_parameters = path_parameters self._current_event = current_event + self._context = context if context is not None else {} @property def route(self) -> str: @@ -113,3 +126,45 @@ def body(self) -> str | None: def json_body(self) -> Any: """Request body deserialized as a Python object (dict / list), or ``None``.""" return self._current_event.json_body + + @property + def resolved_event(self) -> BaseProxyEvent: + """Full Powertools proxy event with all helpers and properties. + + Provides access to the complete ``BaseProxyEvent`` (or subclass) that + Powertools resolved for the current invocation. This includes cookies, + request context, path, and event-source-specific properties that are not + available through the convenience properties on :class:`Request`. + + Examples + -------- + ```python + def get_request_details(request: Request) -> dict: + event = request.resolved_event + return { + "path": event.path, + "cookies": event.cookies, + "request_context": event.request_context, + } + ``` + """ + return self._current_event + + @property + def context(self) -> dict[str, Any]: + """Shared resolver context (``app.context``) for this invocation. + + Provides read/write access to the same ``dict`` that middleware and + ``app.append_context()`` populate. This enables incremental migration + from middleware-based data sharing to ``Depends()``-based injection: + middleware writes to ``app.context``, dependencies read from + ``request.context``. + + Examples + -------- + ```python + def get_current_user(request: Request) -> dict: + return request.context["user"] + ``` + """ + return self._context diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index 0ffd8cee15c..2a7955f38c0 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -1365,6 +1365,37 @@ You can use `append_context` when you want to share data between your App and Ro --8<-- "examples/event_handler_rest/src/split_route_append_context_module.py" ``` +#### Sample layout + +This is a sample project layout for a monolithic function with routes split in different files (`/todos`, `/health`). + +```shell hl_lines="4 7 10 12-13" title="Sample project layout" +. +├── pyproject.toml # project app & dev dependencies; poetry, pipenv, etc. +├── poetry.lock +├── src +│ ├── __init__.py +│ ├── requirements.txt # sam build detect it automatically due to CodeUri: src. poetry export --format src/requirements.txt +│ └── todos +│ ├── __init__.py +│ ├── main.py # this will be our todos Lambda fn; it could be split in folders if we want separate fns same code base +│ └── routers # routers module +│ ├── __init__.py +│ ├── health.py # /health routes. from routers import todos; health.router +│ └── todos.py # /todos routes. from .routers import todos; todos.router +├── template.yml # SAM. CodeUri: src, Handler: todos.main.lambda_handler +└── tests + ├── __init__.py + ├── unit + │ ├── __init__.py + │ └── test_todos.py # unit tests for the todos router + │ └── test_health.py # unit tests for the health router + └── functional + ├── __init__.py + ├── conftest.py # pytest fixtures for the functional tests + └── test_main.py # functional tests for the main lambda handler +``` + ### Dependency injection You can use `Depends()` to declare dependencies that are automatically resolved and injected into your route handlers. This provides type-safe, composable, and testable dependency injection. @@ -1389,10 +1420,36 @@ Dependencies can depend on other dependencies, forming a composable tree. Shared Dependencies that need access to the current request can declare a parameter typed as `Request`. It will be injected automatically. -```python hl_lines="5-6 12 20" +The `Request` object provides: + +* **`headers`**, **`query_parameters`**, **`body`**, **`json_body`**: common request data +* **`resolved_event`**: the full Powertools proxy event with all helpers, cookies, request context, and path +* **`context`**: shared resolver context (`app.context`) for bridging data between middleware and dependencies + +```python hl_lines="5-6 14 20" --8<-- "examples/event_handler_rest/src/dependency_injection_with_request.py" ``` +#### Combining middleware and Depends() + +Middleware and `Depends()` are **complementary patterns**. Use middleware for request interception (auth gates, redirects, response modification) and `Depends()` for typed data injection. + +The bridge between them is `request.context`: middleware writes to `app.context`, and dependencies read from `request.context`: + +```python hl_lines="12-18 22-23 27" +--8<-- "examples/event_handler_rest/src/dependency_injection_with_middleware.py" +``` + +???+ tip "When to use middleware vs Depends()" + | Use case | Middleware | Depends() | + | --- | --- | --- | + | Return custom HTTP responses (redirects, 401s) | **Yes** | No, can only return values or raise exceptions | + | Short-circuit the request pipeline | **Yes** | No | + | Pre/post-process responses (add headers, compress) | **Yes** | No | + | Inject typed, testable data into handlers | No | **Yes** | + | Compose a dependency tree with caching | No | **Yes** | + | Override dependencies in tests | No | **Yes**, via `dependency_overrides` | + #### Testing with dependency overrides Use `dependency_overrides` to replace any dependency with a mock or stub during testing - no monkeypatching needed. @@ -1407,37 +1464,6 @@ Use `dependency_overrides` to replace any dependency with a mock or stub during ???+ info "`append_context` vs `Depends()`" `append_context` remains available for backward compatibility. `Depends()` is recommended for new code because it provides type safety, IDE autocomplete, composable dependency trees, and `dependency_overrides` for testing. -#### Sample layout - -This is a sample project layout for a monolithic function with routes split in different files (`/todos`, `/health`). - -```shell hl_lines="4 7 10 12-13" title="Sample project layout" -. -├── pyproject.toml # project app & dev dependencies; poetry, pipenv, etc. -├── poetry.lock -├── src -│ ├── __init__.py -│ ├── requirements.txt # sam build detect it automatically due to CodeUri: src. poetry export --format src/requirements.txt -│ └── todos -│ ├── __init__.py -│ ├── main.py # this will be our todos Lambda fn; it could be split in folders if we want separate fns same code base -│ └── routers # routers module -│ ├── __init__.py -│ ├── health.py # /health routes. from routers import todos; health.router -│ └── todos.py # /todos routes. from .routers import todos; todos.router -├── template.yml # SAM. CodeUri: src, Handler: todos.main.lambda_handler -└── tests - ├── __init__.py - ├── unit - │ ├── __init__.py - │ └── test_todos.py # unit tests for the todos router - │ └── test_health.py # unit tests for the health router - └── functional - ├── __init__.py - ├── conftest.py # pytest fixtures for the functional tests - └── test_main.py # functional tests for the main lambda handler -``` - ### Considerations This utility is optimized for fast startup, minimal feature set, and to quickly on-board customers familiar with frameworks like Flask — it's not meant to be a fully fledged framework. diff --git a/examples/event_handler_rest/src/dependency_injection_with_middleware.py b/examples/event_handler_rest/src/dependency_injection_with_middleware.py new file mode 100644 index 00000000000..63ee419c345 --- /dev/null +++ b/examples/event_handler_rest/src/dependency_injection_with_middleware.py @@ -0,0 +1,36 @@ +from typing_extensions import Annotated + +from aws_lambda_powertools.event_handler import APIGatewayHttpResolver, Response +from aws_lambda_powertools.event_handler.depends import Depends +from aws_lambda_powertools.event_handler.request import Request +from aws_lambda_powertools.utilities.typing import LambdaContext + +app = APIGatewayHttpResolver() + + +# Middleware handles auth — it can return HTTP responses (redirects, 401s) +def auth_middleware(app, next_middleware): + token = app.current_event.headers.get("authorization", "") + if not token: + return Response(status_code=401, body="Unauthorized") + + # Middleware writes to app.context + app.append_context(user={"id": "user-123", "role": "admin"}) + return next_middleware(app) + + +app.use(middlewares=[auth_middleware]) + + +# Depends() reads what middleware wrote via request.context — typed and testable +def get_current_user(request: Request) -> dict: + return request.context["user"] + + +@app.get("/admin/dashboard") +def admin_dashboard(user: Annotated[dict, Depends(get_current_user)]): + return {"message": f"Welcome {user['id']}", "role": user["role"]} + + +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/dependency_injection_with_request.py b/examples/event_handler_rest/src/dependency_injection_with_request.py index c918b646f46..e8586294f32 100644 --- a/examples/event_handler_rest/src/dependency_injection_with_request.py +++ b/examples/event_handler_rest/src/dependency_injection_with_request.py @@ -10,7 +10,8 @@ def get_authenticated_user(request: Request) -> str: - user_id = request.headers.get("x-user-id") + # Use resolved_event for full Powertools event access (cookies, request_context, path, etc.) + user_id = request.resolved_event.headers.get("x-user-id", "") if not user_id: raise UnauthorizedError("Missing authentication") return user_id diff --git a/tests/functional/event_handler/required_dependencies/test_depends.py b/tests/functional/event_handler/required_dependencies/test_depends.py index 3131be2430e..d5e49e07cdd 100644 --- a/tests/functional/event_handler/required_dependencies/test_depends.py +++ b/tests/functional/event_handler/required_dependencies/test_depends.py @@ -414,3 +414,96 @@ def handler(val: Annotated[str, Depends(broken_dep)]): result = app(API_GW_V2_EVENT, {}) assert result["statusCode"] == 200 assert json.loads(result["body"]) == {"val": "it-works"} + + +# --------------------------------------------------------------------------- +# request.context — bridge between middleware and Depends() +# --------------------------------------------------------------------------- + + +def test_depends_request_context_writable(): + """Dependencies can write to request.context and handlers can read it.""" + app = APIGatewayHttpResolver() + + def set_tenant(request: Request) -> str: + tenant = request.headers.get("x-tenant-id", "default") + request.context["tenant"] = tenant + return tenant + + @app.post("/my/path") + def handler(tenant: Annotated[str, Depends(set_tenant)], request: Request): + return {"tenant": tenant, "from_context": request.context.get("tenant")} + + event = {**API_GW_V2_EVENT, "headers": {**API_GW_V2_EVENT.get("headers", {}), "x-tenant-id": "acme-corp"}} + result = app(event, {}) + + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert body["tenant"] == "acme-corp" + assert body["from_context"] == "acme-corp" + + +def test_depends_request_context_bridges_middleware(): + """Middleware writes to app.context, Depends() reads via request.context.""" + app = APIGatewayHttpResolver() + + def auth_middleware(app, next_middleware): + app.append_context(user="admin-user") + return next_middleware(app) + + app.use(middlewares=[auth_middleware]) + + def get_current_user(request: Request) -> str: + return request.context["user"] + + @app.post("/my/path") + def handler(user: Annotated[str, Depends(get_current_user)]): + return {"user": user} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"user": "admin-user"} + + +def test_depends_request_context_with_router(): + """request.context works when routes come from an included Router.""" + from aws_lambda_powertools.event_handler.api_gateway import Router + + app = APIGatewayHttpResolver() + router = Router() + + def mw(app, next_middleware): + app.append_context(role="admin") + return next_middleware(app) + + app.use(middlewares=[mw]) + + def get_role(request: Request) -> str: + return request.context["role"] + + @router.post("/my/path") + def handler(role: Annotated[str, Depends(get_role)]): + return {"role": role} + + app.include_router(router) + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"role": "admin"} + + +def test_depends_request_resolved_event(): + """Dependencies can access the full event via request.resolved_event.""" + app = APIGatewayHttpResolver() + + def get_path(request: Request) -> str: + return request.resolved_event.path + + @app.post("/my/path") + def handler(path: Annotated[str, Depends(get_path)]): + return {"path": path} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert body["path"] == "/my/path" diff --git a/tests/functional/event_handler/required_dependencies/test_request.py b/tests/functional/event_handler/required_dependencies/test_request.py index 02ae0da5b88..b00ae6659ba 100644 --- a/tests/functional/event_handler/required_dependencies/test_request.py +++ b/tests/functional/event_handler/required_dependencies/test_request.py @@ -586,3 +586,84 @@ def handler(request: Request): # All accesses should return the same cached instance assert len(ids_seen) == 3 assert ids_seen[0] == ids_seen[1] == ids_seen[2] + + +# --------------------------------------------------------------------------- +# resolved_event — full Powertools proxy event access +# --------------------------------------------------------------------------- + + +def test_request_resolved_event_exposes_full_event(): + """resolved_event should return the full BaseProxyEvent with all helpers.""" + app = APIGatewayRestResolver() + captured: list[Request] = [] + + def mw(app: APIGatewayRestResolver, next_middleware): + captured.append(app.request) + return next_middleware(app) + + app.use(middlewares=[mw]) + + @app.get("/my/path") + def handler(): + return {} + + app(API_REST_EVENT, {}) + + req = captured[0] + resolved = req.resolved_event + + # resolved_event should be the same object as app.current_event + assert resolved is not None + assert resolved.http_method == "GET" + # Should have helper methods not available on Request directly + assert hasattr(resolved, "get_header_value") + assert hasattr(resolved, "get_query_string_value") + + +def test_request_resolved_event_provides_cookies_and_path(): + """resolved_event gives access to path and properties not on Request.""" + app = APIGatewayRestResolver() + captured: list[Request] = [] + + def mw(app: APIGatewayRestResolver, next_middleware): + captured.append(app.request) + return next_middleware(app) + + app.use(middlewares=[mw]) + + @app.get("/items/") + def handler(item_id: str): + return {} + + event = _make_rest_event("/items/42", path_parameters={"item_id": "42"}) + app(event, {}) + + resolved = captured[0].resolved_event + assert resolved.path == "/items/42" + + +# --------------------------------------------------------------------------- +# context — shared resolver context (app.context) +# --------------------------------------------------------------------------- + + +def test_request_context_shares_app_context(): + """request.context should be the same dict as app.context.""" + app = APIGatewayRestResolver() + + def mw(app: APIGatewayRestResolver, next_middleware): + app.append_context(user="test-user") + return next_middleware(app) + + app.use(middlewares=[mw]) + + @app.get("/my/path") + def handler(request: Request): + return {"user": request.context.get("user")} + + result = app(API_REST_EVENT, {}) + assert result["statusCode"] == 200 + import json + + assert json.loads(result["body"]) == {"user": "test-user"}