diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 871188e33e..fef374643d 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -241,17 +241,42 @@ def jsonable_encoder( if exclude is not None and not isinstance(exclude, (set, dict)): exclude = set(exclude) # type: ignore[assignment] # ty: ignore[invalid-assignment] if isinstance(obj, BaseModel): + def custom_encoder_fallback(value: Any) -> Any: + if type(value) in custom_encoder: + encoded_value = custom_encoder[type(value)](value) + else: + for encoder_type, encoder_instance in custom_encoder.items(): + if isinstance(value, encoder_type): + encoded_value = encoder_instance(value) + break + else: + raise TypeError( + f"Object of type {type(value).__name__} is not JSON serializable" + ) + return jsonable_encoder( + encoded_value, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + obj_dict = obj.model_dump( - mode="python" if custom_encoder else "json", + mode="json", include=include, exclude=exclude, by_alias=by_alias, exclude_unset=exclude_unset, exclude_none=exclude_none, exclude_defaults=exclude_defaults, + fallback=custom_encoder_fallback if custom_encoder else None, ) return jsonable_encoder( obj_dict, + by_alias=by_alias, + exclude_unset=exclude_unset, exclude_none=exclude_none, exclude_defaults=exclude_defaults, custom_encoder=custom_encoder, diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index d39033a8f6..4e4614c5d9 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -12,7 +12,7 @@ import pytest from fastapi._compat import Undefined from fastapi.encoders import jsonable_encoder from fastapi.exceptions import PydanticV1NotSupportedError -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_serializer class Person: @@ -240,6 +240,50 @@ def test_custom_encoder_model_field(): ) == {"value": "encoded"} +def test_custom_encoder_model_field_preserves_json_mode_serializers(): + class CustomValue: + pass + + class ModelWithJsonSerializer(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + created_at: datetime + value: CustomValue + + @field_serializer("created_at", when_used="json") + def serialize_created_at(self, value): + return "serialized" + + assert jsonable_encoder( + ModelWithJsonSerializer( + created_at=datetime(2024, 1, 1), + value=CustomValue(), + ), + custom_encoder={CustomValue: lambda _: "encoded"}, + ) == {"created_at": "serialized", "value": "encoded"} + + +def test_custom_encoder_model_field_uses_caller_options(): + class CustomValue: + pass + + class EncodedValue(BaseModel): + required: int = Field(alias="Required") + optional: int = 2 + + class ModelWithCustomValue(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + value: CustomValue + + assert jsonable_encoder( + ModelWithCustomValue(value=CustomValue()), + by_alias=False, + exclude_unset=True, + custom_encoder={CustomValue: lambda _: EncodedValue(Required=1)}, + ) == {"value": {"required": 1}} + + def test_custom_enum_encoders(): def custom_enum_encoder(v: Enum): return v.value.lower()