Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(event_handler): improved support for headers and cookies in v2 #1455

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0c1f2f0
feat(event_handler): add support for setting cookies
rubenfonseca Aug 5, 2022
0d9dae3
feat(event_handler): format headers and cookies according to the requ…
rubenfonseca Aug 15, 2022
e28c8ca
chore(tests): move load_event helper to global utils
rubenfonseca Aug 15, 2022
38f1d92
chore(tests): add tests for header serializer
rubenfonseca Aug 15, 2022
8178362
chore(tests): fix all tests
rubenfonseca Aug 15, 2022
d7dea93
chore(tests): typo
rubenfonseca Aug 15, 2022
9089df7
chore(event_handler): move header serializer logic
rubenfonseca Aug 16, 2022
c7edb6d
Revert "chore(tests): move load_event helper to global utils"
rubenfonseca Aug 16, 2022
8ddce93
fix(event_handler): simplified tests
rubenfonseca Aug 16, 2022
66f1c41
docs(event_handler): update example
rubenfonseca Aug 16, 2022
a240f02
fix(event_handler): don't be smart about multiple headers
rubenfonseca Aug 17, 2022
5263a2d
fix(docs): simplified wording
rubenfonseca Aug 17, 2022
7ad8b88
feat(event_handler): add support for multiple headers with same key
rubenfonseca Aug 18, 2022
a705d26
chore(tests): fix headers test
rubenfonseca Aug 18, 2022
8bb2658
chore(tests): move headers test to the correct place
rubenfonseca Aug 18, 2022
e702ab8
tests(event_handler): add first e2e test
rubenfonseca Aug 19, 2022
7e169a9
chore(docs): initial upgrade guide for v2
rubenfonseca Aug 22, 2022
6faeb91
chore(tests): move load_event helper to global utils
rubenfonseca Aug 15, 2022
ba039f8
Revert "chore(tests): move load_event helper to global utils"
rubenfonseca Aug 16, 2022
e2b64d5
feat(event_handler): add e2e tests
rubenfonseca Aug 23, 2022
7646079
chore(deps): add latest cdk packages
rubenfonseca Aug 23, 2022
3271d75
chore(event_handler): address review comments
rubenfonseca Aug 24, 2022
e5ceac6
chore(deps): dropped python 3.6 from pyproject
rubenfonseca Aug 24, 2022
2aca4f0
chore(event_handler): applied suggestions from code review
rubenfonseca Aug 24, 2022
e6ccc69
chore(deps): remove old python version guard
rubenfonseca Aug 24, 2022
bed825d
chore(deps): upgrade black
rubenfonseca Aug 24, 2022
70f61dc
chore(test): move cookie name to GIVEN block
rubenfonseca Aug 25, 2022
1b4b18d
chore(docs): move constant to GIVEN block
rubenfonseca Aug 25, 2022
8fe8398
chore(tests): use table-testing
rubenfonseca Aug 25, 2022
56b0d2c
chore: re-added sorting of CORS headers
rubenfonseca Aug 25, 2022
fbac728
chore: simplified tests
rubenfonseca Aug 25, 2022
840e9c4
chore: change test name to ease scanning
heitorlessa Aug 29, 2022
60d07f7
chore: revert parametrize into single tests like Ruben had
heitorlessa Aug 29, 2022
8ce6c50
chore: revert parametrize into single tests like Ruben had pt2
heitorlessa Aug 29, 2022
a27d674
feat(event_handler): supppor headers with either str or List[str] values
rubenfonseca Aug 29, 2022
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -305,5 +305,8 @@ site/
!404.html
!docs/overrides/*.html

# CDK
.cdk

!.github/workflows/lib
examples/**/sam/.aws-sam
6 changes: 4 additions & 2 deletions aws_lambda_powertools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# -*- coding: utf-8 -*-

"""Top-level package for Lambda Python Powertools."""

from pathlib import Path

"""Top-level package for Lambda Python Powertools."""
from .logging import Logger # noqa: F401
from .metrics import Metrics, single_metric # noqa: F401
from .package_logger import set_package_logger_handler
from .tracing import Tracer # noqa: F401

__author__ = """Amazon Web Services"""

PACKAGE_PATH = Path(__file__).parent

set_package_logger_handler()
27 changes: 17 additions & 10 deletions aws_lambda_powertools/event_handler/api_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
_SAFE_URI = "-._~()'!*:@,;" # https://www.ietf.org/rfc/rfc3986.txt
# API GW/ALB decode non-safe URI chars; we must support them too
_UNSAFE_URI = "%<> \[\]{}|^" # noqa: W605
_NAMED_GROUP_BOUNDARY_PATTERN = fr"(?P\1[{_SAFE_URI}{_UNSAFE_URI}\\w]+)"
_NAMED_GROUP_BOUNDARY_PATTERN = rf"(?P\1[{_SAFE_URI}{_UNSAFE_URI}\\w]+)"


class ProxyEventType(Enum):
Expand Down Expand Up @@ -124,10 +124,11 @@ def __init__(

def to_dict(self) -> Dict[str, str]:
"""Builds the configured Access-Control http headers"""
headers = {
headers: Dict[str, str] = {
"Access-Control-Allow-Origin": self.allow_origin,
"Access-Control-Allow-Headers": ",".join(sorted(self.allow_headers)),
}

if self.expose_headers:
headers["Access-Control-Expose-Headers"] = ",".join(self.expose_headers)
if self.max_age is not None:
Expand All @@ -145,7 +146,8 @@ def __init__(
status_code: int,
content_type: Optional[str],
body: Union[str, bytes, None],
headers: Optional[Dict] = None,
headers: Optional[Dict[str, Union[str, List[str]]]] = None,
cookies: Optional[List[str]] = None,
):
"""

Expand All @@ -158,13 +160,16 @@ def __init__(
provided http headers
body: Union[str, bytes, None]
Optionally set the response body. Note: bytes body will be automatically base64 encoded
headers: dict
Optionally set specific http headers. Setting "Content-Type" hear would override the `content_type` value.
headers: dict[str, Union[str, List[str]]]
Optionally set specific http headers. Setting "Content-Type" here would override the `content_type` value.
cookies: list[str]
Optionally set cookies.
"""
self.status_code = status_code
self.body = body
self.base64_encoded = False
self.headers: Dict = headers or {}
self.headers: Dict[str, Union[str, List[str]]] = headers if headers else {}
self.cookies = cookies or []
if content_type:
self.headers.setdefault("Content-Type", content_type)

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

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

def _compress(self):
"""Compress the response body, but only if `Accept-Encoding` headers includes gzip."""
self.response.headers["Content-Encoding"] = "gzip"
self.response.headers["Content-Encoding"].append("gzip")
if isinstance(self.response.body, str):
logger.debug("Converting string response to bytes before compressing it")
self.response.body = bytes(self.response.body, "utf-8")
Expand All @@ -226,11 +232,12 @@ def build(self, event: BaseProxyEvent, cors: Optional[CORSConfig] = None) -> Dic
logger.debug("Encoding bytes response with base64")
self.response.base64_encoded = True
self.response.body = base64.b64encode(self.response.body).decode()

return {
"statusCode": self.response.status_code,
"headers": self.response.headers,
"body": self.response.body,
"isBase64Encoded": self.response.base64_encoded,
**event.header_serializer().serialize(headers=self.response.headers, cookies=self.response.cookies),
}


Expand Down Expand Up @@ -596,7 +603,7 @@ def _path_starts_with(path: str, prefix: str):

def _not_found(self, method: str) -> ResponseBuilder:
"""Called when no matching route was found and includes support for the cors preflight response"""
headers = {}
headers: Dict[str, Union[str, List[str]]] = {}
if self._cors:
logger.debug("CORS is enabled, updating headers.")
headers.update(self._cors.to_dict())
Expand Down
3 changes: 1 addition & 2 deletions aws_lambda_powertools/event_handler/appsync.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
from abc import ABC
from typing import Any, Callable, Optional, Type, TypeVar

from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
Expand All @@ -10,7 +9,7 @@
AppSyncResolverEventT = TypeVar("AppSyncResolverEventT", bound=AppSyncResolverEvent)


class BaseRouter(ABC):
class BaseRouter:
current_event: AppSyncResolverEventT # type: ignore[valid-type]
lambda_context: LambdaContext

Expand Down
111 changes: 111 additions & 0 deletions aws_lambda_powertools/shared/headers_serializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import warnings
from collections import defaultdict
from typing import Any, Dict, List, Union


class BaseHeadersSerializer:
"""
Helper class to correctly serialize headers and cookies for Amazon API Gateway,
ALB and Lambda Function URL response payload.
"""

def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
"""
Serializes headers and cookies according to the request type.
Returns a dict that can be merged with the response payload.

Parameters
----------
headers: Dict[str, List[str]]
A dictionary of headers to set in the response
cookies: List[str]
A list of cookies to set in the response
"""
raise NotImplementedError()


class HttpApiHeadersSerializer(BaseHeadersSerializer):
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
"""
When using HTTP APIs or LambdaFunctionURLs, everything is taken care automatically for us.
We can directly assign a list of cookies and a dict of headers to the response payload, and the
runtime will automatically serialize them correctly on the output.

https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.proxy-format
https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.response
"""

# Format 2.0 doesn't have multiValueHeaders or multiValueQueryStringParameters fields.
# Duplicate headers are combined with commas and included in the headers field.
combined_headers: Dict[str, str] = {}
for key, values in headers.items():
if isinstance(values, str):
combined_headers[key] = values
else:
combined_headers[key] = ", ".join(values)

return {"headers": combined_headers, "cookies": cookies}


class MultiValueHeadersSerializer(BaseHeadersSerializer):
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
"""
When using REST APIs, headers can be encoded using the `multiValueHeaders` key on the response.
This is also the case when using an ALB integration with the `multiValueHeaders` option enabled.
The solution covers headers with just one key or multiple keys.

https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format
https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers-response
"""
payload: Dict[str, List[str]] = defaultdict(list)

for key, values in headers.items():
if isinstance(values, str):
payload[key].append(values)
else:
for value in values:
payload[key].append(value)

if cookies:
payload.setdefault("Set-Cookie", [])
for cookie in cookies:
payload["Set-Cookie"].append(cookie)

return {"multiValueHeaders": payload}


class SingleValueHeadersSerializer(BaseHeadersSerializer):
def serialize(self, headers: Dict[str, Union[str, List[str]]], cookies: List[str]) -> Dict[str, Any]:
"""
The ALB integration has `multiValueHeaders` disabled by default.
If we try to set multiple headers with the same key, or more than one cookie, print a warning.

https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#respond-to-load-balancer
"""
payload: Dict[str, Dict[str, str]] = {}
payload.setdefault("headers", {})

if cookies:
if len(cookies) > 1:
warnings.warn(
"Can't encode more than one cookie in the response. Sending the last cookie only. "
"Did you enable multiValueHeaders on the ALB Target Group?"
)

# We can only send one cookie, send the last one
payload["headers"]["Set-Cookie"] = cookies[-1]

for key, values in headers.items():
if isinstance(values, str):
payload["headers"][key] = values
else:
if len(values) > 1:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@heitorlessa I'm seeing TypeError: object of type 'NoneType' has no len() when I have the following header set:

'Access-Control-Allow-Origin': None

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasmarc could you create a bug report issue on this please?

We can make a patch release as soon as we reproduce it.

Thanks a lot!!!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with my laptop now

While we await for a bug report and a fix by EOD for headers with None value, you can safely use an empty string instead of None.

Because we provide native support for CORS in Event Handler, you can do as follows:

Test URL: https://xwgyz6mo2m.execute-api.eu-west-1.amazonaws.com/Prod/hello/

from aws_lambda_powertools.event_handler import APIGatewayRestResolver, CORSConfig

cors = CORSConfig(allow_origin="")  # Create CORS configuration
app = APIGatewayRestResolver(cors=cors)  # Ensure CORS is properly configured for all routes


@app.get("/hello")
def hello():
    return {"hello": "world"}


def lambda_handler(event, context):
    return app.resolve(event, context)

If your intent is to set an explicit null in the Allow-Origin...

W3C doesn't recommend having an Allow Origin explicitly set to null due to potential data leak, since data: and file: schemes can accessnull origins - that's not the case with an empty Allow Origin (''). Unless I'm misunderstanding both your intent (bug report helps!) or W3C docs.


With an explicit null

import json

from aws_lambda_powertools.event_handler import (
    APIGatewayRestResolver,
    CORSConfig,
    Response,
)

# Not recommended due to data: and file: schemes also using `null` origin
# https://w3c.github.io/webappsec-cors-for-developers/#avoid-returning-access-control-allow-origin-null
cors = CORSConfig(allow_origin="null")
app = APIGatewayRestResolver(cors=cors)


# https://xwgyz6mo2m.execute-api.eu-west-1.amazonaws.com/Prod/hello/
@app.get("/hello")
def hello():
    return {"hello": "world"}


# chore: verify API Gateway REST Lambda integration behaviour with `None`
# API Gateway REST serializes`None` as an empty string (just like `null` in JS)
# need proper E2E to validate behaviour for API Gateway HTTP (v2), ALB, and Function URL resolvers

# https://xwgyz6mo2m.execute-api.eu-west-1.amazonaws.com/Prod/none/
@app.get("/none")
def hello():
    headers = {"X-Empty": None, "X-Null": "null"}
    response = {"hello": "world"}
    return Response(body=json.dumps(response), headers=headers, status_code=200)


def lambda_handler(event, context):
    return app.resolve(event, context)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@leandrodamascena when you're back online, feel free to create a bug report if we don't hear from the customer within an hour. Leaving the tests and my endpoint above to save you from setting it all up besides E2E.


tests/functional/test_headers_serializer.py

These tests reproduce the issue. What needs to be done is an E2E for each resolver to confirm whether the final value becomes '' or fails (ALB is always the odd one) - this needs to be documented too later.

def test_http_api_headers_serializer_with_null_values():
    serializer = HttpApiHeadersSerializer()
    payload = serializer.serialize(headers={"Foo": None}, cookies=[])
    assert payload == {"headers": {}, "cookies": []}


def test_multi_value_headers_serializer_with_null_values():
    serializer = MultiValueHeadersSerializer()
    payload = serializer.serialize(headers={"Foo": None}, cookies=[])
    assert payload == {"headers": {}, "cookies": []}


def test_single_value_headers_serializer_with_null_values():
    serializer = SingleValueHeadersSerializer()
    payload = serializer.serialize(headers={"Foo": None}, cookies=[])
    assert payload["headers"] == {}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @heitorlessa sorry for the delay. Yes, I worked around it and I did also notice that it was not recommended, so I almost hesitated to bring it up.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have filed #1791

warnings.warn(
f"Can't encode more than one header value for the same key ('{key}') in the response. "
"Did you enable multiValueHeaders on the ALB Target Group?"
)

# We can only set one header per key, send the last one
payload["headers"][key] = values[-1]

return payload
13 changes: 13 additions & 0 deletions aws_lambda_powertools/utilities/data_classes/alb_event.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from typing import Dict, List, Optional

from aws_lambda_powertools.shared.headers_serializer import (
BaseHeadersSerializer,
MultiValueHeadersSerializer,
SingleValueHeadersSerializer,
)
from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent, DictWrapper


Expand Down Expand Up @@ -30,3 +35,11 @@ def multi_value_query_string_parameters(self) -> Optional[Dict[str, List[str]]]:
@property
def multi_value_headers(self) -> Optional[Dict[str, List[str]]]:
return self.get("multiValueHeaders")

def header_serializer(self) -> BaseHeadersSerializer:
# When using the ALB integration, the `multiValueHeaders` feature can be disabled (default) or enabled.
# We can determine if the feature is enabled by looking if the event has a `multiValueHeaders` key.
if self.multi_value_headers:
return MultiValueHeadersSerializer()

return SingleValueHeadersSerializer()
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from typing import Any, Dict, List, Optional

from aws_lambda_powertools.shared.headers_serializer import (
BaseHeadersSerializer,
HttpApiHeadersSerializer,
MultiValueHeadersSerializer,
)
from aws_lambda_powertools.utilities.data_classes.common import (
BaseProxyEvent,
BaseRequestContext,
Expand Down Expand Up @@ -106,6 +111,9 @@ def path_parameters(self) -> Optional[Dict[str, str]]:
def stage_variables(self) -> Optional[Dict[str, str]]:
return self.get("stageVariables")

def header_serializer(self) -> BaseHeadersSerializer:
return MultiValueHeadersSerializer()


class RequestContextV2AuthorizerIam(DictWrapper):
@property
Expand Down Expand Up @@ -250,3 +258,6 @@ def path(self) -> str:
def http_method(self) -> str:
"""The HTTP method used. Valid values include: DELETE, GET, HEAD, OPTIONS, PATCH, POST, and PUT."""
return self.request_context.http.method

def header_serializer(self):
return HttpApiHeadersSerializer()
5 changes: 5 additions & 0 deletions aws_lambda_powertools/utilities/data_classes/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import json
from typing import Any, Dict, Optional

from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer


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

def header_serializer(self) -> BaseHeadersSerializer:
raise NotImplementedError()

rubenfonseca marked this conversation as resolved.
Show resolved Hide resolved

class RequestContextClientCert(DictWrapper):
@property
Expand Down
11 changes: 9 additions & 2 deletions docs/core/event_handler/api_gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,11 +312,18 @@ For convenience, these are the default values when using `CORSConfig` to enable

### Fine grained responses

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.
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.

???+ info
Powertools serializes headers and cookies according to the type of input event.
Some event sources require headers and cookies to be encoded as `multiValueHeaders`.

???+ warning "Using multiple values for HTTP headers in ALB?"
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.

=== "fine_grained_responses.py"

```python hl_lines="7 24-28"
```python hl_lines="7 24-29"
--8<-- "examples/event_handler_rest/src/fine_grained_responses.py"
```

Expand Down
57 changes: 57 additions & 0 deletions docs/upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
title: Upgrade guide
description: Guide to update between major Powertools versions
---

<!-- markdownlint-disable MD043 -->

## Migrate to v2 from v1

The transition from Powertools for Python v1 to v2 is as painless as possible, as we aimed for minimal breaking changes.
Changes at a glance:

* The API for **event handler's `Response`** has minor changes to support multi value headers and cookies.

???+ important
Powertools for Python v2 drops suport for Python 3.6, following the Python 3.6 End-Of-Life (EOL) reached on December 23, 2021.

### Initial Steps

Before you start, we suggest making a copy of your current working project or create a new branch with git.

1. **Upgrade** Python to at least v3.7

2. **Ensure** you have the latest `aws-lambda-powertools`

```bash
pip install aws-lambda-powertools -U
```

3. **Review** the following sections to confirm whether they affect your code

## Event Handler Response (headers and cookies)

The `Response` class of the event handler utility changed slightly:

1. The `headers` parameter now expects either a value or list of values per header (type `Union[str, Dict[str, List[str]]]`)
2. We introduced a new `cookies` parameter (type `List[str]`)

???+ note
Code that set headers as `Dict[str, str]` will still work unchanged.

```python hl_lines="6 12 13"
@app.get("/todos")
def get_todos():
# Before
return Response(
# ...
headers={"Content-Type": "text/plain"}
)

# After
return Response(
# ...
headers={"Content-Type": ["text/plain"]},
cookies=["CookieName=CookieValue"]
)
```
Loading