Search code examples
pythonsqlalchemyfastapipydantic

Interaction between Pydantic models/schemas in the FastAPI Tutorial


I follow the FastAPI Tutorial and am not quite sure what the exact relationship between the proposed data objects is.

We have the models.py file:

from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship

from .database import Base


class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    email = Column(String, unique=True, index=True)
    hashed_password = Column(String)
    is_active = Column(Boolean, default=True)

    items = relationship("Item", back_populates="owner")


class Item(Base):
    __tablename__ = "items"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String, index=True)
    description = Column(String, index=True)
    owner_id = Column(Integer, ForeignKey("users.id"))

    owner = relationship("User", back_populates="items")

And the schemas.py file:

from typing import List, Union

from pydantic import BaseModel


class ItemBase(BaseModel):
    title: str
    description: Union[str, None] = None


class ItemCreate(ItemBase):
    pass


class Item(ItemBase):
    id: int
    owner_id: int

    class Config:
        orm_mode = True


class UserBase(BaseModel):
    email: str


class UserCreate(UserBase):
    password: str


class User(UserBase):
    id: int
    is_active: bool
    items: List[Item] = []

    class Config:
        orm_mode = True

Those classes are then used to define db queries like in the crud.py file:

from sqlalchemy.orm import Session

from . import models, schemas


def get_user(db: Session, user_id: int):
    return db.query(models.User).filter(models.User.id == user_id).first()


def get_user_by_email(db: Session, email: str):
    return db.query(models.User).filter(models.User.email == email).first()


def get_users(db: Session, skip: int = 0, limit: int = 100):
    return db.query(models.User).offset(skip).limit(limit).all()


def create_user(db: Session, user: schemas.UserCreate):
    fake_hashed_password = user.password + "notreallyhashed"
    db_user = models.User(email=user.email, hashed_password=fake_hashed_password)
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    return db_user

def get_items(db: Session, skip: int = 0, limit: int = 100):
    return db.query(models.Item).offset(skip).limit(limit).all()

def create_user_item(db: Session, item: schemas.ItemCreate, user_id: int):
    db_item = models.Item(**item.dict(), owner_id=user_id)
    db.add(db_item)
    db.commit()
    db.refresh(db_item)
    return db_item

And in the FastAPI code main.py:

from typing import List

from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy.orm import Session

from . import crud, models, schemas
from .database import SessionLocal, engine

models.Base.metadata.create_all(bind=engine)

app = FastAPI()


# Dependency
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


@app.post("/users/", response_model=schemas.User)
def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
    db_user = crud.get_user_by_email(db, email=user.email)
    if db_user:
        raise HTTPException(status_code=400, detail="Email already registered")
    return crud.create_user(db=db, user=user)


@app.get("/users/", response_model=List[schemas.User])
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
    users = crud.get_users(db, skip=skip, limit=limit)
    return users


@app.get("/users/{user_id}", response_model=schemas.User)
def read_user(user_id: int, db: Session = Depends(get_db)):
    db_user = crud.get_user(db, user_id=user_id)
    if db_user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return db_user


@app.post("/users/{user_id}/items/", response_model=schemas.Item)
def create_item_for_user(
    user_id: int, item: schemas.ItemCreate, db: Session = Depends(get_db)
):
    return crud.create_user_item(db=db, item=item, user_id=user_id)


@app.get("/items/", response_model=List[schemas.Item])
def read_items(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
    items = crud.get_items(db, skip=skip, limit=limit)
    return items

From what I understand:

  • The models data classes define the SQL tables.
  • The schemas data classes define the API that FastAPI uses to interact with the database.
  • They must be convertible into each other so that the set-up works.

What I don't understand:

  • In crud.create_user_item I expected the return type to be schemas.Item, since that return type is used by FastAPI again.
  • According to my understanding the response model of @app.post("/users/{user_id}/items/", response_model=schemas.Item) in the main.py is wrong, or how can I understand the return type inconsistency?
  • However inferring from the code, the actual return type must be models.Item, how is that handled by FastAPI?
  • What would be the return type of crud.get_user?

Solution

  • I'll go through your bullet points one by one.


    The models data classes define the SQL tables.

    Yes. More precisely, the classes that map to actual database tables are defined in the models module.


    The schemas data classes define the API that FastAPI uses to interact with the database.

    Yes and no. The Pydantic models in the schemas module define the data schemas relevant to the API, yes. But that has nothing to do with the database yet. Some of these schemas define what data is expected to be received by certain API endpoints for the request to be considered valid. Others define what the data returned by certain endpoints will look like.


    They must be convertible into each other so that the set-up works.

    While the database table schemas and the API data schemas are usually very similar, that is not necessarily the case. In the tutorial however, they correspond quite neatly, which allows succinct code, like this:

    db_item = models.Item(**item.dict(), owner_id=user_id)
    

    Here item is a Pydantic model instance, i.e. one of your API data schemas schemas.ItemCreate containing data you decided is necessary for creating a new item. Since its fields (their names and types) correspond to those of the database model models.Item, the latter can be instantiated from the dictionary representation of the former (with the addition of the owner_id).


    In crud.create_user_item I expected the return type to be schemas.Item, since that return type is used by FastAPI again.

    No, this is exactly the magic of FastAPI. The function create_user_item returns an instance of models.Item, i.e. the ORM object as constructed from the database (after calling session.refresh on it):

    def create_user_item(db: Session, item: schemas.ItemCreate, user_id: int):
        db_item = models.Item(**item.dict(), owner_id=user_id)
        ...
        return db_item
    

    And the API route handler function create_item_for_user actually does return that same object (of class models.Item).

    @app.post("/users/{user_id}/items/", response_model=schemas.Item)
    def create_item_for_user(
        user_id: int, item: schemas.ItemCreate, db: Session = Depends(get_db)
    ):
        return crud.create_user_item(db=db, item=item, user_id=user_id)
    

    However, the @app.post decorator takes that object and uses it to construct an instance of the response_model you defined for that route, which is schemas.Item in this case. This is why you set orm_mode in your schemas.Item model:

    class Config:
        orm_mode = True
    

    This allows an instance of that class to be created via the .from_orm method. (This only applies to Pydantic v1 Models.) That all happens behind the scenes and again depends on the SQLAlchemy model corresponding to the Pydantic model with regards to field names and types. Otherwise validation fails.


    According to my understanding the response model [...] is wrong

    No, see above. The decorated route function actually returns an instance of the schemas.Item model.


    However inferring from the code, the actual return type must be models.Item

    Yes, see above. The return type of the undecorated route handler function create_item_for_user is in fact models.Item. But its return type is not the response model.

    I assume that to reduce confusion the documentation example does not annotate the return type of those route functions. If it did, it would look like this:

    @app.post("/users/{user_id}/items/", response_model=schemas.Item)
    def create_item_for_user(
        user_id: int, item: schemas.ItemCreate, db: Session = Depends(get_db)
    ) -> models.Item:
        return crud.create_user_item(db=db, item=item, user_id=user_id)
    

    It may help to remember that a function decorator is just syntactic sugar for a function that takes a function as argument and (usually) returns a function. Typically the returned function actually internally calls the function passed to it as argument and does additional things before and/or after that call. I could rewrite the route above like this and it would be exactly the same:

    def create_item_for_user(
        user_id: int, item: schemas.ItemCreate, db: Session = Depends(get_db)
    ) -> models.Item:
        return crud.create_user_item(db=db, item=item, user_id=user_id)
    
    
    create_item_for_user = app.post(
        "/users/{user_id}/items/", response_model=schemas.Item
    )(create_item_for_user)
    

    What would be the return type of crud.get_user?

    That would be models.User because that is the database model and is what the first method of that query returns.

    def get_user(db: Session, user_id: int) -> models.User:
        return db.query(models.User).filter(models.User.id == user_id).first()
    

    This is then again returned by the read_user API route function in the same fashion as I explained above for models.Item.

    @app.get("/users/{user_id}", response_model=schemas.User)
    def read_user(user_id: int, db: Session = Depends(get_db)) -> models.User:
        db_user = crud.get_user(db, user_id=user_id)
        ...
        return db_user  # <-- instance of `models.User`
    

    That is, the models.User object is intercepted by the internal function of the decorator and (because of the defined response_model) passed to schemas.User.from_orm, which returns a schemas.User object.

    Hope this helps.