Skip to content

Commit 2472076

Browse files
rubenfonsecaheitorlessa
authored andcommitted
feat(event_handler): improved support for headers and cookies in v2 (aws-powertools#1455)
Co-authored-by: Heitor Lessa <[email protected]>
1 parent ef3f794 commit 2472076

33 files changed

+1323
-886
lines changed

Diff for: .gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -305,5 +305,8 @@ site/
305305
!404.html
306306
!docs/overrides/*.html
307307

308+
# CDK
309+
.cdk
310+
308311
!.github/workflows/lib
309312
examples/**/sam/.aws-sam

Diff for: aws_lambda_powertools/__init__.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
# -*- coding: utf-8 -*-
22

3-
"""Top-level package for Lambda Python Powertools."""
4-
3+
from pathlib import Path
54

5+
"""Top-level package for Lambda Python Powertools."""
66
from .logging import Logger # noqa: F401
77
from .metrics import Metrics, single_metric # noqa: F401
88
from .package_logger import set_package_logger_handler
99
from .tracing import Tracer # noqa: F401
1010

1111
__author__ = """Amazon Web Services"""
1212

13+
PACKAGE_PATH = Path(__file__).parent
14+
1315
set_package_logger_handler()

Diff for: aws_lambda_powertools/event_handler/api_gateway.py

+16-9
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,11 @@ def __init__(
124124

125125
def to_dict(self) -> Dict[str, str]:
126126
"""Builds the configured Access-Control http headers"""
127-
headers = {
127+
headers: Dict[str, str] = {
128128
"Access-Control-Allow-Origin": self.allow_origin,
129129
"Access-Control-Allow-Headers": ",".join(sorted(self.allow_headers)),
130130
}
131+
131132
if self.expose_headers:
132133
headers["Access-Control-Expose-Headers"] = ",".join(self.expose_headers)
133134
if self.max_age is not None:
@@ -145,7 +146,8 @@ def __init__(
145146
status_code: int,
146147
content_type: Optional[str],
147148
body: Union[str, bytes, None],
148-
headers: Optional[Dict] = None,
149+
headers: Optional[Dict[str, Union[str, List[str]]]] = None,
150+
cookies: Optional[List[str]] = None,
149151
):
150152
"""
151153
@@ -158,13 +160,16 @@ def __init__(
158160
provided http headers
159161
body: Union[str, bytes, None]
160162
Optionally set the response body. Note: bytes body will be automatically base64 encoded
161-
headers: dict
162-
Optionally set specific http headers. Setting "Content-Type" hear would override the `content_type` value.
163+
headers: dict[str, Union[str, List[str]]]
164+
Optionally set specific http headers. Setting "Content-Type" here would override the `content_type` value.
165+
cookies: list[str]
166+
Optionally set cookies.
163167
"""
164168
self.status_code = status_code
165169
self.body = body
166170
self.base64_encoded = False
167-
self.headers: Dict = headers or {}
171+
self.headers: Dict[str, Union[str, List[str]]] = headers if headers else {}
172+
self.cookies = cookies or []
168173
if content_type:
169174
self.headers.setdefault("Content-Type", content_type)
170175

@@ -196,11 +201,12 @@ def _add_cors(self, cors: CORSConfig):
196201

197202
def _add_cache_control(self, cache_control: str):
198203
"""Set the specified cache control headers for 200 http responses. For non-200 `no-cache` is used."""
199-
self.response.headers["Cache-Control"] = cache_control if self.response.status_code == 200 else "no-cache"
204+
cache_control = cache_control if self.response.status_code == 200 else "no-cache"
205+
self.response.headers["Cache-Control"] = cache_control
200206

201207
def _compress(self):
202208
"""Compress the response body, but only if `Accept-Encoding` headers includes gzip."""
203-
self.response.headers["Content-Encoding"] = "gzip"
209+
self.response.headers["Content-Encoding"].append("gzip")
204210
if isinstance(self.response.body, str):
205211
logger.debug("Converting string response to bytes before compressing it")
206212
self.response.body = bytes(self.response.body, "utf-8")
@@ -226,11 +232,12 @@ def build(self, event: BaseProxyEvent, cors: Optional[CORSConfig] = None) -> Dic
226232
logger.debug("Encoding bytes response with base64")
227233
self.response.base64_encoded = True
228234
self.response.body = base64.b64encode(self.response.body).decode()
235+
229236
return {
230237
"statusCode": self.response.status_code,
231-
"headers": self.response.headers,
232238
"body": self.response.body,
233239
"isBase64Encoded": self.response.base64_encoded,
240+
**event.header_serializer().serialize(headers=self.response.headers, cookies=self.response.cookies),
234241
}
235242

236243

@@ -596,7 +603,7 @@ def _path_starts_with(path: str, prefix: str):
596603

597604
def _not_found(self, method: str) -> ResponseBuilder:
598605
"""Called when no matching route was found and includes support for the cors preflight response"""
599-
headers = {}
606+
headers: Dict[str, Union[str, List[str]]] = {}
600607
if self._cors:
601608
logger.debug("CORS is enabled, updating headers.")
602609
headers.update(self._cors.to_dict())

Diff for: aws_lambda_powertools/shared/headers_serializer.py

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import warnings
2+
from collections import defaultdict
3+
from typing import Any, Dict, List, Union
4+
5+
6+
class BaseHeadersSerializer:
7+
"""
8+
Helper class to correctly serialize headers and cookies for Amazon API Gateway,
9+
ALB and Lambda Function URL response payload.
10+
"""
11+
12+
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
13+
"""
14+
Serializes headers and cookies according to the request type.
15+
Returns a dict that can be merged with the response payload.
16+
17+
Parameters
18+
----------
19+
headers: Dict[str, List[str]]
20+
A dictionary of headers to set in the response
21+
cookies: List[str]
22+
A list of cookies to set in the response
23+
"""
24+
raise NotImplementedError()
25+
26+
27+
class HttpApiHeadersSerializer(BaseHeadersSerializer):
28+
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
29+
"""
30+
When using HTTP APIs or LambdaFunctionURLs, everything is taken care automatically for us.
31+
We can directly assign a list of cookies and a dict of headers to the response payload, and the
32+
runtime will automatically serialize them correctly on the output.
33+
34+
https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.proxy-format
35+
https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response
36+
"""
37+
38+
# Format 2.0 doesn't have multiValueHeaders or multiValueQueryStringParameters fields.
39+
# Duplicate headers are combined with commas and included in the headers field.
40+
combined_headers: Dict[str, str] = {}
41+
for key, values in headers.items():
42+
if isinstance(values, str):
43+
combined_headers[key] = values
44+
else:
45+
combined_headers[key] = ", ".join(values)
46+
47+
return {"headers": combined_headers, "cookies": cookies}
48+
49+
50+
class MultiValueHeadersSerializer(BaseHeadersSerializer):
51+
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
52+
"""
53+
When using REST APIs, headers can be encoded using the `multiValueHeaders` key on the response.
54+
This is also the case when using an ALB integration with the `multiValueHeaders` option enabled.
55+
The solution covers headers with just one key or multiple keys.
56+
57+
https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format
58+
https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers-response
59+
"""
60+
payload: Dict[str, List[str]] = defaultdict(list)
61+
62+
for key, values in headers.items():
63+
if isinstance(values, str):
64+
payload[key].append(values)
65+
else:
66+
for value in values:
67+
payload[key].append(value)
68+
69+
if cookies:
70+
payload.setdefault("Set-Cookie", [])
71+
for cookie in cookies:
72+
payload["Set-Cookie"].append(cookie)
73+
74+
return {"multiValueHeaders": payload}
75+
76+
77+
class SingleValueHeadersSerializer(BaseHeadersSerializer):
78+
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
79+
"""
80+
The ALB integration has `multiValueHeaders` disabled by default.
81+
If we try to set multiple headers with the same key, or more than one cookie, print a warning.
82+
83+
https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#respond-to-load-balancer
84+
"""
85+
payload: Dict[str, Dict[str, str]] = {}
86+
payload.setdefault("headers", {})
87+
88+
if cookies:
89+
if len(cookies) > 1:
90+
warnings.warn(
91+
"Can't encode more than one cookie in the response. Sending the last cookie only. "
92+
"Did you enable multiValueHeaders on the ALB Target Group?"
93+
)
94+
95+
# We can only send one cookie, send the last one
96+
payload["headers"]["Set-Cookie"] = cookies[-1]
97+
98+
for key, values in headers.items():
99+
if isinstance(values, str):
100+
payload["headers"][key] = values
101+
else:
102+
if len(values) > 1:
103+
warnings.warn(
104+
f"Can't encode more than one header value for the same key ('{key}') in the response. "
105+
"Did you enable multiValueHeaders on the ALB Target Group?"
106+
)
107+
108+
# We can only set one header per key, send the last one
109+
payload["headers"][key] = values[-1]
110+
111+
return payload

Diff for: aws_lambda_powertools/utilities/data_classes/alb_event.py

+13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
from typing import Dict, List, Optional
22

3+
from aws_lambda_powertools.shared.headers_serializer import (
4+
BaseHeadersSerializer,
5+
MultiValueHeadersSerializer,
6+
SingleValueHeadersSerializer,
7+
)
38
from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent, DictWrapper
49

510

@@ -30,3 +35,11 @@ def multi_value_query_string_parameters(self) -> Optional[Dict[str, List[str]]]:
3035
@property
3136
def multi_value_headers(self) -> Optional[Dict[str, List[str]]]:
3237
return self.get("multiValueHeaders")
38+
39+
def header_serializer(self) -> BaseHeadersSerializer:
40+
# When using the ALB integration, the `multiValueHeaders` feature can be disabled (default) or enabled.
41+
# We can determine if the feature is enabled by looking if the event has a `multiValueHeaders` key.
42+
if self.multi_value_headers:
43+
return MultiValueHeadersSerializer()
44+
45+
return SingleValueHeadersSerializer()

Diff for: aws_lambda_powertools/utilities/data_classes/api_gateway_proxy_event.py

+11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
from typing import Any, Dict, List, Optional
22

3+
from aws_lambda_powertools.shared.headers_serializer import (
4+
BaseHeadersSerializer,
5+
HttpApiHeadersSerializer,
6+
MultiValueHeadersSerializer,
7+
)
38
from aws_lambda_powertools.utilities.data_classes.common import (
49
BaseProxyEvent,
510
BaseRequestContext,
@@ -106,6 +111,9 @@ def path_parameters(self) -> Optional[Dict[str, str]]:
106111
def stage_variables(self) -> Optional[Dict[str, str]]:
107112
return self.get("stageVariables")
108113

114+
def header_serializer(self) -> BaseHeadersSerializer:
115+
return MultiValueHeadersSerializer()
116+
109117

110118
class RequestContextV2AuthorizerIam(DictWrapper):
111119
@property
@@ -250,3 +258,6 @@ def path(self) -> str:
250258
def http_method(self) -> str:
251259
"""The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT."""
252260
return self.request_context.http.method
261+
262+
def header_serializer(self):
263+
return HttpApiHeadersSerializer()

Diff for: aws_lambda_powertools/utilities/data_classes/common.py

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import json
33
from typing import Any, Dict, Optional
44

5+
from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer
6+
57

68
class DictWrapper:
79
"""Provides a single read only access to a wrapper dict"""
@@ -127,6 +129,9 @@ def get_header_value(
127129
"""
128130
return get_header_value(self.headers, name, default_value, case_sensitive)
129131

132+
def header_serializer(self) -> BaseHeadersSerializer:
133+
raise NotImplementedError()
134+
130135

131136
class RequestContextClientCert(DictWrapper):
132137
@property

Diff for: docs/core/event_handler/api_gateway.md

+9-2
Original file line numberDiff line numberDiff line change
@@ -312,11 +312,18 @@ For convenience, these are the default values when using `CORSConfig` to enable
312312

313313
### Fine grained responses
314314

315-
You can use the `Response` class to have full control over the response, for example you might want to add additional headers or set a custom Content-type.
315+
You can use the `Response` class to have full control over the response. For example, you might want to add additional headers, cookies, or set a custom Content-type.
316+
317+
???+ info
318+
Powertools serializes headers and cookies according to the type of input event.
319+
Some event sources require headers and cookies to be encoded as `multiValueHeaders`.
320+
321+
???+ warning "Using multiple values for HTTP headers in ALB?"
322+
Make sure you [enable the multi value headers feature](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers) to serialize response headers correctly.
316323

317324
=== "fine_grained_responses.py"
318325

319-
```python hl_lines="7 24-28"
326+
```python hl_lines="7 24-29"
320327
--8<-- "examples/event_handler_rest/src/fine_grained_responses.py"
321328
```
322329

Diff for: docs/upgrade.md

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
title: Upgrade guide
3+
description: Guide to update between major Powertools versions
4+
---
5+
6+
<!-- markdownlint-disable MD043 -->
7+
8+
## Migrate to v2 from v1
9+
10+
The transition from Powertools for Python v1 to v2 is as painless as possible, as we aimed for minimal breaking changes.
11+
Changes at a glance:
12+
13+
* The API for **event handler's `Response`** has minor changes to support multi value headers and cookies.
14+
15+
???+ important
16+
Powertools for Python v2 drops suport for Python 3.6, following the Python 3.6 End-Of-Life (EOL) reached on December 23, 2021.
17+
18+
### Initial Steps
19+
20+
Before you start, we suggest making a copy of your current working project or create a new branch with git.
21+
22+
1. **Upgrade** Python to at least v3.7
23+
24+
2. **Ensure** you have the latest `aws-lambda-powertools`
25+
26+
```bash
27+
pip install aws-lambda-powertools -U
28+
```
29+
30+
3. **Review** the following sections to confirm whether they affect your code
31+
32+
## Event Handler Response (headers and cookies)
33+
34+
The `Response` class of the event handler utility changed slightly:
35+
36+
1. The `headers` parameter now expects either a value or list of values per header (type `Union[str, Dict[str, List[str]]]`)
37+
2. We introduced a new `cookies` parameter (type `List[str]`)
38+
39+
???+ note
40+
Code that set headers as `Dict[str, str]` will still work unchanged.
41+
42+
```python hl_lines="6 12 13"
43+
@app.get("/todos")
44+
def get_todos():
45+
# Before
46+
return Response(
47+
# ...
48+
headers={"Content-Type": "text/plain"}
49+
)
50+
51+
# After
52+
return Response(
53+
# ...
54+
headers={"Content-Type": ["text/plain"]},
55+
cookies=["CookieName=CookieValue"]
56+
)
57+
```

Diff for: examples/event_handler_rest/src/binary_responses_output.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"body": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjU2cHgiIGhlaWdodD0iMjU2cHgiIHZpZXdCb3g9IjAgMCAyNTYgMjU2IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIj4KICAgIDx0aXRsZT5BV1MgTGFtYmRhPC90aXRsZT4KICAgIDxkZWZzPgogICAgICAgIDxsaW5lYXJHcmFkaWVudCB4MT0iMCUiIHkxPSIxMDAlIiB4Mj0iMTAwJSIgeTI9IjAlIiBpZD0ibGluZWFyR3JhZGllbnQtMSI+CiAgICAgICAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiNDODUxMUIiIG9mZnNldD0iMCUiPjwvc3RvcD4KICAgICAgICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI0ZGOTkwMCIgb2Zmc2V0PSIxMDAlIj48L3N0b3A+CiAgICAgICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDwvZGVmcz4KICAgIDxnPgogICAgICAgIDxyZWN0IGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQtMSkiIHg9IjAiIHk9IjAiIHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2Ij48L3JlY3Q+CiAgICAgICAgPHBhdGggZD0iTTg5LjYyNDExMjYsMjExLjIgTDQ5Ljg5MDMyNzcsMjExLjIgTDkzLjgzNTQ4MzIsMTE5LjM0NzIgTDExMy43NDcyOCwxNjAuMzM5MiBMODkuNjI0MTEyNiwyMTEuMiBaIE05Ni43MDI5MzU3LDExMC41Njk2IEM5Ni4xNjQwODU4LDEwOS40NjU2IDk1LjA0MTQ4MTMsMTA4Ljc2NDggOTMuODE2MjM4NCwxMDguNzY0OCBMOTMuODA2NjE2MywxMDguNzY0OCBDOTIuNTcxNzUxNCwxMDguNzY4IDkxLjQ0OTE0NjYsMTA5LjQ3NTIgOTAuOTE5OTE4NywxMTAuNTg1NiBMNDEuOTEzNDIwOCwyMTMuMDIwOCBDNDEuNDM4NzE5NywyMTQuMDEyOCA0MS41MDYwNzU4LDIxNS4xNzc2IDQyLjA5NjI0NTEsMjE2LjEwODggQzQyLjY3OTk5OTQsMjE3LjAzNjggNDMuNzA2MzgwNSwyMTcuNiA0NC44MDY1MzMxLDIxNy42IEw5MS42NTQ0MjMsMjE3LjYgQzkyLjg5NTcwMjcsMjE3LjYgOTQuMDIxNTE0OSwyMTYuODg2NCA5NC41NTM5NTAxLDIxNS43Njk2IEwxMjAuMjAzODU5LDE2MS42ODk2IEMxMjAuNjE3NjE5LDE2MC44MTI4IDEyMC42MTQ0MTIsMTU5Ljc5ODQgMTIwLjE4NzgyMiwxNTguOTI4IEw5Ni43MDI5MzU3LDExMC41Njk2IFogTTIwNy45ODUxMTcsMjExLjIgTDE2OC41MDc5MjgsMjExLjIgTDEwNS4xNzM3ODksNzguNjI0IEMxMDQuNjQ0NTYxLDc3LjUxMDQgMTAzLjUxNTU0MSw3Ni44IDEwMi4yNzc0NjksNzYuOCBMNzYuNDQ3OTQzLDc2LjggTDc2LjQ3NjgwOTksNDQuOCBMMTI3LjEwMzA2Niw0NC44IEwxOTAuMTQ1MzI4LDE3Ny4zNzI4IEMxOTAuNjc0NTU2LDE3OC40ODY0IDE5MS44MDM1NzUsMTc5LjIgMTkzLjA0MTY0NywxNzkuMiBMMjA3Ljk4NTExNywxNzkuMiBMMjA3Ljk4NTExNywyMTEuMiBaIE0yMTEuMTkyNTU4LDE3Mi44IEwxOTUuMDcxOTU4LDE3Mi44IEwxMzIuMDI5Njk2LDQwLjIyNzIgQzEzMS41MDA0NjgsMzkuMTEzNiAxMzAuMzcxNDQ5LDM4LjQgMTI5LjEzMDE2OSwzOC40IEw3My4yNzI1NzYsMzguNCBDNzEuNTA1Mjc1OCwzOC40IDcwLjA2ODM0MjEsMzkuODMwNCA3MC4wNjUxMzQ0LDQxLjU5NjggTDcwLjAyOTg1MjgsNzkuOTk2OCBDNzAuMDI5ODUyOCw4MC44NDggNzAuMzYzNDI2Niw4MS42NjA4IDcwLjk2OTYzMyw4Mi4yNjI0IEM3MS41Njk0MjQ2LDgyLjg2NCA3Mi4zODQxMTQ2LDgzLjIgNzMuMjM3Mjk0MSw4My4yIEwxMDAuMjUzNTczLDgzLjIgTDE2My41OTA5MiwyMTUuNzc2IEMxNjQuMTIzMzU1LDIxNi44ODk2IDE2NS4yNDU5NiwyMTcuNiAxNjYuNDg0MDMyLDIxNy42IEwyMTEuMTkyNTU4LDIxNy42IEMyMTIuOTY2Mjc0LDIxNy42IDIxNC40LDIxNi4xNjY0IDIxNC40LDIxNC40IEwyMTQuNCwxNzYgQzIxNC40LDE3NC4yMzM2IDIxMi45NjYyNzQsMTcyLjggMjExLjE5MjU1OCwxNzIuOCBMMjExLjE5MjU1OCwxNzIuOCBaIiBmaWxsPSIjRkZGRkZGIj48L3BhdGg+CiAgICA8L2c+Cjwvc3ZnPg==",
3-
"headers": {
4-
"Content-Type": "image/svg+xml"
3+
"multiValueHeaders": {
4+
"Content-Type": ["image/svg+xml"]
55
},
66
"isBase64Encoded": true,
77
"statusCode": 200

Diff for: examples/event_handler_rest/src/compressing_responses_output.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"statusCode": 200,
3-
"headers": {
4-
"Content-Type": "application/json",
5-
"Content-Encoding": "gzip"
3+
"multiValueHeaders": {
4+
"Content-Type": ["application/json"],
5+
"Content-Encoding": ["gzip"]
66
},
77
"body": "H4sIAAAAAAACE42STU4DMQyFrxJl3QXln96AMyAW7sSDLCVxiJ0Kqerd8TCCUOgii1EmP/783pOPXjmw+N3L0TfB+hz8brvxtC5KGtHvfMCIkzZx0HT5MPmNnziViIr2dIYoeNr8Q1x3xHsjcVadIbkZJoq2RXU8zzQROLseQ9505NzeCNQdMJNBE+UmY4zbzjAJhWtlZ57sB84BWtul+rteH2HPlVgWARwjqXkxpklK5gmEHAQqJBMtFsGVygcKmNVRjG0wxvuzGF2L0dpVUOKMC3bfJNjJgWMrCuZk7cUp02AiD72D6WKHHwUDKbiJs6AZ0VZXKOUx4uNvzdxT+E4mLcMA+6G8nzrLQkaxkNEVrFKW2VGbJCoCY7q2V3+tiv5kGThyxfTecDWbgGz/NfYXhL6ePgF9PnFdPgMAAA==",
88
"isBase64Encoded": true

0 commit comments

Comments
 (0)