"""Cliente HTTP fino al service osint_db (DuckDB, 127.0.0.1:8771). Fuente de verdad de los contactos cuando el feature flag ``OSINT_DB_BACKEND`` está activo. El osint_db es quien escribe la DuckDB y empuja el cambio a Xandikos; esta app solo le habla por HTTP. Todas las respuestas del service son ``200 + {status: "ok"|"error", ...}`` (los errores de dominio viajan en el cuerpo, no en el código HTTP). Solo stdlib (urllib, json) para no añadir dependencias de runtime: el cliente es un wrapper de transporte, no reimplementa lógica del osint_db. Errores de red (timeout, conexión rechazada, host caído) se traducen a la excepción ``OsintDbUnavailable`` para que los endpoints degraden con un 503 claro, igual que el camino DAV, en vez de tumbar el server. Contrato (cuerpo JSON): POST /api/query {sql, params?, max_rows?} → {status, columns, rows} POST /api/contact {collection, fn, telefonos, emails, direcciones, ...} PUT /api/contact/{uid} (mismo cuerpo, sin uid en el body) DELETE /api/contact/{uid} POST /api/addressbook {slug, display_name, color?} """ from __future__ import annotations import json import urllib.error import urllib.parse import urllib.request from typing import Any, Optional # URL base del service. Se mantiene como módulo-global para poder monkeypatchearla # en tests sin tocar cada llamada. BASE_URL = "http://127.0.0.1:8771" # Timeout por petición. El osint_db es local (loopback): si tarda más que esto, # algo va mal y es mejor degradar que colgar el endpoint. _TIMEOUT_S = 20.0 class OsintDbUnavailable(Exception): """El service osint_db no responde (no arrancado, timeout, conexión caída). Los endpoints la capturan y devuelven un 503 JSON claro, en paralelo a ``DavUnavailable`` del camino DAV. """ def _request(method: str, path: str, body: Optional[dict] = None) -> dict: """Hace una petición HTTP al osint_db y devuelve el JSON de respuesta. Args: method: verbo HTTP (``GET``/``POST``/``PUT``/``DELETE``). path: ruta absoluta del endpoint (``/api/query``, ...). body: cuerpo JSON opcional (se serializa con ``ensure_ascii=False``). Returns: El cuerpo de respuesta ya deserializado a dict. Raises: OsintDbUnavailable: si el service no responde o la respuesta no es JSON. """ url = BASE_URL.rstrip("/") + path data = None headers = {"Accept": "application/json"} if body is not None: data = json.dumps(body, ensure_ascii=False).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") except urllib.error.HTTPError as exc: # El contrato dice 200 siempre; un HTTPError es anómalo. Intenta leer el # cuerpo (puede traer {status:error,...}); si no, degrada. try: return json.loads(exc.read().decode("utf-8")) except (ValueError, OSError): raise OsintDbUnavailable( "osint_db respondió HTTP %s en %s" % (exc.code, path) ) from exc except (urllib.error.URLError, OSError, TimeoutError) as exc: raise OsintDbUnavailable( "osint_db no responde en %s: %s" % (BASE_URL, exc) ) from exc try: return json.loads(raw) except ValueError as exc: raise OsintDbUnavailable( "osint_db devolvió una respuesta no-JSON en %s" % path ) from exc def query(sql: str, params: Optional[list] = None, max_rows: int = 2000) -> dict: """Ejecuta una SELECT contra la DuckDB del osint_db. Args: sql: la consulta SQL (de solo lectura; el service la valida). params: parámetros posicionales opcionales. max_rows: tope de filas devueltas. Returns: dict ``{status, columns, rows}`` tal cual lo devuelve el service. Raises: OsintDbUnavailable: si el service no responde. """ body: dict[str, Any] = {"sql": sql, "max_rows": max_rows} if params: body["params"] = params return _request("POST", "/api/query", body) def list_addressbooks() -> list: """Lista las libretas (addressbooks) del osint_db. Devuelve una lista de dicts ``{slug, display_name, collection_path, color}`` ordenados por ``display_name``. Si la consulta falla a nivel de dominio (``status != ok``) devuelve lista vacía, no lanza. Raises: OsintDbUnavailable: si el service no responde. """ res = query( "SELECT slug, display_name, collection_path, color " "FROM addressbooks ORDER BY display_name", max_rows=1000, ) if res.get("status") != "ok": return [] cols = res.get("columns") or [] rows = res.get("rows") or [] out: list = [] for row in rows: # El service puede devolver filas como lista posicional o como dict. if isinstance(row, dict): out.append(row) else: out.append({cols[i]: row[i] for i in range(min(len(cols), len(row)))}) return out def list_contacts() -> list: """Lista los contactos del osint_db, con los campos que consume el frontend. Devuelve filas ``{uid, collection, fn, tels, emails, note_path}``; ``tels`` y ``emails`` llegan como JSON array (string JSON o lista) y se parsean a lista de strings. Raises: OsintDbUnavailable: si el service no responde. """ res = query( "SELECT uid, collection, fn, tels, emails, note_path " "FROM contacts ORDER BY fn", max_rows=5000, ) if res.get("status") != "ok": return [] cols = res.get("columns") or [] rows = res.get("rows") or [] out: list = [] for row in rows: rec = row if isinstance(row, dict) else { cols[i]: row[i] for i in range(min(len(cols), len(row))) } out.append(rec) return out def _parse_json_array(value: Any) -> list: """Normaliza un valor que puede venir como lista o como string JSON a lista. El osint_db devuelve ``tels``/``emails`` como JSON array; según el driver, puede llegar ya como lista Python o como string JSON. Tolera ambos y los valores nulos/vacíos. """ if value is None or value == "": return [] if isinstance(value, list): return [str(v) for v in value if v not in (None, "")] if isinstance(value, str): try: parsed = json.loads(value) except ValueError: return [value] if isinstance(parsed, list): return [str(v) for v in parsed if v not in (None, "")] return [str(parsed)] return [str(value)] def create_contact(payload: dict) -> dict: """Crea un contacto en el osint_db (POST /api/contact). Args: payload: cuerpo JSON del contacto (``collection, fn, telefonos, emails, direcciones, nombre?, aliases?, dni?, pais?, contexto?, notas?``). Returns: El cuerpo de respuesta del service (``{status, uid, ...}``). Raises: OsintDbUnavailable: si el service no responde. """ return _request("POST", "/api/contact", payload) def update_contact(uid: str, payload: dict) -> dict: """Edita un contacto del osint_db (PUT /api/contact/{uid}). Raises: OsintDbUnavailable: si el service no responde. """ return _request("PUT", "/api/contact/%s" % urllib.parse.quote(uid), payload) def delete_contact(uid: str) -> dict: """Borra un contacto del osint_db (DELETE /api/contact/{uid}). Raises: OsintDbUnavailable: si el service no responde. """ return _request("DELETE", "/api/contact/%s" % urllib.parse.quote(uid)) def create_addressbook(slug: str, name: str, color: Optional[str] = None) -> dict: """Crea una libreta (addressbook) en el osint_db (POST /api/addressbook). El osint_db crea la colección CardDAV en Xandikos y la registra en la DuckDB. Returns: El cuerpo de respuesta del service (``{status, slug, ...}``). Raises: OsintDbUnavailable: si el service no responde. """ body: dict[str, Any] = {"slug": slug, "display_name": name} if color: body["color"] = color return _request("POST", "/api/addressbook", body)