Skip to content

Commit 507958e

Browse files
authored
Merge branch 'v2' into chore/add-init-export
2 parents d0c97c3 + c981ea5 commit 507958e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1795
-1662
lines changed

.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

aws_lambda_powertools/__init__.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
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
77
from .metrics import Metrics, single_metric
88
from .package_logger import set_package_logger_handler
@@ -16,4 +16,6 @@
1616
"Tracer",
1717
]
1818

19+
PACKAGE_PATH = Path(__file__).parent
20+
1921
set_package_logger_handler()

aws_lambda_powertools/event_handler/api_gateway.py

+16-8
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 (
@@ -124,10 +125,11 @@ def __init__(
124125

125126
def to_dict(self) -> Dict[str, str]:
126127
"""Builds the configured Access-Control http headers"""
127-
headers = {
128+
headers: Dict[str, str] = {
128129
"Access-Control-Allow-Origin": self.allow_origin,
129130
"Access-Control-Allow-Headers": ",".join(sorted(self.allow_headers)),
130131
}
132+
131133
if self.expose_headers:
132134
headers["Access-Control-Expose-Headers"] = ",".join(self.expose_headers)
133135
if self.max_age is not None:
@@ -145,7 +147,8 @@ def __init__(
145147
status_code: int,
146148
content_type: Optional[str],
147149
body: Union[str, bytes, None],
148-
headers: Optional[Dict] = None,
150+
headers: Optional[Dict[str, Union[str, List[str]]]] = None,
151+
cookies: Optional[List[Cookie]] = None,
149152
):
150153
"""
151154
@@ -158,13 +161,16 @@ def __init__(
158161
provided http headers
159162
body: Union[str, bytes, None]
160163
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.
164+
headers: dict[str, Union[str, List[str]]]
165+
Optionally set specific http headers. Setting "Content-Type" here would override the `content_type` value.
166+
cookies: list[Cookie]
167+
Optionally set cookies.
163168
"""
164169
self.status_code = status_code
165170
self.body = body
166171
self.base64_encoded = False
167-
self.headers: Dict = headers or {}
172+
self.headers: Dict[str, Union[str, List[str]]] = headers if headers else {}
173+
self.cookies = cookies or []
168174
if content_type:
169175
self.headers.setdefault("Content-Type", content_type)
170176

@@ -196,7 +202,8 @@ def _add_cors(self, cors: CORSConfig):
196202

197203
def _add_cache_control(self, cache_control: str):
198204
"""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"
205+
cache_control = cache_control if self.response.status_code == 200 else "no-cache"
206+
self.response.headers["Cache-Control"] = cache_control
200207

201208
def _compress(self):
202209
"""Compress the response body, but only if `Accept-Encoding` headers includes gzip."""
@@ -226,11 +233,12 @@ def build(self, event: BaseProxyEvent, cors: Optional[CORSConfig] = None) -> Dic
226233
logger.debug("Encoding bytes response with base64")
227234
self.response.base64_encoded = True
228235
self.response.body = base64.b64encode(self.response.body).decode()
236+
229237
return {
230238
"statusCode": self.response.status_code,
231-
"headers": self.response.headers,
232239
"body": self.response.body,
233240
"isBase64Encoded": self.response.base64_encoded,
241+
**event.header_serializer().serialize(headers=self.response.headers, cookies=self.response.cookies),
234242
}
235243

236244

@@ -596,7 +604,7 @@ def _path_starts_with(path: str, prefix: str):
596604

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

aws_lambda_powertools/tracing/tracer.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,8 @@ def capture_method(
354354
"""Decorator to create subsegment for arbitrary functions
355355
356356
It also captures both response and exceptions as metadata
357-
and creates a subsegment named `## <method_name>`
357+
and creates a subsegment named `## <method_module.method_qualifiedname>`
358+
# see here: [Qualified name for classes and functions](https://peps.python.org/pep-3155/)
358359
359360
When running [async functions concurrently](https://docs.python.org/3/library/asyncio-task.html#id6),
360361
methods may impact each others subsegment, and can trigger
@@ -508,7 +509,8 @@ async def async_tasks():
508509
functools.partial(self.capture_method, capture_response=capture_response, capture_error=capture_error),
509510
)
510511

511-
method_name = f"{method.__name__}"
512+
# Example: app.ClassA.get_all # noqa E800
513+
method_name = f"{method.__module__}.{method.__qualname__}"
512514

513515
capture_response = resolve_truthy_env_var_choice(
514516
env=os.getenv(constants.TRACER_CAPTURE_RESPONSE_ENV, "true"), choice=capture_response

aws_lambda_powertools/utilities/batch/__init__.py

-3
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,13 @@
1313
batch_processor,
1414
)
1515
from aws_lambda_powertools.utilities.batch.exceptions import ExceptionInfo
16-
from aws_lambda_powertools.utilities.batch.sqs import PartialSQSProcessor, sqs_batch_processor
1716

1817
__all__ = (
1918
"BatchProcessor",
2019
"BasePartialProcessor",
2120
"ExceptionInfo",
2221
"EventType",
2322
"FailureResponse",
24-
"PartialSQSProcessor",
2523
"SuccessResponse",
2624
"batch_processor",
27-
"sqs_batch_processor",
2825
)

0 commit comments

Comments
 (0)