Skip to content

Pydantic support

It takes only 30 lines of code to support pydantic.BaseModel and all of its subclasses. You could add these lines to your project using pydantic and start to benefit from apischema features.

This example deliberately doesn't use set_object_fields but instead the conversions feature in order to roughly include pydantic "as is": it will reuse pydantic coercion, error messages, JSON schema, etc. This makes a full retro-compatible support.

As a result, lot of apischema features like GraphQL schema generation or NewType validation cannot be supported using this method — but they could be by using set_object_fields instead.

import inspect
from collections.abc import Mapping
from typing import Any

import pydantic
import pytest

from apischema import (
    ValidationError,
    deserialize,
    schema,
    serialize,
    serializer,
    settings,
)
from apischema.conversions import AnyConversion, Conversion
from apischema.json_schema import deserialization_schema
from apischema.schemas import Schema

# ---------- Pydantic support code starts here ----------
prev_deserialization = settings.deserialization.default_conversion


def default_deserialization(tp: Any) -> AnyConversion | None:
    if inspect.isclass(tp) and issubclass(tp, pydantic.BaseModel):

        def deserialize_pydantic(data):
            try:
                return tp.parse_obj(data)
            except pydantic.ValidationError as error:
                raise ValidationError.from_errors(
                    [{"loc": err["loc"], "err": err["msg"]} for err in error.errors()]
                )

        return Conversion(
            deserialize_pydantic,
            source=tp.__annotations__.get("__root__", Mapping[str, Any]),
            target=tp,
        )
    else:
        return prev_deserialization(tp)


settings.deserialization.default_conversion = default_deserialization

prev_schema = settings.base_schema.type


def default_schema(tp: Any) -> Schema | None:
    if inspect.isclass(tp) and issubclass(tp, pydantic.BaseModel):
        return schema(extra=tp.schema(), override=True)
    else:
        return prev_schema(tp)


settings.base_schema.type = default_schema


# No need to use settings.serialization because serializer is inherited
@serializer
def serialize_pydantic(obj: pydantic.BaseModel) -> Any:
    # There is currently no way to retrieve `serialize` parameters inside converters,
    # so exclude_unset is set to True as it's the default apischema setting
    return getattr(obj, "__root__", obj.dict(exclude_unset=True))


# ---------- Pydantic support code ends here ----------


class Foo(pydantic.BaseModel):
    bar: int


assert deserialize(Foo, {"bar": 0}) == Foo(bar=0)
assert serialize(Foo, Foo(bar=0)) == {"bar": 0}
assert deserialization_schema(Foo) == {
    "$schema": "http://json-schema.org/draft/2020-12/schema#",
    "title": "Foo",  # pydantic title
    "type": "object",
    "properties": {"bar": {"title": "Bar", "type": "integer"}},
    "required": ["bar"],
}
with pytest.raises(ValidationError) as err:
    deserialize(Foo, {"bar": "not an int"})
assert err.value.errors == [
    {"loc": ["bar"], "err": "value is not a valid integer"}  # pydantic error message
]