Skip to content

Commit 8761fe7

Browse files
feat: Add a flag to ALBResolver to URL-decode query parameters (#7940)
* feat: Add a flag to ALBResolver to URL-decode query parameters * docs: add docstring * adding additional tests --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent c9afa5d commit 8761fe7

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

33323359
def _get_base_path(self) -> str:
33333360
# ALB doesn't have a stage variable, so we just return an empty string
@@ -3354,3 +3381,10 @@ def _to_response(self, result: dict | tuple | Response | BedrockResponse) -> Res
33543381
result.body = ""
33553382

33563383
return super()._to_response(result)
3384+
3385+
@override
3386+
def _to_proxy_event(self, event: dict) -> BaseProxyEvent:
3387+
proxy_event = super()._to_proxy_event(event)
3388+
if isinstance(proxy_event, ALBEvent):
3389+
proxy_event.decode_query_parameters = self.decode_query_parameters
3390+
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)