"""Crea o actualiza (idempotente por nombre) un Team Environment de Hoppscotch. Define las variables de un workspace Hoppscotch self-hosted via GraphQL, resolviendo secretos desde `pass`: cualquier variable cuyo `value` empiece por ``pass:`` se resuelve con pass_get_secret y se marca como `secret=True`. Idempotencia por nombre: lista los environments de la team y, si ya existe uno con el `name` dado, lo actualiza; si no, lo crea. Las mutations estan protegidas por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie `access_token`. Los valores secretos NUNCA se logean ni aparecen en el output: `resolved_secrets` lista solo los KEYS resueltos desde pass, jamas sus valores. """ import json import requests from infra.pass_get_secret import pass_get_secret _LIST_QUERY = "query($t:ID!){ team(teamID:$t){ teamEnvironments{ id name } } }" _CREATE_MUTATION = ( "mutation($n:String!,$t:ID!,$v:String!){" " createTeamEnvironment(name:$n,teamID:$t,variables:$v){ id name } }" ) _UPDATE_MUTATION = ( "mutation($id:ID!,$n:String!,$v:String!){" " updateTeamEnvironment(id:$id,name:$n,variables:$v){ id name } }" ) _PASS_PREFIX = "pass:" def hoppscotch_set_environment( team_id: str, name: str, variables: list[dict], *, access_token: str, backend_url: str = "http://localhost:3170", ) -> dict: """Crea o actualiza un Team Environment de Hoppscotch (idempotente por nombre). Args: team_id: ID de la team duena del environment. name: nombre del environment. La idempotencia es por este nombre dentro de la team: si ya existe uno con este name se actualiza, si no se crea. variables: lista de dicts ``{"key": str, "value": str, "secret": bool}``. Si un `value` empieza por ``pass:`` el resto se resuelve como ruta de pass con pass_get_secret y el secreto resuelto se usa como value real, forzando `secret=True` en esa variable. Campos `secret` ausentes se tratan como False. access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie `access_token`, NO en el header Authorization. backend_url: base del backend Hoppscotch sin barra final. El endpoint GraphQL es ``{backend_url}/graphql``. Returns: Dict. En exito: ``{"status": "ok", "id": str, "name": str, "action": "created"|"updated", "resolved_secrets": list[str]}`` donde `resolved_secrets` son SOLO los keys resueltos desde pass (nunca valores). En error: ``{"status": "error", "error": str}`` (resolucion pass fallida, GraphQL errors, HTTP no 200, o fallo de transporte). Si una variable `pass:` no se puede resolver, NO se crea/actualiza el environment. """ resolved: list[dict] = [] resolved_secrets: list[str] = [] for var in variables: key = var.get("key") value = var.get("value", "") secret = bool(var.get("secret", False)) if isinstance(value, str) and value.startswith(_PASS_PREFIX): pass_path = value[len(_PASS_PREFIX):] secret_res = pass_get_secret(pass_path) if secret_res.get("status") != "ok": # NO crear el env a medias: aborta con el key afectado. return { "status": "error", "error": ( f"pass resolution failed for key {key!r} " f"(path {pass_path!r}): {secret_res.get('error')}" ), } value = secret_res["value"] secret = True resolved_secrets.append(key) resolved.append({"key": key, "value": value, "secret": secret}) variables_json = json.dumps(resolved) # 1) Localiza un environment existente con este nombre (idempotencia). try: list_resp = requests.post( f"{backend_url}/graphql", json={"query": _LIST_QUERY, "variables": {"t": team_id}}, cookies={"access_token": access_token}, timeout=30.0, ) except requests.RequestException as exc: return {"status": "error", "error": f"transport error: {exc}"} list_data = _parse_json(list_resp) if list_data is None: return { "status": "error", "error": f"non-JSON list response (HTTP {list_resp.status_code})", } if list_data.get("errors"): return {"status": "error", "error": "graphql errors", "data": list_data} team = (list_data.get("data") or {}).get("team") or {} existing_id = None for env in team.get("teamEnvironments") or []: if env.get("name") == name: existing_id = env.get("id") break # 2) Update si existe, create si no. if existing_id: query = _UPDATE_MUTATION gql_vars = {"id": existing_id, "n": name, "v": variables_json} result_field = "updateTeamEnvironment" action = "updated" else: query = _CREATE_MUTATION gql_vars = {"n": name, "t": team_id, "v": variables_json} result_field = "createTeamEnvironment" action = "created" try: resp = requests.post( f"{backend_url}/graphql", json={"query": query, "variables": gql_vars}, cookies={"access_token": access_token}, timeout=30.0, ) except requests.RequestException as exc: return {"status": "error", "error": f"transport error: {exc}"} data = _parse_json(resp) if data is None: return { "status": "error", "error": f"non-JSON response (HTTP {resp.status_code})", } if data.get("errors"): return {"status": "error", "error": "graphql errors", "data": data} env = (data.get("data") or {}).get(result_field) if not env or not env.get("id"): return { "status": "error", "error": f"{result_field} returned no id", "data": data, } return { "status": "ok", "id": env["id"], "name": env.get("name", name), "action": action, "resolved_secrets": resolved_secrets, } def _parse_json(resp): """Devuelve el JSON de la respuesta o None si no es JSON valido.""" try: return resp.json() except ValueError: return None