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
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"}
If you declare that you're gonna get an item schema at the basic endpoint,
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"}
If you register the value "Depends" at schema,
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]
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)
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"}
If you inherit the class made of the pydantic Base Model and declare 'metaclass=AllOptional' there,
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
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
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
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)
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'}
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
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`
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"}
When configured in this way, the following swagger documents are generated.
1. find items endpoint with optional parameters
2. create item
3. Put method
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.
4. PATCH method
5. Omitted
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.
Top comments (1)
How would you change things here after the pydantic v2 upgrade?