Development Guide¶
This guide covers everything you need to know about extending, customizing, and developing with the FastAPI boilerplate.
Extending the Boilerplate¶
Adding New Models¶
Follow this step-by-step process to add new entities to your application:
1. Create SQLAlchemy Model¶
Create a new file in src/app/models/
(e.g., category.py
):
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.db.database import Base
class Category(Base):
__tablename__ = "category"
id: Mapped[int] = mapped_column(
"id",
autoincrement=True,
nullable=False,
unique=True,
primary_key=True,
init=False
)
name: Mapped[str] = mapped_column(String(50))
description: Mapped[str | None] = mapped_column(String(255), default=None)
# Relationships
posts: Mapped[list["Post"]] = relationship(back_populates="category")
2. Create Pydantic Schemas¶
Create src/app/schemas/category.py
:
from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, Field, ConfigDict
class CategoryBase(BaseModel):
name: Annotated[str, Field(min_length=1, max_length=50)]
description: Annotated[str | None, Field(max_length=255, default=None)]
class CategoryCreate(CategoryBase):
model_config = ConfigDict(extra="forbid")
class CategoryRead(CategoryBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
class CategoryUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: Annotated[str | None, Field(min_length=1, max_length=50, default=None)]
description: Annotated[str | None, Field(max_length=255, default=None)]
class CategoryUpdateInternal(CategoryUpdate):
updated_at: datetime
class CategoryDelete(BaseModel):
model_config = ConfigDict(extra="forbid")
is_deleted: bool
deleted_at: datetime
3. Create CRUD Operations¶
Create src/app/crud/crud_categories.py
:
from fastcrud import FastCRUD
from ..models.category import Category
from ..schemas.category import CategoryCreate, CategoryUpdate, CategoryUpdateInternal, CategoryDelete
CRUDCategory = FastCRUD[Category, CategoryCreate, CategoryUpdate, CategoryUpdateInternal, CategoryDelete]
crud_categories = CRUDCategory(Category)
4. Update Model Imports¶
Add your new model to src/app/models/__init__.py
:
5. Create Database Migration¶
Generate and apply the migration:
# From the src/ directory
uv run alembic revision --autogenerate -m "Add category model"
uv run alembic upgrade head
6. Create API Endpoints¶
Create src/app/api/v1/categories.py
:
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from fastcrud.paginated import PaginatedListResponse, compute_offset
from sqlalchemy.ext.asyncio import AsyncSession
from ...api.dependencies import get_current_superuser, get_current_user
from ...core.db.database import async_get_db
from ...core.exceptions.http_exceptions import DuplicateValueException, NotFoundException
from ...crud.crud_categories import crud_categories
from ...schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
router = APIRouter(tags=["categories"])
@router.post("/category", response_model=CategoryRead, status_code=201)
async def write_category(
request: Request,
category: CategoryCreate,
current_user: Annotated[dict, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(async_get_db)],
):
category_row = await crud_categories.exists(db=db, name=category.name)
if category_row:
raise DuplicateValueException("Category name already exists")
return await crud_categories.create(db=db, object=category)
@router.get("/categories", response_model=PaginatedListResponse[CategoryRead])
async def read_categories(
request: Request,
db: Annotated[AsyncSession, Depends(async_get_db)],
page: int = 1,
items_per_page: int = 10,
):
categories_data = await crud_categories.get_multi(
db=db,
offset=compute_offset(page, items_per_page),
limit=items_per_page,
schema_to_select=CategoryRead,
is_deleted=False,
)
return categories_data
@router.get("/category/{category_id}", response_model=CategoryRead)
async def read_category(
request: Request,
category_id: int,
db: Annotated[AsyncSession, Depends(async_get_db)],
):
db_category = await crud_categories.get(
db=db,
schema_to_select=CategoryRead,
id=category_id,
is_deleted=False
)
if not db_category:
raise NotFoundException("Category not found")
return db_category
@router.patch("/category/{category_id}", response_model=CategoryRead)
async def patch_category(
request: Request,
category_id: int,
values: CategoryUpdate,
current_user: Annotated[dict, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(async_get_db)],
):
db_category = await crud_categories.get(db=db, id=category_id, is_deleted=False)
if not db_category:
raise NotFoundException("Category not found")
if values.name:
category_row = await crud_categories.exists(db=db, name=values.name)
if category_row and category_row["id"] != category_id:
raise DuplicateValueException("Category name already exists")
return await crud_categories.update(db=db, object=values, id=category_id)
@router.delete("/category/{category_id}")
async def erase_category(
request: Request,
category_id: int,
current_user: Annotated[dict, Depends(get_current_superuser)],
db: Annotated[AsyncSession, Depends(async_get_db)],
):
db_category = await crud_categories.get(db=db, id=category_id, is_deleted=False)
if not db_category:
raise NotFoundException("Category not found")
await crud_categories.delete(db=db, db_row=db_category, garbage_collection=False)
return {"message": "Category deleted"}
7. Register Router¶
Add your router to src/app/api/v1/__init__.py
:
from fastapi import APIRouter
from .categories import router as categories_router
# ... other imports
router = APIRouter()
router.include_router(categories_router, prefix="/categories")
# ... other router includes
Creating Custom Middleware¶
Create middleware in src/app/middleware/
:
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
class CustomHeaderMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Pre-processing
start_time = time.time()
# Process request
response = await call_next(request)
# Post-processing
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
Register in src/app/main.py
:
from .middleware.custom_header_middleware import CustomHeaderMiddleware
app.add_middleware(CustomHeaderMiddleware)
Testing¶
Test Configuration¶
The boilerplate uses pytest for testing. Test configuration is in pytest.ini
and test dependencies in pyproject.toml
.
Database Testing Setup¶
Create test database fixtures in tests/conftest.py
:
import asyncio
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from src.app.core.config import settings
from src.app.core.db.database import Base, async_get_db
from src.app.main import app
# Test database URL
TEST_DATABASE_URL = "postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db"
# Create test engine
test_engine = create_async_engine(TEST_DATABASE_URL, echo=True)
TestSessionLocal = sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
@pytest_asyncio.fixture
async def async_session():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestSessionLocal() as session:
yield session
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def async_client(async_session):
def get_test_db():
return async_session
app.dependency_overrides[async_get_db] = get_test_db
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
Writing Tests¶
Model Tests¶
# tests/test_models.py
import pytest
from src.app.models.user import User
@pytest_asyncio.fixture
async def test_user(async_session):
user = User(
name="Test User",
username="testuser",
email="test@example.com",
hashed_password="hashed_password"
)
async_session.add(user)
await async_session.commit()
await async_session.refresh(user)
return user
async def test_user_creation(test_user):
assert test_user.name == "Test User"
assert test_user.username == "testuser"
assert test_user.email == "test@example.com"
API Endpoint Tests¶
# tests/test_api.py
import pytest
from httpx import AsyncClient
async def test_create_user(async_client: AsyncClient):
user_data = {
"name": "New User",
"username": "newuser",
"email": "new@example.com",
"password": "SecurePass123!"
}
response = await async_client.post("/api/v1/users", json=user_data)
assert response.status_code == 201
data = response.json()
assert data["name"] == "New User"
assert data["username"] == "newuser"
assert "hashed_password" not in data # Ensure password not exposed
async def test_read_users(async_client: AsyncClient):
response = await async_client.get("/api/v1/users")
assert response.status_code == 200
data = response.json()
assert "data" in data
assert "total_count" in data
CRUD Tests¶
# tests/test_crud.py
import pytest
from src.app.crud.crud_users import crud_users
from src.app.schemas.user import UserCreate
async def test_crud_create_user(async_session):
user_data = UserCreate(
name="CRUD User",
username="cruduser",
email="crud@example.com",
password="password123"
)
user = await crud_users.create(db=async_session, object=user_data)
assert user["name"] == "CRUD User"
assert user["username"] == "cruduser"
async def test_crud_get_user(async_session, test_user):
retrieved_user = await crud_users.get(
db=async_session,
id=test_user.id
)
assert retrieved_user["name"] == test_user.name
Running Tests¶
# Run all tests
uv run pytest
# Run with coverage
uv run pytest --cov=src
# Run specific test file
uv run pytest tests/test_api.py
# Run with verbose output
uv run pytest -v
# Run tests matching pattern
uv run pytest -k "test_user"
Customization¶
Environment-Specific Configuration¶
Create environment-specific settings:
# src/app/core/config.py
class LocalSettings(Settings):
ENVIRONMENT: str = "local"
DEBUG: bool = True
class ProductionSettings(Settings):
ENVIRONMENT: str = "production"
DEBUG: bool = False
# Production-specific settings
def get_settings():
env = os.getenv("ENVIRONMENT", "local")
if env == "production":
return ProductionSettings()
return LocalSettings()
settings = get_settings()
Custom Logging¶
Configure logging in src/app/core/config.py
:
import logging
from pythonjsonlogger import jsonlogger
def setup_logging():
# JSON logging for production
if settings.ENVIRONMENT == "production":
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter()
logHandler.setFormatter(formatter)
logger = logging.getLogger()
logger.addHandler(logHandler)
logger.setLevel(logging.INFO)
else:
# Simple logging for development
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
Opting Out of Services¶
Disabling Redis Caching¶
- Remove cache decorators from endpoints
- Update dependencies in
src/app/core/config.py
:
class Settings(BaseSettings):
# Comment out or remove Redis cache settings
# REDIS_CACHE_HOST: str = "localhost"
# REDIS_CACHE_PORT: int = 6379
pass
- Remove Redis cache imports and usage
Disabling Background Tasks (ARQ)¶
- Remove ARQ from
pyproject.toml
dependencies - Remove worker configuration from
docker-compose.yml
- Delete
src/app/core/worker/
directory - Remove task-related endpoints
Disabling Rate Limiting¶
- Remove rate limiting dependencies from endpoints:
- Remove rate limiting models and schemas
- Update database migrations to remove rate limit tables
Disabling Authentication¶
- Remove JWT dependencies from protected endpoints
- Remove user-related models and endpoints
- Update database to remove user tables
- Remove authentication middleware
Minimal FastAPI Setup¶
For a minimal setup with just basic FastAPI:
# src/app/main.py (minimal version)
from fastapi import FastAPI
app = FastAPI(
title="Minimal API",
description="Basic FastAPI application",
version="1.0.0"
)
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
Best Practices¶
Code Organization¶
- Keep models, schemas, and CRUD operations in separate files
- Use consistent naming conventions across the application
- Group related functionality in modules
- Follow FastAPI and Pydantic best practices
Database Operations¶
- Always use transactions for multi-step operations
- Implement soft deletes for important data
- Use database constraints for data integrity
- Index frequently queried columns
API Design¶
- Use consistent response formats
- Implement proper error handling
- Version your APIs from the start
- Document all endpoints with proper schemas
Security¶
- Never expose sensitive data in API responses
- Use proper authentication and authorization
- Validate all input data
- Implement rate limiting for public endpoints
- Use HTTPS in production
Performance¶
- Use async/await consistently
- Implement caching for expensive operations
- Use database connection pooling
- Monitor and optimize slow queries
- Use pagination for large datasets
Troubleshooting¶
Common Issues¶
Import Errors: Ensure all new models are imported in __init__.py
files
Migration Failures: Check model definitions and relationships before generating migrations
Test Failures: Verify test database configuration and isolation
Performance Issues: Check for N+1 queries and missing database indexes
Authentication Problems: Verify JWT configuration and token expiration settings
Debugging Tips¶
- Use FastAPI's automatic interactive docs at
/docs
- Enable SQL query logging in development
- Use proper logging throughout the application
- Test endpoints with realistic data volumes
- Monitor database performance with query analysis
Database Migrations¶
Important Setup for Docker Users
If you're using the database in Docker, you need to expose the port to run migrations. Change this in docker-compose.yml
:
Creating Migrations¶
Model Import Requirement
To create tables if you haven't created endpoints yet, ensure you import the models in src/app/models/__init__.py
. This step is crucial for Alembic to detect new tables.
While in the src
folder, run Alembic migrations:
# Generate migration file
uv run alembic revision --autogenerate -m "Description of changes"
# Apply migrations
uv run alembic upgrade head
Without uv
If you don't have uv, run pip install alembic
first, then use alembic
commands directly.
Migration Workflow¶
- Make Model Changes - Modify your SQLAlchemy models
- Import Models - Ensure models are imported in
src/app/models/__init__.py
- Generate Migration - Run
alembic revision --autogenerate
- Review Migration - Check the generated migration file in
src/migrations/versions/
- Apply Migration - Run
alembic upgrade head
- Test Changes - Verify your changes work as expected
Common Migration Tasks¶
Adding a New Model¶
# 1. Create the model file (e.g., src/app/models/category.py)
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.database import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
description: Mapped[str] = mapped_column(String(255), nullable=True)
# 2. Import in src/app/models/__init__.py
from .user import User
from .post import Post
from .tier import Tier
from .rate_limit import RateLimit
from .category import Category # Add this line
# 3. Generate and apply migration
cd src
uv run alembic revision --autogenerate -m "Add categories table"
uv run alembic upgrade head
Modifying Existing Models¶
# 1. Modify your model
class User(Base):
# ... existing fields ...
bio: Mapped[str] = mapped_column(String(500), nullable=True) # New field
# 2. Generate migration
uv run alembic revision --autogenerate -m "Add bio field to users"
# 3. Review the generated migration file
# 4. Apply migration
uv run alembic upgrade head
This guide provides the foundation for extending and customizing the FastAPI boilerplate. For specific implementation details, refer to the existing code examples throughout the boilerplate.