Repository: aws-powertools/powertools-lambda-python

Issue:
Feature request: Enable to change the deserializer body
### Use case

I updated the schema of my application, changing the amount field from int to float to comply with my company's requirements. However, I noticed that the default JSON parser was losing floating-point precision during requests.


```py
def test_api_gateway_resolver_numeric_value():
    app = ApiGatewayResolver()

    @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}' # assert '{"amount":2.3}' == '{"amount":  2.2999999999999998}'
```

### Solution/User Experience

I've already implemented a solution and will be opening a PR soon.
The fix involves passing a custom deserializer instance to the ApiGatewayResolver constructor to handle floating-point precision correctly.

### Alternative solutions

```markdown

```

### Acknowledgment

- [x] This feature request meets [Powertools for AWS Lambda (Python) Tenets](https://docs.powertools.aws.dev/lambda/python/latest/#tenets)
- [ ] Should this be considered in other Powertools for AWS Lambda languages? i.e. [Java](https://github.com/aws-powertools/powertools-lambda-java/), [TypeScript](https://github.com/aws-powertools/powertools-lambda-typescript/), and [.NET](https://github.com/aws-powertools/powertools-lambda-dotnet/)

Hints from issue comments:
@boring-cyborg: Thanks for opening your first issue here! We'll come back to you as soon as we can.
In the meantime, check out the #python channel on our Powertools for AWS Lambda Discord: [Invite link](https://discord.gg/B8zZKbbyET)
@leandrodamascena: Hey @LucasCMFBraga! Thanks for opening this issue and sending the PR. Sorry for the late reply! 

I'll take a look in the PR right now!!@github-actions: ### ⚠️COMMENT VISIBILITY WARNING⚠️
This issue is now closed. Please be mindful that future comments are hard for our team to see.

If you need more assistance, please either tag a [team member](https://github.com/aws-powertools/powertools-lambda-python/blob/develop/MAINTAINERS.md#current-maintainers) or open a new issue that references this one.

If you wish to keep having a conversation with other community members under this issue feel free to do so.

Solution patch:
diff --git a/.github/workflows/quality_check.yml b/.github/workflows/quality_check.yml
index 891a16e22..66e2ef60e 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:
diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py
index 38ea4c363..f2aa2dedf 100644
--- a/aws_lambda_powertools/event_handler/api_gateway.py
+++ b/aws_lambda_powertools/event_handler/api_gateway.py
@@ -1579,6 +1579,7 @@ class ApiGatewayResolver(BaseRouter):
         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,
     ):
         """
         Parameters
@@ -1600,6 +1601,9 @@ class ApiGatewayResolver(BaseRouter):
             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.
+        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 when integrating with EventSource data class
         """
         self._proxy_type = proxy_type
         self._dynamic_routes: list[Route] = []
@@ -1625,6 +1629,7 @@ class ApiGatewayResolver(BaseRouter):
 
         # Allow for a custom serializer or a concise json serialization
         self._serializer = serializer or partial(json.dumps, separators=(",", ":"), cls=Encoder)
+        self._json_body_deserializer = json_body_deserializer
 
         if self._enable_validation:
             from aws_lambda_powertools.event_handler.middlewares.openapi_validation import OpenAPIValidationMiddleware
@@ -2436,24 +2441,24 @@ class ApiGatewayResolver(BaseRouter):
         """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._json_body_deserializer)
         if self._proxy_type == ProxyEventType.APIGatewayProxyEventV2:
             logger.debug("Converting event to API Gateway HTTP API contract")
-            return APIGatewayProxyEventV2(event)
+            return APIGatewayProxyEventV2(event, self._json_body_deserializer)
         if self._proxy_type == ProxyEventType.BedrockAgentEvent:
             logger.debug("Converting event to Bedrock Agent contract")
-            return BedrockAgentEvent(event)
+            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)
+            return LambdaFunctionUrlEvent(event, self._json_body_deserializer)
         if self._proxy_type == ProxyEventType.VPCLatticeEvent:
             logger.debug("Converting event to VPC Lattice contract")
-            return VPCLatticeEvent(event)
+            return VPCLatticeEvent(event, self._json_body_deserializer)
         if self._proxy_type == ProxyEventType.VPCLatticeEventV2:
             logger.debug("Converting event to VPC LatticeV2 contract")
-            return VPCLatticeEventV2(event)
+            return VPCLatticeEventV2(event, self._json_body_deserializer)
         logger.debug("Converting event to ALB contract")
-        return ALBEvent(event)
+        return ALBEvent(event, self._json_body_deserializer)
 
     def _resolve(self) -> ResponseBuilder:
         """Resolves the response or return the not found response"""
@@ -2870,6 +2875,7 @@ class APIGatewayRestResolver(ApiGatewayResolver):
         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 REST and HTTP API v1 payload resolver"""
         super().__init__(
@@ -2880,6 +2886,7 @@ class APIGatewayRestResolver(ApiGatewayResolver):
             strip_prefixes,
             enable_validation,
             response_validation_error_http_code,
+            json_body_deserializer=json_body_deserializer,
         )
 
     def _get_base_path(self) -> str:
@@ -2956,6 +2963,7 @@ class APIGatewayHttpResolver(ApiGatewayResolver):
         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__(
@@ -2966,6 +2974,7 @@ class APIGatewayHttpResolver(ApiGatewayResolver):
             strip_prefixes,
             enable_validation,
             response_validation_error_http_code,
+            json_body_deserializer=json_body_deserializer,
         )
 
     def _get_base_path(self) -> str:
@@ -2995,6 +3004,7 @@ class ALBResolver(ApiGatewayResolver):
         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__(
@@ -3005,6 +3015,7 @@ class ALBResolver(ApiGatewayResolver):
             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 c3b48bcb9..008aeb0cc 100644
--- a/aws_lambda_powertools/event_handler/bedrock_agent.py
+++ b/aws_lambda_powertools/event_handler/bedrock_agent.py
@@ -103,6 +103,7 @@ class BedrockAgentResolver(ApiGatewayResolver):
             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 dbafe8091..279899b64 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 @@ class LambdaFunctionUrlResolver(ApiGatewayResolver):
         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 @@ class LambdaFunctionUrlResolver(ApiGatewayResolver):
             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 a59acaa97..40eafc01d 100644
--- a/aws_lambda_powertools/event_handler/vpc_lattice.py
+++ b/aws_lambda_powertools/event_handler/vpc_lattice.py
@@ -57,6 +57,7 @@ class VPCLatticeResolver(ApiGatewayResolver):
         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 @@ class VPCLatticeResolver(ApiGatewayResolver):
             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 @@ class VPCLatticeV2Resolver(ApiGatewayResolver):
         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 @@ class VPCLatticeV2Resolver(ApiGatewayResolver):
             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 da500cc56..a3f18f298 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 000000000..3d2de27b7
--- /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)


Test patch:
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 349f220ec..f2fc514a3 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 collections import deque
 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 test_alb_empty_response_object():
     # 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"}'