morf
morf

Reputation: 177

FastAPI create a generic response model that would suit requirements

I've been working with FastAPI for some time, it's a great framework. However real life scenarios can be surprising, sometimes a non-standard approach is necessary. There's a one case I'd like to ask your help with.

There's a strange external requirement that a model response should be formatted as stated in example:

Desired behavior:

GET /object/1

{status: ‘success’, data: {object: {id:‘1’, category: ‘test’ …}}}

GET /objects

{status: ‘success’, data: {objects: [...]}}}

Current behavior:

GET /object/1 would respond:

{id: 1,field1:"content",... }

GET /objects/ would send a List of Object e.g.,:

{
 [
   {id: 1,field1:"content",... },
   {id: 1,field1:"content",... },
    ...
 ]
}

You can substitute 'object' by any class, it's just for description purposes.

How to write a generic response model that will suit those reqs?

I know I can produce response model that would contain status:str and (depending on class) data structure e.g ticket:Ticket or tickets:List[Ticket].

The point is there's a number of classes so I hope there's a more pythonic way to do it.

Thanks for help.

Upvotes: 5

Views: 8335

Answers (1)

Daniil Fajnberg
Daniil Fajnberg

Reputation: 18388

Generic model with static field name

A generic model is a model where one field (or multiple) are annotated with a type variable. Thus the type of that field is unspecified by default and must be specified explicitly during subclassing and/or initialization. But that field is still just an attribute and an attribute must have a name. A fixed name.

To go from your example, say that is your model:

{
  "status": "...",
  "data": {
    "object": {...}  # type variable
  }
}

Then we could define that model as generic in terms of the type of its object attribute.

This can be done using Pydantic's GenericModel like this:

from typing import Generic, TypeVar
from pydantic import BaseModel
from pydantic.generics import GenericModel

M = TypeVar("M", bound=BaseModel)


class GenericSingleObject(GenericModel, Generic[M]):
    object: M


class GenericMultipleObjects(GenericModel, Generic[M]):
    objects: list[M]


class BaseGenericResponse(GenericModel):
    status: str


class GenericSingleResponse(BaseGenericResponse, Generic[M]):
    data: GenericSingleObject[M]


class GenericMultipleResponse(BaseGenericResponse, Generic[M]):
    data: GenericMultipleObjects[M]


class Foo(BaseModel):
    a: str
    b: int


class Bar(BaseModel):
    x: float

As you can see, GenericSingleObject reflects the generic type we want for data, whereas GenericSingleResponse is generic in terms of the type parameter M of GenericSingleObject, which is the type of its data attribute.

If we now want to use one of our generic response models, we would need to specify it with a type argument (a concrete model) first, e.g. GenericSingleResponse[Foo].

FastAPI deals with this just fine and can generate the correct OpenAPI documentation. The JSON schema for GenericSingleResponse[Foo] looks like this:

{
    "title": "GenericSingleResponse[Foo]",
    "type": "object",
    "properties": {
        "status": {
            "title": "Status",
            "type": "string"
        },
        "data": {
            "$ref": "#/definitions/GenericSingleObject_Foo_"
        }
    },
    "required": [
        "status",
        "data"
    ],
    "definitions": {
        "Foo": {
            "title": "Foo",
            "type": "object",
            "properties": {
                "a": {
                    "title": "A",
                    "type": "string"
                },
                "b": {
                    "title": "B",
                    "type": "integer"
                }
            },
            "required": [
                "a",
                "b"
            ]
        },
        "GenericSingleObject_Foo_": {
            "title": "GenericSingleObject[Foo]",
            "type": "object",
            "properties": {
                "object": {
                    "$ref": "#/definitions/Foo"
                }
            },
            "required": [
                "object"
            ]
        }
    }
}

To demonstrate it with FastAPI:

from fastapi import FastAPI


app = FastAPI()


@app.get("/foo/", response_model=GenericSingleResponse[Foo])
async def get_one_foo() -> dict[str, object]:
    return {"status": "foo", "data": {"object": {"a": "spam", "b": 123}}}

Sending a request to that route returns the following:

{
  "status": "foo",
  "data": {
    "object": {
      "a": "spam",
      "b": 123
    }
  }
}

Dynamically created model

If you actually want the attribute name to also be different every time, that is obviously no longer possible with static type annotations. In that case we would have to resort to actually creating the model type dynamically via pydantic.create_model.

In that case there is really no point in genericity anymore because type safety is out of the window anyway, at least for the data model. We still have the option to define a GenericResponse model, which we can specify via our dynamically generated models, but this will make every static type checker mad, since we'll be using variables for types. Still, it might make for otherwise concise code.

We just need to define an algorithm for deriving the model parameters:

from typing import Any, Generic, Optional, TypeVar
from pydantic import BaseModel, create_model
from pydantic.generics import GenericModel

M = TypeVar("M", bound=BaseModel)


def create_data_model(
    model: type[BaseModel],
    plural: bool = False,
    custom_plural_name: Optional[str] = None,
    **kwargs: Any,
) -> type[BaseModel]:
    data_field_name = model.__name__.lower()
    if plural:
        model_name = f"Multiple{model.__name__}"
        if custom_plural_name:
            data_field_name = custom_plural_name
        else:
            data_field_name += "s"
        kwargs[data_field_name] = (list[model], ...)  # type: ignore[valid-type]
    else:
        model_name = f"Single{model.__name__}"
        kwargs[data_field_name] = (model, ...)
    return create_model(model_name, **kwargs)


class GenericResponse(GenericModel, Generic[M]):
    status: str
    data: M

Using the same Foo and Bar examples as before:

class Foo(BaseModel):
    a: str
    b: int


class Bar(BaseModel):
    x: float


SingleFoo = create_data_model(Foo)
MultipleBar = create_data_model(Bar, plural=True)

This also works as expected with FastAPI including the automatically generated schemas/documentations:

from fastapi import FastAPI


app = FastAPI()


@app.get("/foo/", response_model=GenericResponse[SingleFoo])  # type: ignore[valid-type]
async def get_one_foo() -> dict[str, object]:
    return {"status": "foo", "data": {"foo": {"a": "spam", "b": 123}}}


@app.get("/bars/", response_model=GenericResponse[MultipleBar])  # type: ignore[valid-type]
async def get_multiple_bars() -> dict[str, object]:
    return {"status": "bars", "data": {"bars": [{"x": 3.14}, {"x": 0}]}}

Output is essentially the same as with the first approach.

You'll have to see, which one works better for you. I find the second option very strange because of the dynamic key/field name. But maybe that is what you need for some reason.

Upvotes: 14

Related Questions