# 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`): ```python 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`: ```python 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`: ```python 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`: ```python from .category import Category from .user import User from .post import Post # ... other imports ``` #### 5. Create Database Migration Generate and apply the migration: ```bash # 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`: ```python 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`: ```python 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/`: ```python 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`: ```python 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`: ```python 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 ```python # 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 ```python # 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 ```python # 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 ```bash # 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: ```python # 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`: ```python 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 1. Remove cache decorators from endpoints 2. Update dependencies in `src/app/core/config.py`: ```python class Settings(BaseSettings): # Comment out or remove Redis cache settings # REDIS_CACHE_HOST: str = "localhost" # REDIS_CACHE_PORT: int = 6379 pass ``` 3. Remove Redis cache imports and usage ### Disabling Background Tasks (ARQ) 1. Remove ARQ from `pyproject.toml` dependencies 2. Remove worker configuration from `docker-compose.yml` 3. Delete `src/app/core/worker/` directory 4. Remove task-related endpoints ### Disabling Rate Limiting 1. Remove rate limiting dependencies from endpoints: ```python # Remove this dependency dependencies=[Depends(rate_limiter_dependency)] ``` 2. Remove rate limiting models and schemas 3. Update database migrations to remove rate limit tables ### Disabling Authentication 1. Remove JWT dependencies from protected endpoints 2. Remove user-related models and endpoints 3. Update database to remove user tables 4. Remove authentication middleware ### Minimal FastAPI Setup For a minimal setup with just basic FastAPI: ```python # 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 !!! warning "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`: ```yaml db: image: postgres:13 env_file: - ./src/.env volumes: - postgres-data:/var/lib/postgresql/data # -------- replace with comment to run migrations with docker -------- ports: - 5432:5432 # expose: # - "5432" ``` ### Creating Migrations !!! warning "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: ```bash # Generate migration file uv run alembic revision --autogenerate -m "Description of changes" # Apply migrations uv run alembic upgrade head ``` !!! note "Without uv" If you don't have uv, run `pip install alembic` first, then use `alembic` commands directly. ### Migration Workflow 1. **Make Model Changes** - Modify your SQLAlchemy models 2. **Import Models** - Ensure models are imported in `src/app/models/__init__.py` 3. **Generate Migration** - Run `alembic revision --autogenerate` 4. **Review Migration** - Check the generated migration file in `src/migrations/versions/` 5. **Apply Migration** - Run `alembic upgrade head` 6. **Test Changes** - Verify your changes work as expected ### Common Migration Tasks #### Adding a New Model ```python # 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) ``` ```python # 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 ``` ```bash # 3. Generate and apply migration cd src uv run alembic revision --autogenerate -m "Add categories table" uv run alembic upgrade head ``` #### Modifying Existing Models ```python # 1. Modify your model class User(Base): # ... existing fields ... bio: Mapped[str] = mapped_column(String(500), nullable=True) # New field ``` ```bash # 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.