Skip to content

Commit 83a7a26

Browse files
Merge branch 'develop' into fix/openapi-response-schema-bleed
2 parents 2ef4d25 + 8761fe7 commit 83a7a26

File tree

4 files changed

+166
-3
lines changed

4 files changed

+166
-3
lines changed

aws_lambda_powertools/event_handler/api_gateway.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3318,8 +3318,34 @@ def __init__(
33183318
enable_validation: bool = False,
33193319
response_validation_error_http_code: HTTPStatus | int | None = None,
33203320
json_body_deserializer: Callable[[str], dict] | None = None,
3321+
decode_query_parameters: bool = False,
33213322
):
3322-
"""Amazon Application Load Balancer (ALB) resolver"""
3323+
"""Amazon Application Load Balancer (ALB) resolver
3324+
3325+
3326+
Parameters
3327+
----------
3328+
cors: CORSConfig
3329+
Optionally configure and enabled CORS. Not each route will need to have to cors=True
3330+
debug: bool | None
3331+
Enables debug mode, by default False. Can be also be enabled by "POWERTOOLS_DEV"
3332+
environment variable
3333+
serializer: Callable, optional
3334+
function to serialize `obj` to a JSON formatted `str`, by default json.dumps
3335+
strip_prefixes: list[str | Pattern], optional
3336+
optional list of prefixes to be removed from the request path before doing the routing.
3337+
This is often used with api gateways with multiple custom mappings.
3338+
Each prefix can be a static string or a compiled regex pattern
3339+
enable_validation: bool | None
3340+
Enables validation of the request body against the route schema, by default False.
3341+
response_validation_error_http_code
3342+
Sets the returned status code if response is not validated. enable_validation must be True.
3343+
json_body_deserializer: Callable[[str], dict], optional
3344+
function to deserialize `str`, `bytes`, `bytearray` containing a JSON document to a Python `dict`,
3345+
by default json.loads when integrating with EventSource data class
3346+
decode_query_parameters: bool | None
3347+
Enables URL-decoding of query parameters (both keys and values), by default False.
3348+
"""
33233349
super().__init__(
33243350
ProxyEventType.ALBEvent,
33253351
cors,
@@ -3330,6 +3356,7 @@ def __init__(
33303356
response_validation_error_http_code,
33313357
json_body_deserializer=json_body_deserializer,
33323358
)
3359+
self.decode_query_parameters = decode_query_parameters
33333360

33343361
def _get_base_path(self) -> str:
33353362
# ALB doesn't have a stage variable, so we just return an empty string
@@ -3356,3 +3383,10 @@ def _to_response(self, result: dict | tuple | Response | BedrockResponse) -> Res
33563383
result.body = ""
33573384

33583385
return super()._to_response(result)
3386+
3387+
@override
3388+
def _to_proxy_event(self, event: dict) -> BaseProxyEvent:
3389+
proxy_event = super()._to_proxy_event(event)
3390+
if isinstance(proxy_event, ALBEvent):
3391+
proxy_event.decode_query_parameters = self.decode_query_parameters
3392+
return proxy_event

aws_lambda_powertools/utilities/data_classes/alb_event.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import annotations
22

3-
from typing import Any
3+
from typing import Any, Callable
4+
from urllib.parse import unquote
5+
6+
from typing_extensions import override
47

58
from aws_lambda_powertools.shared.headers_serializer import (
69
BaseHeadersSerializer,
@@ -30,13 +33,27 @@ class ALBEvent(BaseProxyEvent):
3033
- https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html
3134
"""
3235

36+
@override
37+
def __init__(self, data: dict[str, Any], json_deserializer: Callable | None = None):
38+
super().__init__(data, json_deserializer)
39+
self.decode_query_parameters = False
40+
3341
@property
3442
def request_context(self) -> ALBEventRequestContext:
3543
return ALBEventRequestContext(self["requestContext"])
3644

3745
@property
3846
def resolved_query_string_parameters(self) -> dict[str, list[str]]:
39-
return self.multi_value_query_string_parameters or super().resolved_query_string_parameters
47+
params = self.multi_value_query_string_parameters or super().resolved_query_string_parameters
48+
if not self.decode_query_parameters:
49+
return params
50+
51+
# Decode the parameter keys and values
52+
decoded_params = {}
53+
for k, vals in params.items():
54+
decoded_params[unquote(k)] = [unquote(v) for v in vals]
55+
56+
return decoded_params
4057

4158
@property
4259
def multi_value_headers(self) -> dict[str, list[str]]:

tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
import datetime
23
import json
34
from dataclasses import dataclass
45
from enum import Enum
@@ -20,6 +21,7 @@
2021
)
2122
from aws_lambda_powertools.event_handler.openapi.exceptions import ResponseValidationError
2223
from aws_lambda_powertools.event_handler.openapi.params import Body, Form, Header, Query
24+
from tests.functional.utils import load_event
2325

2426

2527
def test_validate_scalars(gw_event):
@@ -1070,6 +1072,26 @@ def handler3():
10701072
assert any(text in result["body"] for text in expected_error_text)
10711073

10721074

1075+
def test_validation_query_string_with_encoded_datetime_alb_resolver():
1076+
# GIVEN a ALBResolver with validation enabled,
1077+
# and an event with a url-encoded datetime
1078+
# as a query string parameter
1079+
app = ALBResolver(enable_validation=True, decode_query_parameters=True)
1080+
raw_event = load_event("albEvent.json")
1081+
raw_event["path"] = "/users"
1082+
raw_event["queryStringParameters"] = {"query_dt": "2025-12-20T16%3A56%3A02.032000"}
1083+
1084+
# WHEN a handler is defined with various parameters and routes
1085+
@app.get("/users")
1086+
def handler(query_dt: datetime.datetime):
1087+
return None
1088+
1089+
# THEN the handler should be invoked with the expected result
1090+
# AND the status code should match the expected_status_code
1091+
result = app(raw_event, {})
1092+
assert result["statusCode"] == 200
1093+
1094+
10731095
@pytest.mark.parametrize(
10741096
"handler_func, expected_status_code, expected_error_text",
10751097
[
@@ -2672,3 +2694,60 @@ def query_model_advanced(params: Annotated[QueryAdvanced, Query()]) -> Dict[str,
26722694
full_name_error = next((e for e in errors if "full_name" in e["loc"] or "fullName" in e["loc"]), None)
26732695
assert full_name_error is not None
26742696
assert full_name_error["type"] == "string_too_short"
2697+
2698+
2699+
def test_validation_query_string_with_fully_encoded_datetime_alb_resolver():
2700+
# GIVEN a ALBResolver with validation enabled,
2701+
# and an event with a fully url-encoded datetime
2702+
# as a query string parameter
2703+
app = ALBResolver(enable_validation=True, decode_query_parameters=True)
2704+
raw_event = load_event("albEvent.json")
2705+
raw_event["path"] = "/users"
2706+
# Fully encoded: "2025-12-20T16:56:02.032000" -> "2025-12-20T16%3A56%3A02.032000"
2707+
# With spaces or special chars: "2025-12-20 16:56:02" -> "2025-12-20%2016%3A56%3A02"
2708+
raw_event["queryStringParameters"] = {"query_dt": "2025-12-20T16%3A56%3A02.032000"}
2709+
2710+
@app.get("/users")
2711+
def handler(query_dt: datetime.datetime):
2712+
return {"received": query_dt.isoformat()}
2713+
2714+
result = app(raw_event, {})
2715+
assert result["statusCode"] == 200
2716+
body = json.loads(result["body"])
2717+
assert body["received"] == "2025-12-20T16:56:02.032000"
2718+
2719+
2720+
def test_validation_query_string_with_encoded_key_and_value_alb_resolver():
2721+
# GIVEN a ALBResolver with validation enabled,
2722+
# and an event with url-encoded key AND value
2723+
app = ALBResolver(enable_validation=True, decode_query_parameters=True)
2724+
raw_event = load_event("albEvent.json")
2725+
raw_event["path"] = "/search"
2726+
# Key: "search query" -> "search%20query"
2727+
# Value: "hello world" -> "hello%20world"
2728+
raw_event["queryStringParameters"] = {"search%20query": "hello%20world"}
2729+
2730+
@app.get("/search")
2731+
def handler(search_query: Annotated[str, Query(alias="search query")]):
2732+
return {"result": search_query}
2733+
2734+
result = app(raw_event, {})
2735+
assert result["statusCode"] == 200
2736+
body = json.loads(result["body"])
2737+
assert body["result"] == "hello world"
2738+
2739+
2740+
def test_validation_without_decode_query_parameters_alb_resolver():
2741+
# GIVEN a ALBResolver WITHOUT decode_query_parameters (default behavior)
2742+
app = ALBResolver(enable_validation=True)
2743+
raw_event = load_event("albEvent.json")
2744+
raw_event["path"] = "/users"
2745+
raw_event["queryStringParameters"] = {"query_dt": "2025-12-20T16%3A56%3A02.032000"}
2746+
2747+
@app.get("/users")
2748+
def handler(query_dt: datetime.datetime):
2749+
return None
2750+
2751+
# THEN validation should fail because the encoded string is not a valid datetime
2752+
result = app(raw_event, {})
2753+
assert result["statusCode"] == 422

tests/unit/data_classes/required_dependencies/test_alb_event.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from urllib.parse import quote
4+
35
from aws_lambda_powertools.utilities.data_classes import ALBEvent
46
from tests.functional.utils import load_event
57

@@ -19,3 +21,34 @@ def test_alb_event():
1921
assert parsed_event.multi_value_headers == (raw_event.get("multiValueHeaders") or {})
2022
assert parsed_event.body == raw_event["body"]
2123
assert parsed_event.is_base64_encoded == raw_event["isBase64Encoded"]
24+
25+
26+
def test_alb_event_decode_query_parameters():
27+
expected_key = "this is a key"
28+
expected_value = "single value"
29+
raw_event = load_event("albEvent.json")
30+
raw_event["queryStringParameters"] = {quote(expected_key): quote(expected_value)}
31+
# Without decode_query_parameters, the key and value are not decoded
32+
parsed_event = ALBEvent(raw_event)
33+
assert parsed_event.resolved_query_string_parameters != {expected_key: [expected_value]}
34+
assert parsed_event.resolved_query_string_parameters == {quote(expected_key): [quote(expected_value)]}
35+
36+
# With decode_query_parameters, the key and value are not decoded
37+
parsed_event.decode_query_parameters = True
38+
assert parsed_event.resolved_query_string_parameters == {expected_key: [expected_value]}
39+
40+
41+
def test_alb_event_decode_multi_value_query_parameters():
42+
expected_key = "this is a key"
43+
expected_values = ["first value", "second value"]
44+
45+
raw_event = load_event("albMultiValueQueryStringEvent.json")
46+
raw_event["multiValueQueryStringParameters"] = {quote(expected_key): [quote(v) for v in expected_values]}
47+
# Without decode_query_parameters, the key and value are not decoded
48+
parsed_event = ALBEvent(raw_event)
49+
assert parsed_event.resolved_query_string_parameters != {expected_key: expected_values}
50+
assert parsed_event.resolved_query_string_parameters == {quote(expected_key): [quote(v) for v in expected_values]}
51+
52+
# With decode_query_parameters, the key and value are not decoded
53+
parsed_event.decode_query_parameters = True
54+
assert parsed_event.resolved_query_string_parameters == {expected_key: expected_values}

0 commit comments

Comments
 (0)