"""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": } 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