763e06c127
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
120 lines
5.0 KiB
Python
120 lines
5.0 KiB
Python
"""Cliente HTTP minimo compartido para el service osint_db (FastAPI + DuckDB).
|
|
|
|
NO es una funcion del registry — es un helper privado (modulo prefijado con `_`)
|
|
que comparten las funciones `browser_profile_*`. Por eso no tiene `.md` con
|
|
frontmatter ni se indexa. Mantiene KISS: solo dos helpers sobre `urllib.request`
|
|
de la stdlib (sin `requests`).
|
|
|
|
Contrato del service (FIJO): SIEMPRE responde HTTP 200 con un body JSON
|
|
`{"status":"ok"|"error", ...}`. El codigo HTTP NO indica exito — se parsea el body.
|
|
Estos helpers nunca lanzan por logica de negocio; convierten cualquier fallo de red
|
|
o de parseo en un dict `{"status":"error","error":...}` para que las funciones que
|
|
los usan respeten el contrato "no lanzar, devolver dict de estado".
|
|
"""
|
|
|
|
import json
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
# Timeout por defecto de cada request HTTP al service (segundos).
|
|
_TIMEOUT_S = 10
|
|
|
|
|
|
def _request(base_url: str, path: str, method: str, payload: dict | None = None) -> dict:
|
|
"""Hace una request JSON al service osint_db y devuelve el body parseado.
|
|
|
|
Args:
|
|
base_url: base del service (ej. http://127.0.0.1:8771). Se le quita el "/" final.
|
|
path: ruta del endpoint (ej. /api/browser-profile). Debe empezar por "/".
|
|
method: verbo HTTP (POST, DELETE, GET).
|
|
payload: dict a serializar como JSON en el body (None para no enviar body).
|
|
|
|
Returns:
|
|
El body JSON del service como dict. Si el service esta caido, la respuesta no
|
|
es JSON, o ocurre cualquier error de transporte, devuelve
|
|
{"status":"error","error": <motivo>} para no romper al llamante.
|
|
"""
|
|
url = base_url.rstrip("/") + path
|
|
data = None
|
|
headers = {}
|
|
if payload is not None:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp:
|
|
raw = resp.read().decode("utf-8")
|
|
parsed = json.loads(raw) if raw else {}
|
|
if not isinstance(parsed, dict):
|
|
return {"status": "error", "error": f"respuesta no-dict del service: {raw[:200]}"}
|
|
return parsed
|
|
except urllib.error.HTTPError as e:
|
|
# El contrato dice HTTP 200 siempre; un HTTPError es anomalia del transporte.
|
|
try:
|
|
body = e.read().decode("utf-8")
|
|
parsed = json.loads(body) if body else {}
|
|
if isinstance(parsed, dict):
|
|
return parsed
|
|
except Exception: # noqa: BLE001 - el cuerpo del error puede no ser JSON
|
|
pass
|
|
return {"status": "error", "error": f"HTTP {e.code} desde {url}: {e.reason}"}
|
|
except urllib.error.URLError as e:
|
|
return {"status": "error", "error": f"service osint_db inaccesible en {url}: {e.reason}"}
|
|
except (ValueError, UnicodeDecodeError) as e:
|
|
return {"status": "error", "error": f"respuesta no parseable de {url}: {e}"}
|
|
except Exception as e: # noqa: BLE001 - contrato: nunca lanzar
|
|
return {"status": "error", "error": f"{type(e).__name__}: {e}"}
|
|
|
|
|
|
def post_json(base_url: str, path: str, payload: dict) -> dict:
|
|
"""POST JSON al service. Devuelve el body parseado (o dict de error)."""
|
|
return _request(base_url, path, "POST", payload)
|
|
|
|
|
|
def delete(base_url: str, path: str) -> dict:
|
|
"""DELETE al service. Devuelve el body parseado (o dict de error)."""
|
|
return _request(base_url, path, "DELETE", None)
|
|
|
|
|
|
def query(base_url: str, sql: str, params: list | None = None, max_rows: int | None = None) -> dict:
|
|
"""POST /api/query (read-only). Devuelve {status, columns, rows, row_count} del service.
|
|
|
|
Args:
|
|
base_url: base del service.
|
|
sql: SELECT a ejecutar (read-only en el service).
|
|
params: lista de parametros posicionales para el SQL (None -> []).
|
|
max_rows: tope opcional de filas devueltas.
|
|
|
|
Returns:
|
|
El body JSON del service. En caso ok trae columns/rows/row_count; en error
|
|
trae {"status":"error","error":...}.
|
|
"""
|
|
body: dict = {"sql": sql}
|
|
if params is not None:
|
|
body["params"] = params
|
|
if max_rows is not None:
|
|
body["max_rows"] = max_rows
|
|
return _request(base_url, "/api/query", "POST", body)
|
|
|
|
|
|
def rows_to_dicts(resp: dict) -> list:
|
|
"""Normaliza las filas de una respuesta de /api/query a lista de dicts.
|
|
|
|
El service osint_db devuelve ``rows`` YA como lista de dicts (claves =
|
|
nombres de columna), así que el caso normal es un passthrough. Por robustez,
|
|
si alguna fila viniera como lista/tupla posicional se mapea con ``columns``.
|
|
Si la respuesta no es un read ok (sin ``rows``), devuelve [].
|
|
"""
|
|
rows = resp.get("rows")
|
|
if not isinstance(rows, list):
|
|
return []
|
|
columns = resp.get("columns")
|
|
out: list = []
|
|
for row in rows:
|
|
if isinstance(row, dict):
|
|
out.append(row)
|
|
elif isinstance(row, (list, tuple)) and isinstance(columns, list):
|
|
out.append(dict(zip(columns, row)))
|
|
return out
|