# Dependency Injection Avanzado — FastAPI

## Depends() con clases: parámetros de consulta reutilizables

Usar clases como dependencias evita repetir parámetros de paginación y filtrado en
cada endpoint. FastAPI instancia la clase automáticamente.

```python
# app/dependencies/paginacion.py
from fastapi import Depends, Query
from dataclasses import dataclass

@dataclass
class Paginacion:
    """Dependencia reutilizable para paginación estándar."""
    pagina: int = Query(default=1, ge=1, description="Número de página")
    tamano: int = Query(default=20, ge=1, le=100, description="Elementos por página")

    @property
    def offset(self) -> int:
        return (self.pagina - 1) * self.tamano

    @property
    def limit(self) -> int:
        return self.tamano


@dataclass
class FiltrosFactura:
    """Parámetros de filtrado específicos para facturas."""
    estatus: str | None = Query(default=None, description="Filtrar por estatus")
    cliente_id: str | None = Query(default=None, description="Filtrar por cliente")
    fecha_desde: date | None = Query(default=None)
    fecha_hasta: date | None = Query(default=None)


# Uso en endpoint — FastAPI inyecta la clase completa
@router.get("/facturas", response_model=PaginatedResponse[FacturaRead])
async def listar_facturas(
    paginacion: Paginacion = Depends(),
    filtros: FiltrosFactura = Depends(),
    db: AsyncSession = Depends(get_db),
) -> PaginatedResponse[FacturaRead]:
    query = select(Factura).offset(paginacion.offset).limit(paginacion.limit)
    if filtros.estatus:
        query = query.where(Factura.estatus == filtros.estatus)
    ...
```

---

## Patrón Annotated moderno (recomendado desde FastAPI 0.95)

`Annotated` centraliza la definición de la dependencia y elimina repetición.
El tipo queda documentado y el IDE lo infiere correctamente.

```python
# app/dependencies/tipos.py
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies.database import get_db
from app.dependencies.auth import get_current_user, require_role
from app.models import Usuario

# Tipos anotados — definir una sola vez, usar en toda la app
DbDep = Annotated[AsyncSession, Depends(get_db)]
UsuarioActualDep = Annotated[Usuario, Depends(get_current_user)]
AdminDep = Annotated[Usuario, Depends(require_role(["admin", "superadmin"]))]
PaginacionDep = Annotated[Paginacion, Depends()]


# Uso en endpoints — limpio, sin repetir Depends()
@router.get("/facturas", response_model=list[FacturaRead])
async def listar_facturas(
    db: DbDep,
    usuario: UsuarioActualDep,
    paginacion: PaginacionDep,
) -> list[FacturaRead]:
    ...


@router.delete("/facturas/{factura_id}", status_code=204)
async def eliminar_factura(
    factura_id: str,
    db: DbDep,
    admin: AdminDep,  # Solo admins — validación automática
) -> None:
    ...
```

---

## Depends() anidados: dependencias que dependen de otras

FastAPI resuelve el grafo de dependencias automáticamente y reutiliza instancias
dentro de la misma request cuando se comparte la misma función de dependencia.

```python
# app/dependencies/auth.py
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

bearer = HTTPBearer()

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(bearer),
    db: AsyncSession = Depends(get_db),       # Anidado — FastAPI comparte la sesión
) -> Usuario:
    payload = decode_jwt(credentials.credentials)
    if not payload:
        raise HTTPException(status_code=401, detail="Token inválido o expirado")
    usuario = await db.get(Usuario, payload["sub"])
    if not usuario or not usuario.activo:
        raise HTTPException(status_code=401, detail="Usuario inactivo")
    return usuario


def require_role(*roles: str):
    """Fábrica de dependencias — genera un Depends con roles específicos."""
    async def verificar(
        usuario: Usuario = Depends(get_current_user),  # Anidado en la fábrica
    ) -> Usuario:
        if usuario.rol not in roles:
            raise HTTPException(
                status_code=403,
                detail=f"Se requiere uno de estos roles: {list(roles)}",
            )
        return usuario
    return verificar


def require_empresa_activa():
    """Verifica que la empresa del usuario esté activa — depende de get_current_user."""
    async def verificar(
        usuario: Usuario = Depends(get_current_user),
        db: AsyncSession = Depends(get_db),   # FastAPI reutiliza la MISMA sesión
    ) -> Usuario:
        empresa = await db.get(Empresa, usuario.empresa_id)
        if not empresa or empresa.suspendida:
            raise HTTPException(status_code=403, detail="Empresa suspendida")
        return usuario
    return verificar
```

---

## Dependencias con yield para recursos externos

`yield` permite inicializar y limpiar recursos (sesiones DB, clientes HTTP, locks).

```python
# app/dependencies/database.py
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    """Sesión de base de datos con rollback automático en error."""
    async with AsyncSessionLocal() as session:
        try:
            yield session
            # El commit lo hace el endpoint DESPUÉS de que el service termine
            await session.commit()
        except Exception:
            await session.rollback()
            raise


# app/dependencies/http_client.py
import httpx

async def get_http_client() -> AsyncGenerator[httpx.AsyncClient, None]:
    """Cliente HTTP reutilizable — evita abrir conexiones por request."""
    async with httpx.AsyncClient(timeout=10.0) as client:
        yield client


HttpClientDep = Annotated[httpx.AsyncClient, Depends(get_http_client)]
```

---

## Security Schemes

```python
# app/core/security.py
from fastapi.security import OAuth2PasswordBearer, HTTPBearer, APIKeyHeader
from jose import jwt, JWTError
from passlib.context import CryptContext

# Esquema OAuth2 — expone /token en la documentación Swagger
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")

# Bearer directo — para APIs que emiten sus propios tokens JWT
bearer_scheme = HTTPBearer(auto_error=True)

# API Key en header — para integraciones M2M
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def crear_token(sub: str, rol: str, expire_minutes: int = 30) -> str:
    expire = datetime.utcnow() + timedelta(minutes=expire_minutes)
    return jwt.encode(
        {"sub": sub, "rol": rol, "exp": expire},
        settings.SECRET_KEY,
        algorithm="HS256",
    )


def decode_jwt(token: str) -> dict | None:
    try:
        return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
    except JWTError:
        return None


# Dependencia combinada: JWT o API Key — cualquiera es válido
async def get_current_user_or_apikey(
    bearer: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
    api_key: str | None = Depends(api_key_header),
    db: AsyncSession = Depends(get_db),
) -> Usuario:
    if bearer:
        payload = decode_jwt(bearer.credentials)
        if payload:
            return await _usuario_por_id(db, payload["sub"])
    if api_key:
        usuario = await _usuario_por_api_key(db, api_key)
        if usuario:
            return usuario
    raise HTTPException(status_code=401, detail="Credenciales inválidas")
```

---

## Middleware vs Dependencies — cuándo usar cada uno

| Criterio | Middleware | Dependency |
|----------|-----------|------------|
| Acceso a la request ANTES de rutear | Sí | No |
| Acceso al response DESPUÉS de ejecutar | Sí | No (solo con yield) |
| Acceso a path params / query params | No | Sí |
| Aplica a todos los endpoints | Sí (por defecto) | No (explícito) |
| Puede retornar early (ej. CORS, cache) | Sí | Solo via HTTPException |
| Inyección de valores al endpoint | No | Sí |
| Testeable de forma aislada | Difícil | Fácil con dependency_overrides |

**Usar Middleware para**: logging de requests, timing, CORS, rate limiting global,
propagación de request-id.

**Usar Dependency para**: autenticación, autorización, validación de negocio,
sesiones DB, clientes HTTP, parámetros reutilizables.

---

## Dependencias por router vs por endpoint

```python
# Por router — aplica a TODOS los endpoints del router
router = APIRouter(
    prefix="/admin",
    tags=["Administración"],
    dependencies=[Depends(require_role("admin"))],  # Todos los endpoints requieren admin
)

# Por endpoint — control granular
@router.get("/reporte", dependencies=[Depends(require_empresa_activa())])
async def obtener_reporte(db: DbDep) -> dict:
    ...

# Router con dependencias globales + endpoint con dependencias adicionales
@router.post("/usuarios", dependencies=[Depends(require_role("superadmin"))])
async def crear_usuario_admin(
    datos: UsuarioCreate,
    db: DbDep,
    # get_current_user ya está implícito via require_role
) -> UsuarioRead:
    ...
```

---

## Override de dependencias en tests

```python
# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.dependencies.database import get_db
from app.dependencies.auth import get_current_user

@pytest.fixture
def usuario_fake() -> Usuario:
    return Usuario(id="test-uid", email="test@test.com", rol="admin", activo=True)


@pytest.fixture
async def client_autenticado(db_test, usuario_fake):
    """Cliente con autenticación y BD de prueba inyectadas."""
    async def _get_db_override():
        yield db_test

    async def _get_user_override():
        return usuario_fake

    # Registrar overrides ANTES de crear el cliente
    app.dependency_overrides[get_db] = _get_db_override
    app.dependency_overrides[get_current_user] = _get_user_override

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as ac:
        yield ac

    # Limpiar overrides DESPUÉS del test
    app.dependency_overrides.clear()


@pytest.fixture
async def client_sin_auth(db_test):
    """Cliente sin autenticación para probar endpoints públicos."""
    app.dependency_overrides[get_db] = lambda: db_test
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
        yield ac
    app.dependency_overrides.clear()
```
