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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

* Fix API Gateway v2 (HTTP API) collapsing repeated query parameters such as `?id=1&id=2` into a single comma-joined value; `QUERY_STRING` is now built from the event's `rawQueryString`, which also fixes ASGI apps and Lambda Function URLs (#1472)
* Change default of `num_retained_versions` from `null` (keep all) to `5` (#1453)
- Lambda code storage and SnapStart snapshot-cache cost now have a sane default upper bound.
- On the first `zappa update` after upgrade, published versions older than the newest 5 are deleted; versions referenced by an alias (e.g. ALB) and `$LATEST` are unaffected, but versions referenced by other aliases can still raise `ResourceConflictException` (see #960).
Expand Down
32 changes: 32 additions & 0 deletions tests/test_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,38 @@ def test_asgi_v1_with_query_string(self):
self.assertIn("foo=bar", response["body"])
self.assertIn("baz=qux", response["body"])

def test_asgi_v2_with_repeated_query_string(self):
"""
Repeated query parameters must survive the payload format 2.0
conversion into the ASGI scope, not be collapsed into "id=18,19,20".
https://github.com/zappa/Zappa/issues/1472
"""
lh = LambdaHandler("tests.test_asgi_settings")

event = {
"version": "2.0",
"routeKey": "$default",
"rawPath": "/return/request/url",
"rawQueryString": "id=18&id=19&id=20",
# This is the lossy value API Gateway v2 actually sends alongside it.
"queryStringParameters": {"id": "18,19,20"},
"headers": {
"host": "example.com",
},
"requestContext": {
"http": {
"method": "GET",
"path": "/return/request/url",
},
},
"isBase64Encoded": False,
"body": "",
}
response = lh.handler(event, None)

self.assertEqual(response["statusCode"], 200)
self.assertIn("?id=18&id=19&id=20", response["body"])

def test_asgi_404(self):
"""Ensure ASGI app returns 404 for unknown routes."""
lh = LambdaHandler("tests.test_asgi_settings")
Expand Down
84 changes: 84 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from io import BytesIO
from pathlib import Path
from subprocess import check_output
from urllib.parse import parse_qs

import botocore
import botocore.stub
Expand Down Expand Up @@ -4600,6 +4601,89 @@ def test_wsgi_query_string_with_encodechars(self):
expected = "query=Jane%26John&otherquery=B&test=hello%2Bm.te%26how%26are%26you"
self.assertEqual(request["QUERY_STRING"], expected)

@staticmethod
def _v2_event(**overrides):
"""Minimal API Gateway payload format 2.0 event, for query string tests."""
event = {
"version": "2.0",
"routeKey": "ANY /{proxy+}",
"rawPath": "/path/path1",
"rawQueryString": "",
"headers": {"host": "example.com"},
"requestContext": {
"http": {
"method": "GET",
"path": "/path/path1",
"protocol": "HTTP/1.1",
"sourceIp": "50.191.225.98",
},
"stage": "$default",
},
"isBase64Encoded": False,
}
event.update(overrides)
return event

def test_wsgi_v2_repeated_query_params_preserved(self):
"""
API Gateway v2 flattens ?id=18&id=19&id=20 into
queryStringParameters {"id": "18,19,20"}. rawQueryString keeps the
original repetition and must be what reaches the application.
https://github.com/zappa/Zappa/issues/1472
"""
event = self._v2_event(
rawQueryString="id=18&id=19&id=20",
queryStringParameters={"id": "18,19,20"},
)
request = create_wsgi_request(event)
self.assertEqual(request["QUERY_STRING"], "id=18&id=19&id=20")
self.assertEqual(parse_qs(request["QUERY_STRING"]), {"id": ["18", "19", "20"]})

def test_wsgi_v2_literal_comma_in_value_preserved(self):
"""
A comma inside a single value must not be mistaken for a separator.
queryStringParameters cannot express the difference; rawQueryString can.
"""
event = self._v2_event(
rawQueryString="tags=a%2Cb&tags=c",
queryStringParameters={"tags": "a,b,c"},
)
request = create_wsgi_request(event)
self.assertEqual(parse_qs(request["QUERY_STRING"]), {"tags": ["a,b", "c"]})

def test_wsgi_v2_query_string_not_double_encoded(self):
"""rawQueryString arrives percent-encoded and must be passed through as-is."""
event = self._v2_event(
rawQueryString="test=M%26M&query=C%23D&utf=caf%C3%A9",
queryStringParameters={"test": "M&M", "query": "C#D", "utf": "café"},
)
request = create_wsgi_request(event)
self.assertEqual(request["QUERY_STRING"], "test=M%26M&query=C%23D&utf=caf%C3%A9")
self.assertEqual(
parse_qs(request["QUERY_STRING"]),
{"test": ["M&M"], "query": ["C#D"], "utf": ["café"]},
)

def test_wsgi_v2_empty_query_string(self):
"""An empty rawQueryString must not fall back to queryStringParameters."""
request = create_wsgi_request(self._v2_event())
self.assertEqual(request["QUERY_STRING"], "")

def test_wsgi_v2_without_raw_query_string_falls_back(self):
"""
Invokers other than API Gateway may send a v2-shaped event with no
rawQueryString. Fall back to queryStringParameters, honouring lists.
"""
event = self._v2_event(queryStringParameters={"a": "1", "b": "C#D"})
del event["rawQueryString"]
request = create_wsgi_request(event)
self.assertEqual(request["QUERY_STRING"], "a=1&b=C%23D")

event = self._v2_event(queryStringParameters={"a": ["1", "2"]})
del event["rawQueryString"]
request = create_wsgi_request(event)
self.assertEqual(request["QUERY_STRING"], "a=1&a=2")

@mock.patch("subprocess.Popen")
def test_create_handler_venv_win32_none_stderror_result(self, popen_mock):
class PopenMock:
Expand Down
36 changes: 36 additions & 0 deletions tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,42 @@ def test_wsgi_script_name_with_multi_value_querystring(self):
"https://example.com/return/request/url?multi=value&multi=qs",
)

def test_wsgi_script_name_on_v2_event_with_multi_value_querystring(self):
"""
API Gateway payload format 2.0 has no multiValueQueryStringParameters
and collapses repeats into a comma-joined queryStringParameters value.
The application must still see the repeated parameters.
https://github.com/zappa/Zappa/issues/1472
"""
lh = LambdaHandler("tests.test_wsgi_script_name_settings")

event = {
"version": "2.0",
"routeKey": "$default",
"rawPath": "/return/request/url",
"rawQueryString": "multi=value&multi=qs",
# This is the lossy value API Gateway v2 actually sends alongside it.
"queryStringParameters": {"multi": "value,qs"},
"headers": {
"host": "example.com",
},
"requestContext": {
"http": {
"method": "GET",
"path": "/return/request/url",
},
},
"isBase64Encoded": False,
"body": "",
}
response = lh.handler(event, None)

self.assertEqual(response["statusCode"], 200)
self.assertEqual(
response["body"],
"https://example.com/return/request/url?multi=value&multi=qs",
)

def test_wsgi_script_name_on_test_request(self):
"""
Ensure that requests sent by the "Send test request" button behaves
Expand Down
9 changes: 7 additions & 2 deletions zappa/wsgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,13 @@ def process_lambda_payload_v2(event_info):
if event_info.get("cookies"):
headers["Cookie"] = "; ".join(event_info["cookies"])
path = unquote(event_info["rawPath"])
query = event_info.get("queryStringParameters", {})
query_string = urlencode(query) if query else ""
# queryStringParameters comma-joins repeated params; rawQueryString does not.
# https://github.com/zappa/Zappa/issues/1472
if "rawQueryString" in event_info:
query_string = event_info["rawQueryString"] or ""
else:
query = event_info.get("queryStringParameters", {})
query_string = urlencode(query, doseq=True) if query else ""
# Systems calling the Lambda (other than API Gateway) may not provide the field requestContext
# Extract remote_user, authorizer if Authorizer is enabled
remote_user = None
Expand Down