Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-04-06 08:54:05 -05:00
parent c71741077f
commit d3676aa8ed
19 changed files with 873 additions and 2151 deletions

View File

@@ -1,19 +1,20 @@
import logging
import time
import httpx
from typing import Callable
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from .config import settings
from .database import CoreSessionLocal
from .security import get_tenant_from_token, verify_token
logger = logging.getLogger(__name__)
class TenantMiddleware(BaseHTTPMiddleware):
"""
Middleware original para extraer tenant_id y user_info del token.
"""
async def dispatch(self, request: Request, call_next: Callable):
# Rutas públicas que no requieren tenant
# Permitir acceso sin autenticación a rutas de documentación y salud
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
public_prefixes = [
"/api/v1/auth",
@@ -26,14 +27,12 @@ class TenantMiddleware(BaseHTTPMiddleware):
path = request.url.path
# 3. Bypass para rutas públicas y docs
if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes):
return await call_next(request)
if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes):
return await call_next(request)
# 4. Validación estricta de Token (solo para lo que no es público ni OPTIONS)
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return JSONResponse(
@@ -47,7 +46,7 @@ class TenantMiddleware(BaseHTTPMiddleware):
token = auth_header.split(" ")[1]
try:
user_info = verify_token(token)
user_info = await verify_token(token)
tenant_id = get_tenant_from_token(user_info)
request.state.tenant_id = tenant_id
@@ -63,125 +62,116 @@ class TenantMiddleware(BaseHTTPMiddleware):
}
)
# 5. Continuar con la petición real
return await call_next(request)
class LicenseValidationMiddleware(BaseHTTPMiddleware):
"""
Middleware para validar la licencia del tenant antes de procesar requests
Middleware que valida la licencia contra el Hub de Aduanasoft.
El Hub siempre es requerido — tanto en SaaS como en self-hosted.
Fail-closed: si el Hub no responde o la licencia es inválida, se bloquea el acceso.
"""
async def dispatch(self, request: Request, call_next: Callable):
if not settings.LICENSE_CHECK_ENABLED:
return await call_next(request)
# Rutas que no requieren validación de licencia
exempt_paths = [
"/api/docs",
"/api/redoc",
"/openapi.json",
"/api/v1/auth",
"/api/v1/auth",
"/api/v1/status",
"/api/v1/status",
"/api/health",
"/api/",
"/api/v1/core/help-center",
"/api/docs", "/api/redoc", "/openapi.json",
"/api/v1/auth", "/api/v1/status", "/api/health",
"/api/", "/api/v1/core/help-center",
]
# Verificar si la ruta está exenta (comparación exacta o prefijo)
is_exempt = False
for path in exempt_paths:
if request.url.path == path or (
path != "/" and request.url.path.startswith(path)
):
is_exempt = True
break
is_exempt = any(
request.url.path == path or (path != "/" and request.url.path.startswith(path))
for path in exempt_paths
)
if is_exempt:
return await call_next(request)
# Obtener tenant_id del request state (debe ser seteado por TenantMiddleware)
tenant_id = getattr(request.state, "tenant_id", None)
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
# Permitimos pasar para que TenantMiddleware maneje el 401
return await call_next(request)
token = auth_header.split(" ")[1]
if not tenant_id:
return await call_next(request) # Dejamos que TenantMiddleware maneje esto
# Validar licencia
db = CoreSessionLocal()
try:
# Importar aquí para evitar imports circulares
from api.v1.modules.core.licenses.service import LicenseService
# Validación contra el Hub Central
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{settings.HUB_URL}/api/v1/auth/verify-license",
headers={"Authorization": f"Bearer {token}"}
)
license_service = LicenseService(db)
license_info = license_service.validate_license(tenant_id)
if not license_info["is_valid"]:
if response.status_code == 200:
data = response.json()
if not data.get("valid", False):
return JSONResponse(
status_code=402,
content={
"error": "LICENSE_ERROR",
"message": f"Licencia inválida: {data.get('message', 'Sin suscripción activa')}",
"status_code": 402,
}
)
request.state.license_info = data
return await call_next(request) # <--- Único camino al éxito
elif response.status_code == 403:
return JSONResponse(
status_code=402,
status_code=403,
content={
"error": "HTTP_ERROR",
"message": f"License validation failed: {license_info['reason']}",
"status_code": 402,
"error": "FORBIDDEN",
"message": "El Tenant no tiene permisos en el Hub central.",
"status_code": 403,
}
)
else:
logger.error(f"Hub error status: {response.status_code}")
return JSONResponse(
status_code=503,
content={
"error": "HUB_ERROR",
"message": "Error en el servidor de licencias.",
"status_code": 503,
}
)
# Agregar info de licencia al request state
request.state.license_info = license_info
except Exception as e:
logger.error(f"License validation error: {str(e)}")
except (httpx.ConnectError, httpx.TimeoutException) as e:
logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}")
return JSONResponse(
status_code=500,
status_code=503,
content={
"error": "HTTP_ERROR",
"message": "License validation error",
"status_code": 500,
"error": "HUB_OFFLINE",
"message": "Servicio de licencias fuera de línea. Acceso denegado.",
"status_code": 503,
}
)
finally:
db.close()
response = await call_next(request)
return response
except Exception as e:
logger.error(f"Unexpected license error: {str(e)}")
return JSONResponse(
status_code=500,
content={"error": "VALIDATION_ERROR", "message": "Error interno de validación.", "status_code": 500}
)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""
Middleware para logging de requests
Middleware original para logging de performance.
"""
async def dispatch(self, request: Request, call_next: Callable):
start_time = time.time()
excluded_paths = [
"/api/docs",
"/api/redoc",
"/openapi.json",
"/api/v1/status",
"/api/health",
]
if any(
request.url.path == path or request.url.path.startswith(path + "/")
for path in excluded_paths
):
excluded_paths = ["/api/docs", "/api/redoc", "/openapi.json", "/api/v1/status", "/api/health"]
if any(request.url.path == path or request.url.path.startswith(path + "/") for path in excluded_paths):
return await call_next(request)
# Log request
logger.info(f"Request: {request.method} {request.url.path}")
response = await call_next(request)
# Log response
process_time = time.time() - start_time
logger.info(
f"Response: {request.method} {request.url.path} "
f"Status: {response.status_code} "
f"Duration: {process_time:.3f}s"
)
# Agregar header con tiempo de procesamiento
response.headers["X-Process-Time"] = str(process_time)
return response
return response