Files
IPSExtractor/services/api_gateway/api/main.py
T

236 lines
7.4 KiB
Python

"""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("/")
COUNT_URL = os.getenv("COUNT_URL", "http://count-api:8080").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 count=%s", EXTRACTION_URL, CLEANING_URL, COUNT_URL)
yield
await _http_client.aclose()
_http_client = None
app = FastAPI(title="IPSExtractor 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")
)
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)
counting = await _safe_health(client, COUNT_URL)
return {
"status": "ok" if extraction["ok"] and cleaning["ok"] else "degraded",
"services": {
"extraction": extraction,
"cleaning": cleaning,
"counting": counting,
},
}
@app.get("/models")
async def models() -> Response:
return await _proxy("GET", f"{EXTRACTION_URL}/models")
@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("/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("/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"),
)