diff --git a/aws_lambda_powertools/event_handler/openapi/encoders.py b/aws_lambda_powertools/event_handler/openapi/encoders.py index c12aa0164e1..520c0d71509 100644 --- a/aws_lambda_powertools/event_handler/openapi/encoders.py +++ b/aws_lambda_powertools/event_handler/openapi/encoders.py @@ -14,6 +14,7 @@ from pydantic.types import SecretBytes, SecretStr from aws_lambda_powertools.event_handler.openapi.compat import _model_dump +from aws_lambda_powertools.event_handler.openapi.exceptions import SerializationError from aws_lambda_powertools.event_handler.openapi.types import IncEx """ @@ -69,88 +70,94 @@ def jsonable_encoder( # noqa: PLR0911 if exclude is not None and not isinstance(exclude, (set, dict)): exclude = set(exclude) - # Pydantic models - if isinstance(obj, BaseModel): - return _dump_base_model( - obj=obj, - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_none=exclude_none, - exclude_defaults=exclude_defaults, - ) + try: + # Pydantic models + if isinstance(obj, BaseModel): + return _dump_base_model( + obj=obj, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + exclude_defaults=exclude_defaults, + ) - # Dataclasses - if dataclasses.is_dataclass(obj): - obj_dict = dataclasses.asdict(obj) - return jsonable_encoder( - obj_dict, - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - ) + # Dataclasses + if dataclasses.is_dataclass(obj): + obj_dict = dataclasses.asdict(obj) + return jsonable_encoder( + obj_dict, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) - # Enums - if isinstance(obj, Enum): - return obj.value + # Enums + if isinstance(obj, Enum): + return obj.value - # Paths - if isinstance(obj, PurePath): - return str(obj) + # Paths + if isinstance(obj, PurePath): + return str(obj) - # Scalars - if isinstance(obj, (str, int, float, type(None))): - return obj + # Scalars + if isinstance(obj, (str, int, float, type(None))): + return obj - # Dictionaries - if isinstance(obj, dict): - return _dump_dict( - obj=obj, - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_none=exclude_none, - exclude_unset=exclude_unset, - ) + # Dictionaries + if isinstance(obj, dict): + return _dump_dict( + obj=obj, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_none=exclude_none, + exclude_unset=exclude_unset, + ) + + # Sequences + if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): + return _dump_sequence( + obj=obj, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_none=exclude_none, + exclude_defaults=exclude_defaults, + exclude_unset=exclude_unset, + ) - # Sequences - if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): - return _dump_sequence( + # Other types + if type(obj) in ENCODERS_BY_TYPE: + return ENCODERS_BY_TYPE[type(obj)](obj) + + for encoder, classes_tuple in encoders_by_class_tuples.items(): + if isinstance(obj, classes_tuple): + return encoder(obj) + + # Use custom serializer if present + if custom_serializer: + return custom_serializer(obj) + + # Default + return _dump_other( obj=obj, include=include, exclude=exclude, by_alias=by_alias, exclude_none=exclude_none, - exclude_defaults=exclude_defaults, exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, ) - - # Other types - if type(obj) in ENCODERS_BY_TYPE: - return ENCODERS_BY_TYPE[type(obj)](obj) - - for encoder, classes_tuple in encoders_by_class_tuples.items(): - if isinstance(obj, classes_tuple): - return encoder(obj) - - # Use custom serializer if present - if custom_serializer: - return custom_serializer(obj) - - # Default - return _dump_other( - obj=obj, - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_none=exclude_none, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - ) + except ValueError as exc: + raise SerializationError( + f"Unable to serialize the object {obj} as it is not a supported type. Error details: {exc}", + "See: https://docs.powertools.aws.dev/lambda/python/latest/core/event_handler/api_gateway/#serializing-objects", + ) from exc def _dump_base_model( diff --git a/aws_lambda_powertools/event_handler/openapi/exceptions.py b/aws_lambda_powertools/event_handler/openapi/exceptions.py index 5d81d3af439..e1ed33e67fd 100644 --- a/aws_lambda_powertools/event_handler/openapi/exceptions.py +++ b/aws_lambda_powertools/event_handler/openapi/exceptions.py @@ -23,6 +23,12 @@ def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None: self.body = body +class SerializationError(Exception): + """ + Base exception for all encoding errors + """ + + class SchemaValidationError(ValidationException): """ Raised when the OpenAPI schema validation fails diff --git a/docs/core/event_handler/api_gateway.md b/docs/core/event_handler/api_gateway.md index 0725ff6554c..a8e0ac6a051 100644 --- a/docs/core/event_handler/api_gateway.md +++ b/docs/core/event_handler/api_gateway.md @@ -458,6 +458,25 @@ In the following example, we use a new `Header` OpenAPI type to add [one out of 1. `cloudfront_viewer_country` is a list that must contain values from the `CountriesAllowed` enumeration. +#### Supported types for response serialization + +With data validation enabled, we natively support serializing the following data types to JSON: + +| Data type | Serialized type | +| -------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| **Pydantic models** | `dict` | +| **Python Dataclasses** | `dict` | +| **Enum** | Enum values | +| **Datetime** | Datetime ISO format string | +| **Decimal** | `int` if no exponent, or `float` | +| **Path** | `str` | +| **UUID** | `str` | +| **Set** | `list` | +| **Python primitives** _(dict, string, sequences, numbers, booleans)_ | [Python's default JSON serializable types](https://docs.python.org/3/library/json.html#encoders-and-decoders){target="_blank" rel="nofollow"} | + +???+ info "See [custom serializer section](#custom-serializer) for bringing your own." + Otherwise, we will raise `SerializationError` for any unsupported types _e.g., SQLAlchemy models_. + ### Accessing request details Event Handler integrates with [Event Source Data Classes utilities](../../utilities/data_classes.md){target="_blank"}, and it exposes their respective resolver request details and convenient methods under `app.current_event`. diff --git a/tests/functional/event_handler/test_openapi_encoders.py b/tests/functional/event_handler/test_openapi_encoders.py index 45c65623849..dee9c21b84c 100644 --- a/tests/functional/event_handler/test_openapi_encoders.py +++ b/tests/functional/event_handler/test_openapi_encoders.py @@ -7,6 +7,7 @@ from pydantic.color import Color from aws_lambda_powertools.event_handler.openapi.encoders import jsonable_encoder +from aws_lambda_powertools.event_handler.openapi.exceptions import SerializationError def test_openapi_encode_include(): @@ -184,3 +185,11 @@ def __init__(self, name: str): result = jsonable_encoder(User(name="John")) assert result == {"name": "John"} + + +def test_openapi_encode_with_error(): + class MyClass: + __slots__ = [] + + with pytest.raises(SerializationError, match="Unable to serialize the object*"): + jsonable_encoder(MyClass())