230 lines
7.3 KiB
Python
230 lines
7.3 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
|
|
|
|
|
|
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"),
|
|
)
|