From c5291c287495623fa3be7e49cb488a11ff1fca87 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Fri, 23 Jun 2023 10:42:03 +0100 Subject: [PATCH 1/4] feature: adding Response compress parameter --- .../event_handler/api_gateway.py | 25 ++++++++- docs/core/event_handler/api_gateway.md | 15 ++++-- .../compressing_responses_using_response.py | 31 +++++++++++ ...y => compressing_responses_using_route.py} | 0 .../event_handler/test_api_gateway.py | 52 +++++++++++++++++++ 5 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 examples/event_handler_rest/src/compressing_responses_using_response.py rename examples/event_handler_rest/src/{compressing_responses.py => compressing_responses_using_route.py} (100%) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 962fd51ccf4..b9c30f25499 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -177,6 +177,7 @@ def __init__( body: Union[str, bytes, None] = None, headers: Optional[Dict[str, Union[str, List[str]]]] = None, cookies: Optional[List[Cookie]] = None, + compress: Optional[bool] = None, ): """ @@ -199,6 +200,7 @@ def __init__( self.base64_encoded = False self.headers: Dict[str, Union[str, List[str]]] = headers if headers else {} self.cookies = cookies or [] + self.compress = compress if content_type: self.headers.setdefault("Content-Type", content_type) @@ -250,12 +252,33 @@ def _route(self, event: BaseProxyEvent, cors: Optional[CORSConfig]): self._add_cors(event, cors or CORSConfig()) if self.route.cache_control: self._add_cache_control(self.route.cache_control) - if self.route.compress and "gzip" in (event.get_header_value("accept-encoding", "") or ""): + if ( + self.route.compress + and "gzip" in (event.get_header_value("accept-encoding", "") or "") + and self.response.compress is not False + ): + self._compress() + + def _response(self, event: BaseProxyEvent): + """ + The Response object can encode and compress the response by setting the 'compress' parameter to True. + + Parameters + ---------- + event: BaseProxyEvent + The event object representing the incoming request. + + Returns + ------- + None + """ + if self.response.compress and "gzip" in (event.get_header_value("accept-encoding", "") or ""): self._compress() def build(self, event: BaseProxyEvent, cors: Optional[CORSConfig] = None) -> Dict[str, Any]: """Build the full response dict to be returned by the lambda""" self._route(event, cors) + self._response(event) if isinstance(self.response.body, bytes): logger.debug("Encoding bytes response with base64") diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index 0fafba80b47..ef544a57d0f 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -360,15 +360,24 @@ You can use the `Response` class to have full control over the response. For exa ### Compress -You can compress with gzip and base64 encode your responses via `compress` parameter. +You can compress with gzip and base64 encode your responses via `compress` parameter. You have the option to pass the `compress` parameter when working with a specific route or using the Response object. + +???+ info + The `compress` parameter used in the Response object takes precedence over the one used in the route. ???+ warning The client must send the `Accept-Encoding` header, otherwise a normal response will be sent. -=== "compressing_responses.py" +=== "compressing_responses_using_route.py" ```python hl_lines="17 27" - --8<-- "examples/event_handler_rest/src/compressing_responses.py" + --8<-- "examples/event_handler_rest/src/compressing_responses_using_route.py" + ``` + +=== "compressing_responses_using_response.py" + + ```python hl_lines="24" + --8<-- "examples/event_handler_rest/src/compressing_responses_using_response.py" ``` === "compressing_responses.json" diff --git a/examples/event_handler_rest/src/compressing_responses_using_response.py b/examples/event_handler_rest/src/compressing_responses_using_response.py new file mode 100644 index 00000000000..b777ab40af9 --- /dev/null +++ b/examples/event_handler_rest/src/compressing_responses_using_response.py @@ -0,0 +1,31 @@ +import requests + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import ( + APIGatewayRestResolver, + Response, + content_types, +) +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.utilities.typing import LambdaContext + +tracer = Tracer() +logger = Logger() +app = APIGatewayRestResolver() + + +@app.get("/todos") +@tracer.capture_method +def get_todos(): + todos: requests.Response = requests.get("https://jsonplaceholder.typicode.com/todos") + todos.raise_for_status() + + # for brevity, we'll limit to the first 10 only + return Response(status_code=200, content_type=content_types.APPLICATION_JSON, body=todos.json()[:10], compress=True) + + +# You can continue to use other utilities just as before +@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) +@tracer.capture_lambda_handler +def lambda_handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/examples/event_handler_rest/src/compressing_responses.py b/examples/event_handler_rest/src/compressing_responses_using_route.py similarity index 100% rename from examples/event_handler_rest/src/compressing_responses.py rename to examples/event_handler_rest/src/compressing_responses_using_route.py diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index c17422f8d94..1a00e23181f 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -366,6 +366,58 @@ def test_cors_preflight_body_is_empty_not_null(): assert result["body"] == "" +def test_override_route_compress_parameter(): + # GIVEN a function that has compress=True + # AND an event with a "Accept-Encoding" that include gzip + # AND the Response object with compress=False + app = ApiGatewayResolver() + mock_event = {"path": "/my/request", "httpMethod": "GET", "headers": {"Accept-Encoding": "deflate, gzip"}} + expected_value = '{"test": "value"}' + + @app.get("/my/request", compress=True) + def with_compression() -> Response: + return Response(200, content_types.APPLICATION_JSON, expected_value, compress=False) + + def handler(event, context): + return app.resolve(event, context) + + # WHEN calling the event handler + result = handler(mock_event, None) + + # THEN then the response is not compressed + assert result["isBase64Encoded"] is False + assert result["body"] == expected_value + assert result["multiValueHeaders"].get("Content-Encoding") is None + + +def test_compress_response_object(): + # GIVEN a function + # AND an event with a "Accept-Encoding" that include gzip + # AND the Response object with compress=True + app = ApiGatewayResolver() + mock_event = {"path": "/my/request", "httpMethod": "GET", "headers": {"Accept-Encoding": "deflate, gzip"}} + expected_value = '{"test": "value"}' + + @app.get("/my/request") + def route_without_compression() -> Response: + return Response(200, content_types.APPLICATION_JSON, expected_value, compress=True) + + def handler(event, context): + return app.resolve(event, context) + + # WHEN calling the event handler + result = handler(mock_event, None) + + # THEN then gzip the response and base64 encode as a string + assert result["isBase64Encoded"] is True + body = result["body"] + assert isinstance(body, str) + decompress = zlib.decompress(base64.b64decode(body), wbits=zlib.MAX_WBITS | 16).decode("UTF-8") + assert decompress == expected_value + headers = result["multiValueHeaders"] + assert headers["Content-Encoding"] == ["gzip"] + + def test_compress(): # GIVEN a function that has compress=True # AND an event with a "Accept-Encoding" that include gzip From dc4f211184c991d01f83f933c4965787f7fea510 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Fri, 23 Jun 2023 11:41:18 +0100 Subject: [PATCH 2/4] feature: addressing Heitor's feedback --- .../event_handler/api_gateway.py | 29 +++++++++++++++---- .../event_handler/test_api_gateway.py | 2 +- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index b9c30f25499..43888ba6494 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -235,6 +235,28 @@ def _add_cache_control(self, cache_control: str): cache_control = cache_control if self.response.status_code == 200 else "no-cache" self.response.headers["Cache-Control"] = cache_control + def _check_compress_enabled(self, compress: Optional[bool], event: BaseProxyEvent) -> bool: + """ + Checks if compression is enabled + + Parameters + ---------- + compress: bool, optional + A boolean indicating whether compression is enabled or not. + event: BaseProxyEvent + The event object containing the request details. + + Returns + ------- + bool + True if compression is enabled and the "gzip" encoding is accepted, False otherwise. + """ + + if compress and "gzip" in (event.get_header_value("accept-encoding", "") or ""): + return True + + return False + def _compress(self): """Compress the response body, but only if `Accept-Encoding` headers includes gzip.""" self.response.headers["Content-Encoding"] = "gzip" @@ -252,11 +274,8 @@ def _route(self, event: BaseProxyEvent, cors: Optional[CORSConfig]): self._add_cors(event, cors or CORSConfig()) if self.route.cache_control: self._add_cache_control(self.route.cache_control) - if ( - self.route.compress - and "gzip" in (event.get_header_value("accept-encoding", "") or "") - and self.response.compress is not False - ): + # The `compress` parameter used in the Response object takes precedence over the one used in the route. + if self._check_compress_enabled(self.route.compress, event) and self.response.compress is not False: self._compress() def _response(self, event: BaseProxyEvent): diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index 1a00e23181f..9d2d3c5184e 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -390,7 +390,7 @@ def handler(event, context): assert result["multiValueHeaders"].get("Content-Encoding") is None -def test_compress_response_object(): +def test_response_with_compress_enabled(): # GIVEN a function # AND an event with a "Accept-Encoding" that include gzip # AND the Response object with compress=True From a6280537179e122dbf85319a772d0e1f5c821469 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Fri, 23 Jun 2023 11:44:46 +0100 Subject: [PATCH 3/4] feature: addressing Heitor's feedback --- aws_lambda_powertools/event_handler/api_gateway.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 43888ba6494..11c0853b7b1 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -235,7 +235,7 @@ def _add_cache_control(self, cache_control: str): cache_control = cache_control if self.response.status_code == 200 else "no-cache" self.response.headers["Cache-Control"] = cache_control - def _check_compress_enabled(self, compress: Optional[bool], event: BaseProxyEvent) -> bool: + def _has_compression_enabled(self, compress: Optional[bool], event: BaseProxyEvent) -> bool: """ Checks if compression is enabled @@ -275,7 +275,7 @@ def _route(self, event: BaseProxyEvent, cors: Optional[CORSConfig]): if self.route.cache_control: self._add_cache_control(self.route.cache_control) # The `compress` parameter used in the Response object takes precedence over the one used in the route. - if self._check_compress_enabled(self.route.compress, event) and self.response.compress is not False: + if self._has_compression_enabled(self.route.compress, event) and self.response.compress is not False: self._compress() def _response(self, event: BaseProxyEvent): @@ -291,7 +291,7 @@ def _response(self, event: BaseProxyEvent): ------- None """ - if self.response.compress and "gzip" in (event.get_header_value("accept-encoding", "") or ""): + if self._has_compression_enabled(self.response.compress, event): self._compress() def build(self, event: BaseProxyEvent, cors: Optional[CORSConfig] = None) -> Dict[str, Any]: From 0326db07f7b981f0a170607f64fbed4db2e997c3 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Fri, 23 Jun 2023 14:07:35 +0200 Subject: [PATCH 4/4] refactor(event_handler): make _has_compression_enabled standalone --- .../event_handler/api_gateway.py | 46 ++++++++----------- .../utilities/data_classes/common.py | 1 + 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 11c0853b7b1..75301a8928c 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -235,14 +235,21 @@ def _add_cache_control(self, cache_control: str): cache_control = cache_control if self.response.status_code == 200 else "no-cache" self.response.headers["Cache-Control"] = cache_control - def _has_compression_enabled(self, compress: Optional[bool], event: BaseProxyEvent) -> bool: + @staticmethod + def _has_compression_enabled( + route_compression: bool, response_compression: Optional[bool], event: BaseProxyEvent + ) -> bool: """ - Checks if compression is enabled + Checks if compression is enabled. + + NOTE: Response compression takes precedence. Parameters ---------- - compress: bool, optional - A boolean indicating whether compression is enabled or not. + route_compression: bool, optional + A boolean indicating whether compression is enabled or not in the route setting. + response_compression: bool, optional + A boolean indicating whether compression is enabled or not in the response setting. event: BaseProxyEvent The event object containing the request details. @@ -251,9 +258,12 @@ def _has_compression_enabled(self, compress: Optional[bool], event: BaseProxyEve bool True if compression is enabled and the "gzip" encoding is accepted, False otherwise. """ - - if compress and "gzip" in (event.get_header_value("accept-encoding", "") or ""): - return True + encoding: str = event.get_header_value(name="accept-encoding", default_value="", case_sensitive=False) # type: ignore[assignment] # noqa: E501 + if "gzip" in encoding: + if response_compression is not None: + return response_compression # e.g., Response(compress=False/True)) + if route_compression: + return True # e.g., @app.get(compress=True) return False @@ -274,30 +284,14 @@ def _route(self, event: BaseProxyEvent, cors: Optional[CORSConfig]): self._add_cors(event, cors or CORSConfig()) if self.route.cache_control: self._add_cache_control(self.route.cache_control) - # The `compress` parameter used in the Response object takes precedence over the one used in the route. - if self._has_compression_enabled(self.route.compress, event) and self.response.compress is not False: - self._compress() - - def _response(self, event: BaseProxyEvent): - """ - The Response object can encode and compress the response by setting the 'compress' parameter to True. - - Parameters - ---------- - event: BaseProxyEvent - The event object representing the incoming request. - - Returns - ------- - None - """ - if self._has_compression_enabled(self.response.compress, event): + if self._has_compression_enabled( + route_compression=self.route.compress, response_compression=self.response.compress, event=event + ): self._compress() def build(self, event: BaseProxyEvent, cors: Optional[CORSConfig] = None) -> Dict[str, Any]: """Build the full response dict to be returned by the lambda""" self._route(event, cors) - self._response(event) if isinstance(self.response.body, bytes): logger.debug("Encoding bytes response with base64") diff --git a/aws_lambda_powertools/utilities/data_classes/common.py b/aws_lambda_powertools/utilities/data_classes/common.py index a862c7da454..c778040906d 100644 --- a/aws_lambda_powertools/utilities/data_classes/common.py +++ b/aws_lambda_powertools/utilities/data_classes/common.py @@ -154,6 +154,7 @@ def get_query_string_value(self, name: str, default_value: Optional[str] = None) query_string_parameters=self.query_string_parameters, name=name, default_value=default_value ) + # Maintenance: missing @overload to ensure return type is a str when default_value is set def get_header_value( self, name: str, default_value: Optional[str] = None, case_sensitive: Optional[bool] = False ) -> Optional[str]: