Repository: litestar-org/litestar

Issue:
Bug: Custom data types with validators cannot be serialized since 1.45
**Describe the bug**
Since version 1.45 custom data types with pydantic validators cannot be serialized anymore. Deserialization works fine.

**To Reproduce**
Example app that works in 1.44, but doesn't in 1.45+:
Note that the print gets executed.
```python
from pydantic import BaseModel
from starlite import Starlite, post, Request, Response, LoggingConfig
from starlite.utils import create_exception_response


class CustomType(str):
    @classmethod
    def __get_validators__(cls):
        yield cls.get_validators_must_yield_something_so_here_we_are

    @classmethod
    def get_validators_must_yield_something_so_here_we_are(cls, value):
        return cls(value)


class Model(BaseModel):
    foo: CustomType


@post('/foo')
def foo(data: Model) -> Model:
    print(data)
    return data


def logging_exception_handler(request: Request, exc: Exception) -> Response:
    request.logger.error("Application Exception", exc_info = exc)
    return create_exception_response(exc)


app = Starlite(
    [foo],
    logging_config = LoggingConfig(),
    exception_handlers={Exception: logging_exception_handler},
)
```

**Additional context**
This is the stacktrace that I got with the example above:
```
Traceback (most recent call last):
  File ".../python3.11/site-packages/starlite/response/base.py", line 216, in render
    return encode_json(content, self.serializer)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/starlite/utils/serialization.py", line 59, in encode_json
    return _msgspec_json_encoder.encode(obj)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/starlite/utils/serialization.py", line 24, in default_serializer
    raise TypeError(f"Unsupported type: {type(value)!r}")
TypeError: Unsupported type: <class 'app2.CustomType'>

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File ".../python3.11/site-packages/starlite/middleware/exceptions/middleware.py", line 47, in __call__
    await self.app(scope, receive, send)
  File ".../python3.11/site-packages/starlite/routes/http.py", line 75, in handle
    response = await self._get_response_for_request(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/starlite/routes/http.py", line 127, in _get_response_for_request
    response = await self._call_handler_function(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/starlite/routes/http.py", line 163, in _call_handler_function
    else await route_handler.to_response(
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/starlite/handlers/http.py", line 606, in to_response
    return await response_handler(app=app, data=data, plugins=plugins, request=request)  # type: ignore
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/starlite/handlers/http.py", line 203, in handler
    response = response_class(
               ^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/starlite/response/base.py", line 109, in __init__
    self.body = content if isinstance(content, bytes) else self.render(content)
                                                           ^^^^^^^^^^^^^^^^^^^^
  File ".../python3.11/site-packages/starlite/response/base.py", line 218, in render
    raise ImproperlyConfiguredException("Unable to serialize response content") from e
starlite.exceptions.http_exceptions.ImproperlyConfiguredException: 500: Unable to serialize response content
```



Discussion:
Okay so, this is not a bug but expected behaviour. It's really a limitation of Pydantic, since it doesn't normalise custom Pydantic-types when calling `BaseModel.dict`. Providing a method to convert Pydantic models to stdlib types (as `BaseModel.json()` does internally) has been a long requested feature, but afaict, it's not something that's going to land soon. 

The workaround for this is to add a type encoder for your custom types. We just added a new feature to make this process easier, so if you're on `main` you can just do:

```python
from pydantic import BaseModel

from starlite import Starlite, TestClient, post


class CustomType(str):
    @classmethod
    def __get_validators__(cls):
        yield cls.get_validators_must_yield_something_so_here_we_are

    @classmethod
    def get_validators_must_yield_something_so_here_we_are(cls, value):
        return cls(value)


class Model(BaseModel):
    foo: CustomType


@post("/foo")
def foo(data: Model) -> Model:
    return data


app = Starlite([foo], type_encoders={CustomType: str})

with TestClient(app=app) as client:
    res = client.post("/foo", json={"foo": "bar"})
    print(res.json())
```

<details>
<summary>For previous Starlite versions, you need to create a custom response class</summary>

```python
from pydantic import BaseModel

from starlite import Starlite, TestClient, post, Response


class CustomType(str):
    @classmethod
    def __get_validators__(cls):
        yield cls.get_validators_must_yield_something_so_here_we_are

    @classmethod
    def get_validators_must_yield_something_so_here_we_are(cls, value):
        return cls(value)


class Model(BaseModel):
    foo: CustomType


@post("/foo")
def foo(data: Model) -> Model:
    return data


class MyResponse(Response):
    type_encoders = {CustomType: str}


app = Starlite([foo], response_class=MyResponse)

with TestClient(app=app) as client:
    res = client.post("/foo", json={"foo": "bar"})
    print(res.json())
```
</details>
Although I think we could at least support this use case of subclassing stdlib types, as long as they're "backwards compatible", i.e a subclass `CustomType` of `str` can be turned into a native `str` by calling `str(CustomType)`.
Thanks for the workarounds. Would appreciate if this was supported out of the box, because I use these custom types quite often.
> It's really a limitation of Pydantic, since it doesn't normalise custom Pydantic-types when calling `BaseModel.dict`. Providing a method to convert Pydantic models to stdlib types (as `BaseModel.json()` does internally) has been a long requested feature, but afaict, it's not something that's going to land soon.

This is unfortunate. Another option would be to modify the `enc_hook` used by `starlite` to call `json` (and wrap the result in [`msgspec.Raw`](https://jcristharif.com/msgspec/usage.html#raw)) instead of `dict`. This would likely have some performance cost, but pydantic already has a performance cost so :shrug:.

I think the diff would be to change

https://github.com/starlite-api/starlite/blob/d26f6f637d09294cafd0f695135dc87b9b5cfdd9/starlite/utils/serialization.py#L32

 to

```python
    BaseModel: lambda m: msgspec.Raw(m.json().encode("utf-8")),
This is actually a good idea! I think it might come with a significant decrease though, since this will use the standard library's `json` module. It’s possible to swap this out on the model, but it has to be done via the `Config` class. Maybe we can do some patching magic there.

I’ll do some test and benchmarks on this.
I just created #1006 which would fix my current issue, but won't work for more complex types like datetime.
I have a fix coming up that would work for all customised types like yours, as mentioned. But I’d rather evaluate @jcrist's suggestion and then decide which way to go with this. 

A fix that only works for some types isn’t a good solution in my opinion, since that will cause unexpected behaviour in many places and we’d need to document very explicitly which types are and aren’t supported.
@ottermata @jcrist I think this may actually a bug (or undocumented expected behaviour?) in `msgspec`, as *some* subclasses of supported types can be encoded but not all, and `msgspec` deviates from other json implementations (stdlib, orjson, ujson) in this case. I opened an issue at msgspec for this: https://github.com/jcrist/msgspec/issues/248
@jcrist the performance drop is indeed significant:

<img src="https://user-images.githubusercontent.com/25355197/210065014-f0325d77-cf07-48d7-8579-56d3074ca725.png" width="500">

> @jcrist the performance drop is indeed significant:
> ![rps_serialization](https://user-images.githubusercontent.com/25355197/210065014-f0325d77-cf07-48d7-8579-56d3074ca725.png)
> 

Ouch. 
> the performance drop is indeed significant:

This is with the diff I posted alone? So it's basically comparing starlite w/ `msgspec` (blue) vs the standard library `json` (red), with the overhead of `pydantic` object traversal & conversion (`dict`/`json`) in both cases? Is there any way a call to `.json` can be made to use a different `json.dumps` function at the call site (e..g `obj.json(dumps=msgspec.json.encode)`), rather than configuring it on the pydantic class?
> This is with the diff I posted alone?

Yup, that's the only change. 

> Is there any way a call to .json can be made to use a different json.dumps function at the call site (e..g obj.json(dumps=msgspec.json.encode)), rather than configuring it on the pydantic class?

Unfortunately not. The only way of using this I see would be to do some introspection on our side, advising users to configure their base model to use `msgspec` for serialization.

Adding to this, it's not actually `BaseModel.json()` that supports these custom types, but the stdlib `json`.

We are still significantly faster than FastAPI though:

<img src="https://user-images.githubusercontent.com/25355197/210083320-1e849c9e-5e8e-49ef-ac7b-1ea989186ec5.png" width="500">

I'm not really sure what's the best way moving forward is here.

Resolving patch:
diff --git a/starlite/utils/serialization.py b/starlite/utils/serialization.py
--- a/starlite/utils/serialization.py
+++ b/starlite/utils/serialization.py
@@ -1,27 +1,30 @@
+from collections import deque
+from decimal import Decimal
+from ipaddress import (
+    IPv4Address,
+    IPv4Interface,
+    IPv4Network,
+    IPv6Address,
+    IPv6Interface,
+    IPv6Network,
+)
 from pathlib import PurePosixPath
+from re import Pattern
 from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union
 
 import msgspec
 from pydantic import (
-    AnyUrl,
     BaseModel,
     ByteSize,
     ConstrainedBytes,
     ConstrainedDate,
     ConstrainedDecimal,
-    ConstrainedFloat,
-    ConstrainedFrozenSet,
-    ConstrainedInt,
-    ConstrainedList,
-    ConstrainedSet,
-    ConstrainedStr,
-    EmailStr,
     NameEmail,
-    PaymentCardNumber,
     SecretField,
     StrictBool,
 )
 from pydantic.color import Color
+from pydantic.json import decimal_encoder
 
 if TYPE_CHECKING:
     from starlite.types import TypeEncodersMap
@@ -31,22 +34,36 @@
     # pydantic specific types
     BaseModel: lambda m: m.dict(),
     ByteSize: lambda b: b.real,
-    EmailStr: str,
     NameEmail: str,
     Color: str,
-    AnyUrl: str,
     SecretField: str,
-    ConstrainedInt: int,
-    ConstrainedFloat: float,
-    ConstrainedStr: str,
     ConstrainedBytes: lambda b: b.decode("utf-8"),
-    ConstrainedList: list,
-    ConstrainedSet: set,
-    ConstrainedFrozenSet: frozenset,
     ConstrainedDecimal: float,
     ConstrainedDate: lambda d: d.isoformat(),
-    PaymentCardNumber: str,
-    StrictBool: int,  # pydantic compatibility
+    IPv4Address: str,
+    IPv4Interface: str,
+    IPv4Network: str,
+    IPv6Address: str,
+    IPv6Interface: str,
+    IPv6Network: str,
+    # pydantic compatibility
+    deque: list,
+    Decimal: decimal_encoder,
+    StrictBool: int,
+    Pattern: lambda o: o.pattern,
+    # support subclasses of stdlib types, e.g. pydantic's constrained types. If no
+    # previous type matched, these will be the last type in the mro, so we use this to
+    # (attempt to) convert a subclass into its base class.
+    # see https://github.com/jcrist/msgspec/issues/248
+    # and https://github.com/starlite-api/starlite/issues/1003
+    str: str,
+    int: int,
+    float: float,
+    list: list,
+    tuple: tuple,
+    set: set,
+    frozenset: frozenset,
+    dict: dict,
 }
 
 


Test patch:
diff --git a/tests/utils/test_serialization.py b/tests/utils/test_serialization.py
--- a/tests/utils/test_serialization.py
+++ b/tests/utils/test_serialization.py
@@ -35,6 +35,34 @@
 person = PersonFactory.build()
 
 
+class CustomStr(str):
+    pass
+
+
+class CustomInt(int):
+    pass
+
+
+class CustomFloat(float):
+    pass
+
+
+class CustomList(list):
+    pass
+
+
+class CustomSet(set):
+    pass
+
+
+class CustomFrozenSet(frozenset):
+    pass
+
+
+class CustomTuple(tuple):
+    pass
+
+
 class Model(BaseModel):
     path: PosixPath = PosixPath("example")
 
@@ -62,6 +90,14 @@ class Model(BaseModel):
     strict_bytes: StrictBytes = StrictBytes(b"hello")
     strict_bool: StrictBool = StrictBool(True)
 
+    custom_str: CustomStr = CustomStr()
+    custom_int: CustomInt = CustomInt()
+    custom_float: CustomFloat = CustomFloat()
+    custom_list: CustomList = CustomList()
+    custom_set: CustomSet = CustomSet()
+    custom_frozenset: CustomFrozenSet = CustomFrozenSet()
+    custom_tuple: CustomTuple = CustomTuple()
+
 
 model = Model()
 
@@ -90,6 +126,13 @@ class Model(BaseModel):
         (model.strict_bytes, "hello"),
         (model.strict_bool, 1),
         (model, model.dict()),
+        (model.custom_str, ""),
+        (model.custom_int, 0),
+        (model.custom_float, 0.0),
+        (model.custom_list, []),
+        (model.custom_set, set()),
+        (model.custom_frozenset, frozenset()),
+        (model.custom_tuple, ()),
     ],
 )
 def test_default_serializer(value: Any, expected: Any) -> None: