🌿

CultivaHub API — Patrones FastAPI Producción

Arquitectura por capas · JWT Auth · Async SQLAlchemy · Pydantic v2 · pytest + httpx

✓ FastAPI 0.115 Python 3.12 Pydantic v2 SQLAlchemy 2.0 asyncpg
📁 Estructura del Proyecto
cultivahub/
├── app/
│   ├── main.py          # App factory + lifespan
│   ├── config.py        # Settings (pydantic-settings)
│   ├── dependencies.py  # Deps compartidas: DbDep, ActiveUserDep
│   ├── database.py      # Engine async + AsyncSessionLocal
│   ├── routers/
│   │   ├── auth.py      # /auth/register, /auth/token
│   │   ├── agents.py    # CRUD agentes IA
│   │   └── credits.py   # Balance + consume
│   ├── models/          # ORM SQLAlchemy
│   │   ├── agency.py
│   │   ├── agent.py
│   │   └── credit_log.py
│   ├── schemas/         # Pydantic v2
│   │   ├── agency.py
│   │   ├── agent.py
│   │   └── credits.py
│   └── services/        # Lógica de negocio transaccional
│       ├── agency_service.py
│       ├── agent_service.py
│       └── credit_service.py
├── tests/
│   ├── conftest.py
│   ├── test_auth.py
│   ├── test_agents.py
│   └── test_credits.py
├── migrations/          # Alembic
├── pyproject.toml
└── .env
🏗️ Capas de Arquitectura
🔀

Routers

Thin handlers: validan entrada, delegan al servicio, mapean errores a HTTP

routers/
📐

Schemas Pydantic v2

Validación estricta, model_validator, response_model previene leaks de PII

schemas/
💉

Dependency Injection

DbDep, CurrentUserDep, ActiveUserDep — Annotated + type aliases

dependencies.py
⚙️

Service Layer

Lógica de negocio, transacciones, rollback, errores de dominio

services/
🗂️

ORM Models

SQLAlchemy 2.0 declarative, constraints únicos, relaciones

models/
🐘

PostgreSQL + asyncpg

Engine async, AsyncSessionLocal, connection pool

database.py
🔌 API Endpoints
Método Path Descripción Auth Response Status
POST /auth/register Registrar nueva agencia + hash bcrypt público AgencyResponse 201
POST /auth/token Login → JWT HS256 (30 min TTL) público TokenResponse 200
GET /agents/ Listar agentes de la agencia autenticada (paginado) Bearer JWT AgentListResponse 200
POST /agents/ Crear agente (nombre, tipo, config JSON, modelo LLM) Bearer JWT AgentResponse 201
PATCH /agents/{agent_id} Actualizar config: solo campos enviados (exclude_unset) Bearer JWT AgentResponse 200
DELETE /agents/{agent_id} Soft-delete agente (is_active=False) Bearer JWT 204
GET /credits/balance Saldo actual de créditos de ejecución Bearer JWT CreditBalanceResponse 200
POST /credits/consume Descontar créditos por ejecución de agente Bearer JWT CreditConsumeResponse 200
💻 Código: Patrones Clave
app/main.py App Factory + Lifespan
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.routers import auth, agents, credits

@asynccontextmanager
async def lifespan(app: FastAPI):
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield
    await engine.dispose()  # cleanup pool

def create_app() -> FastAPI:
    app = FastAPI(
        title="CultivaHub API",
        version="1.0.0",
        lifespan=lifespan,
    )
    app.add_middleware(CORSMiddleware,
        allow_origins=settings.allowed_origins,
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    app.include_router(auth.router,
        prefix="/auth", tags=["auth"])
    app.include_router(agents.router,
        prefix="/agents", tags=["agents"])
    app.include_router(credits.router,
        prefix="/credits", tags=["credits"])
    return app
app/schemas/agent.py Pydantic v2 Schemas
from datetime import datetime
from pydantic import BaseModel, Field, model_validator
from enum import StrEnum

class AgentType(StrEnum):
    CHAT     = "chat"
    SEARCH   = "search"
    PIPELINE = "pipeline"

class AgentCreate(BaseModel):
    name: str = Field(min_length=3, max_length=80)
    agent_type: AgentType
    llm_model: str = "claude-3-5-sonnet-20241022"
    config: dict = Field(default_factory=dict)
    credits_per_run: int = Field(ge=1, le=1000)

class AgentUpdate(BaseModel):
    name: str | None = None
    llm_model: str | None = None
    config: dict | None = None
    is_active: bool | None = None

class AgentResponse(BaseModel):
    id: int
    name: str
    agent_type: AgentType
    llm_model: str
    is_active: bool
    credits_per_run: int
    created_at: datetime
    model_config = {"from_attributes": True}

class AgentListResponse(BaseModel):
    total: int; items: list[AgentResponse]
app/dependencies.py Inyección de Dependencias
from typing import Annotated, AsyncGenerator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from jose import JWTError, jwt

oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/token")

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

async def get_current_agency(
    token: Annotated[str, Depends(oauth2)],
    db: Annotated[AsyncSession, Depends(get_db)],
) -> Agency:
    exc = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token,
            settings.secret_key,
            algorithms=[settings.algorithm])
        agency_id = int(payload["sub"])
    except (JWTError, TypeError, ValueError):
        raise exc
    agency = await db.get(Agency, agency_id)
    if not agency: raise exc
    return agency

# Type aliases → routers más limpios
DbDep = Annotated[AsyncSession, Depends(get_db)]
AgencyDep = Annotated[Agency, Depends(get_current_agency)]
app/services/agent_service.py Service Layer Transaccional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.agent import Agent
from app.schemas.agent import AgentCreate, AgentUpdate

class AgentService:
    def __init__(self, db: AsyncSession) -> None:
        self.db = db

    async def create(self, agency_id: int,
                      payload: AgentCreate) -> Agent:
        agent = Agent(**payload.model_dump(),
                      agency_id=agency_id)
        self.db.add(agent)
        await self.db.commit()
        await self.db.refresh(agent)
        return agent

    async def list(self, agency_id: int,
                    skip: int = 0,
                    limit: int = 20) -> tuple:
        total = (await self.db.execute(
            select(func.count(Agent.id))
            .where(Agent.agency_id == agency_id)
        )).scalar_one()
        items = (await self.db.execute(
            select(Agent)
            .where(Agent.agency_id == agency_id)
            .order_by(Agent.id)   # orden determinista
            .offset(skip).limit(limit)
        )).scalars().all()
        return list(items), total

    async def update(self, agent_id: int,
                      agency_id: int,
                      payload: AgentUpdate) -> Agent | None:
        agent = await self.db.get(Agent, agent_id)
        if not agent or agent.agency_id != agency_id:
            return None
        for f, v in payload.model_dump(
                exclude_unset=True).items():
            setattr(agent, f, v)
        await self.db.commit()
        await self.db.refresh(agent)
        return agent
⚡ Anti-patrones → Patrones Correctos
✗ Anti-patrón
# Lógica en el router
@router.post("/agents/")
async def create(p, db: DbDep):
    agent = Agent(
        name=p.name,
        agency_id=p.agency_id,
        # sin validar pertenencia
    )
    db.add(agent)
    await db.commit()
    return agent
✓ Patrón correcto
# Router delgado
@router.post("/", status_code=201,
  response_model=AgentResponse)
async def create(
    p: AgentCreate, db: DbDep,
    agency: AgencyDep,
) -> AgentResponse:
    svc = AgentService(db)
    return await svc.create(
        agency.id, p)
✗ Sync en async
# Bloquea el event loop
@router.get("/agents/")
async def list_agents(
    db: Session = Depends(get_db)
):
    return db.query(Agent).all()
✓ Async correcto
# Event loop libre
@router.get("/", response_model=
  AgentListResponse)
async def list_agents(
    db: DbDep, agency: AgencyDep,
):
    svc = AgentService(db)
    items, total = await svc.list(
        agency.id)
    return AgentListResponse(
        total=total, items=items)
🧪 Tests con pytest + httpx AsyncClient
test_register_agency ✓ PASS
async def test_register_agency(client):
    resp = await client.post("/auth/register", json={
        "email": "hola@cultivaia.com",
        "name": "CULTIVA IA",
        "password": "str0ng_pass!",
    })
    assert resp.status_code == 201
    data = resp.json()
    assert data["email"] == "hola@cultivaia.com"
    assert "hashed_password" not in data
test_create_agent_authenticated ✓ PASS
async def test_create_agent(auth_client):
    resp = await auth_client.post("/agents/", json={
        "name": "Agente SEO Cultiva",
        "agent_type": "pipeline",
        "llm_model": "claude-3-5-sonnet-20241022",
        "credits_per_run": 10,
    })
    assert resp.status_code == 201
    agent = resp.json()
    assert agent["name"] == "Agente SEO Cultiva"
    assert agent["is_active"] is True
test_consume_credits_insufficient ✓ PASS
async def test_credits_insufficient(auth_client):
    resp = await auth_client.post(
        "/credits/consume",
        json={"agent_id": 1, "amount": 99999},
    )
    assert resp.status_code == 402  # Payment Required
    assert "Insufficient credits" in resp.json()["detail"]
✅ Best Practices Aplicadas
🔒 Seguridad
response_model explícito en todos los endpoints — previene filtrar hashed_password u otros campos PII
JWT defensivo: captura JWTError, TypeError y ValueError del cast a int — evita crashes por tokens malformados
401 vs 403 separados: no autenticado ≠ no autorizado — señales HTTP precisas al cliente
bcrypt para hashing de contraseñas con passlib — factor de coste configurable
⚡ Rendimiento
SQLAlchemy async con asyncpg — no bloquea el event loop de asyncio en ninguna query
order_by determinista en todos los endpoints paginados — evita skips o duplicados al cambiar de página
exclude_unset=True en PATCH — solo actualiza campos enviados, evita sobrescribir datos con None
Connection pooling vía AsyncSessionLocal — reutiliza conexiones en lugar de abrir una por request
🏗️ Mantenibilidad
Type aliases DbDep, AgencyDep — routers limpios sin repetir Annotated boilerplate
App factory pattern — facilita tests con in-memory DB sin importar el módulo global
Errores de dominio propios (InsufficientCreditsError, DuplicateAgencyError) — routers no capturan IntegrityError directamente
Alembic para migraciones — no create_all en producción, esquema versionado y reversible