From dd0f1ad6e4392f5fe543a55e5eeb74375b31e29b Mon Sep 17 00:00:00 2001 From: Lucas Braga Date: Fri, 2 May 2025 10:34:47 -0700 Subject: [PATCH 1/4] allowed a new deserializer for parser the body --- .../event_handler/api_gateway.py | 21 ++++++++++------ .../_pydantic/test_api_gateway.py | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index f1f38b399a9..8e3ce1e7e4d 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1575,6 +1575,7 @@ def __init__( strip_prefixes: list[str | Pattern] | None = None, enable_validation: bool = False, response_validation_error_http_code: HTTPStatus | int | None = None, + deserializer: Callable[[str], dict] | None = None, ): """ Parameters @@ -1596,6 +1597,9 @@ def __init__( Enables validation of the request body against the route schema, by default False. response_validation_error_http_code Sets the returned status code if response is not validated. enable_validation must be True. + deserializer: Callable[[str], dict], optional + function to deserialize `str`, `bytes`, `bytearray` containing a JSON document to a Python `dict`, + by default json.loads """ self._proxy_type = proxy_type self._dynamic_routes: list[Route] = [] @@ -1621,6 +1625,7 @@ def __init__( # Allow for a custom serializer or a concise json serialization self._serializer = serializer or partial(json.dumps, separators=(",", ":"), cls=Encoder) + self._deserializer = deserializer if self._enable_validation: from aws_lambda_powertools.event_handler.middlewares.openapi_validation import OpenAPIValidationMiddleware @@ -2431,24 +2436,24 @@ def _to_proxy_event(self, event: dict) -> BaseProxyEvent: # noqa: PLR0911 # ig """Convert the event dict to the corresponding data class""" if self._proxy_type == ProxyEventType.APIGatewayProxyEvent: logger.debug("Converting event to API Gateway REST API contract") - return APIGatewayProxyEvent(event) + return APIGatewayProxyEvent(event, self._deserializer) if self._proxy_type == ProxyEventType.APIGatewayProxyEventV2: logger.debug("Converting event to API Gateway HTTP API contract") - return APIGatewayProxyEventV2(event) + return APIGatewayProxyEventV2(event, self._deserializer) if self._proxy_type == ProxyEventType.BedrockAgentEvent: logger.debug("Converting event to Bedrock Agent contract") - return BedrockAgentEvent(event) + return BedrockAgentEvent(event, self._deserializer) if self._proxy_type == ProxyEventType.LambdaFunctionUrlEvent: logger.debug("Converting event to Lambda Function URL contract") - return LambdaFunctionUrlEvent(event) + return LambdaFunctionUrlEvent(event, self._deserializer) if self._proxy_type == ProxyEventType.VPCLatticeEvent: logger.debug("Converting event to VPC Lattice contract") - return VPCLatticeEvent(event) + return VPCLatticeEvent(event, self._deserializer) if self._proxy_type == ProxyEventType.VPCLatticeEventV2: logger.debug("Converting event to VPC LatticeV2 contract") - return VPCLatticeEventV2(event) + return VPCLatticeEventV2(event, self._deserializer) logger.debug("Converting event to ALB contract") - return ALBEvent(event) + return ALBEvent(event, self._deserializer) def _resolve(self) -> ResponseBuilder: """Resolves the response or return the not found response""" @@ -2865,6 +2870,7 @@ def __init__( strip_prefixes: list[str | Pattern] | None = None, enable_validation: bool = False, response_validation_error_http_code: HTTPStatus | int | None = None, + deserializer: Callable[[str], dict] | None = None, ): """Amazon API Gateway REST and HTTP API v1 payload resolver""" super().__init__( @@ -2875,6 +2881,7 @@ def __init__( strip_prefixes, enable_validation, response_validation_error_http_code, + deserializer, ) def _get_base_path(self) -> str: diff --git a/tests/functional/event_handler/_pydantic/test_api_gateway.py b/tests/functional/event_handler/_pydantic/test_api_gateway.py index ce3fd89e864..bbd13082841 100644 --- a/tests/functional/event_handler/_pydantic/test_api_gateway.py +++ b/tests/functional/event_handler/_pydantic/test_api_gateway.py @@ -1,5 +1,9 @@ from __future__ import annotations +import json +from decimal import Decimal +from functools import partial + from pydantic import BaseModel from aws_lambda_powertools.event_handler import content_types @@ -80,3 +84,24 @@ def get_lambda(param: int): ... assert result["statusCode"] == 422 assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] assert "missing" in result["body"] + + +def test_api_gateway_resolver_numeric_value(): + # GIVEN a basic API Gateway resolver + app = ApiGatewayResolver(deserializer=partial(json.loads, parse_float=Decimal)) + + @app.post("/my/path") + def test_handler(): + return app.current_event.json_body + + # WHEN calling the event handler + event = {} + event.update(LOAD_GW_EVENT) + event["body"] = '{"amount": 2.2999999999999998}' + event["httpMethod"] = "POST" + + result = app(event, {}) + # THEN process event correctly + assert result["statusCode"] == 200 + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] + assert result["body"] == '{"amount":"2.2999999999999998"}' From 1487af250d36b43d4216b55dac986febf174b6f0 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Mon, 26 May 2025 19:00:30 +0100 Subject: [PATCH 2/4] Change the name + add examples --- .../event_handler/api_gateway.py | 28 +++++++++++-------- .../event_handler/bedrock_agent.py | 1 + .../event_handler/lambda_function_url.py | 2 ++ .../event_handler/vpc_lattice.py | 4 +++ docs/core/event_handler/api_gateway.md | 8 ++++++ .../src/custom_json_deserializer.py | 27 ++++++++++++++++++ .../_pydantic/test_api_gateway.py | 25 ----------------- .../required_dependencies/test_api_gateway.py | 26 ++++++++++++++++- 8 files changed, 83 insertions(+), 38 deletions(-) create mode 100644 examples/event_handler_rest/src/custom_json_deserializer.py diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 7114029555b..e2aab60f164 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1579,7 +1579,7 @@ def __init__( strip_prefixes: list[str | Pattern] | None = None, enable_validation: bool = False, response_validation_error_http_code: HTTPStatus | int | None = None, - deserializer: Callable[[str], dict] | None = None, + json_body_deserializer: Callable[[str], dict] | None = None, ): """ Parameters @@ -1601,7 +1601,7 @@ def __init__( Enables validation of the request body against the route schema, by default False. response_validation_error_http_code Sets the returned status code if response is not validated. enable_validation must be True. - deserializer: Callable[[str], dict], optional + json_body_deserializer: Callable[[str], dict], optional function to deserialize `str`, `bytes`, `bytearray` containing a JSON document to a Python `dict`, by default json.loads """ @@ -1629,7 +1629,7 @@ def __init__( # Allow for a custom serializer or a concise json serialization self._serializer = serializer or partial(json.dumps, separators=(",", ":"), cls=Encoder) - self._deserializer = deserializer + self._json_body_deserializer = json_body_deserializer if self._enable_validation: from aws_lambda_powertools.event_handler.middlewares.openapi_validation import OpenAPIValidationMiddleware @@ -2441,24 +2441,24 @@ def _to_proxy_event(self, event: dict) -> BaseProxyEvent: # noqa: PLR0911 # ig """Convert the event dict to the corresponding data class""" if self._proxy_type == ProxyEventType.APIGatewayProxyEvent: logger.debug("Converting event to API Gateway REST API contract") - return APIGatewayProxyEvent(event, self._deserializer) + return APIGatewayProxyEvent(event, self._json_body_deserializer) if self._proxy_type == ProxyEventType.APIGatewayProxyEventV2: logger.debug("Converting event to API Gateway HTTP API contract") - return APIGatewayProxyEventV2(event, self._deserializer) + return APIGatewayProxyEventV2(event, self._json_body_deserializer) if self._proxy_type == ProxyEventType.BedrockAgentEvent: logger.debug("Converting event to Bedrock Agent contract") - return BedrockAgentEvent(event, self._deserializer) + return BedrockAgentEvent(event, self._json_body_deserializer) if self._proxy_type == ProxyEventType.LambdaFunctionUrlEvent: logger.debug("Converting event to Lambda Function URL contract") - return LambdaFunctionUrlEvent(event, self._deserializer) + return LambdaFunctionUrlEvent(event, self._json_body_deserializer) if self._proxy_type == ProxyEventType.VPCLatticeEvent: logger.debug("Converting event to VPC Lattice contract") - return VPCLatticeEvent(event, self._deserializer) + return VPCLatticeEvent(event, self._json_body_deserializer) if self._proxy_type == ProxyEventType.VPCLatticeEventV2: logger.debug("Converting event to VPC LatticeV2 contract") - return VPCLatticeEventV2(event, self._deserializer) + return VPCLatticeEventV2(event, self._json_body_deserializer) logger.debug("Converting event to ALB contract") - return ALBEvent(event, self._deserializer) + return ALBEvent(event, self._json_body_deserializer) def _resolve(self) -> ResponseBuilder: """Resolves the response or return the not found response""" @@ -2875,7 +2875,7 @@ def __init__( strip_prefixes: list[str | Pattern] | None = None, enable_validation: bool = False, response_validation_error_http_code: HTTPStatus | int | None = None, - deserializer: Callable[[str], dict] | None = None, + json_body_deserializer: Callable[[str], dict] | None = None, ): """Amazon API Gateway REST and HTTP API v1 payload resolver""" super().__init__( @@ -2886,7 +2886,7 @@ def __init__( strip_prefixes, enable_validation, response_validation_error_http_code, - deserializer, + json_body_deserializer=json_body_deserializer, ) def _get_base_path(self) -> str: @@ -2963,6 +2963,7 @@ def __init__( strip_prefixes: list[str | Pattern] | None = None, enable_validation: bool = False, response_validation_error_http_code: HTTPStatus | int | None = None, + json_body_deserializer: Callable[[str], dict] | None = None, ): """Amazon API Gateway HTTP API v2 payload resolver""" super().__init__( @@ -2973,6 +2974,7 @@ def __init__( strip_prefixes, enable_validation, response_validation_error_http_code, + json_body_deserializer=json_body_deserializer, ) def _get_base_path(self) -> str: @@ -3002,6 +3004,7 @@ def __init__( strip_prefixes: list[str | Pattern] | None = None, enable_validation: bool = False, response_validation_error_http_code: HTTPStatus | int | None = None, + json_body_deserializer: Callable[[str], dict] | None = None, ): """Amazon Application Load Balancer (ALB) resolver""" super().__init__( @@ -3012,6 +3015,7 @@ def __init__( strip_prefixes, enable_validation, response_validation_error_http_code, + json_body_deserializer=json_body_deserializer, ) def _get_base_path(self) -> str: diff --git a/aws_lambda_powertools/event_handler/bedrock_agent.py b/aws_lambda_powertools/event_handler/bedrock_agent.py index c3b48bcb95e..008aeb0ccdd 100644 --- a/aws_lambda_powertools/event_handler/bedrock_agent.py +++ b/aws_lambda_powertools/event_handler/bedrock_agent.py @@ -103,6 +103,7 @@ def __init__(self, debug: bool = False, enable_validation: bool = True): serializer=None, strip_prefixes=None, enable_validation=enable_validation, + json_body_deserializer=None, ) self._response_builder_class = BedrockResponseBuilder diff --git a/aws_lambda_powertools/event_handler/lambda_function_url.py b/aws_lambda_powertools/event_handler/lambda_function_url.py index dbafe809176..279899b645e 100644 --- a/aws_lambda_powertools/event_handler/lambda_function_url.py +++ b/aws_lambda_powertools/event_handler/lambda_function_url.py @@ -61,6 +61,7 @@ def __init__( strip_prefixes: list[str | Pattern] | None = None, enable_validation: bool = False, response_validation_error_http_code: HTTPStatus | int | None = None, + json_body_deserializer: Callable[[str], dict] | None = None, ): super().__init__( ProxyEventType.LambdaFunctionUrlEvent, @@ -70,6 +71,7 @@ def __init__( strip_prefixes, enable_validation, response_validation_error_http_code, + json_body_deserializer=json_body_deserializer, ) def _get_base_path(self) -> str: diff --git a/aws_lambda_powertools/event_handler/vpc_lattice.py b/aws_lambda_powertools/event_handler/vpc_lattice.py index a59acaa9740..40eafc01d01 100644 --- a/aws_lambda_powertools/event_handler/vpc_lattice.py +++ b/aws_lambda_powertools/event_handler/vpc_lattice.py @@ -57,6 +57,7 @@ def __init__( strip_prefixes: list[str | Pattern] | None = None, enable_validation: bool = False, response_validation_error_http_code: HTTPStatus | int | None = None, + json_body_deserializer: Callable[[str], dict] | None = None, ): """Amazon VPC Lattice resolver""" super().__init__( @@ -67,6 +68,7 @@ def __init__( strip_prefixes, enable_validation, response_validation_error_http_code, + json_body_deserializer=json_body_deserializer, ) def _get_base_path(self) -> str: @@ -115,6 +117,7 @@ def __init__( strip_prefixes: list[str | Pattern] | None = None, enable_validation: bool = False, response_validation_error_http_code: HTTPStatus | int | None = None, + json_body_deserializer: Callable[[str], dict] | None = None, ): """Amazon VPC Lattice resolver""" super().__init__( @@ -125,6 +128,7 @@ def __init__( strip_prefixes, enable_validation, response_validation_error_http_code, + json_body_deserializer=json_body_deserializer, ) def _get_base_path(self) -> str: diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index da500cc56be..a3f18f29883 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -1182,6 +1182,14 @@ You can instruct event handler to use a custom serializer to best suit your need --8<-- "examples/event_handler_rest/src/custom_serializer.py" ``` +### Custom body deserializer + +You can customize how the integrated [Event Source Data Classes](https://docs.powertools.aws.dev/lambda/python/latest/utilities/data_classes/#api-gateway-proxy) parse the JSON request body by providing your own deserializer function. By default it is `json.loads` + +```python hl_lines="15" title="Using a custom JSON deserializer for body" +--8<-- "examples/event_handler_rest/src/custom_json_deserializer.py" +``` + ### Split routes with Router As you grow the number of routes a given Lambda function should handle, it is natural to either break into smaller Lambda functions, or split routes into separate files to ease maintenance - that's where the `Router` feature is useful. diff --git a/examples/event_handler_rest/src/custom_json_deserializer.py b/examples/event_handler_rest/src/custom_json_deserializer.py new file mode 100644 index 00000000000..3d2de27b775 --- /dev/null +++ b/examples/event_handler_rest/src/custom_json_deserializer.py @@ -0,0 +1,27 @@ +import json +from decimal import Decimal +from functools import partial + +from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.utilities.typing import LambdaContext + +tracer = Tracer() +logger = Logger() +app = APIGatewayRestResolver() + + +app = APIGatewayRestResolver(json_body_deserializer=partial(json.loads, parse_float=Decimal)) + + +@app.get("/body") +def get_body(): + return app.current_event.json_body + + +# 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/tests/functional/event_handler/_pydantic/test_api_gateway.py b/tests/functional/event_handler/_pydantic/test_api_gateway.py index bbd13082841..ce3fd89e864 100644 --- a/tests/functional/event_handler/_pydantic/test_api_gateway.py +++ b/tests/functional/event_handler/_pydantic/test_api_gateway.py @@ -1,9 +1,5 @@ from __future__ import annotations -import json -from decimal import Decimal -from functools import partial - from pydantic import BaseModel from aws_lambda_powertools.event_handler import content_types @@ -84,24 +80,3 @@ def get_lambda(param: int): ... assert result["statusCode"] == 422 assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] assert "missing" in result["body"] - - -def test_api_gateway_resolver_numeric_value(): - # GIVEN a basic API Gateway resolver - app = ApiGatewayResolver(deserializer=partial(json.loads, parse_float=Decimal)) - - @app.post("/my/path") - def test_handler(): - return app.current_event.json_body - - # WHEN calling the event handler - event = {} - event.update(LOAD_GW_EVENT) - event["body"] = '{"amount": 2.2999999999999998}' - event["httpMethod"] = "POST" - - result = app(event, {}) - # THEN process event correctly - assert result["statusCode"] == 200 - assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] - assert result["body"] == '{"amount":"2.2999999999999998"}' diff --git a/tests/functional/event_handler/required_dependencies/test_api_gateway.py b/tests/functional/event_handler/required_dependencies/test_api_gateway.py index 349f220ecde..f2fc514a308 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -8,12 +8,15 @@ from copy import deepcopy from decimal import Decimal from enum import Enum +from functools import partial from json import JSONEncoder from pathlib import Path import pytest -from aws_lambda_powertools.event_handler import content_types +from aws_lambda_powertools.event_handler import ( + content_types, +) from aws_lambda_powertools.event_handler.api_gateway import ( ALBResolver, APIGatewayHttpResolver, @@ -1968,3 +1971,24 @@ def opa(): # THEN body should be converted to an empty string assert result["statusCode"] == 200 assert result["body"] == "" + + +def test_api_gateway_resolver_with_custom_deserializer(): + # GIVEN a basic API Gateway resolver + app = ApiGatewayResolver(json_body_deserializer=partial(json.loads, parse_float=Decimal)) + + @app.post("/my/path") + def test_handler(): + return app.current_event.json_body + + # WHEN calling the event handler + event = {} + event.update(LOAD_GW_EVENT) + event["body"] = '{"amount": 2.2999999999999998}' + event["httpMethod"] = "POST" + + result = app(event, {}) + # THEN process event correctly + assert result["statusCode"] == 200 + assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON] + assert result["body"] == '{"amount":"2.2999999999999998"}' From 014c3a2d4d840e2b53278efa9324e2df2ef866e3 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Mon, 26 May 2025 19:31:10 +0100 Subject: [PATCH 3/4] Change the name + add examples --- .github/workflows/quality_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quality_check.yml b/.github/workflows/quality_check.yml index 891a16e2260..66e2ef60e0d 100644 --- a/.github/workflows/quality_check.yml +++ b/.github/workflows/quality_check.yml @@ -44,7 +44,7 @@ jobs: quality_check: runs-on: ubuntu-latest strategy: - max-parallel: 4 + max-parallel: 5 matrix: python-version: ["3.9","3.10","3.11","3.12","3.13"] env: From cbb30272ff3f73d943a7627b90c3be9e06684089 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Mon, 26 May 2025 19:32:02 +0100 Subject: [PATCH 4/4] Change the name + add examples --- aws_lambda_powertools/event_handler/api_gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index e2aab60f164..f2aa2dedf10 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -1603,7 +1603,7 @@ def __init__( Sets the returned status code if response is not validated. enable_validation must be True. json_body_deserializer: Callable[[str], dict], optional function to deserialize `str`, `bytes`, `bytearray` containing a JSON document to a Python `dict`, - by default json.loads + by default json.loads when integrating with EventSource data class """ self._proxy_type = proxy_type self._dynamic_routes: list[Route] = []