Use when structuring a FastAPI application, designing dependency injection chains, defining Pydantic v2 schemas, adding JWT authentication, or writing async route tests with httpx.
Scanned 9/9/2026
Install to Claude Code
npx -y skills add kid-sid/codex-spellbook --skill fastapi --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Fastapi?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/kid-sid-fastapi)More formats (shields.io, HTML) on the badges page.
---
name: fastapi
description: Use when structuring a FastAPI application, designing dependency injection chains, defining Pydantic v2 schemas, adding JWT authentication, or writing async route tests with httpx.
---
# FastAPI Patterns
Modern FastAPI (0.100+) with Pydantic v2, async-first, and typed throughout.
## When to Activate
- Structuring a FastAPI app with routers and layered architecture
- Designing dependency injection chains with `Depends`
- Defining Pydantic v2 request/response schemas
- Handling errors, custom exception handlers, or middleware
- Adding authentication (OAuth2, JWT, API keys)
- Writing background tasks or startup/shutdown logic
- Testing FastAPI routes with `TestClient` or async `httpx`
---
## Project Structure
```
src/
├── api/
│ ├── app.py # create_app(), register routers + middleware
│ ├── dependencies.py # shared Depends (db session, current user, etc.)
│ ├── middleware.py # CORS, logging, request ID
│ └── routes/
│ ├── users.py
│ └── orders.py
├── domain/
│ ├── entities/ # Pure Pydantic models — no ORM, no HTTP
│ ├── use_cases/ # Business logic — orchestrates services
│ └── repositories/ # Abstract interfaces (Protocol or ABC)
├── adapters/
│ ├── database/ # SQLAlchemy models + session factory
│ ├── crud/ # Concrete repository implementations
│ └── external/ # Third-party HTTP clients
├── config/
│ ├── settings.py # Pydantic Settings (env vars)
│ └── dependencies.py # App-wide singletons (DB engine, Redis, etc.)
└── main.py # uvicorn entry point
```
---
## App Factory
```python
# api/app.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config.dependencies import GlobalDependencies
from api.routes import users, orders
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: initialize singletons, DB pools, caches
await GlobalDependencies.initialize()
yield
# Shutdown: close connections cleanly
await GlobalDependencies.close()
def create_app() -> FastAPI:
app = FastAPI(
title="My API",
version="1.0.0",
docs_url="/swagger",
redoc_url="/api",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
app.include_router(orders.router, prefix="/api/v1/orders", tags=["orders"])
return app
```
---
## APIRouter
```python
# api/routes/users.py
from fastapi import APIRouter, Depends, HTTPException, status
from api.dependencies import get_current_user, get_db_session
from api.schemas.users import UserResponse, CreateUserRequest
from domain.use_cases.users import CreateUserUseCase
from domain.entities.user import User
router = APIRouter()
@router.get("/", response_model=list[UserResponse])
async def list_users(
skip: int = 0,
limit: int = 100,
session=Depends(get_db_session),
):
return await UserCRUD(session).list(skip=skip, limit=limit)
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
body: CreateUserRequest,
use_case: CreateUserUseCase = Depends(get_create_user_use_case),
):
return await use_case.execute(body)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: str, session=Depends(get_db_session)):
user = await UserCRUD(session).get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
_current_user: User = Depends(get_current_user), # requires auth
session=Depends(get_db_session),
):
await UserCRUD(session).delete(user_id)
```
---
## Dependency Injection
```python
# api/dependencies.py
from fastapi import Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from config.dependencies import GlobalDependencies
async def get_db_session() -> AsyncSession:
async with GlobalDependencies.db_engine.begin() as session:
yield session # yields inside with-block; rolls back on exception
async def get_api_key(x_api_key: str = Header(...)) -> str:
if x_api_key not in GlobalDependencies.valid_keys:
raise HTTPException(status_code=403, detail="Invalid API key")
return x_api_key
async def get_current_user(
token: str = Depends(oauth2_scheme),
session: AsyncSession = Depends(get_db_session),
) -> User:
payload = decode_jwt(token) # raises 401 on bad token
user = await UserCRUD(session).get(payload["sub"])
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
# Chain dependencies — get_create_user_use_case depends on get_db_session
def get_create_user_use_case(
session: AsyncSession = Depends(get_db_session),
) -> CreateUserUseCase:
return CreateUserUseCase(repo=UserRepo(session))
```
**Key rules:**
- `yield`-based dependencies run cleanup after the response is sent
- FastAPI caches dependencies within a single request — `get_db_session` called 3 times in one request returns the same session
- Use `Depends(get_current_user)` as a parameter to require auth on a route
---
## Pydantic v2 Schemas
```python
# api/schemas/users.py
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
from datetime import datetime
from typing import Annotated
UserId = Annotated[str, Field(min_length=1, description="User UUID")]
class CreateUserRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: EmailStr
role: Literal["admin", "user"] = "user"
age: int = Field(ge=0, le=150)
@field_validator("name")
@classmethod
def strip_name(cls, v: str) -> str:
return v.strip()
@model_validator(mode="after")
def admin_must_have_age(self) -> "CreateUserRequest":
if self.role == "admin" and self.age < 18:
raise ValueError("Admins must be 18+")
return self
class UserResponse(BaseModel):
id: UserId
name: str
email: EmailStr
created_at: datetime
model_config = ConfigDict(from_attributes=True) # allows ORM → schema conversion
# Nested schemas
class OrderWithUserResponse(BaseModel):
id: str
total: float
user: UserResponse # nested
items: list[OrderItemResponse]
```
---
## Settings (Pydantic Settings)
```python
# config/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
environment: str = "development"
database_url: str
redis_url: str = "redis://localhost:6379"
secret_key: str
allowed_origins: list[str] = ["http://localhost:3000"]
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
@lru_cache
def get_settings() -> Settings:
return Settings()
# In dependency:
def get_settings_dep(settings: Settings = Depends(get_settings)) -> Settings:
return settings
```
---
## Error Handling
```python
# domain/exceptions.py
class ClientError(Exception):
"""400 — bad input, caller's fault"""
def __init__(self, message: str): self.message = message
class NotFoundError(Exception):
"""404"""
def __init__(self, resource: str, id: str):
self.message = f"{resource} '{id}' not found"
class ServiceError(Exception):
"""500 — internal failure"""
# api/app.py — register handlers
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(ClientError)
async def client_error_handler(request: Request, exc: ClientError):
return JSONResponse(status_code=400, content={"detail": exc.message})
@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError):
return JSONResponse(status_code=404, content={"detail": exc.message})
@app.exception_handler(ServiceError)
async def service_error_handler(request: Request, exc: ServiceError):
return JSONResponse(status_code=500, content={"detail": "Internal error"})
```
Never raise `HTTPException` inside use cases — only in route handlers or dependencies.
---
## Middleware
```python
# api/middleware.py
import uuid, time
from fastapi import Request
async def request_id_middleware(request: Request, call_next):
request_id = request.headers.get("x-request-id", uuid.uuid4().hex)
request.state.request_id = request_id
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
response.headers["x-request-id"] = request_id
response.headers["x-response-time"] = f"{duration:.3f}s"
return response
# Register as BaseHTTPMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
app.add_middleware(BaseHTTPMiddleware, dispatch=request_id_middleware)
```
---
## Background Tasks
```python
from fastapi import BackgroundTasks
@router.post("/users/")
async def create_user(
body: CreateUserRequest,
background_tasks: BackgroundTasks,
session=Depends(get_db_session),
):
user = await UserCRUD(session).create(body)
# runs after response is sent — good for emails, webhooks, cache invalidation
background_tasks.add_task(send_welcome_email, user.email, user.name)
return user
```
Use background tasks for fire-and-forget work. For durable/retryable work, use a task queue (Celery, ARQ, Temporal).
---
## Authentication (JWT + OAuth2)
```python
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
import jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
@router.post("/auth/token")
async def login(form: OAuth2PasswordRequestForm = Depends(), session=Depends(get_db_session)):
user = await authenticate_user(form.username, form.password, session)
if not user:
raise HTTPException(status_code=401, detail="Incorrect credentials",
headers={"WWW-Authenticate": "Bearer"})
token = jwt.encode(
{"sub": user.id, "exp": datetime.utcnow() + timedelta(hours=24)},
settings.secret_key, algorithm="HS256",
)
return {"access_token": token, "token_type": "bearer"}
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
user_id = payload.get("sub")
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
...
```
---
## WebSockets
```python
from fastapi import WebSocket, WebSocketDisconnect
class ConnectionManager:
def __init__(self):
self.connections: dict[str, WebSocket] = {}
async def connect(self, client_id: str, ws: WebSocket):
await ws.accept()
self.connections[client_id] = ws
def disconnect(self, client_id: str):
self.connections.pop(client_id, None)
async def broadcast(self, message: str):
for ws in self.connections.values():
await ws.send_text(message)
manager = ConnectionManager()
@router.websocket("/ws/{client_id}")
async def websocket_endpoint(websocket: WebSocket, client_id: str):
await manager.connect(client_id, websocket)
try:
while True:
data = await websocket.receive_text()
await manager.broadcast(f"{client_id}: {data}")
except WebSocketDisconnect:
manager.disconnect(client_id)
```
---
## Testing
```python
# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from api.app import create_app
@pytest.fixture
def app():
return create_app()
@pytest.fixture
async def client(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
# tests/test_users.py
@pytest.mark.asyncio
async def test_create_user(client: AsyncClient):
response = await client.post("/api/v1/users/", json={
"name": "Alice",
"email": "alice@example.com",
"role": "user",
"age": 30,
})
assert response.status_code == 201
assert response.json()["email"] == "alice@example.com"
@pytest.mark.asyncio
async def test_get_missing_user(client: AsyncClient):
response = await client.get("/api/v1/users/nonexistent")
assert response.status_code == 404
# Override dependencies in tests
from api.dependencies import get_current_user
app.dependency_overrides[get_current_user] = lambda: fake_user
```
---
## Red Flags
- **Business logic in route handlers** — handlers that do more than parse input, call a service, and return a response become untestable; keep handlers thin and domain logic in service layers
- **Multiple `Depends()` each creating their own DB session** — separate session per dependency in one request can lead to inconsistent reads; use a single session factory via a shared lifespan dependency
- **`BackgroundTasks` for work that must not be lost** — `BackgroundTasks` run in-process and die with the worker on crash or restart; use a proper job queue (Celery, ARQ) for durable background work
- **Pydantic models shared between API and DB layers** — using the same model for request validation and ORM mapping couples the API contract to the DB schema; maintain separate schemas for each layer
- **SDK clients or DB pools initialized at module level without lifespan** — module-level initialization prevents proper startup/shutdown and breaks test isolation; use the `@asynccontextmanager` lifespan pattern
- **`HTTPException` raised from service or domain layers** — HTTP exceptions in business logic couple the domain to the web framework; raise domain exceptions and map them to HTTP responses at the route layer
- **`response_model` omitted on endpoints returning ORM objects** — without `response_model`, FastAPI serializes the full ORM object including internal fields; always declare `response_model` to control the response schema
## Checklist
- [ ] Routes delegate to use cases — no business logic in route handlers
- [ ] Use cases raise domain exceptions (`ClientError`, `NotFoundError`) — not `HTTPException`
- [ ] Exception handlers in `app.py` convert domain exceptions to HTTP responses
- [ ] `yield`-based dependencies used for DB sessions (ensures cleanup)
- [ ] Pydantic schemas separate from ORM models — conversion in adapter layer
- [ ] Settings loaded via `pydantic-settings` from env / `.env` file
- [ ] Background tasks used only for fire-and-forget (use task queue for retryable work)
- [ ] Tests use `AsyncClient` with `ASGITransport` — not `TestClient` for async routes
- [ ] `dependency_overrides` used in tests instead of mocking internals
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!