Files
webber/webber-api/docs/fastapi-best-practices.md
T
jpmschweitzerandClaude Opus 4.5 3b58fa4f8b
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 2m27s
refactor: reorganize into monorepo with separate subprojects
Structure webber into three independent subprojects:
- webber-api/: FastAPI backend server with all agent code
- webber-cli/: Standalone CLI client (renamed from cli/ to webber_cli/)
- webber-sandbox/: Test project for functional testing

Key changes:
- Each subproject has its own .venv (Python 3.12+)
- Added sandbox.sh for managing test project templates
- Created sandbox-templates/ with calculator-cli and empty starter
- Updated CI/CD for prefixed tags (api/v*, cli/v*)
- Added comprehensive AGENTS.md with operational instructions
- Added gitignore filtering to glob and grep tools
- Created pyproject.toml for each subproject

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 10:37:47 +01:00

869 lines
23 KiB
Markdown

# FastAPI Best Practices
> **A comprehensive guide for building production-grade FastAPI applications.**
> Based on patterns from [zhanymkanov/fastapi-best-practices](https://github.com/zhanymkanov/fastapi-best-practices) with additional patterns for logging, authentication, and scalable architecture.
---
## 1. Project Structure
### Domain-Based Organization (NOT File-Type Based)
**Do NOT** group files by type (e.g., one huge `routers/` folder). Group by **domain/module** inside a `src/` directory.
```
project/
├── AGENTS.md # AI/developer guidelines
├── README.md # Project overview
├── CHANGELOG.md # Version history
├── pyproject.toml # Package metadata
├── requirements.txt # Production dependencies only
├── requirements-dev.txt # Dev/test dependencies
├── .env.example # Environment template
├── src/
│ ├── __init__.py
│ ├── main.py # FastAPI app, lifespan (NO routes here)
│ │
│ ├── shared/ # Cross-cutting concerns
│ │ ├── __init__.py
│ │ ├── base.py # BaseController, BaseSchema
│ │ ├── config.py # Pydantic BaseSettings
│ │ ├── logging.py # Logger decorator + centralized setup
│ │ ├── exceptions.py # Custom exception hierarchy
│ │ ├── auth.py # Authentication utilities
│ │ └── context.py # Request context (user provider, etc.)
│ │
│ └── domains/ # Feature domains
│ ├── __init__.py
│ ├── router.py # Root router - composes all domain routers
│ │
│ ├── health/ # Health check domain
│ │ ├── __init__.py
│ │ ├── router.py # Routes
│ │ └── controller.py # Business logic
│ │
│ └── {domain}/ # Each feature domain
│ ├── __init__.py
│ ├── router.py # Domain routes
│ ├── controller.py # Business logic
│ ├── schemas.py # Pydantic models
│ ├── service.py # External service calls (optional)
│ └── exceptions.py # Domain-specific exceptions (optional)
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Pytest fixtures
│ └── test_{domain}.py
└── docs/
└── architecture.md
```
### Key Principles
1. **Clean main.py**: Only app creation, middleware, lifespan. NO routes.
2. **Domain routers**: Each domain has `router.py`. Root `domains/router.py` composes them.
3. **Shared utilities**: Cross-cutting concerns in `shared/` - import from there, not across domains.
4. **Self-contained domains**: Each domain can be understood in isolation.
---
## 2. Base Patterns
### BaseController
```python
from abc import ABC, abstractmethod
from fastapi import APIRouter
class BaseController(ABC):
"""
Base controller with lazy router instantiation.
All domain controllers inherit from this and implement create_router().
"""
def __init__(self, prefix: str, tags: list[str]):
self.prefix = prefix
self.tags = tags
self._router = None
@abstractmethod
def create_router(self) -> APIRouter:
"""Create and configure the FastAPI router with all routes."""
pass
@property
def router(self) -> APIRouter:
"""Lazy router instantiation."""
if self._router is None:
self._router = self.create_router()
return self._router
```
**Usage:**
```python
# src/domains/health/controller.py
class HealthController(BaseController):
def __init__(self):
super().__init__(prefix="", tags=["Health"])
def create_router(self) -> APIRouter:
router = APIRouter(tags=self.tags)
@router.get("/health")
async def health_check():
return {"status": "healthy"}
return router
health_controller = HealthController()
```
### BaseSchema
```python
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
class BaseSchema(BaseModel):
"""Base Pydantic model with standardized configuration."""
model_config = ConfigDict(
strict=False,
populate_by_name=True,
use_enum_values=True,
validate_assignment=True,
json_encoders={
datetime: lambda v: v.isoformat() if v else None
}
)
def dict_without_none(self) -> dict[str, Any]:
"""Return model as dict, excluding None values."""
return {k: v for k, v in self.model_dump().items() if v is not None}
```
---
## 3. Configuration
### Pydantic Settings
```python
# src/shared/config.py
import tomllib
from pathlib import Path
from functools import lru_cache
from pydantic_settings import BaseSettings
def _get_version() -> str:
"""Load version from pyproject.toml."""
try:
with open(Path(__file__).parent.parent.parent / "pyproject.toml", "rb") as f:
return tomllib.load(f).get("project", {}).get("version", "0.0.0")
except FileNotFoundError:
return "0.0.0"
class Settings(BaseSettings):
"""Application settings loaded from environment."""
# Application
app_name: str = "MyApp"
app_version: str = _get_version()
debug: bool = False
# Server
host: str = "0.0.0.0"
port: int = 8000
# Logging
log_level: str = "INFO"
# CORS
cors_origins: list[str] = ["http://localhost:3000"]
cors_credentials: bool = True
cors_methods: list[str] = ["*"]
cors_headers: list[str] = ["*"]
class Config:
env_file = ".env"
case_sensitive = False
extra = "ignore"
@lru_cache()
def get_settings() -> Settings:
"""Cached settings singleton."""
return Settings()
```
---
## 4. Logging with Temporal Benchmarking
### Logger Decorator
```python
# src/shared/logging.py
import functools
import asyncio
import time
import logging
import sys
from pathlib import Path
from typing import Callable, Optional
from contextvars import ContextVar
from dataclasses import dataclass, field
from uuid import uuid4
# === Trace Context ===
@dataclass
class TraceSpan:
"""Represents a timed execution span."""
name: str
trace_id: str
parent_id: Optional[str] = None
span_id: str = field(default_factory=lambda: uuid4().hex[:8])
start_time: float = field(default_factory=time.perf_counter)
end_time: Optional[float] = None
@property
def duration_ms(self) -> float:
end = self.end_time or time.perf_counter()
return (end - self.start_time) * 1000
_current_span: ContextVar[Optional[TraceSpan]] = ContextVar('current_span', default=None)
_trace_id: ContextVar[Optional[str]] = ContextVar('trace_id', default=None)
def get_current_trace_id() -> Optional[str]:
"""Get current trace ID for log correlation."""
return _trace_id.get()
# === Setup ===
def setup_logging(log_level: str = "INFO") -> None:
"""Configure application logging."""
log_dir = Path("logs")
log_dir.mkdir(exist_ok=True)
logging.basicConfig(
level=getattr(logging, log_level.upper()),
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(log_dir / "app.log", encoding="utf-8")
]
)
# Quiet noisy libraries
for name in ["httpx", "httpcore", "uvicorn.access"]:
logging.getLogger(name).setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
"""Get a logger instance."""
return logging.getLogger(name)
# === Decorator ===
def logged(
logger: logging.Logger = None,
slow_threshold_ms: float = 100.0,
warn_threshold_ms: float = 500.0,
include_args: bool = False,
):
"""
Decorator for automatic function logging with temporal benchmarking.
Args:
logger: Logger instance (defaults to module logger)
slow_threshold_ms: Log INFO if execution exceeds this (default 100ms)
warn_threshold_ms: Log WARNING if execution exceeds this (default 500ms)
include_args: Include function arguments in log (careful with sensitive data)
Usage:
@logged()
async def my_function(): ...
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
def critical_path(): ...
"""
def decorator(func: Callable):
nonlocal logger
if logger is None:
logger = logging.getLogger(func.__module__)
func_name = f"{func.__module__}.{func.__qualname__}"
def _create_span() -> TraceSpan:
parent = _current_span.get()
trace_id = _trace_id.get() or uuid4().hex[:16]
if _trace_id.get() is None:
_trace_id.set(trace_id)
return TraceSpan(
name=func_name,
trace_id=trace_id,
parent_id=parent.span_id if parent else None,
)
def _log_completion(span: TraceSpan, error: Exception = None):
span.end_time = time.perf_counter()
duration = span.duration_ms
tid = span.trace_id[:8]
if error:
logger.error(f"[{tid}] {func_name} FAILED after {duration:.2f}ms: {error}", exc_info=True)
elif duration >= warn_threshold_ms:
logger.warning(f"[{tid}] {func_name} SLOW: {duration:.2f}ms (threshold: {warn_threshold_ms}ms)")
elif duration >= slow_threshold_ms:
logger.info(f"[{tid}] {func_name} completed in {duration:.2f}ms")
else:
logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms")
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
span = _create_span()
token = _current_span.set(span)
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
try:
result = await func(*args, **kwargs)
_log_completion(span)
return result
except Exception as e:
_log_completion(span, error=e)
raise
finally:
_current_span.reset(token)
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
span = _create_span()
token = _current_span.set(span)
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
try:
result = func(*args, **kwargs)
_log_completion(span)
return result
except Exception as e:
_log_completion(span, error=e)
raise
finally:
_current_span.reset(token)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
return decorator
# === Context Manager ===
class trace_span:
"""
Context manager for manual span creation.
Usage:
with trace_span("database_query"):
result = db.execute(query)
async with trace_span("external_api_call"):
response = await client.get(url)
"""
def __init__(self, name: str, logger: logging.Logger = None):
self.name = name
self.logger = logger or logging.getLogger(__name__)
self.span: Optional[TraceSpan] = None
self.token = None
def __enter__(self):
parent = _current_span.get()
trace_id = _trace_id.get() or uuid4().hex[:16]
if _trace_id.get() is None:
_trace_id.set(trace_id)
self.span = TraceSpan(
name=self.name,
trace_id=trace_id,
parent_id=parent.span_id if parent else None,
)
self.token = _current_span.set(self.span)
self.logger.debug(f"[{self.span.trace_id[:8]}] -> {self.name}")
return self.span
def __exit__(self, exc_type, exc_val, exc_tb):
if self.span:
self.span.end_time = time.perf_counter()
duration = self.span.duration_ms
tid = self.span.trace_id[:8]
if exc_val:
self.logger.error(f"[{tid}] {self.name} FAILED: {duration:.2f}ms")
else:
self.logger.debug(f"[{tid}] {self.name}: {duration:.2f}ms")
if self.token:
_current_span.reset(self.token)
return False
async def __aenter__(self):
return self.__enter__()
async def __aexit__(self, exc_type, exc_val, exc_tb):
return self.__exit__(exc_type, exc_val, exc_tb)
```
**Example output:**
```
DEBUG [a1b2c3d4] -> src.domains.users.controller.get_user
DEBUG [a1b2c3d4] -> src.domains.users.service.fetch_from_db
DEBUG [a1b2c3d4] src.domains.users.service.fetch_from_db: 12.34ms
DEBUG [a1b2c3d4] src.domains.users.controller.get_user completed in 15.67ms
WARN [a1b2c3d4] src.domains.reports.controller.generate SLOW: 523.45ms
```
---
## 5. Request Context (User Provider)
### Singleton Pattern with ContextVars
```python
# src/shared/context.py
from dataclasses import dataclass
from typing import Optional
from contextvars import ContextVar
@dataclass
class User:
"""Authenticated user context."""
id: str
email: str
tenant_id: Optional[str] = None
roles: list[str] = None
def __post_init__(self):
if self.roles is None:
self.roles = []
_current_user: ContextVar[Optional[User]] = ContextVar('current_user', default=None)
class UserProvider:
"""
Singleton for request-scoped user context.
Set once per request in middleware, accessible everywhere without
passing user through function parameters.
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def set_user(self, user: User) -> None:
_current_user.set(user)
def get_user(self) -> Optional[User]:
return _current_user.get()
def clear_user(self) -> None:
_current_user.set(None)
@property
def current_user(self) -> Optional[User]:
return self.get_user()
# Global singleton
user_provider = UserProvider()
def get_current_user() -> Optional[User]:
"""Convenience function to get current user."""
return user_provider.get_user()
def require_user() -> User:
"""Get current user or raise if not authenticated."""
user = user_provider.get_user()
if user is None:
raise ValueError("No authenticated user in context")
return user
```
**Usage in middleware:**
```python
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
# Validate token, get user...
user = await validate_token(request)
if user:
user_provider.set_user(user)
try:
response = await call_next(request)
return response
finally:
user_provider.clear_user()
```
**Usage in any function:**
```python
from src.shared.context import get_current_user, require_user
async def some_business_logic():
user = require_user() # Raises if not authenticated
# Use user.id, user.tenant_id, etc.
```
---
## 6. Application Entry Point
### Clean main.py
```python
# src/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from src.shared.config import get_settings
from src.shared.logging import setup_logging, get_logger
from src.domains.router import root_router
settings = get_settings()
setup_logging(settings.log_level)
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application startup and shutdown."""
logger.info("=" * 60)
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
logger.info(f"Debug: {settings.debug}")
logger.info("=" * 60)
# Initialize resources (DB connections, caches, etc.)
yield
# Cleanup resources
logger.info("Shutting down")
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
docs_url="/docs",
redoc_url=None,
lifespan=lifespan,
debug=settings.debug,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=settings.cors_credentials,
allow_methods=settings.cors_methods,
allow_headers=settings.cors_headers,
)
# Include all routes from domain router
app.include_router(root_router)
# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "type": type(exc).__name__}
)
```
### Root Router
```python
# src/domains/router.py
from fastapi import APIRouter
from src.domains.health.controller import health_controller
# from src.domains.users.controller import users_controller
# from src.domains.items.controller import items_controller
root_router = APIRouter()
# Include all domain routers
root_router.include_router(health_controller.router)
# root_router.include_router(users_controller.router, prefix="/users", tags=["Users"])
# root_router.include_router(items_controller.router, prefix="/items", tags=["Items"])
```
---
## 7. Dependency Management
### CRITICAL: Always Start with Latest Safe Versions
**When setting up a new project, ALWAYS search for the latest version of each package and verify it has no known CVEs.**
Do NOT copy version numbers from old projects or templates. Package versions in documentation become outdated quickly.
**Process for each dependency:**
1. Search: `"{package} pypi latest version {current_year}"`
2. Check PyPI directly: `https://pypi.org/project/{package}/`
3. Search: `"{package} CVE vulnerability {current_year}"`
4. Verify no unpatched CVEs affect the latest version
5. Pin to the verified latest safe version
**Example search queries:**
```
"fastapi pypi latest version 2026"
"starlette CVE vulnerability 2026"
"pydantic-ai pypi latest version 2026"
```
### requirements.txt (Production)
```
# Production Dependencies
# Minor version pinning (~=) allows patch updates for security fixes
# CVE check date: YYYY-MM-DD <-- UPDATE THIS DATE
# CVE sources: PyPI, GitHub Advisories, Snyk, NVD
# Core - VERIFY LATEST VERSIONS BEFORE USE
fastapi~=0.128.0
starlette~=0.50.0 # CVE-2025-62727, CVE-2025-54121 fixed
uvicorn[standard]~=0.40.0
pydantic~=2.12.4 # CVE-2024-3772 (ReDoS) fixed in 2.4.0+
pydantic-settings~=2.12.0
# HTTP client
httpx~=0.28.1
aiofiles~=25.1.0
# Utilities
python-multipart~=0.0.21
python-dotenv~=1.2.1
```
### requirements-dev.txt (Development)
```
# Development Dependencies
# NOT included in production Docker image
# CVE check date: YYYY-MM-DD <-- UPDATE THIS DATE
-r requirements.txt
# Testing - VERIFY LATEST VERSIONS BEFORE USE
pytest~=9.0.2
anyio~=4.12.1 # Includes pytest-anyio plugin
pytest-cov~=7.0.0
# Security auditing
pip-audit~=2.9.0
# Type checking
mypy~=1.19.1
# Linting (optional)
# ruff~=0.9.0
```
### CVE Check Process
Before adding or updating dependencies:
1. **Check PyPI** for security advisories: `https://pypi.org/project/{package}/`
2. **GitHub Security Advisories**: `https://github.com/advisories`
3. **Snyk vulnerability database**: `https://snyk.io/vuln`
4. **NVD**: `https://nvd.nist.gov/vuln/search`
5. **Run pip-audit**: `pip-audit` before releases
Document decisions in requirements.txt:
```
package~=1.2.0 # CVE-YYYY-XXXXX: pinned due to vulnerability in < 1.2.0
```
---
## 8. Exception Handling
### Custom Exception Hierarchy
```python
# src/shared/exceptions.py
from typing import Any, Optional
class AppException(Exception):
"""Base exception for application errors."""
def __init__(
self,
message: str,
status_code: int = 500,
error_code: Optional[str] = None,
details: Optional[dict[str, Any]] = None,
):
self.message = message
self.status_code = status_code
self.error_code = error_code or self.__class__.__name__
self.details = details or {}
super().__init__(message)
def to_dict(self) -> dict[str, Any]:
return {
"error": self.error_code,
"message": self.message,
"details": self.details,
}
class NotFoundError(AppException):
def __init__(self, resource: str, identifier: Any):
super().__init__(
message=f"{resource} not found: {identifier}",
status_code=404,
details={"resource": resource, "identifier": str(identifier)},
)
class ValidationError(AppException):
def __init__(self, message: str, field: Optional[str] = None):
super().__init__(
message=message,
status_code=422,
details={"field": field} if field else {},
)
class AuthenticationError(AppException):
def __init__(self, message: str = "Authentication required"):
super().__init__(message=message, status_code=401)
class AuthorizationError(AppException):
def __init__(self, message: str = "Permission denied"):
super().__init__(message=message, status_code=403)
class ConflictError(AppException):
def __init__(self, message: str):
super().__init__(message=message, status_code=409)
class RateLimitError(AppException):
def __init__(self, retry_after: int = 60):
super().__init__(
message="Rate limit exceeded",
status_code=429,
details={"retry_after": retry_after},
)
```
### Exception Handler
```python
# In main.py
from src.shared.exceptions import AppException
@app.exception_handler(AppException)
async def app_exception_handler(request, exc: AppException):
return JSONResponse(
status_code=exc.status_code,
content=exc.to_dict(),
)
```
---
## 9. Testing
### conftest.py
```python
# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from src.main import app
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
async def client():
"""Async test client."""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as ac:
yield ac
```
### Example Test
```python
# tests/test_health.py
import pytest
@pytest.mark.anyio
async def test_health_check(client):
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
```
---
## 10. Summary Checklist
### Project Setup
- [ ] Domain-based directory structure (`src/domains/`)
- [ ] Shared utilities in `src/shared/`
- [ ] Clean `main.py` (no routes, only app setup)
- [ ] Root router composing domain routers
### Patterns
- [ ] BaseController with lazy router
- [ ] BaseSchema with standard config
- [ ] Pydantic Settings for configuration
- [ ] Logger decorator with temporal benchmarking
- [ ] UserProvider singleton for request context
- [ ] Custom exception hierarchy
### Dependencies
- [ ] Minor version pinning (`~=`)
- [ ] Separate prod/dev requirements
- [ ] CVE check before updates
- [ ] pip-audit in CI/CD
### Quality
- [ ] Pytest with async support
- [ ] Type hints throughout
- [ ] Docstrings on public APIs
- [ ] CHANGELOG.md maintained