Saltar al contenido

Modelos Extra

Continuando con el ejemplo anterior, será común tener más de un modelo relacionado.

Este es especialmente el caso para los modelos de usuario, porque:

  • El modelo de entrada necesita poder tener una contraseña.
  • El modelo de salida no debería tener una contraseña.
  • El modelo de base de datos probablemente necesitaría tener una contraseña hasheada.

Peligro

Nunca almacenes las contraseñas en texto plano del usuario. Siempre almacena un "hash seguro" que luego puedes verificar.

Si no lo sabes, aprenderás qué es un "hash de contraseña" en los capítulos de seguridad.

Múltiples modelos

Aquí hay una idea general de cómo podrían verse los modelos con sus campos de contraseña y los lugares donde se usan:

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.model_dump(), 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

Sobre **user_in.model_dump()

.model_dump() de Pydantic

user_in es un modelo de Pydantic de la clase UserIn.

Los modelos de Pydantic tienen un método .model_dump() que devuelve un dict con los datos del modelo.

Así, si creamos un objeto de Pydantic user_in como:

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

y luego llamamos:

user_dict = user_in.model_dump()

ahora tenemos un dict con los datos en la variable user_dict (es un dict en lugar de un objeto modelo de Pydantic).

Y si llamamos:

print(user_dict)

obtendríamos un dict de Python con:

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

Desempaquetando un dict

Si tomamos un dict como user_dict y lo pasamos a una función (o clase) con **user_dict, Python lo "desempaquetará". Pasará las claves y valores del user_dict directamente como argumentos clave-valor.

Así, continuando con el user_dict de arriba, escribiendo:

UserInDB(**user_dict)

resultaría en algo equivalente a:

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

O más exactamente, usando user_dict directamente, con cualquier contenido que pueda tener en el futuro:

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

Un modelo de Pydantic a partir del contenido de otro

Como en el ejemplo anterior obtuvimos user_dict de user_in.model_dump(), este código:

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

sería equivalente a:

UserInDB(**user_in.model_dump())

...porque user_in.model_dump() es un dict, y luego hacemos que Python lo "desempaquete" pasándolo a UserInDB prefijado con **.

Así, obtenemos un modelo de Pydantic a partir de los datos de otro modelo de Pydantic.

Desempaquetando un dict y keywords extra

Y luego añadiendo el argumento keyword extra hashed_password=hashed_password, como en:

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

...termina siendo como:

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

Aviso

Las funciones adicionales de soporte fake_password_hasher y fake_save_user son solo para demostrar un posible flujo de los datos, pero por supuesto no proporcionan ninguna seguridad real.

Reducir duplicación

Reducir la duplicación de código es una de las ideas centrales en FastAPI.

Como la duplicación de código aumenta las posibilidades de bugs, problemas de seguridad, problemas de desincronización de código (cuando actualizas en un lugar pero no en los otros), etc.

Y estos modelos están todos compartiendo muchos de los datos y duplicando nombres y tipos de atributos.

Podemos hacerlo mejor.

Podemos declarar un modelo UserBase que sirva como base para nuestros otros modelos. Y luego podemos hacer subclases de ese modelo que hereden sus atributos (declaraciones de tipo, validación, etc).

Toda la conversión de datos, validación, documentación, etc. seguirá funcionando de manera normal.

De esa manera, podemos declarar solo las diferencias entre los modelos (con password en texto plano, con hashed_password y sin contraseña):

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.model_dump(), 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 o anyOf

Puedes declarar que una response sea la Union de dos o más tipos, lo que significa que la response podría ser cualquiera de ellos.

Se definirá en OpenAPI con anyOf.

Para hacer eso, usa el type hint estándar de Python typing.Union:

Nota

Al definir una Union, incluye el tipo más específico primero, seguido del tipo menos específico. En el ejemplo de abajo, el más específico PlaneItem va antes que CarItem en Union[PlaneItem, CarItem].

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=PlaneItem | CarItem)
async def read_item(item_id: str):
    return items[item_id]

Union en Python 3.10

En este ejemplo pasamos Union[PlaneItem, CarItem] como el valor del argumento response_model.

Porque lo estamos pasando como un valor a un argumento en lugar de ponerlo en una anotación de tipo, tenemos que usar Union incluso en Python 3.10.

Si estuviera en una anotación de tipo podríamos haber usado la barra vertical, como:

some_variable: PlaneItem | CarItem

Pero si pusiéramos eso en la asignación response_model=PlaneItem | CarItem obtendríamos un error, porque Python intentaría realizar una operación inválida entre PlaneItem y CarItem en lugar de interpretarlo como una anotación de tipo.

Lista de modelos

De la misma manera, puedes declarar responses de listas de objetos.

Para eso, usa la list estándar de Python:

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

Response con un dict arbitrario

También puedes declarar una response usando un dict arbitrario simple, declarando solo el tipo de las claves y valores, sin usar un modelo de Pydantic.

Esto es útil si no conoces de antemano los nombres válidos de campos/atributos (que serían necesarios para un modelo de Pydantic).

En este caso, puedes usar 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}

Resumen

Usa múltiples modelos de Pydantic y hereda libremente para cada caso.

No necesitas tener un único modelo de datos por entidad si esa entidad debe poder tener diferentes "estados". La "entidad" user es un ejemplo, con estados que incluyen password, password_hash, o sin contraseña.