Перейти к содержанию

Дополнительные модели

В продолжение прошлого примера будет уже обычным делом иметь несколько связанных между собой моделей.

Это особенно применимо в случае моделей пользователя, потому что:

  • Модель для ввода должна иметь возможность содержать пароль.
  • Модель для вывода не должна содержать пароль.
  • Модель для базы данных, возможно, должна содержать хэшированный пароль.

Внимание

Никогда не храните пароли пользователей в чистом виде. Всегда храните "безопасный хэш", который вы затем сможете проверить.

Если вам это не знакомо, вы можете узнать про "хэш пароля" в главах о безопасности.

Множественные модели

Ниже изложена основная идея того, как могут выглядеть эти модели с полями для паролей, а также описаны места, где они используются:

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()


class UserIn(BaseModel):
    username: str
    password: str
    email: EmailStr
    full_name: str | None = None


class UserOut(BaseModel):
    username: str
    email: EmailStr
    full_name: str | None = None


class UserInDB(BaseModel):
    username: str
    hashed_password: str
    email: EmailStr
    full_name: str | None = None


def fake_password_hasher(raw_password: str):
    return "supersecret" + raw_password


def fake_save_user(user_in: UserIn):
    hashed_password = fake_password_hasher(user_in.password)
    user_in_db = UserInDB(**user_in.dict(), hashed_password=hashed_password)
    print("User saved! ..not really")
    return user_in_db


@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
    user_saved = fake_save_user(user_in)
    return user_saved
from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()


class UserIn(BaseModel):
    username: str
    password: str
    email: EmailStr
    full_name: Union[str, None] = None


class UserOut(BaseModel):
    username: str
    email: EmailStr
    full_name: Union[str, None] = None


class UserInDB(BaseModel):
    username: str
    hashed_password: str
    email: EmailStr
    full_name: Union[str, None] = None


def fake_password_hasher(raw_password: str):
    return "supersecret" + raw_password


def fake_save_user(user_in: UserIn):
    hashed_password = fake_password_hasher(user_in.password)
    user_in_db = UserInDB(**user_in.dict(), hashed_password=hashed_password)
    print("User saved! ..not really")
    return user_in_db


@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
    user_saved = fake_save_user(user_in)
    return user_saved

Про **user_in.dict()

.dict() из Pydantic

user_in - это Pydantic-модель класса UserIn.

У Pydantic-моделей есть метод .dict(), который возвращает dict с данными модели.

Поэтому, если мы создадим Pydantic-объект user_in таким способом:

user_in = UserIn(username="john", password="secret", email="john.doe@example.com")

и затем вызовем:

user_dict = user_in.dict()

то теперь у нас есть dict с данными модели в переменной user_dict (это dict вместо объекта Pydantic-модели).

И если мы вызовем:

print(user_dict)

мы можем получить dict с такими данными:

{
    'username': 'john',
    'password': 'secret',
    'email': 'john.doe@example.com',
    'full_name': None,
}

Распаковка dict

Если мы возьмём dict наподобие user_dict и передадим его в функцию (или класс), используя **user_dict, Python распакует его. Он передаст ключи и значения user_dict напрямую как аргументы типа ключ-значение.

Поэтому, продолжая описанный выше пример с user_dict, написание такого кода:

UserInDB(**user_dict)

Будет работать так же, как примерно такой код:

UserInDB(
    username="john",
    password="secret",
    email="john.doe@example.com",
    full_name=None,
)

Или, если для большей точности мы напрямую используем user_dict с любым потенциальным содержимым, то этот пример будет выглядеть так:

UserInDB(
    username = user_dict["username"],
    password = user_dict["password"],
    email = user_dict["email"],
    full_name = user_dict["full_name"],
)

Pydantic-модель из содержимого другой модели

Как в примере выше мы получили user_dict из user_in.dict(), этот код:

user_dict = user_in.dict()
UserInDB(**user_dict)

будет равнозначен такому:

UserInDB(**user_in.dict())

...потому что user_in.dict() - это dict, и затем мы указываем, чтобы Python его "распаковал", когда передаём его в UserInDB и ставим перед ним **.

Таким образом мы получаем Pydantic-модель на основе данных из другой Pydantic-модели.

Распаковка dict и дополнительные именованные аргументы

И затем, если мы добавим дополнительный именованный аргумент hashed_password=hashed_password как здесь:

UserInDB(**user_in.dict(), hashed_password=hashed_password)

... то мы получим что-то подобное:

UserInDB(
    username = user_dict["username"],
    password = user_dict["password"],
    email = user_dict["email"],
    full_name = user_dict["full_name"],
    hashed_password = hashed_password,
)

Предупреждение

Цель использованных в примере вспомогательных функций - не более чем демонстрация возможных операций с данными, но, конечно, они не обеспечивают настоящую безопасность.

Сократите дублирование

Сокращение дублирования кода - это одна из главных идей FastAPI.

Поскольку дублирование кода повышает риск появления багов, проблем с безопасностью, проблем десинхронизации кода (когда вы обновляете код в одном месте, но не обновляете в другом), и т.д.

А все описанные выше модели используют много общих данных и дублируют названия атрибутов и типов.

Мы можем это улучшить.

Мы можем определить модель UserBase, которая будет базовой для остальных моделей. И затем мы можем создать подклассы этой модели, которые будут наследовать её атрибуты (объявления типов, валидацию, и т.п.).

Все операции конвертации, валидации, документации, и т.п. будут по-прежнему работать нормально.

В этом случае мы можем определить только различия между моделями (с password в чистом виде, с hashed_password и без пароля):

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()


class UserBase(BaseModel):
    username: str
    email: EmailStr
    full_name: str | None = None


class UserIn(UserBase):
    password: str


class UserOut(UserBase):
    pass


class UserInDB(UserBase):
    hashed_password: str


def fake_password_hasher(raw_password: str):
    return "supersecret" + raw_password


def fake_save_user(user_in: UserIn):
    hashed_password = fake_password_hasher(user_in.password)
    user_in_db = UserInDB(**user_in.dict(), hashed_password=hashed_password)
    print("User saved! ..not really")
    return user_in_db


@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
    user_saved = fake_save_user(user_in)
    return user_saved
from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()


class UserBase(BaseModel):
    username: str
    email: EmailStr
    full_name: Union[str, None] = None


class UserIn(UserBase):
    password: str


class UserOut(UserBase):
    pass


class UserInDB(UserBase):
    hashed_password: str


def fake_password_hasher(raw_password: str):
    return "supersecret" + raw_password


def fake_save_user(user_in: UserIn):
    hashed_password = fake_password_hasher(user_in.password)
    user_in_db = UserInDB(**user_in.dict(), hashed_password=hashed_password)
    print("User saved! ..not really")
    return user_in_db


@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
    user_saved = fake_save_user(user_in)
    return user_saved

Union или anyOf

Вы можете определить ответ как Union из двух типов. Это означает, что ответ должен соответствовать одному из них.

Он будет определён в OpenAPI как anyOf.

Для этого используйте стандартные аннотации типов в Python typing.Union:

Примечание

При объявлении Union, сначала указывайте наиболее детальные типы, затем менее детальные. В примере ниже более детальный PlaneItem стоит перед CarItem в Union[PlaneItem, CarItem].

from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class BaseItem(BaseModel):
    description: str
    type: str


class CarItem(BaseItem):
    type: str = "car"


class PlaneItem(BaseItem):
    type: str = "plane"
    size: int


items = {
    "item1": {"description": "All my friends drive a low rider", "type": "car"},
    "item2": {
        "description": "Music is my aeroplane, it's my aeroplane",
        "type": "plane",
        "size": 5,
    },
}


@app.get("/items/{item_id}", response_model=Union[PlaneItem, CarItem])
async def read_item(item_id: str):
    return items[item_id]
from typing import Union

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class BaseItem(BaseModel):
    description: str
    type: str


class CarItem(BaseItem):
    type: str = "car"


class PlaneItem(BaseItem):
    type: str = "plane"
    size: int


items = {
    "item1": {"description": "All my friends drive a low rider", "type": "car"},
    "item2": {
        "description": "Music is my aeroplane, it's my aeroplane",
        "type": "plane",
        "size": 5,
    },
}


@app.get("/items/{item_id}", response_model=Union[PlaneItem, CarItem])
async def read_item(item_id: str):
    return items[item_id]

Union в Python 3.10

В этом примере мы передаём Union[PlaneItem, CarItem] в качестве значения аргумента response_model.

Поскольку мы передаём его как значение аргумента вместо того, чтобы поместить его в аннотацию типа, нам придётся использовать Union даже в Python 3.10.

Если оно было бы указано в аннотации типа, то мы могли бы использовать вертикальную черту как в примере:

some_variable: PlaneItem | CarItem

Но если мы помещаем его в response_model=PlaneItem | CarItem мы получим ошибку, потому что Python попытается произвести некорректную операцию между PlaneItem и CarItem вместо того, чтобы интерпретировать это как аннотацию типа.

Список моделей

Таким же образом вы можете определять ответы как списки объектов.

Для этого используйте typing.List из стандартной библиотеки Python (или просто list в Python 3.9 и выше):

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    description: str


items = [
    {"name": "Foo", "description": "There comes my hero"},
    {"name": "Red", "description": "It's my aeroplane"},
]


@app.get("/items/", response_model=list[Item])
async def read_items():
    return items
from typing import List

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    description: str


items = [
    {"name": "Foo", "description": "There comes my hero"},
    {"name": "Red", "description": "It's my aeroplane"},
]


@app.get("/items/", response_model=List[Item])
async def read_items():
    return items

Ответ с произвольным dict

Вы также можете определить ответ, используя произвольный одноуровневый dict и определяя только типы ключей и значений без использования Pydantic-моделей.

Это полезно, если вы заранее не знаете корректных названий полей/атрибутов (которые будут нужны при использовании Pydantic-модели).

В этом случае вы можете использовать typing.Dict (или просто dict в Python 3.9 и выше):

from fastapi import FastAPI

app = FastAPI()


@app.get("/keyword-weights/", response_model=dict[str, float])
async def read_keyword_weights():
    return {"foo": 2.3, "bar": 3.4}
from typing import Dict

from fastapi import FastAPI

app = FastAPI()


@app.get("/keyword-weights/", response_model=Dict[str, float])
async def read_keyword_weights():
    return {"foo": 2.3, "bar": 3.4}

Резюме

Используйте несколько Pydantic-моделей и свободно применяйте наследование для каждой из них.

Вам не обязательно иметь единственную модель данных для каждой сущности, если эта сущность должна иметь возможность быть в разных "состояниях". Как в случае с "сущностью" пользователя, у которого есть состояния с полями password, password_hash и без пароля.