DEV Community

gyudoza
gyudoza

Posted on • Updated on

Best Practice of handling FastAPI Schema

The Best Practice of handling FastAPI Schema

FastAPI Has a concept of Schema. DTO is more familiar name If you have developed at Spring or NestJS. Simply, DTO is Data Transfer Object and It is a kind of promise to exchange object information between methods or classes in a specific model.

FastAPI's schema depends on pydantic model.

from pydantic import BaseModel

class Item(BaseModel):
    name: str
    model: str
    manufacturer: str
    price: float
    tax: float
Enter fullscreen mode Exit fullscreen mode

As you can see, schema is made by inheriting the pydantic Base Model. Now let's use this to construct an api endpoint.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    model: str
    manufacturer: str
    price: float
    tax: float


@app.get("/")
async def root(
        item: Item
):
    return {"message": "Hello World"}

Enter fullscreen mode Exit fullscreen mode

If you declare that you're gonna get an item schema at the basic endpoint,

Image description

In this way, an example of receiving the model is automatically generated. You can use this in other ways like

from fastapi import FastAPI, Depends
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    model: str
    manufacturer: str
    price: float
    tax: float


@app.get("/")
async def root(
        item: Item = Depends()
):
    return {"message": "Hello World"}

Enter fullscreen mode Exit fullscreen mode

If you register the value "Depends" at schema,

Image description

You can see that the part that you want to receive as a Body has changed to Parameter. Doesn't it feel like You've seen it somewhere? Right. It is a common method for constructing search query. However, as can be seen above, it can be seen that there is a limit of required values. It's a search query, but it doesn't make sense that all members have a required option. So it is necessary to change all of this to optional.

very easily

from pydantic import BaseModel
from typing import Optional

class Item(BaseModel):
    name: Optional[str]
    model: Optional[str]
    manufacturer: Optional[str]
    price: Optional[float]
    tax: Optional[float]

Enter fullscreen mode Exit fullscreen mode

It can be made in this way, but if the default value of the schema members are optional, there is a hassle of removing all the Optional when configuring the PUT method endpoint later. Oh, it is recommended that anyone who has no clear distinction between the PUT method and the PATCH method here refer to this article. In short, PUT is the promised method of changing the total value of an element, and PATCH is the method of changing some.

So I use this way.

from pydantic.main import ModelMetaclass

class AllOptional(ModelMetaclass):
    def __new__(self, name, bases, namespaces, **kwargs):
        annotations = namespaces.get('__annotations__', {})
        for base in bases:
            annotations.update(base.__annotations__)
        for field in annotations:
            if not field.startswith('__'):
                annotations[field] = Optional[annotations[field]]
        namespaces['__annotations__'] = annotations
        return super().__new__(self, name, bases, namespaces, **kwargs)

Enter fullscreen mode Exit fullscreen mode

This class has the function of changing all member variables of the class to Optional when declared as metaclass.

from fastapi import FastAPI, Depends
from pydantic import BaseModel
from pydantic.main import ModelMetaclass
from typing import Optional

app = FastAPI()


class AllOptional(ModelMetaclass):
    def __new__(self, name, bases, namespaces, **kwargs):
        annotations = namespaces.get('__annotations__', {})
        for base in bases:
            annotations.update(base.__annotations__)
        for field in annotations:
            if not field.startswith('__'):
                annotations[field] = Optional[annotations[field]]
        namespaces['__annotations__'] = annotations
        return super().__new__(self, name, bases, namespaces, **kwargs)


class Item(BaseModel):
    name: str
    model: str
    manufacturer: str
    price: float
    tax: float


class OptionalItem(Item, metaclass=AllOptional):
    pass


@app.get("/")
async def root(
        item: OptionalItem = Depends()
):
    return {"message": "Hello World"}

Enter fullscreen mode Exit fullscreen mode

If you inherit the class made of the pydantic Base Model and declare 'metaclass=AllOptional' there,

Image description

You can see Parameters without required option.

However, when dealing with a schema in practice, there are things that are not needed when putting it in and necessary when bringing it later. It is common for id, created_datetime, updated_datetime, etc. And sometimes you have to remove some members and show them. In spring or nest, you can flexibly cope with this situation by using the setFieldToIgnore function or the Omit class, but as anyone who has dealt with FastAPI can see, there are no such things.

So after a lot of trial and error, i established a way of using FastAPI schema like the DTO we used before. It would be irrelevant to say that this is the best practice now. Why? I looked up numerous examples, but there was no perfect alternative, so I defined them myself and made them.

This is an example using the schema called Item used above.

from pydantic import BaseModel

class BaseItem(BaseModel):
    name: str
    model: str
    manufacturer: str
    price: float
    tax: float
Enter fullscreen mode Exit fullscreen mode

The most basic members are defined as BaseItem. It can be seen as defining the elements used in Upsert. The pydantic model will basically be a schema used in the PUT method because it will be treated as Required if it is not set as Optional or = None.

class Item(BaseItem, metaclass=AllOptional):
    id: int
    created_datetime: datetime
    updated_datetime: datetime
Enter fullscreen mode Exit fullscreen mode

It is an item that inherits BaseItem. Even if the class inherited the Base Model is inherited, the inherited class also becomes a pydantic model, so it can be declared this simply. It is used to throw items that are not used in Upsert, including basic elements. This is a schema mainly used to inquire items, so wouldn't it be okay not to attach Optional...? But if there is a non-Optional member and its value has no Value, an error is occurred.

class FindBase(BaseModel):
    count: int
    page: int
    order: str


class FindItem(FindBase, BaseItem, metaclass=AllOptional):
    pass

Enter fullscreen mode Exit fullscreen mode

Parameters used to find items can be declared in this way. It inherits the BaseItem and FindBase declared above and treats them both as options with metaclass=AllOptional to remove the required option of the parameter. And lastly,

class Omit(ModelMetaclass):
    def __new__(self, name, bases, namespaces, **kwargs):
        omit_fields = getattr(namespaces.get("Config", {}), "omit_fields", {})
        fields = namespaces.get('__fields__', {})
        annotations = namespaces.get('__annotations__', {})
        for base in bases:
            fields.update(base.__fields__)
            annotations.update(base.__annotations__)
        merged_keys = fields.keys() & annotations.keys()
        [merged_keys.add(field) for field in fields]
        new_fields = {}
        new_annotations = {}
        for field in merged_keys:
            if not field.startswith('__') and field not in omit_fields:
                new_annotations[field] = annotations.get(field, fields[field].type_)
                new_fields[field] = fields[field]
        namespaces['__annotations__'] = new_annotations
        namespaces['__fields__'] = new_fields
        return super().__new__(self, name, bases, namespaces, **kwargs)

Enter fullscreen mode Exit fullscreen mode

This is a metaclass that supports the Omit function. I needed it, but I couldn't find it, so I made it.

class BaseItem(BaseModel):
    name: str
    model: str
    manufacturer: str
    price: float
    tax: float


class OmittedTaxPrice(BaseItem, metaclass=Omit):
    class Config:
        omit_fields = {'tax', 'price'}

Enter fullscreen mode Exit fullscreen mode

If you declare omit_fields in class Config in this way, you can get a new schema with removed fields.

※ And one thing to note is that metaclass is executed every time a class is created, so only one class involved in an inheritance relationship can be declared. For example

class BaseItem(BaseModel):
    name: str
    model: str
    manufacturer: str
    price: float
    tax: float


class Item(BaseItem, metaclass=AllOptional):
    id: int
    created_datetime: datetime
    updated_datetime: datetime



class TestItem(Item):
    additional_number: int

Enter fullscreen mode Exit fullscreen mode

If the Item declared metaclass=AllOptional is inherited from TestItem, AllOptional is executed twice. Once when creating an Item class, once again when creating a TestItem that inherits the Item. Therefore, the additional_number, a member variable of TestItem that inherits the Item, is also Optional. That is metaclass. So, if you declare another metaclass like Omit, the program is broken.

# ...
class TestItem(Item, metaclass=AllOptional)
    pass
# ...
# `TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases`
Enter fullscreen mode Exit fullscreen mode

So, it is good to declare and use metaclass in the last inheritance class before it is used as schema. This makes easy to recognize its function.

Anyway, if you use the above-mentioned four http methods, GET, POST, PUT, and PATCH, which are often used, it consists of this. It can also be found in my git repo. https://github.com/jujumilk3/fastapi-best-practice/tree/schema

from datetime import datetime
from fastapi import FastAPI, Depends
from pydantic import BaseModel
from pydantic.main import ModelMetaclass
from typing import Optional, List


app = FastAPI()


class AllOptional(ModelMetaclass):
    def __new__(self, name, bases, namespaces, **kwargs):
        annotations = namespaces.get('__annotations__', {})
        for base in bases:
            annotations.update(base.__annotations__)
        for field in annotations:
            if not field.startswith('__'):
                annotations[field] = Optional[annotations[field]]
        namespaces['__annotations__'] = annotations
        return super().__new__(self, name, bases, namespaces, **kwargs)


class Omit(ModelMetaclass):
    def __new__(self, name, bases, namespaces, **kwargs):
        omit_fields = getattr(namespaces.get("Config", {}), "omit_fields", {})
        fields = namespaces.get('__fields__', {})
        annotations = namespaces.get('__annotations__', {})
        for base in bases:
            fields.update(base.__fields__)
            annotations.update(base.__annotations__)
        merged_keys = fields.keys() & annotations.keys()
        [merged_keys.add(field) for field in fields]
        new_fields = {}
        new_annotations = {}
        for field in merged_keys:
            if not field.startswith('__') and field not in omit_fields:
                new_annotations[field] = annotations.get(field, fields[field].type_)
                new_fields[field] = fields[field]
        namespaces['__annotations__'] = new_annotations
        namespaces['__fields__'] = new_fields
        return super().__new__(self, name, bases, namespaces, **kwargs)


class BaseItem(BaseModel):
    name: str
    model: str
    manufacturer: str
    price: float
    tax: float


class UpsertItem(BaseItem, metaclass=AllOptional):
    pass


class Item(BaseItem, metaclass=AllOptional):
    id: int
    created_datetime: datetime
    updated_datetime: datetime


class OmittedTaxPrice(BaseItem, metaclass=Omit):
    class Config:
        omit_fields = {'tax', 'price'}


class FindBase(BaseModel):
    count: int
    page: int
    order: str


class FindItem(FindBase, BaseItem, metaclass=AllOptional):
    pass


@app.get("/")
async def root(
):
    return {"message": "Hello World"}


@app.get("/items/", response_model=List[Item])
async def find_items(
        find_query: FindItem = Depends()
):
    return {"hello": "world"}


@app.post("/items/", response_model=Item)
async def create_item(
        schema: UpsertItem
):
    return {"hello": "world"}


@app.patch("/items/{id}", response_model=Item)
async def update_item(
        id: int,
        schema: UpsertItem
):
    return {"hello": "world"}


@app.put("/items/{id}", response_model=Item)
async def put_item(
        id: int,
        schema: BaseItem
):
    return {"hello": "world"}


@app.get("/items/omitted", response_model=OmittedTaxPrice)
async def omitted_item(
        schema: OmittedTaxPrice
):
    return {"hello": "world"}

Enter fullscreen mode Exit fullscreen mode

When configured in this way, the following swagger documents are generated.

1. find items endpoint with optional parameters

Image description
Image description

2. create item

Image description

3. Put method

Image description

If you omit the item name according to the usage of PUT METHOD, 422 error is returned to send it with some name. The PUT was formed by declaring that it would receive BaseItem as a body.

Image description
Image description

4. PATCH method

Image description

5. Omitted

Image description

In this way, we learned how to deal with FastAPIschema. If you use something, you may see more necessary functions, but the endpoint and docs configuration that you need this much is progressing without difficulty in my case. If you want to actively use FastAPI's schema to write docs, please refer to it to reduce trial and error.

Discussion (0)