"""Gateway HTTP sin compartir codigo interno de los microservicios.""" from __future__ import annotations import json import logging import os import time from contextlib import asynccontextmanager from typing import Any import httpx from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware _log = logging.getLogger("api_gateway") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") EXTRACTION_URL = os.getenv("EXTRACTION_URL", "http://extraction:8001").rstrip("/") CLEANING_URL = os.getenv("CLEANING_URL", "http://cleaning:8002").rstrip("/") POBLACION_APTA_URL = os.getenv("POBLACION_APTA_URL", "http://poblacion_apta:8003").rstrip("/") COUNT_URL = os.getenv("COUNT_URL", "http://count-api:8080").rstrip("/") BOT_DESCARGA_URL = os.getenv("BOT_DESCARGA_URL", "http://bot-descarga:8200").rstrip("/") BOT_DIGITADOR_URL = os.getenv("BOT_DIGITADOR_URL", "http://bot-digitador:8210").rstrip("/") # Timeouts: JSON ligero vs uploads OCR/conteo _TIMEOUT_JSON = httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0) _TIMEOUT_UPLOAD = httpx.Timeout(connect=10.0, read=600.0, write=600.0, pool=10.0) _http_client: httpx.AsyncClient | None = None @asynccontextmanager async def lifespan(app: FastAPI): global _http_client _http_client = httpx.AsyncClient( timeout=_TIMEOUT_JSON, limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), ) _log.info( "Gateway iniciado — extraction=%s cleaning=%s poblacion_apta=%s count=%s bot_descarga=%s bot_digitador=%s", EXTRACTION_URL, CLEANING_URL, POBLACION_APTA_URL, COUNT_URL, BOT_DESCARGA_URL, BOT_DIGITADOR_URL, ) yield await _http_client.aclose() _http_client = None app = FastAPI(title="Flujo IPS API Gateway", version="1.0.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def log_requests(request: Request, call_next): start = time.perf_counter() response = await call_next(request) elapsed_ms = (time.perf_counter() - start) * 1000 _log.info("%s %s -> %s (%.0fms)", request.method, request.url.path, response.status_code, elapsed_ms) return response def _client() -> httpx.AsyncClient: if _http_client is None: raise RuntimeError("HTTP client no inicializado") return _http_client def _is_upload_path(path: str) -> bool: return ( path.startswith("/count/") or "/ingest" in path or "/upload" in path or path.startswith("/cleaning/analyze-csv") or path.startswith("/cleaning/jobs/from-csv") or path.startswith("/poblacion-apta/jobs/from-csv") or path.startswith("/poblacion-apta/jobs/from-cleaning") or path.startswith("/bot-descarga/jobs") or path.startswith("/bot-digitador/jobs") ) def _timeout_for_path(path: str) -> httpx.Timeout: return _TIMEOUT_UPLOAD if _is_upload_path(path) else _TIMEOUT_JSON @app.get("/health/live") async def health_live() -> dict[str, str]: """Liveness sin consultar upstreams (para healthcheck de Docker).""" return {"status": "ok"} @app.get("/health") async def health() -> dict[str, Any]: client = _client() extraction = await _safe_health(client, EXTRACTION_URL) cleaning = await _safe_health(client, CLEANING_URL) poblacion_apta = await _safe_health(client, POBLACION_APTA_URL) counting = await _safe_health(client, COUNT_URL) bot_descarga = await _safe_health(client, BOT_DESCARGA_URL) bot_digitador = await _safe_health(client, BOT_DIGITADOR_URL) return { "status": "ok" if extraction["ok"] and cleaning["ok"] and poblacion_apta["ok"] else "degraded", "services": { "extraction": extraction, "cleaning": cleaning, "poblacion_apta": poblacion_apta, "counting": counting, "bot_descarga": bot_descarga, "bot_digitador": bot_digitador, }, } @app.get("/models") async def models() -> Response: return await _proxy("GET", f"{EXTRACTION_URL}/models") @app.get("/openai/balance") async def openai_balance() -> Response: return await _proxy("GET", f"{EXTRACTION_URL}/openai/balance") @app.get("/config") async def config() -> dict[str, Any]: client = _client() extraction = await _safe_json(client, f"{EXTRACTION_URL}/config") cleaning_rules = await _safe_json(client, f"{CLEANING_URL}/rules") return { "extraction": extraction, "cleaning": {"rules": cleaning_rules}, } @app.api_route("/extraction/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def extraction_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{EXTRACTION_URL}/{path}") @app.api_route("/tasks", methods=["GET"]) async def tasks_root_proxy(request: Request) -> Response: return await _proxy_request(request, f"{EXTRACTION_URL}/tasks") @app.api_route("/tasks/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def tasks_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{EXTRACTION_URL}/tasks/{path}") @app.api_route("/cleaning/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def cleaning_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{CLEANING_URL}/{path}") @app.api_route("/poblacion-apta/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def poblacion_apta_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{POBLACION_APTA_URL}/{path}") @app.api_route("/count/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def count_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{COUNT_URL}/{path}") @app.api_route("/count-jobs", methods=["GET", "POST"]) async def count_jobs_root_proxy(request: Request) -> Response: return await _proxy_request(request, f"{EXTRACTION_URL}/count-jobs") @app.api_route("/count-jobs/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def count_jobs_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{EXTRACTION_URL}/count-jobs/{path}") @app.api_route("/bot-descarga/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def bot_descarga_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{BOT_DESCARGA_URL}/{path}") @app.api_route("/bot-digitador/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def bot_digitador_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{BOT_DIGITADOR_URL}/{path}") @app.api_route("/jobs", methods=["GET", "POST"]) async def jobs_root_proxy(request: Request) -> Response: return await _proxy_request(request, f"{EXTRACTION_URL}/jobs") @app.api_route("/jobs/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) async def jobs_proxy(path: str, request: Request) -> Response: return await _proxy_request(request, f"{EXTRACTION_URL}/jobs/{path}") async def _safe_health(client: httpx.AsyncClient, base_url: str) -> dict[str, Any]: try: response = await client.get(f"{base_url}/health", timeout=_TIMEOUT_JSON) return {"ok": response.is_success, "status_code": response.status_code} except Exception as exc: return {"ok": False, "error": str(exc)} async def _safe_json(client: httpx.AsyncClient, url: str) -> Any: try: response = await client.get(url, timeout=_TIMEOUT_JSON) response.raise_for_status() return response.json() except Exception as exc: return {"error": str(exc)} async def _proxy(method: str, url: str) -> Response: upstream = await _client().request(method, url, timeout=_TIMEOUT_JSON) return _to_response(upstream) async def _proxy_request(request: Request, url: str) -> Response: body = await request.body() headers = { key: value for key, value in request.headers.items() if key.lower() not in {"host", "content-length"} } timeout = _timeout_for_path(request.url.path) try: upstream = await _client().request( request.method, url, params=request.query_params, content=body, headers=headers, timeout=timeout, ) return _to_response(upstream) except httpx.ConnectError as exc: _log.error("Upstream no alcanzable %s: %s", url, exc) return Response( content=json.dumps( {"detail": "Servicio de conteo no disponible. Puede estar reiniciándose; intente de nuevo."} ), status_code=503, media_type="application/json", ) except httpx.TimeoutException as exc: _log.error("Timeout upstream %s: %s", url, exc) return Response( content=json.dumps({"detail": "Tiempo de espera agotado al contactar el servicio upstream."}), status_code=504, media_type="application/json", ) def _to_response(upstream: httpx.Response) -> Response: excluded = {"content-encoding", "transfer-encoding", "connection"} headers = { key: value for key, value in upstream.headers.items() if key.lower() not in excluded } return Response( content=upstream.content, status_code=upstream.status_code, headers=headers, media_type=upstream.headers.get("content-type"), )