fastapi-patterns
SKILL.md
FastAPI Patterns
Build production-ready FastAPI applications with clean architecture, dependency injection, multi-tenancy support, and async patterns.
When to Use This Skill
- Creating new FastAPI applications or routers
- Implementing middleware (tenant resolution, logging, CORS)
- Designing dependency injection chains
- Integrating Supabase Auth with FastAPI
- Building multi-tenant API endpoints
- Configuring database sessions and connection pooling
- Structuring FastAPI projects for the monorepo
Project Structure
src/{app_name}/
├── __init__.py
├── main.py # App factory and lifespan
├── config.py # Settings (Pydantic BaseSettings)
├── dependencies.py # Shared dependencies (auth, tenant, db)
├── middleware/
│ ├── __init__.py
│ ├── tenant.py # Tenant resolution middleware
│ ├── logging.py # Request logging middleware
│ └── timing.py # Request timing middleware
├── routers/
│ ├── __init__.py
│ ├── invoices.py # Invoice endpoints
│ ├── customers.py # Customer endpoints
│ └── health.py # Health check endpoints
├── models/ # SQLAlchemy models (if needed)
│ ├── __init__.py
│ └── invoice.py
├── schemas/ # Pydantic request/response schemas
│ ├── __init__.py
│ ├── invoice.py
│ ├── customer.py
│ └── common.py # Shared schemas (pagination, errors)
├── services/ # Business logic layer
│ ├── __init__.py
│ ├── invoice_service.py
│ └── customer_service.py
└── utils/
├── __init__.py
└── helpers.py
Application Factory Pattern
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app_name.config import Settings
from app_name.routers import invoices, customers, health
from app_name.middleware.tenant import TenantMiddleware
from app_name.middleware.logging import RequestLoggingMiddleware
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Application lifespan: startup and shutdown events."""
# Startup
settings = app.state.settings
# Initialize database pool, caches, etc.
yield
# Shutdown
# Close connections, flush buffers, etc.
def create_app(settings: Settings | None = None) -> FastAPI:
"""Application factory for FastAPI."""
if settings is None:
settings = Settings()
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
docs_url="/docs" if settings.debug else None,
redoc_url="/redoc" if settings.debug else None,
lifespan=lifespan,
)
# Store settings in app state
app.state.settings = settings
# Middleware (order matters: last added = first executed)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(TenantMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(health.router, tags=["health"])
app.include_router(
invoices.router,
prefix="/v1/invoices",
tags=["invoices"],
)
app.include_router(
customers.router,
prefix="/v1/customers",
tags=["customers"],
)
return app
Configuration with Pydantic BaseSettings
from pydantic import Field
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = {"env_prefix": "APP_", "env_file": ".env"}
# Application
app_name: str = "VeriFactu API"
app_version: str = "0.1.0"
debug: bool = False
# Server
host: str = "0.0.0.0"
port: int = 8000
# Supabase
supabase_url: str = Field(..., description="Supabase project URL")
supabase_key: str = Field(..., description="Supabase anon key")
supabase_jwt_secret: str = Field(..., description="JWT secret for validation")
# Database
database_url: str = Field(..., description="PostgreSQL connection string")
db_pool_size: int = 5
db_max_overflow: int = 10
# CORS
cors_origins: list[str] = ["http://localhost:5173"]
Dependency Injection
Authentication Dependency
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import jwt, JWTError
from pydantic import BaseModel
security = HTTPBearer()
class AuthUser(BaseModel):
"""Authenticated user from JWT."""
id: str
email: str
tenant_id: str | None = None
role: str = "user"
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
request: Request = ...,
) -> AuthUser:
"""Validate Supabase JWT and extract user info."""
settings = request.app.state.settings
try:
payload = jwt.decode(
credentials.credentials,
settings.supabase_jwt_secret,
algorithms=["HS256"],
audience="authenticated",
)
return AuthUser(
id=payload["sub"],
email=payload.get("email", ""),
tenant_id=payload.get("tenant_id"),
role=payload.get("role", "user"),
)
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
) from e
async def require_admin(
user: AuthUser = Depends(get_current_user),
) -> AuthUser:
"""Require admin role."""
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
Tenant Resolution Dependency
from fastapi import Depends, HTTPException, Request
class TenantContext(BaseModel):
"""Current tenant context."""
tenant_id: str
schema_name: str
async def get_current_tenant(
request: Request,
user: AuthUser = Depends(get_current_user),
) -> TenantContext:
"""Resolve tenant from authenticated user."""
tenant_id = user.tenant_id
if not tenant_id:
# Fallback: check header
tenant_id = request.headers.get("X-Tenant-ID")
if not tenant_id:
raise HTTPException(
status_code=400,
detail="Tenant context required",
)
return TenantContext(
tenant_id=tenant_id,
schema_name=f"tenant_{tenant_id}",
)
Database Session Dependency
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from fastapi import Depends, Request
def get_engine(request: Request) -> AsyncEngine:
"""Get the database engine from app state."""
return request.app.state.db_engine
async def get_db(
request: Request,
tenant: TenantContext = Depends(get_current_tenant),
) -> AsyncIterator[AsyncSession]:
"""Provide a database session with tenant schema set."""
engine = request.app.state.db_engine
async_session = async_sessionmaker(engine, expire_on_commit=False)
async with async_session() as session:
# Set the search_path for tenant isolation
await session.execute(
text(f"SET search_path TO {tenant.schema_name}, public")
)
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
Router Patterns
CRUD Router
from fastapi import APIRouter, Depends, HTTPException, Query, status
from uuid import UUID
from app_name.dependencies import AuthUser, get_current_user, get_db
from app_name.schemas.invoice import (
InvoiceCreate,
InvoiceResponse,
InvoiceUpdate,
InvoiceListResponse,
)
from app_name.services.invoice_service import InvoiceService
router = APIRouter()
@router.get("", response_model=InvoiceListResponse)
async def list_invoices(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
status_filter: str | None = Query(None, alias="status"),
user: AuthUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> InvoiceListResponse:
"""List invoices with pagination and filtering."""
service = InvoiceService(db)
return await service.list_invoices(
page=page,
page_size=page_size,
status_filter=status_filter,
)
@router.post("", response_model=InvoiceResponse, status_code=status.HTTP_201_CREATED)
async def create_invoice(
data: InvoiceCreate,
user: AuthUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> InvoiceResponse:
"""Create a new invoice."""
service = InvoiceService(db)
return await service.create_invoice(data, created_by=user.id)
@router.get("/{invoice_id}", response_model=InvoiceResponse)
async def get_invoice(
invoice_id: UUID,
user: AuthUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> InvoiceResponse:
"""Get an invoice by ID."""
service = InvoiceService(db)
invoice = await service.get_invoice(invoice_id)
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
return invoice
@router.patch("/{invoice_id}", response_model=InvoiceResponse)
async def update_invoice(
invoice_id: UUID,
data: InvoiceUpdate,
user: AuthUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> InvoiceResponse:
"""Update an invoice (partial update)."""
service = InvoiceService(db)
invoice = await service.update_invoice(invoice_id, data)
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
return invoice
@router.delete("/{invoice_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_invoice(
invoice_id: UUID,
user: AuthUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Delete an invoice."""
service = InvoiceService(db)
deleted = await service.delete_invoice(invoice_id)
if not deleted:
raise HTTPException(status_code=404, detail="Invoice not found")
Action Endpoints
@router.post("/{invoice_id}/submit", response_model=InvoiceResponse)
async def submit_invoice(
invoice_id: UUID,
user: AuthUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> InvoiceResponse:
"""Submit an invoice to the tax authority (VeriFactu)."""
service = InvoiceService(db)
return await service.submit_to_verifactu(invoice_id, submitted_by=user.id)
Middleware Patterns
Tenant Resolution Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class TenantMiddleware(BaseHTTPMiddleware):
"""Extract tenant context from request and store in state."""
SKIP_PATHS = {"/docs", "/redoc", "/openapi.json", "/health"}
async def dispatch(self, request: Request, call_next) -> Response:
if request.url.path in self.SKIP_PATHS:
return await call_next(request)
# Extract tenant from subdomain
host = request.headers.get("host", "")
parts = host.split(".")
if len(parts) >= 3:
request.state.tenant_slug = parts[0]
else:
request.state.tenant_slug = request.headers.get("X-Tenant-ID")
return await call_next(request)
Request Logging Middleware
import time
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger(__name__)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Log all requests with timing information."""
async def dispatch(self, request: Request, call_next) -> Response:
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
logger.info(
"request completed",
extra={
"method": request.method,
"path": request.url.path,
"status": response.status_code,
"duration_ms": round(duration_ms, 2),
"tenant": getattr(request.state, "tenant_slug", None),
},
)
response.headers["X-Request-Duration-Ms"] = str(round(duration_ms, 2))
return response
Schema Patterns (Pydantic)
Request/Response Schemas
from datetime import datetime
from decimal import Decimal
from uuid import UUID
from pydantic import BaseModel, Field
class InvoiceCreate(BaseModel):
"""Schema for creating an invoice."""
customer_id: UUID
invoice_number: str = Field(..., min_length=1, max_length=50)
amount: Decimal = Field(..., gt=0, decimal_places=2)
currency: str = Field(default="EUR", pattern=r"^[A-Z]{3}$")
notes: str | None = Field(None, max_length=1000)
class InvoiceUpdate(BaseModel):
"""Schema for partially updating an invoice."""
amount: Decimal | None = Field(None, gt=0, decimal_places=2)
notes: str | None = Field(None, max_length=1000)
status: str | None = Field(None, pattern=r"^(draft|sent|paid|cancelled)$")
class InvoiceResponse(BaseModel):
"""Schema for invoice response."""
id: UUID
invoice_number: str
customer_id: UUID
amount: Decimal
currency: str
status: str
created_at: datetime
updated_at: datetime
class PaginationMeta(BaseModel):
"""Pagination metadata."""
page: int
page_size: int
total_items: int
total_pages: int
has_next: bool
has_prev: bool
class InvoiceListResponse(BaseModel):
"""Paginated list of invoices."""
data: list[InvoiceResponse]
pagination: PaginationMeta
Error Responses (RFC 7807)
from fastapi import Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
class ProblemDetail(BaseModel):
"""RFC 7807 Problem Details response."""
type: str = "about:blank"
title: str
status: int
detail: str | None = None
instance: str | None = None
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
"""Convert HTTPExceptions to RFC 7807 format."""
return JSONResponse(
status_code=exc.status_code,
content=ProblemDetail(
title=exc.detail if isinstance(exc.detail, str) else "Error",
status=exc.status_code,
instance=str(request.url.path),
).model_dump(),
)
Background Tasks
from fastapi import BackgroundTasks
@router.post("/{invoice_id}/send")
async def send_invoice(
invoice_id: UUID,
background_tasks: BackgroundTasks,
user: AuthUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> InvoiceResponse:
"""Send invoice via email (async background task)."""
service = InvoiceService(db)
invoice = await service.get_invoice(invoice_id)
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
# Queue email sending as background task
background_tasks.add_task(
send_invoice_email,
invoice_id=invoice_id,
recipient_email=invoice.customer_email,
)
return invoice
async def send_invoice_email(invoice_id: UUID, recipient_email: str) -> None:
"""Background task to send invoice email."""
# Email sending logic
...
Pagination (Offset + Cursor)
Offset-Based Pagination
from sqlalchemy import select, func
async def paginate_query(
session: AsyncSession,
query,
page: int = 1,
page_size: int = 20,
) -> tuple[list, PaginationMeta]:
"""Apply offset-based pagination to a query."""
# Count total items
count_query = select(func.count()).select_from(query.subquery())
total_items = (await session.execute(count_query)).scalar_one()
# Apply pagination
offset = (page - 1) * page_size
results = (
await session.execute(query.offset(offset).limit(page_size))
).scalars().all()
total_pages = (total_items + page_size - 1) // page_size
meta = PaginationMeta(
page=page,
page_size=page_size,
total_items=total_items,
total_pages=total_pages,
has_next=page < total_pages,
has_prev=page > 1,
)
return list(results), meta
Cursor-Based Pagination
from datetime import datetime
from pydantic import BaseModel
import base64
class CursorPage(BaseModel):
"""Cursor-based pagination response."""
data: list
next_cursor: str | None = None
has_more: bool = False
def encode_cursor(created_at: datetime, id: str) -> str:
"""Encode a cursor from sort key + ID."""
raw = f"{created_at.isoformat()}|{id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(cursor: str) -> tuple[datetime, str]:
"""Decode a cursor into sort key + ID."""
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
created_at_str, id_ = raw.split("|", 1)
return datetime.fromisoformat(created_at_str), id_
Health Check Endpoint
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class HealthResponse(BaseModel):
status: str = "ok"
version: str
environment: str
@router.get("/health", response_model=HealthResponse)
async def health_check(request: Request) -> HealthResponse:
"""Health check endpoint for Kubernetes probes."""
settings = request.app.state.settings
return HealthResponse(
version=settings.app_version,
environment="production" if not settings.debug else "development",
)
Testing Patterns
Test Client Setup
import pytest
from httpx import ASGITransport, AsyncClient
from app_name.main import create_app
from app_name.config import Settings
@pytest.fixture
def test_settings() -> Settings:
return Settings(
debug=True,
supabase_url="http://localhost:54321",
supabase_key="test-key",
supabase_jwt_secret="test-secret",
database_url="sqlite+aiosqlite:///test.db",
)
@pytest.fixture
async def client(test_settings: Settings) -> AsyncClient:
app = create_app(test_settings)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
def auth_headers() -> dict[str, str]:
"""Create a valid JWT for testing."""
from jose import jwt
token = jwt.encode(
{"sub": "user-123", "email": "test@example.com", "tenant_id": "acme"},
"test-secret",
algorithm="HS256",
)
return {"Authorization": f"Bearer {token}"}
Endpoint Tests
import pytest
@pytest.mark.anyio
async def test_list_invoices(client: AsyncClient, auth_headers: dict) -> None:
response = await client.get("/v1/invoices", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "data" in data
assert "pagination" in data
@pytest.mark.anyio
async def test_create_invoice(client: AsyncClient, auth_headers: dict) -> None:
payload = {
"customer_id": "550e8400-e29b-41d4-a716-446655440000",
"invoice_number": "INV-2026-0001",
"amount": "1500.00",
"currency": "EUR",
}
response = await client.post(
"/v1/invoices",
json=payload,
headers=auth_headers,
)
assert response.status_code == 201
@pytest.mark.anyio
async def test_unauthenticated_request(client: AsyncClient) -> None:
response = await client.get("/v1/invoices")
assert response.status_code == 403
API-First Design
- Design the API contract before implementing code
- Write OpenAPI 3.1 specifications as the source of truth
- Use the spec to generate client SDKs, documentation, and tests
- Keep the spec in version control alongside the code
- Review API design with stakeholders before implementation
URL Convention
# Resources (plural nouns)
GET /v1/invoices # List invoices
POST /v1/invoices # Create invoice
GET /v1/invoices/{invoice_id} # Get invoice
PATCH /v1/invoices/{invoice_id} # Update invoice
DELETE /v1/invoices/{invoice_id} # Delete invoice
# Nested resources (ownership)
GET /v1/customers/{customer_id}/invoices # Customer's invoices
# Actions (when REST verbs don't fit)
POST /v1/invoices/{invoice_id}/submit # Submit to tax authority
POST /v1/invoices/{invoice_id}/cancel # Cancel invoice
# Query parameters
GET /v1/invoices?status=draft&sort=-created_at&page=1&page_size=20
Versioning Strategy
- Use URL path versioning (
/v1/invoices) for major breaking changes - Use additive changes (new fields, new endpoints) for non-breaking evolution
- Deprecate before removing — communicate via response headers
- Maintain max 2 active API versions simultaneously
- Document migration guides between versions
Response Envelopes
Single Resource
{
"data": {
"id": "inv_abc123",
"type": "invoice",
"attributes": {
"invoice_number": "INV-2026-0001",
"amount": 1500.00,
"currency": "EUR",
"status": "draft"
}
}
}
Collection with Pagination
{
"data": [
{ "id": "inv_abc123", "type": "invoice", "attributes": { ... } }
],
"pagination": {
"page": 1,
"page_size": 20,
"total_items": 47,
"total_pages": 3,
"has_next": true,
"has_prev": false
}
}
Rate Limiting
from fastapi import Request, HTTPException
from collections import defaultdict
from time import time
class RateLimiter:
"""Simple in-memory rate limiter per tenant."""
def __init__(self, requests_per_minute: int = 60):
self.rpm = requests_per_minute
self.requests: dict[str, list[float]] = defaultdict(list)
async def check(self, request: Request) -> None:
tenant_id = getattr(request.state, "tenant_id", "anonymous")
now = time()
# Clean old entries
self.requests[tenant_id] = [
t for t in self.requests[tenant_id] if now - t < 60
]
if len(self.requests[tenant_id]) >= self.rpm:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded",
headers={"Retry-After": "60"},
)
self.requests[tenant_id].append(now)
OpenAPI 3.1 Specification Pattern
openapi: "3.1.0"
info:
title: EasyFactu API
version: "1.0.0"
description: REST API for invoice management and Spanish tax compliance.
servers:
- url: https://api.easyfactu.es/v1
description: Production
- url: http://localhost:8000/v1
description: Local development
security:
- BearerAuth: []
paths:
/invoices:
get:
operationId: listInvoices
summary: List invoices
tags: [Invoices]
parameters:
- name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
- name: page_size
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
- name: status
in: query
schema: { $ref: "#/components/schemas/InvoiceStatus" }
responses:
"200":
description: Paginated list of invoices
content:
application/json:
schema:
$ref: "#/components/schemas/PaginatedInvoices"
"401":
$ref: "#/components/responses/Unauthorized"
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
InvoiceStatus:
type: string
enum: [draft, sent, paid, cancelled]
responses:
Unauthorized:
description: Authentication required
content:
application/json:
schema:
$ref: "#/components/schemas/ProblemDetail"
Guidelines
- Always use the application factory pattern for testability
- Keep routers thin — delegate business logic to services
- Use Pydantic models for all request/response schemas
- Apply dependency injection for cross-cutting concerns (auth, db, tenant)
- Use async/await for all I/O-bound operations
- Return RFC 7807 error responses for consistency
- Include health check endpoints for Kubernetes probes
- Write integration tests using
httpx.AsyncClientwithASGITransport - Design the API contract before writing code (API-first)
- Use URL path versioning (
/v1/) for breaking changes - Apply rate limiting per tenant
Weekly Installs
1
Repository
franciscosanche…factu-esFirst Seen
12 days ago
Security Audits
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1