8

I want to include a custom class into a route's response. I'm mostly using nested pydantic.BaseModels in my application, so it would be nice to return the whole thing without writing a translation from the internal data representation to what the route returns.

As long as everything inherits from pydantic.BaseModel this is trivial, but I'm using a class Foo in my backend which can't do that, and I can't subclass it for this purpose either. Can I somehow duck type that class's definition in a way that fastapi accepts it? What I have right now is essentially this:

main.py

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Foo:
    """Foo holds data and can't inherit from `pydantic.BaseModel`."""
    def __init__(self, x: int):
        self.x = x


class Response(BaseModel):
    foo: Foo
    # plus some more stuff that doesn't matter right now because it works


@app.get("/", response_model=Response)
def root():
    return Response(foo=Foo(1))


if __name__ == '__main__':
    import uvicorn
    uvicorn.run("main:app")  # RuntimeError
Arne
  • 17,706
  • 5
  • 83
  • 99
  • You could use `BaseConfig.arbitrary_types_allowed = True` - [see fastapi issue 2382](https://github.com/tiangolo/fastapi/issues/2382#issuecomment-865635415) – michaPau Jun 26 '21 at 14:37
  • 1
    Doing that leads to a valid pydantic class, but you'll still get runtime errors both when trying to get a response as well when trying to render the OpenAPI pages, because fastapi doesn't know how to turn a `Foo` instance into json. And since you need to add validators to do that, when you're done you don't need to allow arbitrary types any more because then it's well defined. – Arne Jun 28 '21 at 14:23
  • 1
    Actually, the server won't even start: `fastapi.exceptions.FastAPIError: Invalid args for response field! Hint: check that is a valid pydantic field type` – Arne Jun 28 '21 at 14:26
  • 1
    Declared global in the main app file (`BaseConfig.arbitrary_types_allowed = True`) the server starts fine for me and returns `foo: x: 1`(minimal example on localhost) - the API definition fails for me as well.. - I just commented because I stumbled over the issue which seems to talk about the same problem... – michaPau Jun 28 '21 at 16:50
  • You're right, I wonder why setting arbitrary types globally is so different from setting it on the `Response` class. I'll simplify my answer – Arne Jun 29 '21 at 08:35

2 Answers2

7

It's not documented, but you can make non-pydantic classes work with fastapi. What you need to do is:

  1. Tell pydantic that using arbitrary classes is fine. It will try to jsonify them using vars(), so only straight forward data containers will work - no using property, __slots__ or stuff like that[1].

  2. Create a proxy BaseModel, and tell Foo to offer it if someone asks for its schema - which is what fastapis OpenAPI pages do. I'll just assume that you want them to work too since they're amazing.

main.py

from fastapi import FastAPI
from pydantic import BaseModel, BaseConfig, create_model

app = FastAPI()
BaseConfig.arbitrary_types_allowed = True  # change #1


class Foo:
    """Foo holds data and can't inherit from `pydantic.BaseModel`."""    
    def __init__(self, x: int):
        self.x = x

    __pydantic_model__ = create_model("Foo", x=(int, ...))  # change #2


class Response(BaseModel):
    foo: Foo


@app.get("/", response_model=Response)
def root():
    return Response(foo=Foo(1))


if __name__ == '__main__':
    import uvicorn
    uvicorn.run("main:app")  # works

[1] If you want more complex jsonification, you need to provide it to the Response class explicitly via Config.json_encoders.

Arne
  • 17,706
  • 5
  • 83
  • 99
1

Here is a full implementation using a subclass with validators and extra schema:

from psycopg2.extras import DateTimeTZRange as DateTimeTZRangeBase
from sqlalchemy.dialects.postgresql import TSTZRANGE
from sqlmodel import (
    Column,
    Field,
    Identity,
    SQLModel,
)

from pydantic.json import ENCODERS_BY_TYPE

ENCODERS_BY_TYPE |= {DateTimeTZRangeBase: str}


class DateTimeTZRange(DateTimeTZRangeBase):
    @classmethod
    def __get_validators__(cls):
        yield cls.validate

    @classmethod
    def validate(cls, v):
        if isinstance(v, str):
            lower = v.split(", ")[0][1:].strip().strip()
            upper = v.split(", ")[1][:-1].strip().strip()
            bounds = v[:1] + v[-1:]
            return DateTimeTZRange(lower, upper, bounds)
        elif isinstance(v, DateTimeTZRangeBase):
            return v
        raise TypeError("Type must be string or DateTimeTZRange")

    @classmethod
    def __modify_schema__(cls, field_schema):
        field_schema.update(type="string", example="[2022,01,01, 2022,02,02)")


class EventBase(SQLModel):
    __tablename__ = "event"
    timestamp_range: DateTimeTZRange = Field(
        sa_column=Column(
            TSTZRANGE(),
            nullable=False,
        ),
    )


class Event(EventBase, table=True):
    id: int | None = Field(
        default=None,
        sa_column_args=(Identity(always=True),),
        primary_key=True,
        nullable=False,
    )

as per @Arne 's solution you need to add your own validators and schema if the Type you are using has __slots__ and basically no way to get out a dict.

Link to Github issue: https://github.com/tiangolo/sqlmodel/issues/235#issuecomment-1162063590

Zaffer
  • 1,290
  • 13
  • 32