Set up Webber - multi-agent AI development system with: - Domain-based project structure (src/domains/, src/shared/) - BaseController pattern with lazy router instantiation - Pydantic Settings configuration with env file support - Logger decorator with temporal benchmarking and trace IDs - UserProvider singleton for request-scoped context - Custom exception hierarchy - Health endpoints (/, /health) Dependencies (CVE checked 2026-01-09): - FastAPI 0.128.0, Starlette 0.50.0, Uvicorn 0.40.0 - Pydantic 2.12.4, PydanticAI 1.40.0 - All packages at latest safe versions Placeholder domains for future implementation: - agents/ (explore, plan, task) - tools/ (file, shell, search) - auth/ (tatlock integration) Port: 8086 (per CONTAINERS.md allocation) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
869 lines
23 KiB
Markdown
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
|