Arquitectura por capas · JWT Auth · Async SQLAlchemy · Pydantic v2 · pytest + httpx
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
Thin handlers: validan entrada, delegan al servicio, mapean errores a HTTP
Validación estricta, model_validator, response_model previene leaks de PII
DbDep, CurrentUserDep, ActiveUserDep — Annotated + type aliases
Lógica de negocio, transacciones, rollback, errores de dominio
SQLAlchemy 2.0 declarative, constraints únicos, relaciones
Engine async, AsyncSessionLocal, connection pool
| 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 |
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
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]
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)]
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
# 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
# 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)
# Bloquea el event loop @router.get("/agents/") async def list_agents( db: Session = Depends(get_db) ): return db.query(Agent).all()
# 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)
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
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
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"]