Skip to content

Commit 2fa40e9

Browse files
IlyaSukhanovkhamaileon
authored andcommitted
HTTP Gateway V2: Remove use of obsolete multiValueHeaders (Kludex#216)
HTTP Gateway V2 drops both the multiValueHeaders and multiValueQueryStringParameters elements. With this change: * Cookies passed to request and response as cookie element containing list of all cookie values. * Headers with multiple values are concatenated through comma and passed under Headers element instead of the obsolete multiValueHeaders. https://medium.com/@lancers/amazon-api-gateway-explaining-lambda-payload-version-2-0-in-http-api-24b0b4db5d36 https://aws.amazon.com/blogs/compute/building-better-apis-http-apis-now-generally-available/ Addresses issue Kludex#215.
1 parent 6ba4517 commit 2fa40e9

File tree

4 files changed

+248
-38
lines changed

4 files changed

+248
-38
lines changed

mangum/handlers/aws_http_gateway.py

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import base64
22
import urllib.parse
3-
from typing import Dict, Any
3+
from typing import Dict, Any, List, Tuple
44

55
from . import AwsApiGateway
66
from .. import Response, Request
@@ -122,37 +122,65 @@ def transform_response(self, response: Response) -> Dict[str, Any]:
122122
123123
https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response
124124
"""
125+
if self.event_version == "1.0":
126+
return self.transform_response_v1(response)
127+
elif self.event_version == "2.0":
128+
return self.transform_response_v2(response)
129+
raise RuntimeError( # pragma: no cover
130+
"Misconfigured event unable to return value, unsupported version."
131+
)
132+
133+
def transform_response_v1(self, response: Response) -> Dict[str, Any]:
125134
headers, multi_value_headers = self._handle_multi_value_headers(
126135
response.headers
127136
)
128137

129-
if self.event_version == "1.0":
130-
body, is_base64_encoded = self._handle_base64_response_body(
131-
response.body, headers
132-
)
133-
return {
134-
"statusCode": response.status,
135-
"headers": headers,
136-
"multiValueHeaders": multi_value_headers,
137-
"body": body,
138-
"isBase64Encoded": is_base64_encoded,
139-
}
140-
elif self.event_version == "2.0":
141-
# The API Gateway will infer stuff for us, but we'll just do that inference
142-
# here and keep the output consistent
143-
if "content-type" not in headers and response.body is not None:
144-
headers["content-type"] = "application/json"
138+
body, is_base64_encoded = self._handle_base64_response_body(
139+
response.body, headers
140+
)
141+
return {
142+
"statusCode": response.status,
143+
"headers": headers,
144+
"multiValueHeaders": multi_value_headers,
145+
"body": body,
146+
"isBase64Encoded": is_base64_encoded,
147+
}
148+
149+
def _combine_headers_v2(
150+
self, input_headers: List[List[bytes]]
151+
) -> Tuple[Dict[str, str], List[str]]:
152+
output_headers: Dict[str, str] = {}
153+
cookies: List[str] = []
154+
for key, value in input_headers:
155+
normalized_key: str = key.decode().lower()
156+
normalized_value: str = value.decode()
157+
if normalized_key == "set-cookie":
158+
cookies.append(normalized_value)
159+
else:
160+
if normalized_key in output_headers:
161+
normalized_value = (
162+
f"{output_headers[normalized_key]},{normalized_value}"
163+
)
164+
output_headers[normalized_key] = normalized_value
165+
return output_headers, cookies
145166

146-
body, is_base64_encoded = self._handle_base64_response_body(
147-
response.body, headers
148-
)
149-
return {
150-
"statusCode": response.status,
151-
"headers": headers,
152-
"multiValueHeaders": multi_value_headers,
153-
"body": body,
154-
"isBase64Encoded": is_base64_encoded,
155-
}
156-
raise RuntimeError( # pragma: no cover
157-
"Misconfigured event unable to return value, unsupported version."
167+
def transform_response_v2(self, response_in: Response) -> Dict[str, Any]:
168+
# The API Gateway will infer stuff for us, but we'll just do that inference
169+
# here and keep the output consistent
170+
171+
headers, cookies = self._combine_headers_v2(response_in.headers)
172+
173+
if "content-type" not in headers and response_in.body is not None:
174+
headers["content-type"] = "application/json"
175+
176+
body, is_base64_encoded = self._handle_base64_response_body(
177+
response_in.body, headers
158178
)
179+
response_out = {
180+
"statusCode": response_in.status,
181+
"body": body,
182+
"headers": headers or None,
183+
"cookies": cookies or None,
184+
"isBase64Encoded": is_base64_encoded,
185+
}
186+
return {key: value for key, value in response_out.items() if value is not None}

tests/conftest.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def mock_aws_api_gateway_event(request):
6565

6666

6767
@pytest.fixture
68-
def mock_http_api_event(request):
68+
def mock_http_api_event_v2(request):
6969
method = request.param[0]
7070
body = request.param[1]
7171
multi_value_query_parameters = request.param[2]
@@ -120,6 +120,67 @@ def mock_http_api_event(request):
120120
return event
121121

122122

123+
@pytest.fixture
124+
def mock_http_api_event_v1(request):
125+
method = request.param[0]
126+
body = request.param[1]
127+
multi_value_query_parameters = request.param[2]
128+
query_string = request.param[3]
129+
event = {
130+
"version": "1.0",
131+
"routeKey": "$default",
132+
"rawPath": "/my/path",
133+
"path": "/my/path",
134+
"httpMethod": method,
135+
"rawQueryString": query_string,
136+
"cookies": ["cookie1", "cookie2"],
137+
"headers": {
138+
"accept-encoding": "gzip,deflate",
139+
"x-forwarded-port": "443",
140+
"x-forwarded-proto": "https",
141+
"host": "test.execute-api.us-west-2.amazonaws.com",
142+
},
143+
"queryStringParameters": {
144+
k: v[-1] for k, v in multi_value_query_parameters.items()
145+
}
146+
if multi_value_query_parameters
147+
else None,
148+
"multiValueQueryStringParameters": {
149+
k: v for k, v in multi_value_query_parameters.items()
150+
}
151+
if multi_value_query_parameters
152+
else None,
153+
"requestContext": {
154+
"accountId": "123456789012",
155+
"apiId": "api-id",
156+
"authorizer": {
157+
"jwt": {
158+
"claims": {"claim1": "value1", "claim2": "value2"},
159+
"scopes": ["scope1", "scope2"],
160+
}
161+
},
162+
"domainName": "id.execute-api.us-east-1.amazonaws.com",
163+
"domainPrefix": "id",
164+
"http": {
165+
"protocol": "HTTP/1.1",
166+
"sourceIp": "192.168.100.1",
167+
"userAgent": "agent",
168+
},
169+
"requestId": "id",
170+
"routeKey": "$default",
171+
"stage": "$default",
172+
"time": "12/Mar/2020:19:03:58 +0000",
173+
"timeEpoch": 1583348638390,
174+
},
175+
"body": body,
176+
"pathParameters": {"parameter1": "value1"},
177+
"isBase64Encoded": False,
178+
"stageVariables": {"stageVariable1": "value1", "stageVariable2": "value2"},
179+
}
180+
181+
return event
182+
183+
123184
@pytest.fixture
124185
def mock_lambda_at_edge_event(request):
125186
method = request.param[0]

tests/handlers/test_aws_http_gateway.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,5 @@ async def app(scope, receive, send):
605605
"statusCode": 200,
606606
"isBase64Encoded": res_base64_encoded,
607607
"headers": {"content-type": content_type.decode()},
608-
"multiValueHeaders": {},
609608
"body": res_body,
610609
}

tests/test_http.py

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ async def app(scope, receive, send):
253253

254254

255255
@pytest.mark.parametrize(
256-
"mock_http_api_event",
256+
"mock_http_api_event_v2",
257257
[
258258
(["GET", None, None, ""]),
259259
(["GET", None, {"name": ["me"]}, "name=me"]),
@@ -267,9 +267,9 @@ async def app(scope, receive, send):
267267
]
268268
),
269269
],
270-
indirect=["mock_http_api_event"],
270+
indirect=["mock_http_api_event_v2"],
271271
)
272-
def test_set_cookies(mock_http_api_event) -> None:
272+
def test_set_cookies_v2(mock_http_api_event_v2) -> None:
273273
async def app(scope, receive, send):
274274
assert scope == {
275275
"asgi": {"version": "3.0"},
@@ -279,15 +279,17 @@ async def app(scope, receive, send):
279279
"version": "2.0",
280280
"routeKey": "$default",
281281
"rawPath": "/my/path",
282-
"rawQueryString": mock_http_api_event["rawQueryString"],
282+
"rawQueryString": mock_http_api_event_v2["rawQueryString"],
283283
"cookies": ["cookie1", "cookie2"],
284284
"headers": {
285285
"accept-encoding": "gzip,deflate",
286286
"x-forwarded-port": "443",
287287
"x-forwarded-proto": "https",
288288
"host": "test.execute-api.us-west-2.amazonaws.com",
289289
},
290-
"queryStringParameters": mock_http_api_event["queryStringParameters"],
290+
"queryStringParameters": mock_http_api_event_v2[
291+
"queryStringParameters"
292+
],
291293
"requestContext": {
292294
"accountId": "123456789012",
293295
"apiId": "api-id",
@@ -331,7 +333,127 @@ async def app(scope, receive, send):
331333
"http_version": "1.1",
332334
"method": "GET",
333335
"path": "/my/path",
334-
"query_string": mock_http_api_event["rawQueryString"].encode(),
336+
"query_string": mock_http_api_event_v2["rawQueryString"].encode(),
337+
"raw_path": None,
338+
"root_path": "",
339+
"scheme": "https",
340+
"server": ("test.execute-api.us-west-2.amazonaws.com", 443),
341+
"type": "http",
342+
}
343+
344+
await send(
345+
{
346+
"type": "http.response.start",
347+
"status": 200,
348+
"headers": [
349+
[b"content-type", b"text/plain; charset=utf-8"],
350+
[b"set-cookie", b"cookie1=cookie1; Secure"],
351+
[b"set-cookie", b"cookie2=cookie2; Secure"],
352+
[b"multivalue", b"foo"],
353+
[b"multivalue", b"bar"],
354+
],
355+
}
356+
)
357+
await send({"type": "http.response.body", "body": b"Hello, world!"})
358+
359+
handler = Mangum(app, lifespan="off")
360+
response = handler(mock_http_api_event_v2, {})
361+
assert response == {
362+
"statusCode": 200,
363+
"isBase64Encoded": False,
364+
"headers": {
365+
"content-type": "text/plain; charset=utf-8",
366+
"multivalue": "foo,bar",
367+
},
368+
"cookies": ["cookie1=cookie1; Secure", "cookie2=cookie2; Secure"],
369+
"body": "Hello, world!",
370+
}
371+
372+
373+
@pytest.mark.parametrize(
374+
"mock_http_api_event_v1",
375+
[
376+
(["GET", None, None, ""]),
377+
(["GET", None, {"name": ["me"]}, "name=me"]),
378+
(["GET", None, {"name": ["me", "you"]}, "name=me&name=you"]),
379+
(
380+
[
381+
"GET",
382+
None,
383+
{"name": ["me", "you"], "pet": ["dog"]},
384+
"name=me&name=you&pet=dog",
385+
]
386+
),
387+
],
388+
indirect=["mock_http_api_event_v1"],
389+
)
390+
def test_set_cookies_v1(mock_http_api_event_v1) -> None:
391+
async def app(scope, receive, send):
392+
assert scope == {
393+
"asgi": {"version": "3.0"},
394+
"aws.eventType": "AWS_HTTP_GATEWAY",
395+
"aws.context": {},
396+
"aws.event": {
397+
"version": "1.0",
398+
"routeKey": "$default",
399+
"rawPath": "/my/path",
400+
"path": "/my/path",
401+
"httpMethod": "GET",
402+
"rawQueryString": mock_http_api_event_v1["rawQueryString"],
403+
"cookies": ["cookie1", "cookie2"],
404+
"headers": {
405+
"accept-encoding": "gzip,deflate",
406+
"x-forwarded-port": "443",
407+
"x-forwarded-proto": "https",
408+
"host": "test.execute-api.us-west-2.amazonaws.com",
409+
},
410+
"queryStringParameters": mock_http_api_event_v1[
411+
"queryStringParameters"
412+
],
413+
"multiValueQueryStringParameters": mock_http_api_event_v1[
414+
"multiValueQueryStringParameters"
415+
],
416+
"requestContext": {
417+
"accountId": "123456789012",
418+
"apiId": "api-id",
419+
"authorizer": {
420+
"jwt": {
421+
"claims": {"claim1": "value1", "claim2": "value2"},
422+
"scopes": ["scope1", "scope2"],
423+
}
424+
},
425+
"domainName": "id.execute-api.us-east-1.amazonaws.com",
426+
"domainPrefix": "id",
427+
"http": {
428+
"protocol": "HTTP/1.1",
429+
"sourceIp": "192.168.100.1",
430+
"userAgent": "agent",
431+
},
432+
"requestId": "id",
433+
"routeKey": "$default",
434+
"stage": "$default",
435+
"time": "12/Mar/2020:19:03:58 +0000",
436+
"timeEpoch": 1_583_348_638_390,
437+
},
438+
"body": None,
439+
"pathParameters": {"parameter1": "value1"},
440+
"isBase64Encoded": False,
441+
"stageVariables": {
442+
"stageVariable1": "value1",
443+
"stageVariable2": "value2",
444+
},
445+
},
446+
"client": (None, 0),
447+
"headers": [
448+
[b"accept-encoding", b"gzip,deflate"],
449+
[b"x-forwarded-port", b"443"],
450+
[b"x-forwarded-proto", b"https"],
451+
[b"host", b"test.execute-api.us-west-2.amazonaws.com"],
452+
],
453+
"http_version": "1.1",
454+
"method": "GET",
455+
"path": "/my/path",
456+
"query_string": mock_http_api_event_v1["rawQueryString"].encode(),
335457
"raw_path": None,
336458
"root_path": "",
337459
"scheme": "https",
@@ -353,7 +475,7 @@ async def app(scope, receive, send):
353475
await send({"type": "http.response.body", "body": b"Hello, world!"})
354476

355477
handler = Mangum(app, lifespan="off")
356-
response = handler(mock_http_api_event, {})
478+
response = handler(mock_http_api_event_v1, {})
357479
assert response == {
358480
"statusCode": 200,
359481
"isBase64Encoded": False,

0 commit comments

Comments
 (0)