Skip to content

Commit 2179577

Browse files
committed
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
1 parent 04436bd commit 2179577

File tree

5 files changed

+705
-0
lines changed

5 files changed

+705
-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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
import re
88
import traceback
9+
import typing
910
import warnings
1011
import zlib
1112
from abc import ABC, abstractmethod
@@ -20,6 +21,7 @@
2021
from aws_lambda_powertools.event_handler import content_types
2122
from aws_lambda_powertools.event_handler.exception_handling import ExceptionHandlerManager
2223
from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError
24+
from aws_lambda_powertools.event_handler.request import Request
2325
from aws_lambda_powertools.event_handler.openapi.config import OpenAPIConfig
2426
from aws_lambda_powertools.event_handler.openapi.constants import (
2527
DEFAULT_API_VERSION,
@@ -466,6 +468,11 @@ def __init__(
466468

467469
self.custom_response_validation_http_code = custom_response_validation_http_code
468470

471+
# _request_param_name caches the name of any Request-typed parameter in the handler (None = "not found").
472+
# _request_param_checked avoids re-scanning the signature on every invocation.
473+
self._request_param_name: str | None = None
474+
self._request_param_name_checked: bool = False
475+
469476
def __call__(
470477
self,
471478
router_middlewares: list[Callable],
@@ -1608,6 +1615,41 @@ def clear_context(self):
16081615
"""Resets routing context"""
16091616
self.context.clear()
16101617

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

16121654
class MiddlewareFrame:
16131655
"""
@@ -1680,6 +1722,22 @@ def __call__(self, app: ApiGatewayResolver) -> dict | tuple | Response:
16801722
return self.current_middleware(app, self.next_middleware)
16811723

16821724

1725+
def _find_request_param_name(func: Callable) -> str | None:
1726+
"""Return the name of the first parameter annotated as ``Request``, or ``None``."""
1727+
try:
1728+
# get_type_hints resolves string annotations from ``from __future__ import annotations``
1729+
# using the function's own module globals — no pydantic dependency required.
1730+
hints = typing.get_type_hints(func)
1731+
except Exception:
1732+
hints = {}
1733+
1734+
for param_name, annotation in hints.items():
1735+
if annotation is Request:
1736+
return param_name
1737+
1738+
return None
1739+
1740+
16831741
def _registered_api_adapter(
16841742
app: ApiGatewayResolver,
16851743
next_middleware: Callable[..., Any],
@@ -1708,6 +1766,17 @@ def _registered_api_adapter(
17081766
"""
17091767
route_args: dict = app.context.get("_route_args", {})
17101768
logger.debug(f"Calling API Route Handler: {route_args}")
1769+
1770+
# Inject a Request object when the handler declares a parameter typed as Request.
1771+
# Lookup is cached on the Route object to avoid repeated signature inspection.
1772+
route: Route | None = app.context.get("_route")
1773+
if route is not None:
1774+
if not route._request_param_name_checked:
1775+
route._request_param_name = _find_request_param_name(next_middleware)
1776+
route._request_param_name_checked = True
1777+
if route._request_param_name:
1778+
route_args = {**route_args, route._request_param_name: app.request}
1779+
17111780
return app._to_response(next_middleware(**route_args))
17121781

17131782

aws_lambda_powertools/event_handler/openapi/dependant.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55
from typing import TYPE_CHECKING, Any, ForwardRef, cast
66

7+
from aws_lambda_powertools.event_handler.request import Request
78
from aws_lambda_powertools.event_handler.openapi.compat import (
89
ModelField,
910
create_body_model,
@@ -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)