Skip to content

Commit f3f76bb

Browse files
feat(event_handler): add cookies as 1st class citizen in v2 (aws-powertools#1487)
Co-authored-by: Heitor Lessa <[email protected]> Co-authored-by: Heitor Lessa <[email protected]>
1 parent 0243d37 commit f3f76bb

15 files changed

+333
-101
lines changed

aws_lambda_powertools/event_handler/api_gateway.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from aws_lambda_powertools.event_handler import content_types
1616
from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError
1717
from aws_lambda_powertools.shared import constants
18+
from aws_lambda_powertools.shared.cookies import Cookie
1819
from aws_lambda_powertools.shared.functions import resolve_truthy_env_var_choice
1920
from aws_lambda_powertools.shared.json_encoder import Encoder
2021
from aws_lambda_powertools.utilities.data_classes import (
@@ -147,7 +148,7 @@ def __init__(
147148
content_type: Optional[str],
148149
body: Union[str, bytes, None],
149150
headers: Optional[Dict[str, Union[str, List[str]]]] = None,
150-
cookies: Optional[List[str]] = None,
151+
cookies: Optional[List[Cookie]] = None,
151152
):
152153
"""
153154
@@ -162,7 +163,7 @@ def __init__(
162163
Optionally set the response body. Note: bytes body will be automatically base64 encoded
163164
headers: dict[str, Union[str, List[str]]]
164165
Optionally set specific http headers. Setting "Content-Type" here would override the `content_type` value.
165-
cookies: list[str]
166+
cookies: list[Cookie]
166167
Optionally set cookies.
167168
"""
168169
self.status_code = status_code
+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from datetime import datetime
2+
from enum import Enum
3+
from io import StringIO
4+
from typing import List, Optional
5+
6+
7+
class SameSite(Enum):
8+
"""
9+
SameSite allows a server to define a cookie attribute making it impossible for
10+
the browser to send this cookie along with cross-site requests. The main
11+
goal is to mitigate the risk of cross-origin information leakage, and provide
12+
some protection against cross-site request forgery attacks.
13+
14+
See https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 for details.
15+
"""
16+
17+
DEFAULT_MODE = ""
18+
LAX_MODE = "Lax"
19+
STRICT_MODE = "Strict"
20+
NONE_MODE = "None"
21+
22+
23+
def _format_date(timestamp: datetime) -> str:
24+
# Specification example: Wed, 21 Oct 2015 07:28:00 GMT
25+
return timestamp.strftime("%a, %d %b %Y %H:%M:%S GMT")
26+
27+
28+
class Cookie:
29+
"""
30+
A Cookie represents an HTTP cookie as sent in the Set-Cookie header of an
31+
HTTP response or the Cookie header of an HTTP request.
32+
33+
See https://tools.ietf.org/html/rfc6265 for details.
34+
"""
35+
36+
def __init__(
37+
self,
38+
name: str,
39+
value: str,
40+
path: str = "",
41+
domain: str = "",
42+
secure: bool = True,
43+
http_only: bool = False,
44+
max_age: Optional[int] = None,
45+
expires: Optional[datetime] = None,
46+
same_site: Optional[SameSite] = None,
47+
custom_attributes: Optional[List[str]] = None,
48+
):
49+
"""
50+
51+
Parameters
52+
----------
53+
name: str
54+
The name of this cookie, for example session_id
55+
value: str
56+
The cookie value, for instance an uuid
57+
path: str
58+
The path for which this cookie is valid. Optional
59+
domain: str
60+
The domain for which this cookie is valid. Optional
61+
secure: bool
62+
Marks the cookie as secure, only sendable to the server with an encrypted request over the HTTPS protocol
63+
http_only: bool
64+
Enabling this attribute makes the cookie inaccessible to the JavaScript `Document.cookie` API
65+
max_age: Optional[int]
66+
Defines the period of time after which the cookie is invalid. Use negative values to force cookie deletion.
67+
expires: Optional[datetime]
68+
Defines a date where the permanent cookie expires.
69+
same_site: Optional[SameSite]
70+
Determines if the cookie should be sent to third party websites
71+
custom_attributes: Optional[List[str]]
72+
List of additional custom attributes to set on the cookie
73+
"""
74+
self.name = name
75+
self.value = value
76+
self.path = path
77+
self.domain = domain
78+
self.secure = secure
79+
self.expires = expires
80+
self.max_age = max_age
81+
self.http_only = http_only
82+
self.same_site = same_site
83+
self.custom_attributes = custom_attributes
84+
85+
def __str__(self) -> str:
86+
payload = StringIO()
87+
payload.write(f"{self.name}={self.value}")
88+
89+
if self.path:
90+
payload.write(f"; Path={self.path}")
91+
92+
if self.domain:
93+
payload.write(f"; Domain={self.domain}")
94+
95+
if self.expires:
96+
payload.write(f"; Expires={_format_date(self.expires)}")
97+
98+
if self.max_age:
99+
if self.max_age > 0:
100+
payload.write(f"; MaxAge={self.max_age}")
101+
else:
102+
# negative or zero max-age should be set to 0
103+
payload.write("; MaxAge=0")
104+
105+
if self.http_only:
106+
payload.write("; HttpOnly")
107+
108+
if self.secure:
109+
payload.write("; Secure")
110+
111+
if self.same_site:
112+
payload.write(f"; SameSite={self.same_site.value}")
113+
114+
if self.custom_attributes:
115+
for attr in self.custom_attributes:
116+
payload.write(f"; {attr}")
117+
118+
return payload.getvalue()

aws_lambda_powertools/shared/headers_serializer.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22
from collections import defaultdict
33
from typing import Any, Dict, List, Union
44

5+
from aws_lambda_powertools.shared.cookies import Cookie
6+
57

68
class BaseHeadersSerializer:
79
"""
810
Helper class to correctly serialize headers and cookies for Amazon API Gateway,
911
ALB and Lambda Function URL response payload.
1012
"""
1113

12-
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
14+
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[Cookie]) -> Dict[str, Any]:
1315
"""
1416
Serializes headers and cookies according to the request type.
1517
Returns a dict that can be merged with the response payload.
@@ -25,7 +27,7 @@ def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str
2527

2628

2729
class HttpApiHeadersSerializer(BaseHeadersSerializer):
28-
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
30+
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[Cookie]) -> Dict[str, Any]:
2931
"""
3032
When using HTTP APIs or LambdaFunctionURLs, everything is taken care automatically for us.
3133
We can directly assign a list of cookies and a dict of headers to the response payload, and the
@@ -44,11 +46,11 @@ def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str
4446
else:
4547
combined_headers[key] = ", ".join(values)
4648

47-
return {"headers": combined_headers, "cookies": cookies}
49+
return {"headers": combined_headers, "cookies": list(map(str, cookies))}
4850

4951

5052
class MultiValueHeadersSerializer(BaseHeadersSerializer):
51-
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
53+
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[Cookie]) -> Dict[str, Any]:
5254
"""
5355
When using REST APIs, headers can be encoded using the `multiValueHeaders` key on the response.
5456
This is also the case when using an ALB integration with the `multiValueHeaders` option enabled.
@@ -69,13 +71,13 @@ def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str
6971
if cookies:
7072
payload.setdefault("Set-Cookie", [])
7173
for cookie in cookies:
72-
payload["Set-Cookie"].append(cookie)
74+
payload["Set-Cookie"].append(str(cookie))
7375

7476
return {"multiValueHeaders": payload}
7577

7678

7779
class SingleValueHeadersSerializer(BaseHeadersSerializer):
78-
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
80+
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[Cookie]) -> Dict[str, Any]:
7981
"""
8082
The ALB integration has `multiValueHeaders` disabled by default.
8183
If we try to set multiple headers with the same key, or more than one cookie, print a warning.
@@ -93,7 +95,7 @@ def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str
9395
)
9496

9597
# We can only send one cookie, send the last one
96-
payload["headers"]["Set-Cookie"] = cookies[-1]
98+
payload["headers"]["Set-Cookie"] = str(cookies[-1])
9799

98100
for key, values in headers.items():
99101
if isinstance(values, str):

docs/core/event_handler/api_gateway.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ You can use the `Response` class to have full control over the response. For exa
323323

324324
=== "fine_grained_responses.py"
325325

326-
```python hl_lines="7 24-29"
326+
```python hl_lines="7 25-30"
327327
--8<-- "examples/event_handler_rest/src/fine_grained_responses.py"
328328
```
329329

docs/upgrade.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def get_todos():
5353
return Response(
5454
# ...
5555
headers={"Content-Type": ["text/plain"]},
56-
cookies=["CookieName=CookieValue"]
56+
cookies=[Cookie(name="session_id", value="12345", secure=True, http_only=True)],
5757
)
5858
```
5959

examples/event_handler_rest/src/fine_grained_responses.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from aws_lambda_powertools import Logger, Tracer
77
from aws_lambda_powertools.event_handler import APIGatewayRestResolver, Response, content_types
88
from aws_lambda_powertools.logging import correlation_paths
9+
from aws_lambda_powertools.shared.cookies import Cookie
910
from aws_lambda_powertools.utilities.typing import LambdaContext
1011

1112
tracer = Tracer()
@@ -26,7 +27,7 @@ def get_todos():
2627
content_type=content_types.APPLICATION_JSON,
2728
body=todos.json()[:10],
2829
headers=custom_headers,
29-
cookies=["<cookie-name>=<cookie-value>; Secure; Expires=<date>"],
30+
cookies=[Cookie(name="session_id", value="12345")],
3031
)
3132

3233

examples/event_handler_rest/src/fine_grained_responses_output.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"multiValueHeaders": {
44
"Content-Type": ["application/json"],
55
"X-Transaction-Id": ["3490eea9-791b-47a0-91a4-326317db61a9"],
6-
"Set-Cookie": ["<cookie-name>=<cookie-value>; Secure; Expires=<date>"]
6+
"Set-Cookie": ["session_id=12345; Secure"]
77
},
88
"body": "{\"todos\":[{\"userId\":1,\"id\":1,\"title\":\"delectus aut autem\",\"completed\":false},{\"userId\":1,\"id\":2,\"title\":\"quis ut nam facilis et officia qui\",\"completed\":false},{\"userId\":1,\"id\":3,\"title\":\"fugiat veniam minus\",\"completed\":false},{\"userId\":1,\"id\":4,\"title\":\"et porro tempora\",\"completed\":true},{\"userId\":1,\"id\":5,\"title\":\"laboriosam mollitia et enim quasi adipisci quia provident illum\",\"completed\":false},{\"userId\":1,\"id\":6,\"title\":\"qui ullam ratione quibusdam voluptatem quia omnis\",\"completed\":false},{\"userId\":1,\"id\":7,\"title\":\"illo expedita consequatur quia in\",\"completed\":false},{\"userId\":1,\"id\":8,\"title\":\"quo adipisci enim quam ut ab\",\"completed\":true},{\"userId\":1,\"id\":9,\"title\":\"molestiae perspiciatis ipsa\",\"completed\":false},{\"userId\":1,\"id\":10,\"title\":\"illo est ratione doloremque quia maiores aut\",\"completed\":true}]}",
99
"isBase64Encoded": false

tests/e2e/event_handler/handlers/alb_handler.py

+14-6
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@
33
app = ALBResolver()
44

55

6-
@app.get("/todos")
6+
@app.post("/todos")
77
def hello():
8+
payload = app.current_event.json_body
9+
10+
body = payload.get("body", "Hello World")
11+
status_code = payload.get("status_code", 200)
12+
headers = payload.get("headers", {})
13+
cookies = payload.get("cookies", [])
14+
content_type = headers.get("Content-Type", content_types.TEXT_PLAIN)
15+
816
return Response(
9-
status_code=200,
10-
content_type=content_types.TEXT_PLAIN,
11-
body="Hello world",
12-
cookies=["CookieMonster", "MonsterCookie"],
13-
headers={"Foo": ["bar", "zbr"]},
17+
status_code=status_code,
18+
content_type=content_type,
19+
body=body,
20+
cookies=cookies,
21+
headers=headers,
1422
)
1523

1624

tests/e2e/event_handler/handlers/api_gateway_http_handler.py

+14-6
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@
33
app = APIGatewayHttpResolver()
44

55

6-
@app.get("/todos")
6+
@app.post("/todos")
77
def hello():
8+
payload = app.current_event.json_body
9+
10+
body = payload.get("body", "Hello World")
11+
status_code = payload.get("status_code", 200)
12+
headers = payload.get("headers", {})
13+
cookies = payload.get("cookies", [])
14+
content_type = headers.get("Content-Type", content_types.TEXT_PLAIN)
15+
816
return Response(
9-
status_code=200,
10-
content_type=content_types.TEXT_PLAIN,
11-
body="Hello world",
12-
cookies=["CookieMonster", "MonsterCookie"],
13-
headers={"Foo": ["bar", "zbr"]},
17+
status_code=status_code,
18+
content_type=content_type,
19+
body=body,
20+
cookies=cookies,
21+
headers=headers,
1422
)
1523

1624

tests/e2e/event_handler/handlers/api_gateway_rest_handler.py

+14-6
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@
33
app = APIGatewayRestResolver()
44

55

6-
@app.get("/todos")
6+
@app.post("/todos")
77
def hello():
8+
payload = app.current_event.json_body
9+
10+
body = payload.get("body", "Hello World")
11+
status_code = payload.get("status_code", 200)
12+
headers = payload.get("headers", {})
13+
cookies = payload.get("cookies", [])
14+
content_type = headers.get("Content-Type", content_types.TEXT_PLAIN)
15+
816
return Response(
9-
status_code=200,
10-
content_type=content_types.TEXT_PLAIN,
11-
body="Hello world",
12-
cookies=["CookieMonster", "MonsterCookie"],
13-
headers={"Foo": ["bar", "zbr"]},
17+
status_code=status_code,
18+
content_type=content_type,
19+
body=body,
20+
cookies=cookies,
21+
headers=headers,
1422
)
1523

1624

tests/e2e/event_handler/handlers/lambda_function_url_handler.py

+14-6
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@
33
app = LambdaFunctionUrlResolver()
44

55

6-
@app.get("/todos")
6+
@app.post("/todos")
77
def hello():
8+
payload = app.current_event.json_body
9+
10+
body = payload.get("body", "Hello World")
11+
status_code = payload.get("status_code", 200)
12+
headers = payload.get("headers", {})
13+
cookies = payload.get("cookies", [])
14+
content_type = headers.get("Content-Type", content_types.TEXT_PLAIN)
15+
816
return Response(
9-
status_code=200,
10-
content_type=content_types.TEXT_PLAIN,
11-
body="Hello world",
12-
cookies=["CookieMonster", "MonsterCookie"],
13-
headers={"Foo": ["bar", "zbr"]},
17+
status_code=status_code,
18+
content_type=content_type,
19+
body=body,
20+
cookies=cookies,
21+
headers=headers,
1422
)
1523

1624

tests/e2e/event_handler/infrastructure.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def _create_api_gateway_http(self, function: Function):
6161
apigw = apigwv2.HttpApi(self.stack, "APIGatewayHTTP", create_default_stage=True)
6262
apigw.add_routes(
6363
path="/todos",
64-
methods=[apigwv2.HttpMethod.GET],
64+
methods=[apigwv2.HttpMethod.POST],
6565
integration=apigwv2integrations.HttpLambdaIntegration("TodosIntegration", function),
6666
)
6767

@@ -71,7 +71,7 @@ def _create_api_gateway_rest(self, function: Function):
7171
apigw = apigwv1.RestApi(self.stack, "APIGatewayRest", deploy_options=apigwv1.StageOptions(stage_name="dev"))
7272

7373
todos = apigw.root.add_resource("todos")
74-
todos.add_method("GET", apigwv1.LambdaIntegration(function, proxy=True))
74+
todos.add_method("POST", apigwv1.LambdaIntegration(function, proxy=True))
7575

7676
CfnOutput(self.stack, "APIGatewayRestUrl", value=apigw.url)
7777

0 commit comments

Comments
 (0)