# Core FastAPI — Estructura y convenciones

Documentación interna sobre cómo está organizado el core y cómo extenderlo en cada proyecto.

---

## Estructura de carpetas

```
core_fastapi/
├── main.py                        # Entry point y factory de la app
├── requirements                   # Dependencias Python
├── .env                           # Variables de entorno (no commitear)
│
└── app/
    ├── api/
    │   └── v1/
    │       └── __init__.py        # Cargador dinámico de routers
    │
    ├── core/
    │   ├── config.py              # Settings (pydantic_settings)
    │   ├── DBConnect.py           # Pool de conexiones SQLAlchemy
    │   ├── CRUD.py                # Clase CRUD genérica
    │   ├── logger_config.py       # Configuración del logger
    │   ├── Utilities.py           # Helpers reutilizables
    │   └── TablePartitioner.py    # Manejo de tablas particionadas PostgreSQL
    │
    ├── dependencies/
    │   ├── auth.py                # Dependencia JWT (get_current_user)
    │   └── permissions.py         # Dependencia de permisos por ruta
    │
    └── exceptions/
        ├── __init__.py            # Exporta todas las excepciones de dominio
        ├── domain.py              # Jerarquía de errores (DomainError y subclases)
        └── http_map.py            # Mapeo DomainError → HTTP status code
```

---

## Cómo agregar un router

Los routers se cargan **automáticamente** desde `app/api/v1/`. Solo necesitas crear el archivo y definir una variable `router`.

**1. Crear el archivo del router:**

```python
# app/api/v1/productos.py
from fastapi import APIRouter, Depends
from app.dependencies.auth import get_current_user

router = APIRouter(prefix="/productos", tags=["Productos"])

@router.get("/")
def listar_productos(user=Depends(get_current_user)):
    return {"data": []}

@router.post("/")
def crear_producto(user=Depends(get_current_user)):
    return {"success": True}
```

**2. Listo.** El archivo se detecta y registra solo. No hay que tocar `main.py` ni `__init__.py`.

El cargador dinámico en `app/api/v1/__init__.py` escanea todos los `.py` del directorio, importa los que tengan una variable `router` y los registra con prefijo `/v1`.

---

## Cómo agregar un servicio

Los servicios van en `app/services/`. No hay convención de clase base obligatoria; lo habitual es una clase con métodos estáticos o de instancia.

```python
# app/services/ProductoService.py
from app.core.CRUD import CRUD
from app.models.Producto import Producto
from app.exceptions import NotFoundError

class ProductoService:

    @staticmethod
    def obtener(producto_id: int) -> dict:
        crud = CRUD(Producto)
        result = crud.get_by_id(producto_id)
        if not result:
            raise NotFoundError(f"Producto {producto_id} no encontrado")
        return result

    @staticmethod
    def crear(**kwargs) -> dict:
        crud = CRUD(Producto)
        return crud.create(**kwargs)
```

Luego en el router:

```python
from app.services.ProductoService import ProductoService

@router.get("/{producto_id}")
def detalle(producto_id: int, user=Depends(get_current_user)):
    return ProductoService.obtener(producto_id)
```

---

## Cómo agregar un modelo (SQLAlchemy)

Los modelos van en `app/models/`. Cada archivo define una tabla con `declarative_base`.

```python
# app/models/Producto.py
from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP
from sqlalchemy.orm import declarative_base
from datetime import datetime
import pytz

Base = declarative_base()

class Producto(Base):
    __tablename__ = "productos"
    __table_args__ = {"schema": "mi_schema"}

    id      = Column(Integer, primary_key=True, autoincrement=True)
    nombre  = Column(String(150), nullable=False)
    activo  = Column(Boolean, default=True)
    created_at = Column(TIMESTAMP, default=datetime.now(pytz.utc))
```

---

## Clase CRUD

`app/core/CRUD.py` es una capa genérica sobre SQLAlchemy. Se instancia pasándole el modelo.

```python
from app.core.CRUD import CRUD
from app.models.Producto import Producto

crud = CRUD(Producto)
```

| Método | Descripción |
|---|---|
| `crud.create(**kwargs)` | Inserta un registro, retorna `{success, message, info: {id_insertado}}` |
| `crud.get_all()` | Retorna todos los registros como lista de dicts |
| `crud.get_by_id(id)` | Retorna un registro por PK o `None` |
| `crud.update(id, **kwargs)` | Actualiza solo los campos que cambiaron |
| `crud.delete(id)` | Elimina por PK, retorna `bool` |
| `crud.search(filters)` | Búsqueda con operadores avanzados (ver abajo) |
| `crud.get_paginated(p_size, c_page, search_term, where)` | Paginación con búsqueda y filtros fijos |

### Filtros avanzados en `search()`

```python
crud.search({
    "estatus":   "activo",               # igualdad
    "precio":    (">=", 100),            # comparación
    "categoria": ("in", [1, 2, 3]),      # IN clause
    "deleted_at": ("is_null",),          # IS NULL
    "nombre":    ("!=", "prueba"),       # desigualdad
})
```

Operadores disponibles: `>=`, `<=`, `>`, `<`, `!=`, `in`, `is_null`, `not_null`.

---

## Manejo de errores (excepciones de dominio)

El core usa una jerarquía de errores semánticos que se mapean automáticamente a HTTP en `main.py`.

### Lanzar una excepción

```python
from app.exceptions import NotFoundError, ConflictError, UnauthorizedError

raise NotFoundError("El recurso no existe")
raise ConflictError("El email ya está registrado")
raise UnauthorizedError("Token inválido", data={"token": token})
```

El segundo argumento `data` es opcional y se incluye en la respuesta JSON.

### Excepciones disponibles

| Clase | HTTP |
|---|---|
| `DomainError` | 400 |
| `UnauthorizedError` | 401 |
| `ForbiddenError` | 403 |
| `NotFoundError` | 404 |
| `ConflictError` | 409 |
| `GoneError` | 410 |
| `LockedError` | 423 |
| `TooManyRequestsError` | 429 |
| `UnavailableForLegalReasonsError` | 451 |

### Respuesta JSON generada automáticamente

```json
{
  "success": false,
  "message": "El recurso no existe",
  "data": null
}
```

### Agregar una nueva excepción de dominio

```python
# app/exceptions/domain.py
class PaymentRequiredError(DomainError): pass
```

```python
# app/exceptions/http_map.py
from app.exceptions.domain import PaymentRequiredError

DOMAIN_TO_HTTP = {
    ...
    PaymentRequiredError: status.HTTP_402_PAYMENT_REQUIRED,
}
```

```python
# app/exceptions/__init__.py
from .domain import (
    ...
    PaymentRequiredError,
)
```

---

## Dependencias de autenticación y permisos

### Proteger una ruta con JWT

```python
from fastapi import Depends
from app.dependencies.auth import get_current_user

@router.get("/perfil")
def perfil(user=Depends(get_current_user)):
    # user es el payload decodificado del token
    return {"user_id": user["id"]}
```

`get_current_user` lee el header `Authorization: Bearer <token>`, valida el JWT con `AuthService.validate_token()` y retorna el payload.

### Verificar permisos por ruta

```python
from app.dependencies.permissions import permission_required_by_path

@router.delete("/{id}")
async def eliminar(
    id: int,
    user=Depends(get_current_user),
    _=Depends(permission_required_by_path),
):
    ...
```

---

## Variables de entorno (.env)

```env
# App
APP_TITLE=Nombre de mi API
APP_DESCRIPTION=Descripción
APP_VERSION=1.0.0
NAME_APP=main:app
HOSTING=0.0.0.0
PORT=8000
RELOAD=True
ALLOW_ORIGINS=*
URL_HOST=https://api.midominio.com
URL_FRONT=https://midominio.com

# Base de datos
DB_USER=postgres
DB_PASSWORD=secret
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mi_base

# Auth
SECRET_KEY=una-clave-muy-segura
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60

# Email
EMAIL_USERNAME=noreply@midominio.com
EMAIL_PASSWORD=secret
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
NAME_COMPANY=Mi Empresa

# Tokens de registro
TIME_EXPIRE_TOKEN_REGISTER=1440
TIME_EXPIRE_TOKEN_INVITE=4320
VERIFICATION_TOKEN_ATTEMPTS=5
TIME_VERIFICATION_TOKEN_ATTEMPTS=60
FAILED_LOGIN_ATTEMPTS=5
TIME_EXPIRE_TOKEN_RECOVERY_PASSWD=60

# Encriptación
ENCRYPTION_KEY=clave-de-encriptacion
CREDENTIALS_STORAGE=db
```

---

## Utilities

`app/core/Utilities.py` expone helpers de uso general:

```python
from app.core.Utilities import Utilities

# Respuesta estándar exitosa
Utilities.ok("Operación completada", data={"id": 1})
# → {"success": True, "message": "Operación completada", "data": {"id": 1}}

# Convertir fecha UTC a zona horaria local
Utilities.timezone_convert(fecha_utc, zona_horaria="America/Mexico_City")

# Hash corto de un email (16 chars MD5)
Utilities.generate_short_hash("usuario@ejemplo.com")
```
