Repository: pydantic/pydantic
Pull request: Properly fetch plain serializer function when serializing default value in JSON Schema


<!-- Thank you for your contribution! -->
<!-- Unless your change is trivial, please create an issue to discuss the change before creating a PR -->

## Change Summary

Fixes https://github.com/pydantic/pydantic/issues/11446.

<!-- Please give a short summary of the changes. -->

## Related issue number

<!-- WARNING: please use "fix #123" style references so the issue is closed when this PR is merged. -->

## Checklist

* [ ] The pull request title is a good summary of the changes - it will be used in the changelog
* [ ] Unit tests for the changes exist
* [ ] Tests pass on CI
* [ ] Documentation reflects the changes where applicable
* [ ] My PR is ready to review, **please add a comment including the phrase "please review" to assign reviewers**


Problem statement:
Serializer is not applied to json schema default value
### Initial Checks

- [x] I confirm that I'm using Pydantic V2

### Description

Hello to all,

I am unsure if this is an actual issue, or a skill issue from my part. Here is the case :

I have an external class, Voltage, I cannot modify.
As suggested by the docs, I wrap it with Annotated serializer/deserializers.
For json ser/deserialization, everything works great.

However the json schema generates a warning : _PydanticJsonSchemaWarning: Default value 0V is not JSON serializable;
 excluding default from JSON schema [non-serializable-default]_

### What I've tried
- Wrapping the default value with a = Field(default=Voltage(17)) : same issue
- Specifying the default value only through a Field annotation : same issue
- Using __get_pydantic_core_schema__ instead of Annotated  : same issue

For some reason, specifying the deprecated config json_encoders did not produce the warning, but the default value still is wrong

### Example Code

```Python
import pydantic
from typing import Annotated

class Voltage:
    def __init__(self, value: float):
        self.__value = value

    def get(self) -> float:
        return self.__value

    def __str__(self):
        return f"{self.__value}V"


PydanticVoltage = Annotated[
    Voltage,
    pydantic.WithJsonSchema({"type": "number"}),
    pydantic.PlainSerializer(lambda v: v.get()),
    pydantic.BeforeValidator(lambda v: Voltage(v)),
]


class MyClass(pydantic.BaseModel):
    model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
    voltage: PydanticVoltage = Voltage(17)


print(MyClass.model_json_schema())
```

### Python, Pydantic & OS Version

```Text
pydantic version: 2.10.6
        pydantic-core version: 2.27.2
          pydantic-core build: profile=release pgo=false
                 install path: /home/user/dev/test/.venv/lib/python3.12/site-packages/pydantic
               python version: 3.12.3 (main, May  7 2024, 14:08:13) [GCC 11.4.1 20231218 (Red Hat 11.4.1-3)]
                     platform: Linux-5.14.0-427.13.1.el9_4.x86_64-x86_64-with-glibc2.34
             related packages: typing_extensions-4.12.2 fastapi-0.115.8
                       commit: unknown
```

Hints from issue discussion:
Hi @Horrih,

First off, thanks for the clean and reproducible example - makes things super easy for us!!

Admittedly, the solution here is frustrating. You'll need to have the `PlainSerializer` on the outer level for the JSON schema generator to recognize that said transformer needs to be applied:

```py
import pydantic
from typing import Annotated

class Voltage:
    def __init__(self, value: float):
        self.__value = value

    def get(self) -> float:
        return self.__value

    def __str__(self):
        return f"{self.__value}V"


PydanticVoltage = Annotated[
    Voltage,
    pydantic.WithJsonSchema({"type": "number"}),
    pydantic.BeforeValidator(lambda v: Voltage(v)),
    pydantic.PlainSerializer(lambda v: v.get()),
]


class MyClass(pydantic.BaseModel):
    model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
    voltage: PydanticVoltage = Voltage(17)


v = MyClass()
print(v.model_json_schema(mode='serialization'))
#> {'properties': {'voltage': {'default': 17, 'title': 'Voltage', 'type': 'number'}}, 'title': 'MyClass', 'type': 'object'}
```

@Viicos, do you recall if we have an issue tracking improvement of this logic?

Commit message: Properly fetch plain serializer function when serializing default value in JSON Schema

Patch to review:
diff --git a/pydantic/json_schema.py b/pydantic/json_schema.py
index be9595c4ffc..fae265f8880 100644
--- a/pydantic/json_schema.py
+++ b/pydantic/json_schema.py
@@ -1112,31 +1112,41 @@ def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaVal
         if default is NoDefault:
             return json_schema
 
+        def _get_ser_schema(schema: CoreSchema) -> core_schema.PlainSerializerFunctionSerSchema | None:
+            if (
+                (ser_schema := schema.get('serialization'))
+                and ser_schema['type'] == 'function-plain'
+                and not ser_schema.get('info_arg')
+            ):
+                return ser_schema
+            if _core_utils.is_function_with_inner_schema(schema):
+                return _get_ser_schema(schema['schema'])
+
         # we reflect the application of custom plain, no-info serializers to defaults for
         # JSON Schemas viewed in serialization mode:
         # TODO: improvements along with https://github.com/pydantic/pydantic/issues/8208
-        if (
-            self.mode == 'serialization'
-            and (ser_schema := schema['schema'].get('serialization'))
-            and (ser_func := ser_schema.get('function'))
-            and ser_schema.get('type') == 'function-plain'
-            and not ser_schema.get('info_arg')
-            and not (default is None and ser_schema.get('when_used') in ('unless-none', 'json-unless-none'))
-        ):
-            try:
-                default = ser_func(default)  # type: ignore
-            except Exception:
-                # It might be that the provided default needs to be validated (read: parsed) first
-                # (assuming `validate_default` is enabled). However, we can't perform
-                # such validation during JSON Schema generation so we don't support
-                # this pattern for now.
-                # (One example is when using `foo: ByteSize = '1MB'`, which validates and
-                # serializes as an int. In this case, `ser_func` is `int` and `int('1MB')` fails).
-                self.emit_warning(
-                    'non-serializable-default',
-                    f'Unable to serialize value {default!r} with the plain serializer; excluding default from JSON schema',
-                )
-                return json_schema
+        if self.mode == 'serialization':
+            # `_get_ser_schema()` is used to unpack potentially nested validator schemas:
+            ser_schema = _get_ser_schema(schema['schema'])
+            if (
+                ser_schema is not None
+                and (ser_func := ser_schema.get('function'))
+                and not (default is None and ser_schema.get('when_used') in ('unless-none', 'json-unless-none'))
+            ):
+                try:
+                    default = ser_func(default)  # type: ignore
+                except Exception:
+                    # It might be that the provided default needs to be validated (read: parsed) first
+                    # (assuming `validate_default` is enabled). However, we can't perform
+                    # such validation during JSON Schema generation so we don't support
+                    # this pattern for now.
+                    # (One example is when using `foo: ByteSize = '1MB'`, which validates and
+                    # serializes as an int. In this case, `ser_func` is `int` and `int('1MB')` fails).
+                    self.emit_warning(
+                        'non-serializable-default',
+                        f'Unable to serialize value {default!r} with the plain serializer; excluding default from JSON schema',
+                    )
+                    return json_schema
 
         try:
             encoded_default = self.encode_default(default)
diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py
index 1a0ff60c5a6..e74edaa052e 100644
--- a/tests/test_json_schema.py
+++ b/tests/test_json_schema.py
@@ -37,6 +37,7 @@
 
 import pydantic
 from pydantic import (
+    AfterValidator,
     BaseModel,
     BeforeValidator,
     Field,
@@ -6375,6 +6376,22 @@ class Model(BaseModel):
     }
 
 
+def test_plain_serializer_applies_to_default_when_nested_under_validators() -> None:
+    class Model(BaseModel):
+        custom_str: Annotated[
+            str,
+            PlainSerializer(lambda x: f'serialized-{x}', return_type=str),
+            BeforeValidator(lambda v: v + '_a'),
+            AfterValidator(lambda v: v + '_b'),
+        ] = 'foo'
+
+    assert Model.model_json_schema(mode='serialization') == {
+        'properties': {'custom_str': {'default': 'serialized-foo', 'title': 'Custom Str', 'type': 'string'}},
+        'title': 'Model',
+        'type': 'object',
+    }
+
+
 def test_plain_serializer_does_not_apply_with_unless_none() -> None:
     """Test plain serializers aren't used to compute the JSON Schema default if mode is 'json-unless-none'
     and default value is `None`."""


Code review comments:
diff_hunk: @@ -1112,31 +1112,41 @@ def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaVal
         if default is NoDefault:
             return json_schema
 
+        def _get_ser_schema(schema: CoreSchema) -> core_schema.PlainSerializerFunctionSerSchema | None:
+            if (
+                (ser_schema := schema.get('serialization'))
+                and ser_schema['type'] == 'function-plain'
+                and not ser_schema.get('info_arg')
+            ):
+                return ser_schema
+            if _core_utils.is_function_with_inner_schema(schema):
+                return _get_ser_schema(schema['schema'])
path: pydantic/json_schema.py
text: @user1:
Looks like this function doesn't capture any state from the surrounding body, maybe move it to the top level to avoid the perf hit of creating a new function object each time?