Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions aws_lambda_powertools/event_handler/http_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ProxyEventType,
)
from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer
from aws_lambda_powertools.utilities.data_classes import APIGatewayProxyEvent, APIGatewayProxyEventV2
from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent

if TYPE_CHECKING:
Expand Down Expand Up @@ -66,18 +67,23 @@ 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 = {
"httpMethod": method.upper(),
"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 {},
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -268,7 +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."""
"""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)
Expand Down Expand Up @@ -345,14 +363,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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
from __future__ import annotations

import asyncio
import base64
import inspect
import json
from typing import Any

import pytest

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
Expand Down Expand Up @@ -1454,3 +1457,123 @@ 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)}


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