FastAPI Extra Models
Introduction
When building real-world APIs, you'll often need to work with multiple related models rather than just a single data structure. FastAPI provides elegant ways to define, relate, and use multiple Pydantic models to create complex yet maintainable API schemas.
In this tutorial, we'll explore how to work with multiple models in FastAPI, establish relationships between them, and use them effectively in your API endpoints. This is a crucial skill for building robust applications with proper data separation and organization.
Why Use Multiple Models?
Before diving into the code, let's understand why we might need multiple models:
- Input vs Output: Often the data structure you receive might differ from what you return
- Data Validation: Different endpoints might require different validation rules
- Documentation: Multiple models help create clearer, more specific API documentation
- Security: You can exclude sensitive fields from response models
- Database Integration: Your database models might not match your API models
Basic Multiple Model Example
Let's start with a simple example of using different models for input and output:
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
from typing import List, Optional
app = FastAPI()
class UserIn(BaseModel):
username: str
email: EmailStr
password: str
full_name: Optional[str] = None
class UserOut(BaseModel):
username: str
email: EmailStr
full_name: Optional[str] = None
@app.post("/users/", response_model=UserOut)
async def create_user(user: UserIn):
# In a real app, you would save the user to a database here
# Note that we return the input user, but FastAPI will filter
# out the password field because we use UserOut as response_model
return user
In this example:
UserIn
includespassword
and is used for input validationUserOut
excludespassword
and is used for the response- The
response_model=UserOut
parameter ensures that FastAPI filters the output to matchUserOut
When testing with a request body like:
{
"username": "johndoe",
"email": "[email protected]",
"password": "secret123",
"full_name": "John Doe"
}
The API will return:
{
"username": "johndoe",
"email": "[email protected]",
"full_name": "John Doe"
}
Advanced Model Relationships
Real applications often have complex relationships between models. Let's explore some common patterns:
Nested Models
You can define models that contain other models:
from fastapi import FastAPI
from pydantic import BaseModel
from typing import List, Optional
app = FastAPI()
class Item(BaseModel):
name: str
description: Optional[str] = None
price: float
tax: Optional[float] = None
class User(BaseModel):
username: str
full_name: Optional[str] = None
email: str
class Order(BaseModel):
items: List[Item]
customer: User
@app.post("/orders/")
async def create_order(order: Order):
return {"order_id": "123", "order": order}
With this structure, your API will expect a request body like:
{
"items": [
{
"name": "Laptop",
"description": "High-performance laptop",
"price": 999.99,
"tax": 81.00
}
],
"customer": {
"username": "johndoe",
"full_name": "John Doe",
"email": "[email protected]"
}
}
Union Type Models
Sometimes you might need to accept different types of models at the same endpoint. For example, a payment API might handle different payment methods:
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Union
app = FastAPI()
class CreditCardPayment(BaseModel):
card_number: str
expiry_date: str
security_code: str
class BankTransferPayment(BaseModel):
account_number: str
routing_number: str
class PaymentResponse(BaseModel):
payment_id: str
amount: float
status: str
@app.post("/payments/", response_model=PaymentResponse)
async def process_payment(payment: Union[CreditCardPayment, BankTransferPayment]):
# Process payment based on type
if isinstance(payment, CreditCardPayment):
# Process credit card
payment_method = "credit_card"
else:
# Process bank transfer
payment_method = "bank_transfer"
return {
"payment_id": "pay_123456",
"amount": 100.50,
"status": f"Processing {payment_method} payment"
}
Model Inheritance
Pydantic models support inheritance, which is great for when you have models that share many fields:
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional, List
app = FastAPI()
class ItemBase(BaseModel):
name: str
description: Optional[str] = None
price: float
class ItemCreate(ItemBase):
tax: Optional[float] = None
class ItemInDB(ItemBase):
id: int
stock: int
@app.post("/items/", response_model=ItemInDB)
async def create_item(item: ItemCreate):
# Simulate creating an item in database and returning it
return ItemInDB(
id=1,
stock=20,
name=item.name,
description=item.description,
price=item.price
)
Using Extra Models for Database Operations
When working with databases, it's common to have distinct models for:
- Creating records (with validation)
- Reading records (for responses)
- Updating records (with partial data)
- Database models (ORM integration)
Here's an example using SQLAlchemy with FastAPI:
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, List
# Database setup (simplified)
from database import get_db, Base
from sqlalchemy import Column, Integer, String, Float
from sqlalchemy.ext.declarative import declarative_base
app = FastAPI()
# SQLAlchemy model
class ProductDB(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
description = Column(String)
price = Column(Float)
stock = Column(Integer)
# Pydantic models
class ProductBase(BaseModel):
name: str
description: Optional[str] = None
price: float
class ProductCreate(ProductBase):
stock: int
class ProductUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
stock: Optional[int] = None
class Product(ProductBase):
id: int
stock: int
class Config:
orm_mode = True
@app.post("/products/", response_model=Product)
def create_product(product: ProductCreate, db: Session = Depends(get_db)):
db_product = ProductDB(**product.dict())
db.add(db_product)
db.commit()
db.refresh(db_product)
return db_product
@app.get("/products/{product_id}", response_model=Product)
def read_product(product_id: int, db: Session = Depends(get_db)):
db_product = db.query(ProductDB).filter(ProductDB.id == product_id).first()
if db_product is None:
raise HTTPException(status_code=404, detail="Product not found")
return db_product
@app.put("/products/{product_id}", response_model=Product)
def update_product(product_id: int, product: ProductUpdate, db: Session = Depends(get_db)):
db_product = db.query(ProductDB).filter(ProductDB.id == product_id).first()
if db_product is None:
raise HTTPException(status_code=404, detail="Product not found")
update_data = product.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_product, key, value)
db.commit()
db.refresh(db_product)
return db_product
In this example:
ProductBase
contains common fieldsProductCreate
is used for creating new productsProductUpdate
makes all fields optional for partial updatesProduct
is the response model with all fields including the IDProductDB
is the SQLAlchemy ORM model
Using Generic Models
For APIs with common response patterns, you can create generic models to reduce repetition:
from fastapi import FastAPI
from pydantic import BaseModel, Generic, TypeVar
from typing import Optional, List, Dict, Any
T = TypeVar('T')
app = FastAPI()
class User(BaseModel):
id: int
name: str
email: str
class Item(BaseModel):
id: int
name: str
price: float
class PaginatedResponse(BaseModel, Generic[T]):
items: List[T]
total: int
page: int
size: int
class GenericResponse(BaseModel, Generic[T]):
data: T
status: str
message: Optional[str] = None
meta: Optional[Dict[str, Any]] = None
@app.get("/users/", response_model=PaginatedResponse[User])
async def list_users():
# Simulate database fetch
users = [
User(id=1, name="Alice", email="[email protected]"),
User(id=2, name="Bob", email="[email protected]"),
]
return PaginatedResponse(
items=users,
total=2,
page=1,
size=10
)
@app.get("/items/{item_id}", response_model=GenericResponse[Item])
async def get_item(item_id: int):
# Simulate database fetch
item = Item(id=item_id, name="Laptop", price=999.99)
return GenericResponse(
data=item,
status="success",
message="Item retrieved successfully",
meta={"accessed_at": "2023-07-15T15:30:00Z"}
)
This approach provides consistent responses across different endpoint types while maintaining type safety.
Summary
Working with multiple models in FastAPI offers several advantages:
- Security: Keep sensitive data out of responses
- Flexibility: Handle different input and output requirements
- Organization: Create logical separations between your data models
- Maintainability: Update input validation without affecting output models
- Documentation: Generate clear API documentation with specific schemas
By properly using extra models, you can create more robust, secure, and maintainable API designs that handle complex real-world requirements.
Exercises
To reinforce your understanding of extra models in FastAPI:
- Create an e-commerce API with models for products, customers, and orders with appropriate relationships between them.
- Implement a blog system with models for users, posts, and comments using inheritance to reduce code duplication.
- Design an authentication system with separate models for registration, login, and user profiles.
- Create a generic pagination response model and apply it to several endpoints.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)