From bc18fd0f58d0b03778911958d81829075542c582 Mon Sep 17 00:00:00 2001 From: jasperdingg Date: Sun, 18 Oct 2026 22:28:50 -0700 Subject: [PATCH 1/2] fix(event_handler): make HttpResolverLocal usable as a Lambda handler - __call__ dispatches (event, context) to resolve() and (scope, receive, send) to the ASGI handler. The async handler is also exposed as the instance's __call__ attribute so uvicorn and hypercorn still detect an ASGI3 application. - Map v2 payload events (HTTP API, Function URL) to APIGatewayProxyEventV2. - Base64-encode request bodies that are not valid UTF-8. Refs #8582 (item 3) --- .../event_handler/http_resolver.py | 36 +++++--- .../test_http_resolver.py | 86 +++++++++++++++++++ 2 files changed, 108 insertions(+), 14 deletions(-) diff --git a/aws_lambda_powertools/event_handler/http_resolver.py b/aws_lambda_powertools/event_handler/http_resolver.py index 480e8695b52..f5ebfc0136a 100644 --- a/aws_lambda_powertools/event_handler/http_resolver.py +++ b/aws_lambda_powertools/event_handler/http_resolver.py @@ -12,6 +12,7 @@ ProxyEventType, ) from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer +from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEventV2 from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent if TYPE_CHECKING: @@ -66,10 +67,15 @@ def __init__( multi_query_params = parsed query_params = {k: v[-1] for k, v in parsed.items()} - # Normalize body to string - body_str = None - if body is not None: - body_str = body.decode("utf-8") if isinstance(body, bytes) else body + # Normalize body to string, base64 if not UTF-8 + body_str = body + is_base64_encoded = False + if isinstance(body, bytes): + try: + body_str = body.decode("utf-8") + except UnicodeDecodeError: + body_str = base64.b64encode(body).decode("ascii") + is_base64_encoded = True # Build the internal dict structure that BaseProxyEvent expects data = { @@ -77,7 +83,7 @@ def __init__( "path": path, "headers": headers or {}, "body": body_str, - "isBase64Encoded": False, + "isBase64Encoded": is_base64_encoded, "queryStringParameters": query_params, "multiValueQueryStringParameters": multi_query_params, "pathParameters": path_parameters or {}, @@ -226,6 +232,8 @@ def __init__( strip_prefixes=strip_prefixes, enable_validation=enable_validation, ) + # ASGI servers inspect app.__call__ to detect an ASGI3 app + self.__call__ = self.asgi_handler # type: ignore[method-assign] @property def _state(self) -> _RequestState: @@ -268,7 +276,10 @@ def processed_stack_frames(self, value: list[str]) -> None: self._state.processed_stack_frames = value def _to_proxy_event(self, event: dict) -> BaseProxyEvent: - """Convert event dict to HttpProxyEvent.""" + """Convert event dict to HttpProxyEvent, or APIGatewayProxyEventV2 for v2 events.""" + if event.get("version") == "2.0" and "httpMethod" not in event: + return APIGatewayProxyEventV2(event, self._json_body_deserializer) + # Create HttpProxyEvent directly from the dict data # The dict already has queryStringParameters and multiValueQueryStringParameters return HttpProxyEvent._from_dict(event) @@ -345,14 +356,11 @@ async def asgi_handler(self, scope: MutableMapping[str, Any], receive: Callable, # Send HTTP response await self._send_response(send, response) - async def __call__( # type: ignore[override] - self, - scope: MutableMapping[str, Any], - receive: Callable, - send: Callable, - ) -> None: - """ASGI interface - allows running with uvicorn/hypercorn/etc.""" - await self.asgi_handler(scope, receive, send) + def __call__(self, *args: Any) -> Any: + """Lambda handler or ASGI app, depending on the arguments.""" + if len(args) == 3: + return self.asgi_handler(*args) + return self.resolve(*args) async def _send_response(self, send: Callable, response: dict) -> None: """Send the response via ASGI.""" diff --git a/tests/functional/event_handler/required_dependencies/test_http_resolver.py b/tests/functional/event_handler/required_dependencies/test_http_resolver.py index 4665812e64a..389b5691908 100644 --- a/tests/functional/event_handler/required_dependencies/test_http_resolver.py +++ b/tests/functional/event_handler/required_dependencies/test_http_resolver.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +import base64 +import inspect import json from typing import Any @@ -10,6 +12,7 @@ from aws_lambda_powertools.event_handler import HttpResolverLocal, Response from aws_lambda_powertools.event_handler.http_resolver import MockLambdaContext +from tests.functional.utils import load_event # ============================================================================= # ASGI Test Helpers @@ -1454,3 +1457,86 @@ async def send(message: dict[str, Any]) -> None: assert "X-Not-Allowed" not in allow_headers_value # AND the explicitly allowed header IS present assert "X-Custom-Allowed" in allow_headers_value + + +# ============================================================================= +# Lambda Handler Tests +# ============================================================================= + + +def test_app_as_lambda_handler(): + # GIVEN an app used directly as the Lambda handler (handler = app) + app = HttpResolverLocal() + + @app.get("/my/path") + def get_path(): + return {"ok": True} + + # WHEN Lambda invokes it with (event, context) + result = app(load_event("apiGatewayProxyEvent.json"), MockLambdaContext()) + + # THEN it resolves like app.resolve() + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"ok": True} + + +def test_app_is_detected_as_asgi3_application(): + # GIVEN an app that is also callable as a Lambda handler + app = HttpResolverLocal() + + # THEN ASGI servers still detect it as an ASGI3 app + assert asyncio.iscoroutinefunction(app.__call__) + assert inspect.iscoroutinefunction(app.__call__) + + +@pytest.mark.parametrize( + "event_file,method,path", + [ + ("apiGatewayProxyV2Event.json", "POST", "/my/path"), + ("lambdaFunctionUrlEvent.json", "GET", "/"), + ], +) +def test_resolve_v2_payload_events(event_file: str, method: str, path: str): + # GIVEN an app with a route matching an HTTP API v2 or Function URL event + app = HttpResolverLocal() + + @app.route(path, method=method) + def handler(): + return {"ok": True} + + # WHEN resolving the v2-shaped event + result = app.resolve(load_event(event_file), MockLambdaContext()) + + # THEN it is mapped to the matching event contract and resolved + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"ok": True} + + +@pytest.mark.asyncio +async def test_asgi_binary_request_body(): + # GIVEN an app that echoes the raw request body back + app = HttpResolverLocal() + binary_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00" # not valid UTF-8 + + @app.post("/upload") + def upload(): + assert app.current_event.is_base64_encoded + return {"size": len(base64.b64decode(app.current_event.body))} + + scope = { + "type": "http", + "method": "POST", + "path": "/upload", + "query_string": b"", + "headers": [(b"content-type", b"application/octet-stream")], + } + + receive = make_asgi_receive(binary_data) + send, captured = make_asgi_send() + + # WHEN a non-UTF-8 body is sent via ASGI + await app(scope, receive, send) + + # THEN the body is base64-encoded instead of failing to decode + assert captured["status_code"] == 200 + assert json.loads(captured["body"]) == {"size": len(binary_data)} From b767eb34b696e27d8a073beed860378c262d79da Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Sun, 25 Oct 2026 18:19:05 +0000 Subject: [PATCH 2/2] fix(event_handler): keep the REST API response shape when HttpResolverLocal handles a v1 event --- .../event_handler/http_resolver.py | 11 +++++- .../test_http_resolver.py | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/event_handler/http_resolver.py b/aws_lambda_powertools/event_handler/http_resolver.py index f5ebfc0136a..79a55e1331d 100644 --- a/aws_lambda_powertools/event_handler/http_resolver.py +++ b/aws_lambda_powertools/event_handler/http_resolver.py @@ -12,7 +12,7 @@ ProxyEventType, ) from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer -from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEventV2 +from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent, APIGatewayProxyEventV2 from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent if TYPE_CHECKING: @@ -276,10 +276,17 @@ def processed_stack_frames(self, value: list[str]) -> None: self._state.processed_stack_frames = value def _to_proxy_event(self, event: dict) -> BaseProxyEvent: - """Convert event dict to HttpProxyEvent, or APIGatewayProxyEventV2 for v2 events.""" + """Map a Lambda event to its data class, or an ASGI-built dict to HttpProxyEvent. + + The data class decides the response shape: REST API needs `multiValueHeaders` and rejects + a `cookies` key, HTTP API and Function URL expect `headers` and `cookies`. + """ if event.get("version") == "2.0" and "httpMethod" not in event: return APIGatewayProxyEventV2(event, self._json_body_deserializer) + if "resource" in event and "multiValueHeaders" in event: + return APIGatewayProxyEvent(event, self._json_body_deserializer) + # Create HttpProxyEvent directly from the dict data # The dict already has queryStringParameters and multiValueQueryStringParameters return HttpProxyEvent._from_dict(event) diff --git a/tests/functional/event_handler/required_dependencies/test_http_resolver.py b/tests/functional/event_handler/required_dependencies/test_http_resolver.py index 389b5691908..ffcc6135339 100644 --- a/tests/functional/event_handler/required_dependencies/test_http_resolver.py +++ b/tests/functional/event_handler/required_dependencies/test_http_resolver.py @@ -1540,3 +1540,40 @@ def upload(): # THEN the body is base64-encoded instead of failing to decode assert captured["status_code"] == 200 assert json.loads(captured["body"]) == {"size": len(binary_data)} + + +def test_lambda_handler_rest_event_uses_rest_response_shape(): + # GIVEN an HttpResolver used directly as the Lambda handler behind a REST API + app = HttpResolverLocal() + + @app.get("/my/path") + def handler(): + return {"ok": True} + + # WHEN a REST API (v1) event is resolved + result = app(load_event("apiGatewayProxyEvent.json"), {}) + + # THEN the response uses the v1 shape; REST API returns 502 when a `cookies` key is present + assert result["statusCode"] == 200 + assert "multiValueHeaders" in result + assert "cookies" not in result + + +def test_lambda_handler_http_api_event_uses_v2_response_shape(): + # GIVEN an HttpResolver used directly as the Lambda handler behind an HTTP API + app = HttpResolverLocal() + event = load_event("apiGatewayProxyV2Event.json") + event["rawPath"] = event["requestContext"]["http"]["path"] = "/my/path" + event["requestContext"]["http"]["method"] = "GET" + + @app.get("/my/path") + def handler(): + return {"ok": True} + + # WHEN an HTTP API (v2) event is resolved + result = app(event, {}) + + # THEN the response uses the v2 shape + assert result["statusCode"] == 200 + assert "cookies" in result + assert "multiValueHeaders" not in result