Python FastAPI service

Have agents structure APIs with route modules, Depends, and Pydantic v2 models; emit consistent OpenAPI and error bodies; use lifespan for pools and startup/shutdown; document async boundaries and test entry points in the SKILL.

This page gives Agents a complete FastAPI service reference: dependency injection (DB connections/auth/permission checks), Pydantic v2 models (validators/computed_fields), async routes with background tasks, middleware examples (request logging/CORS/gzip), and TestClient usage.

The SKILL defines APIRouter split rules, global exception handlers, and HTTPException usage boundaries; async I/O guidelines: blocking libraries go in thread pools (run_in_executor); SQLAlchemy 2.0 async session factory initialized in lifespan.

  • Security: OAuth2/JWT Bearer + Depends(get_current_user).
  • Testing: TestClient + dependency_overrides to inject a fake DB.
  • Tie-in with API contract skill: FastAPI auto-generates /openapi.json; export and run schema diff.

Pydantic v2 models (validators and computed_fields)

# models/item.py — Pydantic v2 complete model definition
from pydantic import BaseModel, Field, field_validator, computed_field, model_validator
from decimal import Decimal
from typing import Optional
import re

class ItemCreate(BaseModel):
    name: str = Field(min_length=1, max_length=200, description="Product name")
    price: Decimal = Field(gt=0, decimal_places=2, description="Price (positive)")
    sku: str = Field(description="Stock keeping unit, format XXX-000")
    discount_pct: Optional[float] = Field(default=None, ge=0, le=100)

    @field_validator('sku')
    @classmethod
    def validate_sku(cls, v: str) -> str:
        if not re.match(r'^[A-Z]{3}-\d{3}$', v):
            raise ValueError('SKU must match pattern XXX-000 (e.g. WGT-001)')
        return v

    @model_validator(mode='after')
    def check_discount_requires_price(self) -> 'ItemCreate':
        if self.discount_pct is not None and self.price < 10:
            raise ValueError('Discount only available for items priced >= 10')
        return self

class ItemResponse(ItemCreate):
    id: str
    created_at: str

    @computed_field  # type: ignore[misc]
    @property
    def final_price(self) -> Decimal:
        if self.discount_pct:
            return self.price * Decimal(1 - self.discount_pct / 100)
        return self.price

Request flow (Middleware → Depends → handler)

Typical resolution order for one HTTP call in FastAPI; exact middleware stack and handlers follow project registration order. Key point: dependencies resolve before the endpoint runs, and nested dependencies can be cached (same Depends per request may reuse the result).

  [ Client HTTP request ]
        │
        ▼
  ┌─────────────┐     CORS, trusted proxy, request_id, timeout (if custom)
  │  Middleware │
  └─────────────┘
        │
        ▼
  ┌─────────────┐     Match method + path; stack APIRouter prefixes
  │ Route match │
  └─────────────┘
        │
        ▼
  ┌─────────────┐     Depends in signature order (incl. sub-deps); may yield cleanup
  │ Depends chain│──── Common: Settings, DB session, CurrentUser, permissions
  └─────────────┘
        │
        ▼
  ┌─────────────┐     Query/Path/Body → Pydantic; return response_model
  │   Handler   │──── Uncaught → exception_handler / HTTPException
  └─────────────┘
        │
        ▼
  [ JSON / HTML / Stream / File / Redirect ]

Registration order: middleware added later sits closer to the endpoint; global exception_handler should cover business exception types with a unified error body—avoid raising unmapped types from inside handlers.

Dependency injection: database / auth / permissions

# dependencies/database.py — DB session dependency
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from typing import AsyncGenerator, Annotated
from fastapi import Depends

engine = create_async_engine(settings.DATABASE_URL, pool_size=10, max_overflow=5)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

# Type alias (recommended Annotated style)
DbSession = Annotated[AsyncSession, Depends(get_db)]

# dependencies/auth.py — JWT auth dependency
from fastapi import HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt

bearer_scheme = HTTPBearer()

async def get_current_user(
    credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
    db: DbSession,
) -> User:
    try:
        payload = jwt.decode(credentials.credentials, settings.JWT_SECRET, algorithms=['HS256'])
        user = await UserRepo(db).get_by_id(payload['sub'])
        if not user:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='User not found')
        return user
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail='Token expired')
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail='Invalid token')

# Permission check dependency (factory function)
def require_role(*roles: str):
    async def check(current_user: Annotated[User, Depends(get_current_user)]):
        if current_user.role not in roles:
            raise HTTPException(status_code=403, detail='Insufficient permissions')
        return current_user
    return check

CurrentUser = Annotated[User, Depends(get_current_user)]
AdminUser   = Annotated[User, Depends(require_role('admin'))]

# Route usage
@router.delete('/{item_id}')
async def delete_item(item_id: str, db: DbSession, _: AdminUser):
    ...

Async routes, background tasks, and TestClient

Async routes + BackgroundTasks:

# routers/orders.py — async routes + BackgroundTasks
from fastapi import APIRouter, BackgroundTasks
import asyncio

router = APIRouter(prefix='/orders', tags=['Orders'])

async def send_confirmation_email(order_id: str, email: str):
    """Background task: does not block the request response"""
    await asyncio.sleep(0)  # In production, integrate with email service
    print(f'Sending confirmation for order {order_id} to {email}')

@router.post('/', response_model=OrderResponse, status_code=201)
async def create_order(
    body: OrderCreate,
    background_tasks: BackgroundTasks,
    db: DbSession,
    current_user: CurrentUser,
):
    order = await OrderService(db).create(body, user_id=current_user.id)
    # Register background task (runs asynchronously after response is returned)
    background_tasks.add_task(send_confirmation_email, str(order.id), current_user.email)
    return order

# Middleware examples: request logging + gzip + CORS
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware

app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(CORSMiddleware,
    allow_origins=settings.ALLOWED_ORIGINS,
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
)

# Request logging middleware
from starlette.middleware.base import BaseHTTPMiddleware
import time, uuid

class RequestLogMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        req_id = str(uuid.uuid4())
        request.state.req_id = req_id
        start = time.perf_counter()
        response = await call_next(request)
        duration = time.perf_counter() - start
        print(f'{req_id} {request.method} {request.url.path} {response.status_code} {duration:.3f}s')
        response.headers['X-Request-Id'] = req_id
        return response

app.add_middleware(RequestLogMiddleware)

TestClient (with dependency_overrides):

# tests/test_orders.py
import pytest
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock
from app.main import app
from app.dependencies.auth import get_current_user
from app.models.user import User

@pytest.fixture
def mock_user():
    return User(id='test-user-id', email='test@example.com', role='admin')

@pytest.fixture
def client(mock_user: User):
    # Override auth dependency — no real JWT needed
    app.dependency_overrides[get_current_user] = lambda: mock_user
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

def test_create_order(client: TestClient):
    resp = client.post('/api/v1/orders', json={
        'items': [{'sku': 'WGT-001', 'quantity': 2}]
    })
    assert resp.status_code == 201
    data = resp.json()
    assert data['userId'] == 'test-user-id'
    assert 'id' in data

def test_create_order_validation_error(client: TestClient):
    resp = client.post('/api/v1/orders', json={'items': []})
    assert resp.status_code == 422
    assert resp.json()['detail'][0]['loc'] == ['body', 'items']
  • Mount pools and clients on app.state or read them from dependency factories — single creation point.
  • Create connection pools in lifespan; use dependency_overrides for in-memory fakes in tests.
  • Align with ASGI server (Uvicorn) worker count: one connection pool per process.

Route prefix normalizer preview

Drafts often mix backslashes, omit a leading /, or add a trailing /. The tool below normalizes input for APIRouter(prefix=...) or include_router(..., prefix=...): single leading slash, collapse duplicate slashes, strip trailing slash (root stays /). This is a teaching convention—verify against your gateway and OpenAPI base URL before release.

Normalized result and paste snippet


              

---
name: python-fastapi-service
description: REST API with FastAPI DI and Pydantic
---
# Rules
- Split APIRouter by domain (routers/items.py, routers/orders.py)
- Dependency injection: DB session via yield dependency; auth via Annotated[User, Depends(get_current_user)]
- Pydantic v2: field_validator + model_validator + computed_field
- Async routes: all I/O uses async def; blocking libs use asyncio.to_thread() or run_in_executor
- Background tasks: lightweight tasks use BackgroundTasks; heavy tasks use Celery/RQ
- Error handling: known errors raise HTTPException; global exceptions use @app.exception_handler
- Testing: TestClient + dependency_overrides to override DB/auth dependencies

# Steps
1. Initialize DB engine and connection pool in lifespan (avoid creating at import time)
2. Implement get_db() yield dependency + get_current_user() + require_role() factory
3. Define Pydantic models: CreateSchema / ResponseSchema / UpdateSchema separately
4. Use Annotated type aliases in routes (DbSession, CurrentUser, AdminUser)
5. Use BackgroundTasks.add_task() for background tasks; do not await emails/notifications in endpoints
6. Middleware: GZip + CORS + RequestLogMiddleware (registration order: last added = first executed)
7. Tests: pytest fixtures override dependency_overrides, assert status_code + response body

Back to skills More skills