Skip to content

Commit 7323c05

Browse files
feat(event_handler): add Request object for middleware access to resolved route and args (#8036)
* feat(event_handler): add Request object for middleware access to resolved route and args Introduce a Request class that provides structured access to the resolved route pattern, path parameters, HTTP method, headers, query parameters, and body. Available via app.request in middleware and via type-annotation injection in route handlers. Closes #7992, #4609 * fix(event_handler): remove unused import and move NextMiddleware to TYPE_CHECKING block * style: fix import ordering in api_gateway.py and dependant.py * Improve workflow --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent 92a0db3 commit 7323c05

File tree

5 files changed

+787
-0
lines changed

5 files changed

+787
-0
lines changed

aws_lambda_powertools/event_handler/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from aws_lambda_powertools.event_handler.lambda_function_url import (
2222
LambdaFunctionUrlResolver,
2323
)
24+
from aws_lambda_powertools.event_handler.request import Request
2425
from aws_lambda_powertools.event_handler.vpc_lattice import VPCLatticeResolver, VPCLatticeV2Resolver
2526

2627
__all__ = [
@@ -37,6 +38,7 @@
3738
"CORSConfig",
3839
"HttpResolverLocal",
3940
"LambdaFunctionUrlResolver",
41+
"Request",
4042
"Response",
4143
"VPCLatticeResolver",
4244
"VPCLatticeV2Resolver",

aws_lambda_powertools/event_handler/api_gateway.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
validation_error_definition,
4343
validation_error_response_definition,
4444
)
45+
from aws_lambda_powertools.event_handler.request import Request
4546
from aws_lambda_powertools.event_handler.util import (
4647
_FrozenDict,
4748
_FrozenListDict,
@@ -466,6 +467,11 @@ def __init__(
466467

467468
self.custom_response_validation_http_code = custom_response_validation_http_code
468469

470+
# Caches the name of any Request-typed parameter in the handler.
471+
# Avoids re-scanning the signature on every invocation.
472+
self.request_param_name: str | None = None
473+
self.request_param_name_checked: bool = False
474+
469475
def __call__(
470476
self,
471477
router_middlewares: list[Callable],
@@ -1608,6 +1614,47 @@ def clear_context(self):
16081614
"""Resets routing context"""
16091615
self.context.clear()
16101616

1617+
@property
1618+
def request(self) -> Request:
1619+
"""Current resolved :class:`Request` object.
1620+
1621+
Available inside middleware and in route handlers that declare a parameter
1622+
typed as :class:`Request <aws_lambda_powertools.event_handler.request.Request>`.
1623+
1624+
Raises
1625+
------
1626+
RuntimeError
1627+
When accessed before route resolution (i.e. outside of middleware / handler scope).
1628+
1629+
Examples
1630+
--------
1631+
**Middleware**
1632+
1633+
```python
1634+
def my_middleware(app, next_middleware):
1635+
req = app.request
1636+
print(req.route, req.method, req.path_parameters)
1637+
return next_middleware(app)
1638+
```
1639+
"""
1640+
cached: Request | None = self.context.get("_request")
1641+
if cached is not None:
1642+
return cached
1643+
1644+
route: Route | None = self.context.get("_route")
1645+
if route is None:
1646+
raise RuntimeError(
1647+
"app.request is only available after route resolution. Use it inside middleware or a route handler.",
1648+
)
1649+
1650+
request = Request(
1651+
route_path=route.openapi_path,
1652+
path_parameters=self.context.get("_route_args", {}),
1653+
current_event=self.current_event,
1654+
)
1655+
self.context["_request"] = request
1656+
return request
1657+
16111658

16121659
class MiddlewareFrame:
16131660
"""
@@ -1680,6 +1727,24 @@ def __call__(self, app: ApiGatewayResolver) -> dict | tuple | Response:
16801727
return self.current_middleware(app, self.next_middleware)
16811728

16821729

1730+
def _find_request_param_name(func: Callable) -> str | None:
1731+
"""Return the name of the first parameter annotated as ``Request``, or ``None``."""
1732+
from typing import get_type_hints
1733+
1734+
try:
1735+
# get_type_hints resolves string annotations from ``from __future__ import annotations``
1736+
# using the function's own module globals.
1737+
hints = get_type_hints(func)
1738+
except Exception:
1739+
hints = {}
1740+
1741+
for param_name, annotation in hints.items():
1742+
if annotation is Request:
1743+
return param_name
1744+
1745+
return None
1746+
1747+
16831748
def _registered_api_adapter(
16841749
app: ApiGatewayResolver,
16851750
next_middleware: Callable[..., Any],
@@ -1708,6 +1773,17 @@ def _registered_api_adapter(
17081773
"""
17091774
route_args: dict = app.context.get("_route_args", {})
17101775
logger.debug(f"Calling API Route Handler: {route_args}")
1776+
1777+
# Inject a Request object when the handler declares a parameter typed as Request.
1778+
# Lookup is cached on the Route object to avoid repeated signature inspection.
1779+
route: Route | None = app.context.get("_route")
1780+
if route is not None:
1781+
if not route.request_param_name_checked:
1782+
route.request_param_name = _find_request_param_name(next_middleware)
1783+
route.request_param_name_checked = True
1784+
if route.request_param_name:
1785+
route_args = {**route_args, route.request_param_name: app.request}
1786+
17111787
return app._to_response(next_middleware(**route_args))
17121788

17131789

aws_lambda_powertools/event_handler/openapi/dependant.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
get_flat_dependant,
2323
)
2424
from aws_lambda_powertools.event_handler.openapi.types import OpenAPIResponse, OpenAPIResponseContentModel
25+
from aws_lambda_powertools.event_handler.request import Request
2526

2627
if TYPE_CHECKING:
2728
from collections.abc import Callable
@@ -187,6 +188,11 @@ def get_dependant(
187188

188189
# Add each parameter to the dependant model
189190
for param_name, param in signature_params.items():
191+
# Request-typed parameters are injected by the resolver at call time;
192+
# they carry no OpenAPI meaning and must be excluded from schema generation.
193+
if param.annotation is Request:
194+
continue
195+
190196
# If the parameter is a path parameter, we need to set the in_ field to "path".
191197
is_path_param = param_name in path_param_names
192198

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Resolved HTTP Request object for Event Handler."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Any
6+
7+
if TYPE_CHECKING:
8+
from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent
9+
10+
11+
class Request:
12+
"""Represents the resolved HTTP request.
13+
14+
Provides structured access to the matched route pattern, extracted path parameters,
15+
HTTP method, headers, query parameters, and body. Available via ``app.request``
16+
inside middleware and, when added as a type-annotated parameter, inside route handlers.
17+
18+
Examples
19+
--------
20+
**Middleware usage**
21+
22+
```python
23+
from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Request, Response
24+
from aws_lambda_powertools.event_handler.middlewares import NextMiddleware
25+
26+
app = APIGatewayRestResolver()
27+
28+
def auth_middleware(app: APIGatewayRestResolver, next_middleware: NextMiddleware) -> Response:
29+
request: Request = app.request
30+
31+
route = request.route # "/applications/{application_id}"
32+
path_params = request.path_parameters # {"application_id": "4da715ee-..."}
33+
method = request.method # "PUT"
34+
35+
if not is_authorized(route, method, path_params):
36+
return Response(status_code=403, body="Forbidden")
37+
38+
return next_middleware(app)
39+
40+
app.use(middlewares=[auth_middleware])
41+
```
42+
43+
**Route handler injection (type-annotated)**
44+
45+
```python
46+
from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Request
47+
48+
app = APIGatewayRestResolver()
49+
50+
@app.get("/applications/<application_id>")
51+
def get_application(application_id: str, request: Request):
52+
user_agent = request.headers.get("user-agent")
53+
return {"id": application_id, "user_agent": user_agent}
54+
```
55+
"""
56+
57+
__slots__ = ("_current_event", "_path_parameters", "_route_path")
58+
59+
def __init__(
60+
self,
61+
route_path: str,
62+
path_parameters: dict[str, Any],
63+
current_event: BaseProxyEvent,
64+
) -> None:
65+
self._route_path = route_path
66+
self._path_parameters = path_parameters
67+
self._current_event = current_event
68+
69+
@property
70+
def route(self) -> str:
71+
"""Matched route pattern in OpenAPI path-template format.
72+
73+
Examples
74+
--------
75+
For a route registered as ``/applications/<application_id>`` the value is
76+
``/applications/{application_id}``.
77+
"""
78+
return self._route_path
79+
80+
@property
81+
def path_parameters(self) -> dict[str, Any]:
82+
"""Extracted path parameters for the matched route.
83+
84+
Examples
85+
--------
86+
For a request to ``/applications/4da715ee``, matched against
87+
``/applications/<application_id>``, the value is
88+
``{"application_id": "4da715ee"}``.
89+
"""
90+
return self._path_parameters
91+
92+
@property
93+
def method(self) -> str:
94+
"""HTTP method in upper-case, e.g. ``"GET"``, ``"PUT"``."""
95+
return self._current_event.http_method.upper()
96+
97+
@property
98+
def headers(self) -> dict[str, str]:
99+
"""Request headers dict (lower-cased keys may vary by event source)."""
100+
return self._current_event.headers or {}
101+
102+
@property
103+
def query_parameters(self) -> dict[str, str] | None:
104+
"""Query string parameters, or ``None`` when none are present."""
105+
return self._current_event.query_string_parameters
106+
107+
@property
108+
def body(self) -> str | None:
109+
"""Raw request body string, or ``None`` when the request has no body."""
110+
return self._current_event.body
111+
112+
@property
113+
def json_body(self) -> Any:
114+
"""Request body deserialized as a Python object (dict / list), or ``None``."""
115+
return self._current_event.json_body

0 commit comments

Comments
 (0)