From 4d867a91ca2e554f3a557cb97aae96e01b2c24ed Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 25 Jul 2023 14:39:50 +0200 Subject: [PATCH 1/5] fix(parameters): make cache aware of single vs multiple calls Signed-off-by: heitorlessa --- aws_lambda_powertools/utilities/parameters/base.py | 2 +- aws_lambda_powertools/utilities/parameters/types.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/utilities/parameters/base.py b/aws_lambda_powertools/utilities/parameters/base.py index 710634636d0..411a2520ae9 100644 --- a/aws_lambda_powertools/utilities/parameters/base.py +++ b/aws_lambda_powertools/utilities/parameters/base.py @@ -27,7 +27,7 @@ from aws_lambda_powertools.shared import constants, user_agent from aws_lambda_powertools.shared.functions import resolve_max_age -from aws_lambda_powertools.utilities.parameters.types import TransformOptions +from aws_lambda_powertools.utilities.parameters.types import RecursiveOptions, TransformOptions from .exceptions import GetParameterError, TransformParameterError diff --git a/aws_lambda_powertools/utilities/parameters/types.py b/aws_lambda_powertools/utilities/parameters/types.py index faa06cee89e..a916f1a344d 100644 --- a/aws_lambda_powertools/utilities/parameters/types.py +++ b/aws_lambda_powertools/utilities/parameters/types.py @@ -1,3 +1,4 @@ from aws_lambda_powertools.shared.types import Literal TransformOptions = Literal["json", "binary", "auto", None] +RecursiveOptions = Literal[True, False] From aede19360666d4e80153d49a29ed851aa93907f4 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 25 Jul 2023 15:16:51 +0200 Subject: [PATCH 2/5] chore: cleanup, add test for single and nested Signed-off-by: heitorlessa --- aws_lambda_powertools/utilities/parameters/base.py | 2 +- aws_lambda_powertools/utilities/parameters/types.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/aws_lambda_powertools/utilities/parameters/base.py b/aws_lambda_powertools/utilities/parameters/base.py index 411a2520ae9..710634636d0 100644 --- a/aws_lambda_powertools/utilities/parameters/base.py +++ b/aws_lambda_powertools/utilities/parameters/base.py @@ -27,7 +27,7 @@ from aws_lambda_powertools.shared import constants, user_agent from aws_lambda_powertools.shared.functions import resolve_max_age -from aws_lambda_powertools.utilities.parameters.types import RecursiveOptions, TransformOptions +from aws_lambda_powertools.utilities.parameters.types import TransformOptions from .exceptions import GetParameterError, TransformParameterError diff --git a/aws_lambda_powertools/utilities/parameters/types.py b/aws_lambda_powertools/utilities/parameters/types.py index a916f1a344d..faa06cee89e 100644 --- a/aws_lambda_powertools/utilities/parameters/types.py +++ b/aws_lambda_powertools/utilities/parameters/types.py @@ -1,4 +1,3 @@ from aws_lambda_powertools.shared.types import Literal TransformOptions = Literal["json", "binary", "auto", None] -RecursiveOptions = Literal[True, False] From 8376a6b3426367056595fdba4dff050aea190e4c Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 5 Dec 2023 17:19:26 +0100 Subject: [PATCH 3/5] feat(event_handler): support pydantic/dataclass exception handler response --- .../event_handler/api_gateway.py | 2 +- aws_lambda_powertools/shared/json_encoder.py | 18 ++++++++-- .../event_handler/test_api_gateway.py | 30 +++++++++++++++- tests/unit/test_json_encoder.py | 34 +++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 0007540453d..31536457344 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -709,7 +709,7 @@ class ResponseBuilder(Generic[ResponseEventT]): def __init__( self, response: Response, - serializer: Callable[[Any], str] = json.dumps, + serializer: Callable[[Any], str] = partial(json.dumps, separators=(",", ":"), cls=Encoder), route: Optional[Route] = None, ): self.response = response diff --git a/aws_lambda_powertools/shared/json_encoder.py b/aws_lambda_powertools/shared/json_encoder.py index 32a094abd85..7456a495323 100644 --- a/aws_lambda_powertools/shared/json_encoder.py +++ b/aws_lambda_powertools/shared/json_encoder.py @@ -4,8 +4,9 @@ class Encoder(json.JSONEncoder): - """ - Custom JSON encoder to allow for serialization of Decimals, similar to the serializer used by Lambda internally. + """Custom JSON encoder to allow for serialization of Decimals, Pydantic and Dataclasses. + + It's similar to the serializer used by Lambda internally. """ def default(self, obj): @@ -13,4 +14,17 @@ def default(self, obj): if obj.is_nan(): return math.nan return str(obj) + + # Pydantic model (v1/v2) + if hasattr(obj, "json"): + from aws_lambda_powertools.event_handler.openapi.compat import _model_dump + + return _model_dump(obj) + + # Standard dataclass + if hasattr(obj, "__dataclass_fields__"): + import dataclasses + + return dataclasses.asdict(obj) + return super().default(obj) diff --git a/tests/functional/event_handler/test_api_gateway.py b/tests/functional/event_handler/test_api_gateway.py index d4c88b541aa..9c98faff062 100644 --- a/tests/functional/event_handler/test_api_gateway.py +++ b/tests/functional/event_handler/test_api_gateway.py @@ -10,6 +10,7 @@ from typing import Dict import pytest +from pydantic import BaseModel from aws_lambda_powertools.event_handler import content_types from aws_lambda_powertools.event_handler.api_gateway import ( @@ -1465,7 +1466,6 @@ def test_exception_handler_with_data_validation(): @app.exception_handler(RequestValidationError) def handle_validation_error(ex: RequestValidationError): - print(f"request path is '{app.current_event.path}'") return Response( status_code=422, content_type=content_types.TEXT_PLAIN, @@ -1486,6 +1486,34 @@ def get_lambda(param: int): assert result["body"] == "Invalid data. Number of errors: 1" +def test_exception_handler_with_data_validation_pydantic_response(): + # GIVEN a resolver with an exception handler defined for RequestValidationError + app = ApiGatewayResolver(enable_validation=True) + + class Err(BaseModel): + msg: str + + @app.exception_handler(RequestValidationError) + def handle_validation_error(ex: RequestValidationError): + return Response( + status_code=422, + content_type=content_types.APPLICATION_JSON, + body=Err(msg=f"Invalid data. Number of errors: {len(ex.errors())}"), + ) + + @app.get("/my/path") + def get_lambda(param: int): + ... + + # WHEN calling the event handler + # AND a RequestValidationError is raised + result = app(LOAD_GW_EVENT, {}) + + # THEN exception handler's pydantic response should be serialized correctly + assert result["statusCode"] == 422 + assert result["body"] == '{"msg":"Invalid data. Number of errors: 1"}' + + def test_data_validation_error(): # GIVEN a resolver without an exception handler app = ApiGatewayResolver(enable_validation=True) diff --git a/tests/unit/test_json_encoder.py b/tests/unit/test_json_encoder.py index af8de4257a8..0dad7634df5 100644 --- a/tests/unit/test_json_encoder.py +++ b/tests/unit/test_json_encoder.py @@ -1,7 +1,9 @@ import decimal import json +from dataclasses import dataclass import pytest +from pydantic import BaseModel from aws_lambda_powertools.shared.json_encoder import Encoder @@ -22,3 +24,35 @@ class CustomClass: with pytest.raises(TypeError): json.dumps({"val": CustomClass()}, cls=Encoder) + + +def test_json_encode_pydantic(): + # GIVEN a Pydantic model + class Model(BaseModel): + data: dict + + data = {"msg": "hello"} + model = Model(data=data) + + # WHEN json.dumps use our custom Encoder + result = json.dumps(model, cls=Encoder) + + # THEN we should serialize successfully; not raise a TypeError + assert result == json.dumps({"data": data}, cls=Encoder) + + +def test_json_encode_dataclasses(): + # GIVEN a standard dataclass + + @dataclass + class Model: + data: dict + + data = {"msg": "hello"} + model = Model(data=data) + + # WHEN json.dumps use our custom Encoder + result = json.dumps(model, cls=Encoder) + + # THEN we should serialize successfully; not raise a TypeError + assert result == json.dumps({"data": data}, cls=Encoder) From 96ddab91cf884dc1424ad09d9a8518224c5f1586 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 5 Dec 2023 18:24:53 +0100 Subject: [PATCH 4/5] refactor: move pydantic/dataclass detect as fn --- aws_lambda_powertools/shared/functions.py | 87 ++++++++++++++++++-- aws_lambda_powertools/shared/json_encoder.py | 16 ++-- 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/aws_lambda_powertools/shared/functions.py b/aws_lambda_powertools/shared/functions.py index 82ea7dad8d8..fb36b98dc34 100644 --- a/aws_lambda_powertools/shared/functions.py +++ b/aws_lambda_powertools/shared/functions.py @@ -1,7 +1,6 @@ from __future__ import annotations import base64 -import dataclasses import itertools import logging import os @@ -168,8 +167,86 @@ def extract_event_from_common_models(data: Any) -> Dict | Any: return data.raw_event # Is it a Pydantic Model? - if callable(getattr(data, "dict", None)): - return data.dict() + if is_pydantic(data): + return pydantic_to_dict(data) - # Is it a Dataclass? If not return as is - return dataclasses.asdict(data) if dataclasses.is_dataclass(data) else data + # Is it a Dataclass? + if is_dataclass(data): + return dataclass_to_dict(data) + + # Return as is + return data + + +def is_pydantic(data) -> bool: + """Whether data is a Pydantic model by checking common field available in v1/v2 + + Parameters + ---------- + data: BaseModel + Pydantic model + + Returns + ------- + bool + Whether it's a Pydantic model + """ + return getattr(data, "json", False) + + +def is_dataclass(data) -> bool: + """Whether data is a dataclass + + Parameters + ---------- + data: dataclass + Dataclass obj + + Returns + ------- + bool + Whether it's a Dataclass + """ + return getattr(data, "__dataclass_fields__", False) + + +def pydantic_to_dict(data) -> dict: + """Dump Pydantic model v1 and v2 as dict. + + Note we use lazy import since Pydantic is an optional dependency. + + Parameters + ---------- + data: BaseModel + Pydantic model + + Returns + ------- + + dict: + Pydantic model serialized to dict + """ + from aws_lambda_powertools.event_handler.openapi.compat import _model_dump + + return _model_dump(data) + + +def dataclass_to_dict(data) -> dict: + """Dump standard dataclass as dict. + + Note we use lazy import to prevent bloating other code parts. + + Parameters + ---------- + data: dataclass + Dataclass + + Returns + ------- + + dict: + Pydantic model serialized to dict + """ + import dataclasses + + return dataclasses.asdict(data) diff --git a/aws_lambda_powertools/shared/json_encoder.py b/aws_lambda_powertools/shared/json_encoder.py index 7456a495323..867745b2866 100644 --- a/aws_lambda_powertools/shared/json_encoder.py +++ b/aws_lambda_powertools/shared/json_encoder.py @@ -2,6 +2,8 @@ import json import math +from aws_lambda_powertools.shared.functions import dataclass_to_dict, is_dataclass, is_pydantic, pydantic_to_dict + class Encoder(json.JSONEncoder): """Custom JSON encoder to allow for serialization of Decimals, Pydantic and Dataclasses. @@ -15,16 +17,10 @@ def default(self, obj): return math.nan return str(obj) - # Pydantic model (v1/v2) - if hasattr(obj, "json"): - from aws_lambda_powertools.event_handler.openapi.compat import _model_dump - - return _model_dump(obj) - - # Standard dataclass - if hasattr(obj, "__dataclass_fields__"): - import dataclasses + if is_pydantic(obj): + return pydantic_to_dict(obj) - return dataclasses.asdict(obj) + if is_dataclass(obj): + return dataclass_to_dict(obj) return super().default(obj) From e35794edc3a33539f49c6bd2588b2886e0286ab4 Mon Sep 17 00:00:00 2001 From: heitorlessa Date: Tue, 5 Dec 2023 18:30:46 +0100 Subject: [PATCH 5/5] chore: ignore json_encoder complexity B --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2cff4996889..f6ad229e1eb 100644 --- a/Makefile +++ b/Makefile @@ -84,7 +84,7 @@ complexity-baseline: $(info Maintenability index) poetry run radon mi aws_lambda_powertools $(info Cyclomatic complexity index) - poetry run xenon --max-absolute C --max-modules A --max-average A aws_lambda_powertools + poetry run xenon --max-absolute C --max-modules A --max-average A aws_lambda_powertools --exclude aws_lambda_powertools/shared/json_encoder.py # # Use `poetry version /` for version bump