feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,28 @@
|
||||
from .setup_logger import setup_logger, get_logger
|
||||
from .generate_app_icon import generate_app_icon
|
||||
from .generate_initials_avatar import generate_initials_avatar
|
||||
from .http_replay_sequence import http_replay_sequence
|
||||
from .hoppscotch_login import hoppscotch_login
|
||||
from .hoppscotch_create_request import hoppscotch_create_request
|
||||
from .hoppscotch_update_request import hoppscotch_update_request
|
||||
from .hoppscotch_delete_request import hoppscotch_delete_request
|
||||
from .hoppscotch_list_requests import hoppscotch_list_requests
|
||||
from .pass_get_secret import pass_get_secret
|
||||
from .hoppscotch_set_environment import hoppscotch_set_environment
|
||||
from .hoppscotch_run_request import hoppscotch_run_request
|
||||
|
||||
__all__ = [
|
||||
"setup_logger",
|
||||
"get_logger",
|
||||
"generate_app_icon",
|
||||
"generate_initials_avatar",
|
||||
"http_replay_sequence",
|
||||
"hoppscotch_login",
|
||||
"hoppscotch_create_request",
|
||||
"hoppscotch_update_request",
|
||||
"hoppscotch_delete_request",
|
||||
"hoppscotch_list_requests",
|
||||
"pass_get_secret",
|
||||
"hoppscotch_set_environment",
|
||||
"hoppscotch_run_request",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: build_hoppscotch_collection
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def build_hoppscotch_collection(calls: list[dict], *, name: str = \"Collection\", request_names: list[str] | None = None) -> dict"
|
||||
description: "Helper interno de serializacion del grupo hoppscotch: convierte call specs (method/url/headers/body/body_type) en el formato HoppRESTRequest/coleccion Hoppscotch (request v:2). Lo usan hoppscotch_create_request y hoppscotch_update_request para construir el campo request de las mutations GraphQL del self-host. NO uses el dict resultante para escribir un .json e importarlo a mano: el flujo canonico es operar el self-host por la API (ver docs/capabilities/hoppscotch.md). Pura: solo stdlib, sin red."
|
||||
tags: [hoppscotch, flow-replay, http, infra, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: calls
|
||||
desc: "lista de call specs (tipicamente la salida de har_extract_calls). Cada elemento es un dict con claves opcionales: method (str), url (str), headers (dict name->value), cookies (dict name->value), body (str|None), body_type (json|form|raw|None). Otras claves como status o sets_cookies se ignoran."
|
||||
- name: name
|
||||
desc: "nombre de la coleccion Hoppscotch resultante. Default 'Collection'."
|
||||
- name: request_names
|
||||
desc: "nombres explicitos por request, alineados por indice con calls. Si se pasa y existe el indice, sobreescribe el nombre derivado. None = derivar todos como '<METHOD> <path>'."
|
||||
output: "dict de coleccion Hoppscotch JSON-serializable: {\"v\": 1, \"name\", \"folders\": [], \"requests\": [...]}. Cada request lleva v:'2', endpoint, name, method (upper), headers (lista key/value/active con header Cookie inyectado si habia cookies), body (contentType+body segun body_type), auth none, params/requestVariables vacios."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_get_simple"
|
||||
- "test_edge_post_json_con_headers_y_cookies"
|
||||
- "test_request_names_sobreescribe_nombre_derivado"
|
||||
- "test_form_body_genera_contenttype_urlencoded"
|
||||
- "test_raw_body_genera_contenttype_text_plain"
|
||||
- "test_body_type_desconocido_da_body_null"
|
||||
- "test_lista_vacia"
|
||||
- "test_call_spec_sin_url_ni_method"
|
||||
- "test_sin_cookies_no_anade_header_cookie"
|
||||
test_file_path: "python/functions/infra/build_hoppscotch_collection_test.py"
|
||||
file_path: "python/functions/infra/build_hoppscotch_collection.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import json
|
||||
from build_hoppscotch_collection import build_hoppscotch_collection
|
||||
|
||||
# Call specs tal cual salen de har_extract_calls.
|
||||
calls = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/api/search?q=foo",
|
||||
"headers": {"Accept": "application/json"},
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/login",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"cookies": {"session": "abc", "csrf": "xyz"},
|
||||
"body": '{"user":"neo","pass":"<<password>>"}',
|
||||
"body_type": "json",
|
||||
},
|
||||
]
|
||||
|
||||
collection = build_hoppscotch_collection(calls, name="Example flow")
|
||||
# {
|
||||
# "v": 1,
|
||||
# "name": "Example flow",
|
||||
# "folders": [],
|
||||
# "requests": [
|
||||
# {
|
||||
# "v": "2",
|
||||
# "endpoint": "https://api.example.com/api/search?q=foo",
|
||||
# "name": "GET /api/search",
|
||||
# "params": [],
|
||||
# "headers": [{"key": "Accept", "value": "application/json", "active": True}],
|
||||
# "method": "GET",
|
||||
# "auth": {"authType": "none", "authActive": True},
|
||||
# "preRequestScript": "",
|
||||
# "testScript": "",
|
||||
# "body": {"contentType": None, "body": None},
|
||||
# "requestVariables": [],
|
||||
# },
|
||||
# {
|
||||
# "v": "2",
|
||||
# "endpoint": "https://api.example.com/login",
|
||||
# "name": "POST /login",
|
||||
# "params": [],
|
||||
# "headers": [
|
||||
# {"key": "Content-Type", "value": "application/json", "active": True},
|
||||
# {"key": "Cookie", "value": "session=abc; csrf=xyz", "active": True},
|
||||
# ],
|
||||
# "method": "POST",
|
||||
# "auth": {"authType": "none", "authActive": True},
|
||||
# "preRequestScript": "",
|
||||
# "testScript": "",
|
||||
# "body": {"contentType": "application/json", "body": '{"user":"neo","pass":"<<password>>"}'},
|
||||
# "requestVariables": [],
|
||||
# },
|
||||
# ],
|
||||
# }
|
||||
|
||||
# Listo para escribir a disco e importar en la app Desktop / hopp CLI.
|
||||
with open("flow.collection.json", "w") as f:
|
||||
json.dump(collection, f, indent=2)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando quieras abrir en la GUI de Hoppscotch unas peticiones que ya grabaste y destilaste con el patron grabar->destilar->reproducir (HAR -> har_filter_flows -> har_extract_calls). Pasas las call specs por esta funcion, guardas el dict resultante como `.json` y lo importas en la app Desktop o con el CLI `hopp`. Es la salida amigable para humanos del flujo de replay: cuando prefieras inspeccionar/tocar las peticiones a mano en el GUI antes de promoverlas a una funcion-accion del registry con http_replay_sequence. La funcion inversa, parse_hoppscotch_collection, reimporta una coleccion editada en el GUI de vuelta a call specs.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Genera el formato canonico estable v1/v2.** La coleccion sale como `v:1` y cada request como `v:"2"` — la forma de los fixtures oficiales de Hoppscotch, garantizada importable. Hoppscotch la migra automaticamente a su ultima version interna al importar; no intentes emitir la version "ultima" a mano.
|
||||
- **Las cookies se inyectan como header Cookie.** Si un call spec trae `cookies` no vacio, se anade un unico header `Cookie` al final con formato `k1=v1; k2=v2`. Hoppscotch no tiene un slot de cookies separado en el request, asi que viajan en headers; al reimportar con parse_hoppscotch_collection se vuelven a separar.
|
||||
- **Los secretos NO se sustituyen.** La funcion copia headers, cookies y body tal cual. Tokens de sesion, `Authorization` y contrasenas viajan en claro en el dict resultante. Si quieres parametrizar, es el caller quien debe marcar los valores con `<<var>>` (referencia a variable de environment de Hoppscotch) antes de llamar a esta funcion. NO commitear el `.json` resultante sin redactar.
|
||||
- **Claves extra del call spec se ignoran.** `status`, `sets_cookies` y cualquier otra clave que no sea method/url/headers/cookies/body/body_type no aparecen en la coleccion (son metadata de la captura, no del request a reproducir).
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Convierte call specs del registry en una coleccion Hoppscotch importable.
|
||||
|
||||
Mitad "exportar al GUI" del puente entre el motor de replay del registry y
|
||||
Hoppscotch. La funcion inversa es parse_hoppscotch_collection.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _request_name(call: dict, fallback_index: int) -> str:
|
||||
"""Deriva un nombre legible para la request a partir del metodo y el path.
|
||||
|
||||
Args:
|
||||
call: call spec con (opcional) method y url.
|
||||
fallback_index: indice de la call dentro de la lista (no usado en el
|
||||
nombre derivado, reservado para desambiguar si hiciera falta).
|
||||
|
||||
Returns:
|
||||
nombre del estilo "GET /api/search".
|
||||
"""
|
||||
method = str(call.get("method") or "GET").upper()
|
||||
url = str(call.get("url") or "")
|
||||
path = urlparse(url).path or "/"
|
||||
return f"{method} {path}"
|
||||
|
||||
|
||||
def _build_headers(call: dict) -> list[dict]:
|
||||
"""Construye la lista de headers Hoppscotch desde el dict del call spec.
|
||||
|
||||
Convierte el dict headers (preservando orden de insercion) a la lista
|
||||
[{"key", "value", "active": True}, ...] y, si el call spec trae cookies no
|
||||
vacias, anade un header extra "Cookie" al final con formato "k1=v1; k2=v2".
|
||||
|
||||
Args:
|
||||
call: call spec con (opcional) headers y cookies.
|
||||
|
||||
Returns:
|
||||
lista de headers Hoppscotch.
|
||||
"""
|
||||
headers: list[dict] = []
|
||||
raw_headers = call.get("headers") or {}
|
||||
for key, value in raw_headers.items():
|
||||
headers.append({"key": key, "value": value, "active": True})
|
||||
|
||||
cookies = call.get("cookies") or {}
|
||||
if cookies:
|
||||
cookie_value = "; ".join(f"{name}={val}" for name, val in cookies.items())
|
||||
headers.append({"key": "Cookie", "value": cookie_value, "active": True})
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
def _build_body(call: dict) -> dict:
|
||||
"""Construye el objeto body Hoppscotch segun body_type del call spec.
|
||||
|
||||
Args:
|
||||
call: call spec con (opcional) body y body_type.
|
||||
|
||||
Returns:
|
||||
dict con contentType y body. Si no hay body o el body_type es
|
||||
desconocido/None, ambos campos son None.
|
||||
"""
|
||||
body = call.get("body")
|
||||
body_type = call.get("body_type")
|
||||
|
||||
content_types = {
|
||||
"json": "application/json",
|
||||
"form": "application/x-www-form-urlencoded",
|
||||
"raw": "text/plain",
|
||||
}
|
||||
|
||||
if body is None or body_type not in content_types:
|
||||
return {"contentType": None, "body": None}
|
||||
|
||||
return {"contentType": content_types[body_type], "body": body}
|
||||
|
||||
|
||||
def build_hoppscotch_collection(
|
||||
calls: list[dict],
|
||||
*,
|
||||
name: str = "Collection",
|
||||
request_names: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Convierte una lista de call specs en una coleccion Hoppscotch importable.
|
||||
|
||||
Genera el formato canonico estable (coleccion v:1, request v:"2") que
|
||||
Hoppscotch migra a la ultima version al importar. Pura: sin I/O ni red,
|
||||
solo stdlib, determinista.
|
||||
|
||||
Args:
|
||||
calls: lista de call specs (salida de har_extract_calls). Cada elemento
|
||||
es un dict con claves opcionales: method, url, headers, cookies,
|
||||
body, body_type. Otras claves (status, sets_cookies, ...) se ignoran.
|
||||
name: nombre de la coleccion Hoppscotch.
|
||||
request_names: nombres explicitos por request, alineados por indice. Si
|
||||
se pasa y existe el indice, sobreescribe el nombre derivado. None =
|
||||
derivar todos los nombres como "<METHOD> <path>".
|
||||
|
||||
Returns:
|
||||
dict con la coleccion Hoppscotch: {"v": 1, "name", "folders": [],
|
||||
"requests": [...]}. JSON-serializable.
|
||||
"""
|
||||
requests: list[dict] = []
|
||||
|
||||
for index, call in enumerate(calls):
|
||||
if request_names is not None and index < len(request_names):
|
||||
req_name = request_names[index]
|
||||
else:
|
||||
req_name = _request_name(call, index)
|
||||
|
||||
endpoint = str(call.get("url") or "")
|
||||
method = str(call.get("method") or "GET").upper()
|
||||
|
||||
requests.append(
|
||||
{
|
||||
"v": "2",
|
||||
"endpoint": endpoint,
|
||||
"name": req_name,
|
||||
"params": [],
|
||||
"headers": _build_headers(call),
|
||||
"method": method,
|
||||
"auth": {"authType": "none", "authActive": True},
|
||||
"preRequestScript": "",
|
||||
"testScript": "",
|
||||
"body": _build_body(call),
|
||||
"requestVariables": [],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"v": 1,
|
||||
"name": name,
|
||||
"folders": [],
|
||||
"requests": requests,
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests para build_hoppscotch_collection."""
|
||||
|
||||
import json
|
||||
|
||||
from build_hoppscotch_collection import build_hoppscotch_collection
|
||||
|
||||
|
||||
def test_golden_get_simple():
|
||||
calls = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/api/search?q=foo",
|
||||
"headers": {"Accept": "application/json"},
|
||||
}
|
||||
]
|
||||
|
||||
result = build_hoppscotch_collection(calls, name="MyCol")
|
||||
|
||||
assert result["v"] == 1
|
||||
assert result["name"] == "MyCol"
|
||||
assert result["folders"] == []
|
||||
assert len(result["requests"]) == 1
|
||||
|
||||
req = result["requests"][0]
|
||||
assert req["v"] == "2"
|
||||
assert req["endpoint"] == "https://api.example.com/api/search?q=foo"
|
||||
assert req["name"] == "GET /api/search"
|
||||
assert req["method"] == "GET"
|
||||
assert req["params"] == []
|
||||
assert req["headers"] == [
|
||||
{"key": "Accept", "value": "application/json", "active": True}
|
||||
]
|
||||
assert req["auth"] == {"authType": "none", "authActive": True}
|
||||
assert req["preRequestScript"] == ""
|
||||
assert req["testScript"] == ""
|
||||
assert req["requestVariables"] == []
|
||||
assert req["body"] == {"contentType": None, "body": None}
|
||||
|
||||
# JSON-serializable
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
def test_edge_post_json_con_headers_y_cookies():
|
||||
calls = [
|
||||
{
|
||||
"method": "post",
|
||||
"url": "https://api.example.com/login",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"X-Csrf": "tok",
|
||||
},
|
||||
"cookies": {"session": "abc", "csrf": "xyz"},
|
||||
"body": '{"user":"neo"}',
|
||||
"body_type": "json",
|
||||
}
|
||||
]
|
||||
|
||||
result = build_hoppscotch_collection(calls)
|
||||
|
||||
req = result["requests"][0]
|
||||
assert req["method"] == "POST"
|
||||
assert req["name"] == "POST /login"
|
||||
|
||||
# Header Cookie generado al final con formato "; " join
|
||||
assert req["headers"] == [
|
||||
{"key": "Content-Type", "value": "application/json", "active": True},
|
||||
{"key": "X-Csrf", "value": "tok", "active": True},
|
||||
{"key": "Cookie", "value": "session=abc; csrf=xyz", "active": True},
|
||||
]
|
||||
|
||||
assert req["body"] == {
|
||||
"contentType": "application/json",
|
||||
"body": '{"user":"neo"}',
|
||||
}
|
||||
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
def test_request_names_sobreescribe_nombre_derivado():
|
||||
calls = [
|
||||
{"method": "GET", "url": "https://api.example.com/a"},
|
||||
{"method": "GET", "url": "https://api.example.com/b"},
|
||||
]
|
||||
|
||||
result = build_hoppscotch_collection(
|
||||
calls, request_names=["Custom A"]
|
||||
)
|
||||
|
||||
# Indice 0 usa el nombre explicito; indice 1 cae al derivado.
|
||||
assert result["requests"][0]["name"] == "Custom A"
|
||||
assert result["requests"][1]["name"] == "GET /b"
|
||||
|
||||
|
||||
def test_form_body_genera_contenttype_urlencoded():
|
||||
calls = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/form",
|
||||
"body": "a=1&b=2",
|
||||
"body_type": "form",
|
||||
}
|
||||
]
|
||||
|
||||
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||
assert req["body"] == {
|
||||
"contentType": "application/x-www-form-urlencoded",
|
||||
"body": "a=1&b=2",
|
||||
}
|
||||
|
||||
|
||||
def test_raw_body_genera_contenttype_text_plain():
|
||||
calls = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/raw",
|
||||
"body": "hello",
|
||||
"body_type": "raw",
|
||||
}
|
||||
]
|
||||
|
||||
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||
assert req["body"] == {"contentType": "text/plain", "body": "hello"}
|
||||
|
||||
|
||||
def test_body_type_desconocido_da_body_null():
|
||||
calls = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/x",
|
||||
"body": "ignored",
|
||||
"body_type": "binary",
|
||||
}
|
||||
]
|
||||
|
||||
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||
assert req["body"] == {"contentType": None, "body": None}
|
||||
|
||||
|
||||
def test_lista_vacia():
|
||||
result = build_hoppscotch_collection([], name="Empty")
|
||||
assert result == {
|
||||
"v": 1,
|
||||
"name": "Empty",
|
||||
"folders": [],
|
||||
"requests": [],
|
||||
}
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
def test_call_spec_sin_url_ni_method():
|
||||
calls = [{}]
|
||||
|
||||
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||
assert req["endpoint"] == ""
|
||||
assert req["method"] == "GET"
|
||||
assert req["name"] == "GET /"
|
||||
assert req["headers"] == []
|
||||
assert req["body"] == {"contentType": None, "body": None}
|
||||
|
||||
|
||||
def test_sin_cookies_no_anade_header_cookie():
|
||||
calls = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/x",
|
||||
"headers": {"Accept": "*/*"},
|
||||
"cookies": {},
|
||||
}
|
||||
]
|
||||
|
||||
req = build_hoppscotch_collection(calls)["requests"][0]
|
||||
assert all(h["key"] != "Cookie" for h in req["headers"])
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: generate_initials_avatar
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def generate_initials_avatar(text: str, out_path: str, bg_hex: str = \"\", size: int = 256, fg_hex: str = \"#FFFFFF\") -> str"
|
||||
description: "Genera un avatar circular de iniciales (foto de perfil) como PNG: circulo de color con 1-2 iniciales blancas centradas. Color de fondo derivado de forma determinista del texto si no se especifica."
|
||||
tags: [avatar, icon, rofi, pillow, profile, initials]
|
||||
params:
|
||||
- name: text
|
||||
desc: "Nombre del que derivar las iniciales (ej. 'John Doe', 'osint_01'). Se trocea por espacios, guiones y guiones bajos."
|
||||
- name: out_path
|
||||
desc: "Ruta de salida del PNG. Se crea el directorio padre si no existe. Rutas relativas se resuelven contra el cwd."
|
||||
- name: bg_hex
|
||||
desc: "Color de fondo del circulo en formato '#RRGGBB'. Si va vacio ('') se deriva de forma determinista de text via md5 sobre una paleta de 12 colores."
|
||||
- name: size
|
||||
desc: "Lado del PNG cuadrado en pixels. Default 256. El circulo deja ~4% de margen; fuera queda transparente."
|
||||
- name: fg_hex
|
||||
desc: "Color del texto de las iniciales en '#RRGGBB'. Default blanco '#FFFFFF'."
|
||||
output: "La misma out_path recibida. Efecto: escribe un PNG RGBA cuadrado con el avatar circular en disco."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [hashlib, os, pathlib, PIL]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/generate_initials_avatar.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra.generate_initials_avatar import generate_initials_avatar
|
||||
|
||||
# Color de fondo determinista derivado del nombre.
|
||||
generate_initials_avatar("Aurgi", "/tmp/aurgi.png") # -> circulo con "A"
|
||||
generate_initials_avatar("John Doe", "/tmp/john.png") # -> circulo con "JD"
|
||||
|
||||
# Color de fondo explicito + tamano custom.
|
||||
generate_initials_avatar("Personal", "/tmp/personal.png", bg_hex="#7c3aed", size=128)
|
||||
```
|
||||
|
||||
Desde el dispatcher (genera con defaults, fondo derivado del texto):
|
||||
|
||||
```bash
|
||||
./fn run generate_initials_avatar_py_infra "Aurgi" /tmp/aurgi.png
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un icono reconocible de un perfil (navegador, usuario, cuenta)
|
||||
y no tengas una foto real: genera un avatar de iniciales determinista por nombre.
|
||||
Util para entradas de rofi, launchers, listas de perfiles o cualquier UI que
|
||||
muestre un identificador visual estable. Mismo `text` -> mismo color siempre.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe un PNG a disco. Crea el directorio padre si falta y lanza
|
||||
`OSError` con mensaje claro si la escritura falla.
|
||||
- **Fondo transparente**: solo el circulo (con ~4% de margen) lleva color; las
|
||||
esquinas del PNG quedan con alpha 0. Si lo pegas sobre un fondo claro, el
|
||||
circulo se ve recortado correctamente, pero un visor que ignore el alpha
|
||||
mostrara las esquinas negras.
|
||||
- **Dependencia Pillow**: requiere `PIL` (Pillow) instalado en el venv del
|
||||
registry (`python/.venv`). No usa cairosvg.
|
||||
- **Fuente DejaVu hardcodeada**: usa `/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf`.
|
||||
Si no existe (otro SO/distro), cae a `ImageFont.load_default()`, que es mas
|
||||
pequena y pixelada — las iniciales se veran peor pero no falla.
|
||||
- **Antialiasing 4x**: renderiza a `size*4` y reduce con LANCZOS. Para `size`
|
||||
muy grande (>1024) el coste de memoria/tiempo crece cuadraticamente.
|
||||
|
||||
## Notas
|
||||
|
||||
Reglas de iniciales: trocea por espacios, `-` y `_`; toma la primera letra
|
||||
alfabetica de los dos primeros tokens que empiecen por letra (max 2, en
|
||||
mayusculas). Si solo un token tiene letra inicial -> 1 inicial. Si ninguno
|
||||
empieza por letra -> primer caracter alfanumerico del texto. Ejemplos:
|
||||
"Aurgi" -> "A", "Work" -> "W", "osint_01" -> "O", "John Doe" -> "JD",
|
||||
"Personal" -> "P".
|
||||
|
||||
Paleta determinista (12 colores tipo Tailwind 500): sky, emerald, violet,
|
||||
amber, rose, indigo, teal, orange, fuchsia, lime, cyan, red. El indice se
|
||||
elige con `int(md5(text), 16) % 12`, estable entre procesos.
|
||||
|
||||
Las funciones auxiliares `derive_initials(text)` y `derive_bg_color(text)` son
|
||||
publicas y reutilizables por separado si solo necesitas la logica de iniciales
|
||||
o de color sin generar el PNG.
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Genera un avatar circular de iniciales tipo foto de perfil.
|
||||
|
||||
Dibuja un circulo relleno de color con 1-2 iniciales blancas centradas sobre
|
||||
fondo transparente y lo exporta como PNG cuadrado. El color de fondo se puede
|
||||
fijar explicitamente o derivar de forma DETERMINISTA del texto (mismo texto ->
|
||||
mismo color siempre), lo que produce avatares reconocibles y distintos por
|
||||
nombre sin necesidad de una imagen real.
|
||||
|
||||
Funciones publicas reutilizables:
|
||||
derive_initials — extrae 1-2 iniciales en mayusculas de un nombre
|
||||
derive_bg_color — mapea un texto a un color de paleta de forma estable
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Fuente bold preinstalada en este Linux. Si no existe, se cae al default de PIL.
|
||||
DEFAULT_FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||
|
||||
# Paleta agradable tipo Tailwind 500-600 (12 colores). El indice se elige de
|
||||
# forma determinista a partir del hash del texto -> mismo texto, mismo color.
|
||||
PALETTE = [
|
||||
"#0ea5e9", # sky-500
|
||||
"#10b981", # emerald-500
|
||||
"#8b5cf6", # violet-500
|
||||
"#f59e0b", # amber-500
|
||||
"#f43f5e", # rose-500
|
||||
"#6366f1", # indigo-500
|
||||
"#14b8a6", # teal-500
|
||||
"#f97316", # orange-500
|
||||
"#d946ef", # fuchsia-500
|
||||
"#84cc16", # lime-500
|
||||
"#06b6d4", # cyan-500
|
||||
"#ef4444", # red-500
|
||||
]
|
||||
|
||||
# Factor de supersampling para antialiasing: se renderiza a NxN veces el tamano
|
||||
# final y se reduce con LANCZOS para obtener bordes suaves.
|
||||
_SUPERSAMPLE = 4
|
||||
|
||||
# Margen del circulo respecto al canvas (~4% por lado).
|
||||
_MARGIN_RATIO = 0.04
|
||||
|
||||
# Tamano de fuente como fraccion del lado del canvas.
|
||||
_FONT_RATIO = 0.46
|
||||
|
||||
|
||||
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
|
||||
"""Convierte un color "#RRGGBB" (o "RRGGBB") a una tupla RGB."""
|
||||
h = h.lstrip("#")
|
||||
if len(h) != 6:
|
||||
raise ValueError(f"color hex invalido, se espera #RRGGBB: {h!r}")
|
||||
return tuple(int(h[i: i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def derive_initials(text: str) -> str:
|
||||
"""Extrae 1-2 iniciales en mayusculas a partir de un nombre.
|
||||
|
||||
Trocea el texto por espacios, guiones y guiones bajos. Toma la primera
|
||||
letra alfabetica de los dos primeros tokens que empiecen por letra. Si solo
|
||||
un token tiene letra inicial, devuelve 1 inicial. Si ninguno empieza por
|
||||
letra, usa el primer caracter alfanumerico del texto completo.
|
||||
|
||||
Args:
|
||||
text: Nombre del que derivar las iniciales (ej. "John Doe", "osint_01").
|
||||
|
||||
Returns:
|
||||
1 o 2 caracteres en mayusculas. Cadena vacia si no hay nada alfanumerico.
|
||||
"""
|
||||
# Normaliza separadores a espacios.
|
||||
normalized = text.replace("-", " ").replace("_", " ")
|
||||
tokens = [t for t in normalized.split() if t]
|
||||
|
||||
initials = []
|
||||
for token in tokens:
|
||||
# Primera letra alfabetica del token (el token debe empezar por letra).
|
||||
if token[0].isalpha():
|
||||
initials.append(token[0].upper())
|
||||
if len(initials) == 2:
|
||||
break
|
||||
|
||||
if initials:
|
||||
return "".join(initials)
|
||||
|
||||
# Fallback: primer caracter alfanumerico del texto entero.
|
||||
for ch in text:
|
||||
if ch.isalnum():
|
||||
return ch.upper()
|
||||
return ""
|
||||
|
||||
|
||||
def derive_bg_color(text: str) -> str:
|
||||
"""Mapea un texto a un color de la paleta de forma estable y determinista.
|
||||
|
||||
Usa md5 del texto para indexar la paleta, de modo que el mismo texto
|
||||
produce siempre el mismo color entre ejecuciones y procesos.
|
||||
|
||||
Args:
|
||||
text: Texto del que derivar el color.
|
||||
|
||||
Returns:
|
||||
Color en formato "#RRGGBB" de la paleta interna.
|
||||
"""
|
||||
digest = hashlib.md5(text.encode("utf-8")).hexdigest()
|
||||
idx = int(digest, 16) % len(PALETTE)
|
||||
return PALETTE[idx]
|
||||
|
||||
|
||||
def _load_font(font_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
"""Carga la fuente DejaVu Bold al tamano dado, con fallback al default."""
|
||||
try:
|
||||
return ImageFont.truetype(DEFAULT_FONT_PATH, font_size)
|
||||
except OSError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def generate_initials_avatar(
|
||||
text: str,
|
||||
out_path: str,
|
||||
bg_hex: str = "",
|
||||
size: int = 256,
|
||||
fg_hex: str = "#FFFFFF",
|
||||
) -> str:
|
||||
"""Genera un avatar circular de iniciales y lo guarda como PNG.
|
||||
|
||||
Dibuja un circulo relleno con `bg_hex` (o un color derivado de `text` si va
|
||||
vacio) y centra 1-2 iniciales en `fg_hex` sobre el. El fondo fuera del
|
||||
circulo queda transparente. Renderiza a 4x y reduce con LANCZOS para bordes
|
||||
suaves.
|
||||
|
||||
Args:
|
||||
text: Nombre del que derivar las iniciales (ej. "Aurgi", "John Doe").
|
||||
out_path: Ruta de salida del PNG. El directorio padre se crea si falta.
|
||||
bg_hex: Color de fondo del circulo en "#RRGGBB". Si va vacio (""), se
|
||||
deriva de forma determinista de `text`.
|
||||
size: Lado del PNG cuadrado en pixels (default 256).
|
||||
fg_hex: Color del texto en "#RRGGBB" (default blanco "#FFFFFF").
|
||||
|
||||
Returns:
|
||||
La misma `out_path` recibida.
|
||||
|
||||
Raises:
|
||||
ValueError: Si algun color no tiene formato "#RRGGBB" o `size` <= 0.
|
||||
OSError: Si falla la escritura del archivo a disco.
|
||||
"""
|
||||
if size <= 0:
|
||||
raise ValueError(f"size debe ser positivo, recibido: {size!r}")
|
||||
|
||||
background = bg_hex if bg_hex else derive_bg_color(text)
|
||||
bg_rgb = _hex_to_rgb(background)
|
||||
fg_rgb = _hex_to_rgb(fg_hex)
|
||||
|
||||
initials = derive_initials(text)
|
||||
|
||||
# Render a 4x para antialiasing, luego se reduce con LANCZOS.
|
||||
big = size * _SUPERSAMPLE
|
||||
canvas = Image.new("RGBA", (big, big), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
margin = int(big * _MARGIN_RATIO)
|
||||
circle_box = [margin, margin, big - margin - 1, big - margin - 1]
|
||||
draw.ellipse(circle_box, fill=bg_rgb + (255,))
|
||||
|
||||
if initials:
|
||||
font_size = max(1, int(big * _FONT_RATIO))
|
||||
font = _load_font(font_size)
|
||||
|
||||
# Bounding box real del glyph (no solo ascent) para centrado optico.
|
||||
bbox = draw.textbbox((0, 0), initials, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
# El origen del texto se desplaza por el offset del bbox para que el
|
||||
# glyph quede centrado tanto horizontal como verticalmente.
|
||||
text_x = (big - text_w) / 2 - bbox[0]
|
||||
text_y = (big - text_h) / 2 - bbox[1]
|
||||
draw.text((text_x, text_y), initials, font=font, fill=fg_rgb + (255,))
|
||||
|
||||
final = canvas.resize((size, size), Image.LANCZOS)
|
||||
|
||||
out = Path(out_path)
|
||||
if not out.is_absolute():
|
||||
out = Path.cwd() / out
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
final.save(out, format="PNG")
|
||||
except OSError as exc:
|
||||
raise OSError(f"no se pudo escribir el avatar en {out}: {exc}") from exc
|
||||
|
||||
return out_path
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: hoppscotch_create_request
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_create_request(collection_id: str, method: str, url: str, *, title: str | None = None, headers: dict | None = None, body: str | None = None, body_type: str | None = None, team_id: str | None = None, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
|
||||
description: "Crea una request REST dentro de una team collection de Hoppscotch self-hosted via la mutation GraphQL createRequestInCollection. Construye el HoppRESTRequest canonico reusando build_hoppscotch_collection del registry y lo envia como json string en el campo request del input. La mutation esta protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token. Algunas versiones del backend exigen team_id dentro del input."
|
||||
tags: [hoppscotch, flow-replay, http, infra, crud]
|
||||
uses_functions: [build_hoppscotch_collection_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, requests]
|
||||
params:
|
||||
- name: collection_id
|
||||
desc: "ID de la team collection donde insertar la request."
|
||||
- name: method
|
||||
desc: "metodo HTTP de la request (GET, POST, PUT, ...). Se normaliza a mayusculas por build_hoppscotch_collection."
|
||||
- name: url
|
||||
desc: "endpoint completo de la request (con query string si aplica)."
|
||||
- name: title
|
||||
desc: "nombre visible de la request en la GUI de Hoppscotch. None = derivar de method + path (p.ej. 'GET /ping')."
|
||||
- name: headers
|
||||
desc: "dict name->value de cabeceras de la request. None = sin cabeceras."
|
||||
- name: body
|
||||
desc: "cuerpo de la request como texto YA serializado (no se re-serializa). None = sin cuerpo."
|
||||
- name: body_type
|
||||
desc: "tipo de cuerpo: 'json' | 'form' | 'raw' | None. Determina el contentType del HoppRESTRequest."
|
||||
- name: team_id
|
||||
desc: "ID de la team duena de la collection. Requerido por las versiones del backend cuyo CreateTeamRequestInput exige teamID (el self-host de referencia lo exige). Si el backend no lo pide, None."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
|
||||
output: "dict. En exito: {status: 'ok', id: str, title: str} con el ID de la request creada. En error (GraphQL errors, respuesta no JSON, sin id, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_crea_request_y_devuelve_id"
|
||||
- "test_body_json_se_serializa_en_el_request"
|
||||
- "test_team_id_se_incluye_en_data"
|
||||
- "test_team_id_omitido_no_aparece_en_data"
|
||||
- "test_error_graphql_errors"
|
||||
test_file_path: "python/functions/infra/hoppscotch_create_request_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_create_request.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_create_request import hoppscotch_create_request
|
||||
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
# Crear una request POST con body JSON en una team collection.
|
||||
result = hoppscotch_create_request(
|
||||
collection_id="cmq8lt8ta000t0xls4ddy6sdz",
|
||||
method="POST",
|
||||
url="https://api.example.com/login",
|
||||
title="Login",
|
||||
headers={"Content-Type": "application/json"},
|
||||
body='{"user":"neo","pass":"<<secret>>"}',
|
||||
body_type="json",
|
||||
team_id="cmq8kn0v500030xls1nvminjy", # requerido por este backend
|
||||
access_token=token,
|
||||
)
|
||||
print(result) # {"status": "ok", "id": "...", "title": "Login"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el agente quiera preparar una request REST en una team Hoppscotch
|
||||
self-hosted via API para que el humano la vea aparecer en vivo en la GUI (las
|
||||
subscriptions de Hoppscotch propagan la creacion en tiempo real). Util en el
|
||||
patron grabar->destilar->reproducir: tras destilar un flujo a call specs, se
|
||||
materializan como requests dentro de una collection que el humano inspecciona.
|
||||
Primero obten el `access_token` con `hoppscotch_login`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** La mutation
|
||||
esta protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
|
||||
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
|
||||
con `hoppscotch_login`.
|
||||
- **`request` debe ser un json string de un HoppRESTRequest v:"2".** No se pasa el
|
||||
dict directo: el campo `request` del input es un string. Esta funcion serializa
|
||||
con `json.dumps` el item que produce `build_hoppscotch_collection`.
|
||||
- **`team_id` puede ser obligatorio.** El self-host de referencia exige `teamID`
|
||||
dentro de `CreateTeamRequestInput`. Si lo omites contra ese backend, GraphQL
|
||||
responde "Field teamID of required type ID! was not provided". Pasa `team_id`.
|
||||
- **Secreto — nunca logear el token en crudo.** No imprimas `access_token` en
|
||||
claro; trata el JWT como un secreto.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. CRUD validado contra el self-host vivo el 10/06/2026.
|
||||
Se anadio `team_id` opcional porque el backend de referencia exige `teamID` en el
|
||||
input.
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Crea una request REST dentro de una team collection de Hoppscotch.
|
||||
|
||||
Construye el HoppRESTRequest canonico (reusando build_hoppscotch_collection del
|
||||
registry) y lo inserta en una team collection via la mutation GraphQL
|
||||
createRequestInCollection del backend self-hosted. La mutation esta protegida
|
||||
por GqlAuthGuard: el JWT de sesion viaja en la cookie `access_token`.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
from infra.build_hoppscotch_collection import build_hoppscotch_collection
|
||||
|
||||
_MUTATION = (
|
||||
"mutation($c:ID!,$d:CreateTeamRequestInput!){"
|
||||
" createRequestInCollection(collectionID:$c, data:$d){ id title } }"
|
||||
)
|
||||
|
||||
|
||||
def hoppscotch_create_request(
|
||||
collection_id: str,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
headers: dict | None = None,
|
||||
body: str | None = None,
|
||||
body_type: str | None = None,
|
||||
team_id: str | None = None,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
) -> dict:
|
||||
"""Crea una request en una team collection de Hoppscotch.
|
||||
|
||||
Args:
|
||||
collection_id: ID de la team collection donde insertar la request.
|
||||
method: metodo HTTP de la request (GET, POST, ...).
|
||||
url: endpoint de la request.
|
||||
title: nombre visible de la request en la GUI. None = derivar de
|
||||
method + path via build_hoppscotch_collection.
|
||||
headers: dict name->value de cabeceras de la request.
|
||||
body: cuerpo de la request como texto ya serializado.
|
||||
body_type: tipo de cuerpo ("json"|"form"|"raw"|None).
|
||||
team_id: ID de la team duena de la collection. Requerido por las
|
||||
versiones del backend cuyo CreateTeamRequestInput exige `teamID`
|
||||
(el self-host de referencia lo exige). Si el backend no lo pide,
|
||||
dejar None.
|
||||
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).
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "id": str, "title": str}``. En error
|
||||
(GraphQL errors, HTTP no 200, transporte): ``{"status": "error",
|
||||
"error": str, "data": ...}`` con el cuerpo GraphQL si lo hubo.
|
||||
"""
|
||||
spec = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": headers or {},
|
||||
"body": body,
|
||||
"body_type": body_type,
|
||||
}
|
||||
req_names = [title] if title else None
|
||||
req_item = build_hoppscotch_collection([spec], request_names=req_names)[
|
||||
"requests"
|
||||
][0]
|
||||
|
||||
data: dict = {
|
||||
"title": req_item["name"],
|
||||
"request": json.dumps(req_item),
|
||||
}
|
||||
if team_id is not None:
|
||||
data["teamID"] = team_id
|
||||
|
||||
payload = {
|
||||
"query": _MUTATION,
|
||||
"variables": {
|
||||
"c": collection_id,
|
||||
"d": data,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json=payload,
|
||||
cookies={"access_token": access_token},
|
||||
timeout=30.0,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"non-JSON response (HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
if data.get("errors"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "graphql errors",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
created = (data.get("data") or {}).get("createRequestInCollection")
|
||||
if not created or not created.get("id"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "createRequestInCollection returned no id",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"id": created["id"],
|
||||
"title": created.get("title"),
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Tests para hoppscotch_create_request.
|
||||
|
||||
Deterministas: monkeypatchean requests.post para no tocar la red. Verifican que
|
||||
el POST GraphQL lleva la mutation createRequestInCollection, el access_token en
|
||||
la cookie, y que `request` es el json string de un HoppRESTRequest v:"2".
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import infra.hoppscotch_create_request # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_create_request"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def test_golden_crea_request_y_devuelve_id(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"data": {
|
||||
"createRequestInCollection": {
|
||||
"id": "req-99",
|
||||
"title": "Ping",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_create_request(
|
||||
"col-1",
|
||||
"GET",
|
||||
"https://api.example.com/ping",
|
||||
title="Ping",
|
||||
headers={"Accept": "application/json"},
|
||||
access_token="ACCESS-JWT",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["id"] == "req-99"
|
||||
assert result["title"] == "Ping"
|
||||
|
||||
# El POST fue al endpoint GraphQL con la cookie access_token.
|
||||
assert captured["url"].endswith("/graphql")
|
||||
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
|
||||
payload = captured["kwargs"]["json"]
|
||||
assert "createRequestInCollection" in payload["query"]
|
||||
variables = payload["variables"]
|
||||
assert variables["c"] == "col-1"
|
||||
assert variables["d"]["title"] == "Ping"
|
||||
|
||||
# `request` es un json string de un HoppRESTRequest v:"2".
|
||||
req = json.loads(variables["d"]["request"])
|
||||
assert req["v"] == "2"
|
||||
assert req["method"] == "GET"
|
||||
assert req["endpoint"] == "https://api.example.com/ping"
|
||||
assert req["name"] == "Ping"
|
||||
|
||||
|
||||
def test_body_json_se_serializa_en_el_request(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{"data": {"createRequestInCollection": {"id": "r", "title": "t"}}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
mod.hoppscotch_create_request(
|
||||
"col-2",
|
||||
"POST",
|
||||
"https://api.example.com/login",
|
||||
body='{"user":"neo"}',
|
||||
body_type="json",
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
req = json.loads(captured["kwargs"]["json"]["variables"]["d"]["request"])
|
||||
assert req["method"] == "POST"
|
||||
assert req["body"] == {
|
||||
"contentType": "application/json",
|
||||
"body": '{"user":"neo"}',
|
||||
}
|
||||
|
||||
|
||||
def test_team_id_se_incluye_en_data(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{"data": {"createRequestInCollection": {"id": "r", "title": "t"}}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
mod.hoppscotch_create_request(
|
||||
"col-3",
|
||||
"GET",
|
||||
"https://api.example.com/x",
|
||||
team_id="team-abc",
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
data = captured["kwargs"]["json"]["variables"]["d"]
|
||||
assert data["teamID"] == "team-abc"
|
||||
|
||||
|
||||
def test_team_id_omitido_no_aparece_en_data(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{"data": {"createRequestInCollection": {"id": "r", "title": "t"}}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
mod.hoppscotch_create_request(
|
||||
"col-4", "GET", "https://api.example.com/x", access_token="A"
|
||||
)
|
||||
|
||||
data = captured["kwargs"]["json"]["variables"]["d"]
|
||||
assert "teamID" not in data
|
||||
|
||||
|
||||
def test_error_graphql_errors(monkeypatch):
|
||||
def fake_post(url, **kwargs):
|
||||
return _FakeResponse(
|
||||
200, {"errors": [{"message": "team_req/not_found"}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_create_request(
|
||||
"bad", "GET", "https://x", access_token="A"
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "graphql errors"
|
||||
assert "errors" in result["data"]
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: hoppscotch_delete_request
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_delete_request(request_id: str, *, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
|
||||
description: "Borra una request REST de una team collection de Hoppscotch self-hosted via la mutation GraphQL deleteRequest. Protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token. Confirma que la mutation devolvio true antes de reportar exito."
|
||||
tags: [hoppscotch, flow-replay, http, infra, crud]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [requests]
|
||||
params:
|
||||
- name: request_id
|
||||
desc: "ID de la request a borrar."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
|
||||
output: "dict. En exito (data.deleteRequest == true): {status: 'ok', deleted: str}. En error (GraphQL errors, deleteRequest != true, respuesta no JSON, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_delete_true"
|
||||
- "test_error_delete_false"
|
||||
test_file_path: "python/functions/infra/hoppscotch_delete_request_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_delete_request.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_delete_request import hoppscotch_delete_request
|
||||
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
result = hoppscotch_delete_request(
|
||||
request_id="cmq8lue8l000x0xlsd62bncpi",
|
||||
access_token=token,
|
||||
)
|
||||
print(result) # {"status": "ok", "deleted": "cmq8lue8l000x0xlsd62bncpi"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras eliminar una request que el agente creo (o que ya no hace falta) de
|
||||
una team collection Hoppscotch self-hosted, y que el humano vea la baja en vivo en
|
||||
la GUI por subscriptions. Util para limpiar requests temporales tras un flujo de
|
||||
prueba. Necesitas el `request_id` (de `hoppscotch_list_requests`) y un
|
||||
`access_token` fresco de `hoppscotch_login`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** La mutation
|
||||
esta protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
|
||||
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
|
||||
con `hoppscotch_login`.
|
||||
- **Operacion destructiva.** Borra la request de verdad; no es reversible. Confirma
|
||||
el `request_id` (p.ej. con `hoppscotch_list_requests`) antes de borrar.
|
||||
- **Solo `data.deleteRequest == true` es exito.** Cualquier otro valor (false, null)
|
||||
o un bloque `errors` se reporta como `status: 'error'`.
|
||||
- **Secreto — nunca logear el token en crudo.** Trata el JWT como un secreto.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Validado contra el self-host vivo el 10/06/2026
|
||||
(delete confirmo que la request desaparece de la lista posterior).
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Borra una request REST de una team collection de Hoppscotch.
|
||||
|
||||
Invoca la mutation GraphQL deleteRequest del backend self-hosted. Protegida por
|
||||
GqlAuthGuard: el JWT de sesion viaja en la cookie `access_token`.
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
_MUTATION = "mutation($r:ID!){ deleteRequest(requestID:$r) }"
|
||||
|
||||
|
||||
def hoppscotch_delete_request(
|
||||
request_id: str,
|
||||
*,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
) -> dict:
|
||||
"""Borra una request de Hoppscotch por su ID.
|
||||
|
||||
Args:
|
||||
request_id: ID de la request a borrar.
|
||||
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).
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "deleted": str}``. En error (GraphQL
|
||||
errors, deleteRequest != true, HTTP no 200, transporte):
|
||||
``{"status": "error", "error": str, "data": ...}``.
|
||||
"""
|
||||
payload = {
|
||||
"query": _MUTATION,
|
||||
"variables": {"r": request_id},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json=payload,
|
||||
cookies={"access_token": access_token},
|
||||
timeout=30.0,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"non-JSON response (HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
if data.get("errors"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "graphql errors",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
deleted = (data.get("data") or {}).get("deleteRequest")
|
||||
if deleted is not True:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "deleteRequest did not return true",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return {"status": "ok", "deleted": request_id}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests para hoppscotch_delete_request.
|
||||
|
||||
Deterministas: monkeypatchean requests.post. Verifican el camino ok
|
||||
(deleteRequest=true) y el de error (deleteRequest=false).
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import infra.hoppscotch_delete_request # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_delete_request"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def test_golden_delete_true(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(200, {"data": {"deleteRequest": True}})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_delete_request("req-1", access_token="ACCESS-JWT")
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["deleted"] == "req-1"
|
||||
assert captured["url"].endswith("/graphql")
|
||||
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
assert "deleteRequest" in captured["kwargs"]["json"]["query"]
|
||||
assert captured["kwargs"]["json"]["variables"] == {"r": "req-1"}
|
||||
|
||||
|
||||
def test_error_delete_false(monkeypatch):
|
||||
def fake_post(url, **kwargs):
|
||||
return _FakeResponse(200, {"data": {"deleteRequest": False}})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_delete_request("req-2", access_token="A")
|
||||
assert result["status"] == "error"
|
||||
assert "did not return true" in result["error"]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Test e2e real del grupo hoppscotch contra un self-host VIVO.
|
||||
|
||||
NO corre en la suite normal (skip). Para ejecutarlo a mano contra la instancia
|
||||
viva, quita temporalmente el skip y asegura:
|
||||
- backend Hoppscotch en http://localhost:3170
|
||||
- mailpit en http://localhost:8025
|
||||
- una team collection real cuyo ID pongas en COLLECTION_ID abajo.
|
||||
|
||||
El flujo: login(admin@example.com) -> create_request -> list -> delete.
|
||||
|
||||
Nota: el backend de referencia exige `teamID` dentro de CreateTeamRequestInput,
|
||||
asi que la create pasa `team_id=TEAM_ID`. Rellena COLLECTION_ID y TEAM_ID con
|
||||
una team collection real (consultables via myTeams / rootCollectionsOfTeam).
|
||||
Este flujo se valido con exito contra la instancia viva el 10/06/2026.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_create_request import hoppscotch_create_request
|
||||
from infra.hoppscotch_list_requests import hoppscotch_list_requests
|
||||
from infra.hoppscotch_delete_request import hoppscotch_delete_request
|
||||
from infra.hoppscotch_run_request import hoppscotch_run_request
|
||||
|
||||
# Rellenar con IDs reales del self-host antes de correr.
|
||||
TEAM_ID = "REPLACE_WITH_REAL_TEAM_ID"
|
||||
COLLECTION_ID = "REPLACE_WITH_REAL_COLLECTION_ID"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="e2e real contra self-host vivo, correr a mano")
|
||||
def test_e2e_crud_request_real():
|
||||
login = hoppscotch_login("admin@example.com")
|
||||
assert login["status"] == "ok", login
|
||||
|
||||
token = login["access_token"]
|
||||
|
||||
created = hoppscotch_create_request(
|
||||
COLLECTION_ID,
|
||||
"GET",
|
||||
"https://api.example.com/e2e-ping",
|
||||
title="e2e ping",
|
||||
team_id=TEAM_ID,
|
||||
access_token=token,
|
||||
)
|
||||
assert created["status"] == "ok", created
|
||||
req_id = created["id"]
|
||||
|
||||
listed = hoppscotch_list_requests(COLLECTION_ID, access_token=token)
|
||||
assert listed["status"] == "ok", listed
|
||||
assert any(r["id"] == req_id for r in listed["requests"])
|
||||
|
||||
deleted = hoppscotch_delete_request(req_id, access_token=token)
|
||||
assert deleted["status"] == "ok", deleted
|
||||
assert deleted["deleted"] == req_id
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="e2e real self-host vivo")
|
||||
def test_e2e_run_request_aparece_en_user_history():
|
||||
"""Ejecuta una request real y verifica que entra en el UserHistory.
|
||||
|
||||
login -> run_request GET <<baseURL>>/api/status -> status_code 200 +
|
||||
recorded True. La entry debe verse en la pestana History de la GUI
|
||||
(subscription userHistoryCreated). Validado contra la instancia viva.
|
||||
"""
|
||||
login = hoppscotch_login("admin@example.com")
|
||||
assert login["status"] == "ok", login
|
||||
token = login["access_token"]
|
||||
|
||||
result = hoppscotch_run_request(
|
||||
"GET",
|
||||
"<<baseURL>>/api/status",
|
||||
title="Status (e2e)",
|
||||
variables={"baseURL": "https://registry.organic-machine.com"},
|
||||
access_token=token,
|
||||
)
|
||||
assert result["status"] == "ok", result
|
||||
assert result["status_code"] == 200, result
|
||||
assert result["recorded"] is True, result
|
||||
assert result["history_id"], result
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: hoppscotch_list_requests
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_list_requests(collection_id: str, *, access_token: str, backend_url: str = \"http://localhost:3170\", take: int = 50) -> dict"
|
||||
description: "Lista las requests de una team collection de Hoppscotch self-hosted via la query GraphQL requestsInCollection. Devuelve cada request como {id, title}. Protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token."
|
||||
tags: [hoppscotch, flow-replay, http, infra, crud]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [requests]
|
||||
params:
|
||||
- name: collection_id
|
||||
desc: "ID de la team collection cuyas requests se quieren listar."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
|
||||
- name: take
|
||||
desc: "numero maximo de requests a devolver (argumento take de la query). Default 50."
|
||||
output: "dict. En exito: {status: 'ok', requests: [{id: str, title: str}, ...]}. En error (GraphQL errors, requestsInCollection null, respuesta no JSON, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_lista_dos_requests"
|
||||
- "test_take_se_pasa_a_la_query"
|
||||
- "test_error_graphql_errors"
|
||||
test_file_path: "python/functions/infra/hoppscotch_list_requests_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_list_requests.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_list_requests import hoppscotch_list_requests
|
||||
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
result = hoppscotch_list_requests(
|
||||
collection_id="cmq8lt8ta000t0xls4ddy6sdz",
|
||||
access_token=token,
|
||||
)
|
||||
print(result)
|
||||
# {"status": "ok", "requests": [{"id": "...", "title": "Login"}, ...]}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites enumerar las requests de una team collection Hoppscotch
|
||||
self-hosted: para verificar que un `hoppscotch_create_request` aparecio, para
|
||||
obtener el `request_id` que pasar a `hoppscotch_update_request` /
|
||||
`hoppscotch_delete_request`, o para auditar el contenido de una collection antes
|
||||
de modificarla. Necesitas un `access_token` fresco de `hoppscotch_login`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** La query esta
|
||||
protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
|
||||
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
|
||||
con `hoppscotch_login`.
|
||||
- **Devuelve solo {id, title}, no el HoppRESTRequest completo.** La query pide
|
||||
unicamente id y title; no incluye method/url/headers/body. Para el cuerpo
|
||||
completo de una request, consulta su detalle aparte.
|
||||
- **`take` limita el resultado.** Solo se devuelven hasta `take` requests (default
|
||||
50). Sube `take` si la collection tiene mas.
|
||||
- **Secreto — nunca logear el token en crudo.** Trata el JWT como un secreto.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Validado contra el self-host vivo el 10/06/2026
|
||||
(list reflejo la creacion y la baja de una request real).
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Lista las requests de una team collection de Hoppscotch.
|
||||
|
||||
Invoca la query GraphQL requestsInCollection del backend self-hosted. Protegida
|
||||
por GqlAuthGuard: el JWT de sesion viaja en la cookie `access_token`.
|
||||
"""
|
||||
|
||||
import requests
|
||||
|
||||
_QUERY = (
|
||||
"query($c:ID!,$t:Int){"
|
||||
" requestsInCollection(collectionID:$c, take:$t){ id title } }"
|
||||
)
|
||||
|
||||
|
||||
def hoppscotch_list_requests(
|
||||
collection_id: str,
|
||||
*,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
take: int = 50,
|
||||
) -> dict:
|
||||
"""Lista las requests de una team collection de Hoppscotch.
|
||||
|
||||
Args:
|
||||
collection_id: ID de la team collection a listar.
|
||||
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).
|
||||
take: numero maximo de requests a devolver (default 50).
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "requests": [{"id": str,
|
||||
"title": str}, ...]}``. En error (GraphQL errors, HTTP no 200,
|
||||
transporte): ``{"status": "error", "error": str, "data": ...}``.
|
||||
"""
|
||||
payload = {
|
||||
"query": _QUERY,
|
||||
"variables": {"c": collection_id, "t": take},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json=payload,
|
||||
cookies={"access_token": access_token},
|
||||
timeout=30.0,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"non-JSON response (HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
if data.get("errors"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "graphql errors",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
rows = (data.get("data") or {}).get("requestsInCollection")
|
||||
if rows is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "requestsInCollection returned null",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"requests": [
|
||||
{"id": r.get("id"), "title": r.get("title")} for r in rows
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests para hoppscotch_list_requests.
|
||||
|
||||
Deterministas: monkeypatchean requests.post. Verifican el camino ok (devuelve la
|
||||
lista normalizada) y el de error (GraphQL errors).
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import infra.hoppscotch_list_requests # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_list_requests"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def test_golden_lista_dos_requests(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"data": {
|
||||
"requestsInCollection": [
|
||||
{"id": "r1", "title": "Ping"},
|
||||
{"id": "r2", "title": "Login"},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_list_requests("col-1", access_token="ACCESS-JWT")
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["requests"] == [
|
||||
{"id": "r1", "title": "Ping"},
|
||||
{"id": "r2", "title": "Login"},
|
||||
]
|
||||
assert captured["url"].endswith("/graphql")
|
||||
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
assert "requestsInCollection" in captured["kwargs"]["json"]["query"]
|
||||
assert captured["kwargs"]["json"]["variables"] == {"c": "col-1", "t": 50}
|
||||
|
||||
|
||||
def test_take_se_pasa_a_la_query(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(200, {"data": {"requestsInCollection": []}})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_list_requests("col", access_token="A", take=10)
|
||||
assert result["status"] == "ok"
|
||||
assert result["requests"] == []
|
||||
assert captured["kwargs"]["json"]["variables"]["t"] == 10
|
||||
|
||||
|
||||
def test_error_graphql_errors(monkeypatch):
|
||||
def fake_post(url, **kwargs):
|
||||
return _FakeResponse(
|
||||
200, {"errors": [{"message": "team_coll/not_found"}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_list_requests("bad", access_token="A")
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "graphql errors"
|
||||
assert "errors" in result["data"]
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: hoppscotch_login
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_login(email: str, *, backend_url: str = \"http://localhost:3170\", mailpit_url: str = \"http://localhost:8025\", timeout_s: float = 15.0) -> dict"
|
||||
description: "Login headless contra un Hoppscotch self-hosted via magic link, leyendo el correo de verificacion desde una instancia Mailpit de pruebas. Reproduce el flujo sin navegador: POST /v1/auth/signin (deviceIdentifier) -> lee el correo 'Sign in' del email en Mailpit -> extrae el token (?token=...) del cuerpo -> POST /v1/auth/verify (Set-Cookie access_token + refresh_token). Devuelve los JWT de sesion que las mutations GraphQL protegidas esperan en la cookie access_token."
|
||||
tags: [hoppscotch, flow-replay, http, infra, auth]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [re, requests]
|
||||
params:
|
||||
- name: email
|
||||
desc: "correo del usuario que inicia sesion. Debe poder recibir el correo de verificacion en la instancia Mailpit indicada (en el self-host de pruebas, admin@example.com)."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. Los endpoints REST de auth cuelgan de {backend_url}/v1/auth/signin y /v1/auth/verify. Default http://localhost:3170."
|
||||
- name: mailpit_url
|
||||
desc: "base de la API de Mailpit donde aterriza el correo de verificacion, sin barra final. Default http://localhost:8025."
|
||||
- name: timeout_s
|
||||
desc: "timeout por request HTTP en segundos. Default 15.0."
|
||||
output: "dict. En exito: {status: 'ok', access_token: str, refresh_token: str, email: str}. En error (signin != 201, no llega correo 'Sign in', token no encontrado en el correo, verify != 200, o fallo de transporte): {status: 'error', error: str}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_login_devuelve_tokens"
|
||||
- "test_verify_recibe_token_extraido_y_device_identifier"
|
||||
- "test_error_signin_no_201"
|
||||
- "test_error_correo_no_encontrado"
|
||||
- "test_error_token_no_en_correo"
|
||||
test_file_path: "python/functions/infra/hoppscotch_login_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_login.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_create_request import hoppscotch_create_request
|
||||
|
||||
# 1) Obtener un JWT de sesion via magic link (headless, lee el correo de Mailpit).
|
||||
login = hoppscotch_login("admin@example.com")
|
||||
assert login["status"] == "ok", login["error"]
|
||||
token = login["access_token"]
|
||||
|
||||
# 2) Usar el token para crear una request en una team collection.
|
||||
# El self-host de referencia exige team_id dentro del input.
|
||||
created = hoppscotch_create_request(
|
||||
collection_id="cmq8lt8ta000t0xls4ddy6sdz",
|
||||
method="GET",
|
||||
url="https://api.example.com/ping",
|
||||
title="Ping",
|
||||
team_id="cmq8kn0v500030xls1nvminjy",
|
||||
access_token=token,
|
||||
)
|
||||
print(created) # {"status": "ok", "id": "...", "title": "Ping"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un JWT de sesion de un Hoppscotch self-hosted para operar su API
|
||||
GraphQL protegida (crear/editar/borrar requests, gestionar collections) sin abrir
|
||||
el navegador. Es el primer paso de cualquier flujo CRUD del grupo `hoppscotch`:
|
||||
llama esto, captura `access_token`, y paselo a `hoppscotch_create_request` /
|
||||
`hoppscotch_update_request` / `hoppscotch_delete_request` / `hoppscotch_list_requests`.
|
||||
Requiere que el backend mande el correo de verificacion a una instancia Mailpit
|
||||
accesible (entorno de pruebas).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** Las mutations
|
||||
GraphQL leen el JWT de la cookie `access_token`. Cada funcion del grupo lo manda
|
||||
con `cookies={"access_token": ...}`.
|
||||
- **El token expira (~24h).** Cuando una llamada GraphQL devuelva un error de auth,
|
||||
re-loguea con `hoppscotch_login` para obtener un access_token fresco.
|
||||
- **Depende de Mailpit.** El flujo lee el correo de verificacion de una instancia
|
||||
Mailpit de pruebas. No funciona contra un backend que mande el correo a un buzon
|
||||
real al que esta funcion no pueda consultar por API.
|
||||
- **Secreto — nunca logear el token en crudo.** `access_token`/`refresh_token` son
|
||||
credenciales de sesion. No los imprimas ni los persistas en claro; trataelos como
|
||||
un secreto (vault/pass) si los guardas entre ejecuciones.
|
||||
- **Coincidencia del correo por subject + destinatario.** Se elige el mensaje mas
|
||||
reciente cuyo destinatario sea `email` y cuyo subject contenga "Sign in". Si hay
|
||||
varios magic links pendientes para el mismo email, se usa el ultimo de la lista.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Flujo magic link headless validado contra el self-host
|
||||
vivo (login + CRUD completo) el 10/06/2026.
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Login headless contra un Hoppscotch self-hosted via magic link.
|
||||
|
||||
Reproduce el flujo de magic link de Hoppscotch sin navegador, leyendo el
|
||||
correo de verificacion desde una instancia Mailpit de pruebas:
|
||||
|
||||
1. POST /v1/auth/signin -> deviceIdentifier
|
||||
2. GET mailpit messages -> ultimo correo "Sign in" para ese email
|
||||
3. GET mailpit message/{id} -> extrae el token (?token=...) del cuerpo
|
||||
4. POST /v1/auth/verify -> Set-Cookie access_token + refresh_token
|
||||
|
||||
Devuelve los JWT de sesion (access_token / refresh_token). El access_token es
|
||||
el que las mutations GraphQL protegidas por GqlAuthGuard esperan en la cookie
|
||||
`access_token` (no en el header Authorization).
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
# El correo de Hoppscotch incluye un enlace con ?token=<jwt>. El token es un
|
||||
# JWT (3 segmentos base64url separados por puntos), asi que aceptamos letras,
|
||||
# digitos, guion, guion bajo y punto.
|
||||
_TOKEN_RE = re.compile(r"token=([A-Za-z0-9_\-.]+)")
|
||||
|
||||
|
||||
def hoppscotch_login(
|
||||
email: str,
|
||||
*,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
mailpit_url: str = "http://localhost:8025",
|
||||
timeout_s: float = 15.0,
|
||||
) -> dict:
|
||||
"""Obtiene un JWT de sesion de Hoppscotch via magic link (headless).
|
||||
|
||||
Args:
|
||||
email: correo del usuario que inicia sesion. Debe poder recibir el
|
||||
correo de verificacion en la instancia Mailpit indicada.
|
||||
backend_url: base del backend Hoppscotch (sin barra final). El endpoint
|
||||
REST de auth cuelga de ``{backend_url}/v1/auth/...``.
|
||||
mailpit_url: base de la API de Mailpit donde aterriza el correo de
|
||||
verificacion (sin barra final).
|
||||
timeout_s: timeout por request HTTP en segundos.
|
||||
|
||||
Returns:
|
||||
Dict. En exito:
|
||||
``{"status": "ok", "access_token": str, "refresh_token": str,
|
||||
"email": str}``. En error (signin no 201, no llega correo, token no
|
||||
encontrado, verify no 200, o fallo de transporte):
|
||||
``{"status": "error", "error": str}``.
|
||||
"""
|
||||
session = requests.Session()
|
||||
|
||||
try:
|
||||
# 1) Signin: pide el magic link. Respuesta 201 con deviceIdentifier.
|
||||
signin = session.post(
|
||||
f"{backend_url}/v1/auth/signin",
|
||||
json={"email": email},
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if signin.status_code != 201:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"signin returned {signin.status_code} "
|
||||
f"(expected 201): {signin.text[:200]}"
|
||||
),
|
||||
}
|
||||
try:
|
||||
device_identifier = signin.json().get("deviceIdentifier")
|
||||
except ValueError:
|
||||
device_identifier = None
|
||||
if not device_identifier:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "signin response missing deviceIdentifier",
|
||||
}
|
||||
|
||||
# 2) Localiza el correo de verificacion mas reciente para este email.
|
||||
messages = session.get(
|
||||
f"{mailpit_url}/api/v1/messages",
|
||||
params={"limit": 5},
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if messages.status_code != 200:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"mailpit messages returned {messages.status_code} "
|
||||
"(expected 200)"
|
||||
),
|
||||
}
|
||||
try:
|
||||
inbox = messages.json().get("messages") or []
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "mailpit messages response is not valid JSON",
|
||||
}
|
||||
|
||||
message_id = None
|
||||
for msg in inbox:
|
||||
recipients = msg.get("To") or []
|
||||
to_match = any(
|
||||
(addr.get("Address") or "").lower() == email.lower()
|
||||
for addr in recipients
|
||||
)
|
||||
subject = msg.get("Subject") or ""
|
||||
if to_match and "Sign in" in subject:
|
||||
message_id = msg.get("ID")
|
||||
break
|
||||
if not message_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"no 'Sign in' email found for {email} in mailpit",
|
||||
}
|
||||
|
||||
# 3) Descarga el cuerpo del correo y extrae el token.
|
||||
message = session.get(
|
||||
f"{mailpit_url}/api/v1/message/{message_id}",
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if message.status_code != 200:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"mailpit message returned {message.status_code} "
|
||||
"(expected 200)"
|
||||
),
|
||||
}
|
||||
try:
|
||||
body = message.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "mailpit message response is not valid JSON",
|
||||
}
|
||||
haystack = f"{body.get('Text') or ''}\n{body.get('HTML') or ''}"
|
||||
token_match = _TOKEN_RE.search(haystack)
|
||||
if not token_match:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "magic-link token not found in verification email",
|
||||
}
|
||||
token = token_match.group(1)
|
||||
|
||||
# 4) Verify: canjea el token + deviceIdentifier por las cookies de
|
||||
# sesion (access_token / refresh_token).
|
||||
verify = session.post(
|
||||
f"{backend_url}/v1/auth/verify",
|
||||
json={"token": token, "deviceIdentifier": device_identifier},
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if verify.status_code != 200:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"verify returned {verify.status_code} "
|
||||
f"(expected 200): {verify.text[:200]}"
|
||||
),
|
||||
}
|
||||
|
||||
access_token = session.cookies.get("access_token")
|
||||
refresh_token = session.cookies.get("refresh_token")
|
||||
if not access_token:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "verify succeeded but no access_token cookie was set",
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"email": email,
|
||||
}
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Tests para hoppscotch_login.
|
||||
|
||||
Deterministas: monkeypatchean requests.Session para no tocar la red. Simulan el
|
||||
flujo magic link completo (signin -> mailpit list -> mailpit message -> verify)
|
||||
y verifican que se devuelven los JWT, asi como los caminos de error.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import infra.hoppscotch_login # noqa: F401 (registra el submodulo en sys.modules)
|
||||
|
||||
# El __init__ del paquete rebinds el nombre `hoppscotch_login` a la funcion,
|
||||
# que sombrea el submodulo. Recuperamos el submodulo real desde sys.modules
|
||||
# para monkeypatchear su simbolo `requests`.
|
||||
mod = sys.modules["infra.hoppscotch_login"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Respuesta HTTP mockeada minima: status_code, json(), text."""
|
||||
|
||||
def __init__(self, status_code=200, json_data=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
class _FakeCookies:
|
||||
def __init__(self, store):
|
||||
self._store = store
|
||||
|
||||
def get(self, name):
|
||||
return self._store.get(name)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Session mockeada: despacha por (method, path) a respuestas predefinidas."""
|
||||
|
||||
def __init__(self, routes, cookie_store):
|
||||
self._routes = routes
|
||||
self.cookies = _FakeCookies(cookie_store)
|
||||
self.calls = []
|
||||
|
||||
def _dispatch(self, method, url, **kwargs):
|
||||
self.calls.append((method, url, kwargs))
|
||||
for (m, fragment), resp in self._routes.items():
|
||||
if m == method and fragment in url:
|
||||
return resp
|
||||
raise AssertionError(f"unexpected {method} {url}")
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
return self._dispatch("POST", url, **kwargs)
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
return self._dispatch("GET", url, **kwargs)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _install_session(monkeypatch, routes, cookie_store):
|
||||
session = _FakeSession(routes, cookie_store)
|
||||
monkeypatch.setattr(mod.requests, "Session", lambda: session)
|
||||
return session
|
||||
|
||||
|
||||
def test_golden_login_devuelve_tokens(monkeypatch):
|
||||
routes = {
|
||||
("POST", "/v1/auth/signin"): _FakeResponse(
|
||||
201, {"deviceIdentifier": "dev-123"}
|
||||
),
|
||||
("GET", "/api/v1/messages"): _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"ID": "msg-1",
|
||||
"Subject": "Sign in to Hoppscotch",
|
||||
"To": [{"Address": "admin@example.com"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
("GET", "/api/v1/message/msg-1"): _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"Text": "Click here",
|
||||
"HTML": (
|
||||
"<a href='http://localhost:3170/?token="
|
||||
"eyJhbGciOi.JhbGci_Q-zz'>Sign in</a>"
|
||||
),
|
||||
},
|
||||
),
|
||||
("POST", "/v1/auth/verify"): _FakeResponse(200, {"ok": True}),
|
||||
}
|
||||
_install_session(
|
||||
monkeypatch,
|
||||
routes,
|
||||
{"access_token": "ACCESS-JWT", "refresh_token": "REFRESH-JWT"},
|
||||
)
|
||||
|
||||
result = mod.hoppscotch_login("admin@example.com")
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["access_token"] == "ACCESS-JWT"
|
||||
assert result["refresh_token"] == "REFRESH-JWT"
|
||||
assert result["email"] == "admin@example.com"
|
||||
|
||||
|
||||
def test_verify_recibe_token_extraido_y_device_identifier(monkeypatch):
|
||||
routes = {
|
||||
("POST", "/v1/auth/signin"): _FakeResponse(
|
||||
201, {"deviceIdentifier": "dev-xyz"}
|
||||
),
|
||||
("GET", "/api/v1/messages"): _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"ID": "m9",
|
||||
"Subject": "Sign in",
|
||||
"To": [{"Address": "admin@example.com"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
("GET", "/api/v1/message/m9"): _FakeResponse(
|
||||
200,
|
||||
{"Text": "verify at ?token=abc.DEF-123_456", "HTML": ""},
|
||||
),
|
||||
("POST", "/v1/auth/verify"): _FakeResponse(200, {}),
|
||||
}
|
||||
session = _install_session(
|
||||
monkeypatch, routes, {"access_token": "A", "refresh_token": "R"}
|
||||
)
|
||||
|
||||
result = mod.hoppscotch_login("admin@example.com")
|
||||
assert result["status"] == "ok"
|
||||
|
||||
# El POST a verify llevo el token extraido del correo + el deviceIdentifier.
|
||||
verify_call = next(
|
||||
c for c in session.calls if c[0] == "POST" and "verify" in c[1]
|
||||
)
|
||||
sent = verify_call[2]["json"]
|
||||
assert sent["token"] == "abc.DEF-123_456"
|
||||
assert sent["deviceIdentifier"] == "dev-xyz"
|
||||
|
||||
|
||||
def test_error_signin_no_201(monkeypatch):
|
||||
routes = {
|
||||
("POST", "/v1/auth/signin"): _FakeResponse(
|
||||
500, None, text="boom"
|
||||
),
|
||||
}
|
||||
_install_session(monkeypatch, routes, {})
|
||||
|
||||
result = mod.hoppscotch_login("admin@example.com")
|
||||
assert result["status"] == "error"
|
||||
assert "signin returned 500" in result["error"]
|
||||
|
||||
|
||||
def test_error_correo_no_encontrado(monkeypatch):
|
||||
routes = {
|
||||
("POST", "/v1/auth/signin"): _FakeResponse(
|
||||
201, {"deviceIdentifier": "d"}
|
||||
),
|
||||
("GET", "/api/v1/messages"): _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"ID": "x",
|
||||
"Subject": "Newsletter",
|
||||
"To": [{"Address": "other@example.com"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
}
|
||||
_install_session(monkeypatch, routes, {})
|
||||
|
||||
result = mod.hoppscotch_login("admin@example.com")
|
||||
assert result["status"] == "error"
|
||||
assert "no 'Sign in' email" in result["error"]
|
||||
|
||||
|
||||
def test_error_token_no_en_correo(monkeypatch):
|
||||
routes = {
|
||||
("POST", "/v1/auth/signin"): _FakeResponse(
|
||||
201, {"deviceIdentifier": "d"}
|
||||
),
|
||||
("GET", "/api/v1/messages"): _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"ID": "m",
|
||||
"Subject": "Sign in",
|
||||
"To": [{"Address": "admin@example.com"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
("GET", "/api/v1/message/m"): _FakeResponse(
|
||||
200, {"Text": "no token here", "HTML": "<p>nada</p>"}
|
||||
),
|
||||
}
|
||||
_install_session(monkeypatch, routes, {})
|
||||
|
||||
result = mod.hoppscotch_login("admin@example.com")
|
||||
assert result["status"] == "error"
|
||||
assert "token not found" in result["error"]
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: hoppscotch_run_request
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_run_request(method: str, url: str, *, title: str | None = None, headers: dict | None = None, body: str | None = None, body_type: str | None = None, variables: dict | None = None, access_token: str, backend_url: str = \"http://localhost:3170\", record_history: bool = True, timeout_s: float = 30.0, verify_tls: bool = True) -> dict"
|
||||
description: "Ejecuta una peticion HTTP real (resolviendo placeholders <<var>>/{{var}} con un dict de variables) y la registra en el UserHistory de un Hoppscotch self-hosted via la mutation GraphQL createUserHistory, para que el humano la vea aparecer en vivo en la pestana History de su GUI (subscription userHistoryCreated). La request se ejecuta con las variables resueltas, pero en el History se guarda SIN resolver (con los literales <<var>>) igual que en el editor. resMetadata minimo: statusCode + duration. El access_token va como cookie, no como header Authorization."
|
||||
tags: [hoppscotch, flow-replay, http]
|
||||
uses_functions: [build_hoppscotch_collection_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, re, requests]
|
||||
params:
|
||||
- name: method
|
||||
desc: "metodo HTTP de la peticion (GET, POST, ...)."
|
||||
- name: url
|
||||
desc: "endpoint de la peticion. Puede contener placeholders <<var>> o {{var}} que se resuelven con `variables` antes de ejecutar."
|
||||
- name: title
|
||||
desc: "nombre visible de la request en el History. None = derivar de method + path via build_hoppscotch_collection."
|
||||
- name: headers
|
||||
desc: "dict name->value de cabeceras. Sus values tambien admiten placeholders <<var>>/{{var}}."
|
||||
- name: body
|
||||
desc: "cuerpo de la peticion como texto ya serializado. Admite placeholders. None = sin cuerpo."
|
||||
- name: body_type
|
||||
desc: "tipo de cuerpo para el HoppRESTRequest del History: 'json' | 'form' | 'raw' | None."
|
||||
- name: variables
|
||||
desc: "dict name->value para resolver los placeholders al EJECUTAR. Una variable que falte deja el literal intacto. None = no se resuelve nada."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization. Necesario para registrar en el History."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch self-host sin barra final. La mutation cuelga de {backend_url}/graphql. Default http://localhost:3170."
|
||||
- name: record_history
|
||||
desc: "si True y hay access_token, registra la request ejecutada en el UserHistory via createUserHistory. Default True."
|
||||
- name: timeout_s
|
||||
desc: "timeout en segundos de la peticion HTTP ejecutada (y del POST de History). Default 30.0."
|
||||
- name: verify_tls
|
||||
desc: "verificacion del certificado TLS de la peticion ejecutada. Default True."
|
||||
output: "dict. En exito de la ejecucion HTTP: {status: 'ok', status_code: int, duration_ms: int, response_body: str (truncado a 5000 chars), response_headers: dict, recorded: bool, history_id: str|None}. Si la ejecucion fue ok pero el registro de History fallo, status sigue 'ok', recorded False y se anade history_error. Si la ejecucion HTTP falla (RequestException): {status: 'error', error: str, recorded: False}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_ejecuta_resolviendo_variables_angle"
|
||||
- "test_ejecuta_resolviendo_variables_brace"
|
||||
- "test_record_history_registra_request_sin_resolver"
|
||||
- "test_record_history_false_no_llama_create_user_history"
|
||||
- "test_request_exception_status_error"
|
||||
- "test_variable_faltante_conserva_literal"
|
||||
test_file_path: "python/functions/infra/hoppscotch_run_request_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_run_request.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_run_request import hoppscotch_run_request
|
||||
|
||||
# 1) Obtener un JWT de sesion (headless, lee el correo de Mailpit).
|
||||
login = hoppscotch_login("admin@example.com")
|
||||
assert login["status"] == "ok", login["error"]
|
||||
token = login["access_token"]
|
||||
|
||||
# 2) Ejecutar una request con una variable y dejar rastro en el History de la GUI.
|
||||
result = hoppscotch_run_request(
|
||||
"GET",
|
||||
"<<baseURL>>/api/status",
|
||||
title="Status",
|
||||
variables={"baseURL": "https://registry.organic-machine.com"},
|
||||
access_token=token,
|
||||
)
|
||||
print(result["status_code"], result["recorded"], result["history_id"])
|
||||
# 200 True hist-...
|
||||
# -> aparece en vivo en la pestana History del Hoppscotch self-host.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el agente ejecuta una consulta HTTP y quiere que el humano la vea en el
|
||||
History de su GUI Hoppscotch self-hosted, en vivo. La entry aparece via la
|
||||
subscription `userHistoryCreated` sin que el humano refresque. Util para hacer
|
||||
auditable/observable lo que el agente prueba: cada `hoppscotch_run_request` deja
|
||||
en la pestana History la request (con sus variables sin resolver) y su statusCode
|
||||
+ duracion. Encadena con `hoppscotch_login` para obtener el `access_token`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** La mutation
|
||||
`createUserHistory` lee el JWT de la cookie `access_token`. Se manda con
|
||||
`cookies={"access_token": ...}`. Si expira (~24h), re-loguea con
|
||||
`hoppscotch_login`.
|
||||
- **reqData lleva la request SIN resolver.** Lo que se guarda en el History es el
|
||||
HoppRESTRequest con los placeholders `<<var>>`/`{{var}}` literales, igual que en
|
||||
el editor de la GUI, para que el humano vea la plantilla con sus variables y no
|
||||
los valores expandidos. La peticion SI se ejecuta con las variables resueltas.
|
||||
- **Soporta `<<>>` y `{{}}`.** Hoppscotch usa `<<var>>`; muchas plantillas traen
|
||||
`{{var}}`. Ambas sintaxis se resuelven al ejecutar. Una variable que falte en
|
||||
`variables` deja el literal intacto (no rompe).
|
||||
- **resMetadata minimo: statusCode + duration.** Se envia
|
||||
`{"statusCode": ..., "duration": ...}`. Si una version del backend exigiera mas
|
||||
campos, el registro fallaria con `history_error` (la ejecucion HTTP sigue siendo
|
||||
ok). Ajustar el shape si el self-host lo pide.
|
||||
- **El body de respuesta se trunca a 5000 chars** en `response_body` del output,
|
||||
para no devolver payloads enormes. Los `response_headers` van completos.
|
||||
- **duration_ms viene de `resp.elapsed`,** no de `time.time()`: es la latencia que
|
||||
midio `requests` para la peticion ejecutada.
|
||||
- **Degradacion suave del History:** si la ejecucion HTTP fue ok pero el POST de la
|
||||
mutation falla (transporte, no-JSON, errores GraphQL, sin id), `status` sigue
|
||||
"ok", `recorded` es False y se anade `history_error` con el detalle.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Ejecucion + registro en UserHistory del self-host;
|
||||
resolucion de placeholders `<<>>`/`{{}}`.
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Ejecuta una peticion HTTP y la registra en el History de Hoppscotch self-host.
|
||||
|
||||
Doble proposito: (1) lanza la request real con `requests` resolviendo placeholders
|
||||
de variables y (2) opcionalmente la persiste en el UserHistory del backend
|
||||
Hoppscotch self-hosted via la mutation GraphQL createUserHistory, de modo que el
|
||||
humano la vea aparecer en vivo en la pestana History de su GUI (la GUI escucha la
|
||||
subscription `userHistoryCreated`).
|
||||
|
||||
La request se ejecuta con las variables resueltas, pero lo que se guarda en el
|
||||
History es la request SIN resolver (con `<<var>>`/`{{var}}` literales), igual que
|
||||
en la GUI: asi el humano ve la plantilla con sus variables, no los valores
|
||||
expandidos. La mutation esta protegida por GqlAuthGuard: el JWT de sesion viaja en
|
||||
la cookie `access_token`.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from infra.build_hoppscotch_collection import build_hoppscotch_collection
|
||||
|
||||
# Hoppscotch usa la sintaxis <<var>>; muchas plantillas tambien traen {{var}}.
|
||||
# Aceptamos ambas: grupo 1 = delimitador de apertura, grupo 2 = nombre de la
|
||||
# variable, grupo 3 = delimitador de cierre.
|
||||
_VAR_RE = re.compile(r"(<<|\{\{)\s*([A-Za-z0-9_]+)\s*(>>|\}\})")
|
||||
|
||||
# Limite del cuerpo de respuesta en el output, para no devolver payloads enormes.
|
||||
_BODY_TRUNCATE = 5000
|
||||
|
||||
_HISTORY_MUTATION = (
|
||||
"mutation($d:String!,$m:String!,$t:ReqType!){"
|
||||
" createUserHistory(reqData:$d, resMetadata:$m, reqType:$t){ id } }"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_placeholders(text: str, variables: dict) -> str:
|
||||
"""Sustituye <<var>>/{{var}} por su valor en `variables`.
|
||||
|
||||
Si la variable no esta en `variables`, se conserva el literal tal cual
|
||||
(incluidos los delimitadores). Determinista y sin I/O.
|
||||
|
||||
Args:
|
||||
text: cadena con (opcionales) placeholders.
|
||||
variables: dict name->value con los valores de sustitucion.
|
||||
|
||||
Returns:
|
||||
la cadena con los placeholders conocidos resueltos.
|
||||
"""
|
||||
|
||||
def repl(match: re.Match) -> str:
|
||||
name = match.group(2)
|
||||
if name in variables:
|
||||
return str(variables[name])
|
||||
return match.group(0)
|
||||
|
||||
return _VAR_RE.sub(repl, text)
|
||||
|
||||
|
||||
def hoppscotch_run_request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
headers: dict | None = None,
|
||||
body: str | None = None,
|
||||
body_type: str | None = None,
|
||||
variables: dict | None = None,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
record_history: bool = True,
|
||||
timeout_s: float = 30.0,
|
||||
verify_tls: bool = True,
|
||||
) -> dict:
|
||||
"""Ejecuta una request HTTP y la registra en el History de Hoppscotch.
|
||||
|
||||
Resuelve los placeholders `<<var>>`/`{{var}}` de la url, los headers y el
|
||||
body usando `variables`, lanza la peticion real con `requests`, y (si
|
||||
`record_history`) guarda en el UserHistory del backend self-host la request
|
||||
SIN resolver (para que en la GUI History se vea con las variables, igual que
|
||||
en el editor).
|
||||
|
||||
Args:
|
||||
method: metodo HTTP (GET, POST, ...).
|
||||
url: endpoint, puede contener placeholders `<<var>>`/`{{var}}`.
|
||||
title: nombre visible de la request en el History. None = derivar de
|
||||
method + path via build_hoppscotch_collection.
|
||||
headers: dict name->value de cabeceras. Sus values admiten placeholders.
|
||||
body: cuerpo de la request como texto ya serializado. Admite placeholders.
|
||||
body_type: tipo de cuerpo ("json"|"form"|"raw"|None).
|
||||
variables: dict name->value para resolver los placeholders al EJECUTAR.
|
||||
None = no se resuelve nada (los literales viajan tal cual).
|
||||
access_token: JWT de sesion (de hoppscotch_login). Viaja en la cookie
|
||||
`access_token`, NO en el header Authorization. Necesario para grabar
|
||||
en el History.
|
||||
backend_url: base del backend Hoppscotch self-host (sin barra final).
|
||||
record_history: si True y hay access_token, registra la request en el
|
||||
UserHistory via createUserHistory.
|
||||
timeout_s: timeout de la peticion HTTP en segundos.
|
||||
verify_tls: verificacion del certificado TLS de la request ejecutada.
|
||||
|
||||
Returns:
|
||||
Dict. En exito de la ejecucion HTTP:
|
||||
``{"status": "ok", "status_code": int, "duration_ms": int,
|
||||
"response_body": str (truncado a 5000 chars), "response_headers": dict,
|
||||
"recorded": bool, "history_id": str|None}``. Si la ejecucion fue ok pero
|
||||
el registro de History fallo, `status` sigue "ok", `recorded` False y se
|
||||
anade `history_error`. Si la ejecucion HTTP falla (RequestException):
|
||||
``{"status": "error", "error": str, "recorded": False}``.
|
||||
"""
|
||||
variables = variables or {}
|
||||
headers = headers or {}
|
||||
|
||||
# 1) Resolver placeholders para EJECUTAR (copia; los originales se conservan
|
||||
# para registrarlos sin resolver en el History).
|
||||
resolved_url = _resolve_placeholders(url, variables)
|
||||
resolved_headers = {
|
||||
key: _resolve_placeholders(str(value), variables)
|
||||
for key, value in headers.items()
|
||||
}
|
||||
resolved_body = (
|
||||
_resolve_placeholders(body, variables) if body is not None else None
|
||||
)
|
||||
|
||||
# 2) Ejecutar la peticion real.
|
||||
try:
|
||||
resp = requests.request(
|
||||
method,
|
||||
resolved_url,
|
||||
headers=resolved_headers,
|
||||
data=resolved_body if resolved_body is not None else None,
|
||||
timeout=timeout_s,
|
||||
verify=verify_tls,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"transport error: {exc}",
|
||||
"recorded": False,
|
||||
}
|
||||
|
||||
duration_ms = int(resp.elapsed.total_seconds() * 1000)
|
||||
status_code = resp.status_code
|
||||
response_body = resp.text[:_BODY_TRUNCATE]
|
||||
response_headers = dict(resp.headers)
|
||||
|
||||
result = {
|
||||
"status": "ok",
|
||||
"status_code": status_code,
|
||||
"duration_ms": duration_ms,
|
||||
"response_body": response_body,
|
||||
"response_headers": response_headers,
|
||||
"recorded": False,
|
||||
"history_id": None,
|
||||
}
|
||||
|
||||
# 3) Registrar en el UserHistory (request SIN resolver, como en la GUI).
|
||||
if not record_history or not access_token:
|
||||
return result
|
||||
|
||||
spec = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
"body_type": body_type,
|
||||
}
|
||||
req_names = [title] if title else None
|
||||
req_item = build_hoppscotch_collection([spec], request_names=req_names)[
|
||||
"requests"
|
||||
][0]
|
||||
req_data = json.dumps(req_item)
|
||||
res_metadata = json.dumps(
|
||||
{"statusCode": status_code, "duration": duration_ms}
|
||||
)
|
||||
|
||||
payload = {
|
||||
"query": _HISTORY_MUTATION,
|
||||
"variables": {"d": req_data, "m": res_metadata, "t": "REST"},
|
||||
}
|
||||
|
||||
try:
|
||||
hist_resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json=payload,
|
||||
cookies={"access_token": access_token},
|
||||
timeout=timeout_s,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
result["history_error"] = f"transport error: {exc}"
|
||||
return result
|
||||
|
||||
try:
|
||||
hist_data = hist_resp.json()
|
||||
except ValueError:
|
||||
result["history_error"] = (
|
||||
f"non-JSON history response (HTTP {hist_resp.status_code})"
|
||||
)
|
||||
return result
|
||||
|
||||
if hist_data.get("errors"):
|
||||
result["history_error"] = f"graphql errors: {hist_data['errors']}"
|
||||
return result
|
||||
|
||||
created = (hist_data.get("data") or {}).get("createUserHistory")
|
||||
if not created or not created.get("id"):
|
||||
result["history_error"] = "createUserHistory returned no id"
|
||||
return result
|
||||
|
||||
result["recorded"] = True
|
||||
result["history_id"] = created["id"]
|
||||
return result
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Tests para hoppscotch_run_request.
|
||||
|
||||
Deterministas: monkeypatchean requests.request (ejecucion HTTP) y requests.post
|
||||
(mutation createUserHistory). Verifican la resolucion de placedores `<<>>`/`{{}}`
|
||||
para EJECUTAR, que el History recibe la request SIN resolver, y los caminos de
|
||||
record_history=False y de error de transporte.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
import infra.hoppscotch_run_request # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_run_request"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Respuesta minima para requests.request (ejecucion)."""
|
||||
|
||||
def __init__(self, status_code=200, text="OK", headers=None, elapsed_ms=12):
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self.headers = headers or {"Content-Type": "text/plain"}
|
||||
self.elapsed = timedelta(milliseconds=elapsed_ms)
|
||||
|
||||
|
||||
class _FakeGraphQLResponse:
|
||||
"""Respuesta minima para requests.post (createUserHistory)."""
|
||||
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def _patch_history_ok(monkeypatch, captured):
|
||||
def fake_post(url, **kwargs):
|
||||
captured["history_url"] = url
|
||||
captured["history_kwargs"] = kwargs
|
||||
return _FakeGraphQLResponse(
|
||||
200, {"data": {"createUserHistory": {"id": "hist-42"}}}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
|
||||
def test_ejecuta_resolviendo_variables_angle(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
captured["method"] = method
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(200, text="pong")
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
_patch_history_ok(monkeypatch, captured)
|
||||
|
||||
result = mod.hoppscotch_run_request(
|
||||
"GET",
|
||||
"<<baseURL>>/x",
|
||||
variables={"baseURL": "https://h"},
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
# La request ejecutada lleva la url resuelta.
|
||||
assert captured["url"] == "https://h/x"
|
||||
assert result["status"] == "ok"
|
||||
assert result["status_code"] == 200
|
||||
assert result["response_body"] == "pong"
|
||||
assert result["duration_ms"] == 12
|
||||
|
||||
|
||||
def test_ejecuta_resolviendo_variables_brace(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
captured["url"] = url
|
||||
return _FakeResponse(200)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
_patch_history_ok(monkeypatch, captured)
|
||||
|
||||
mod.hoppscotch_run_request(
|
||||
"GET",
|
||||
"{{baseURL}}/x",
|
||||
variables={"baseURL": "https://h"},
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
# {{var}} resuelve igual que <<var>>.
|
||||
assert captured["url"] == "https://h/x"
|
||||
|
||||
|
||||
def test_record_history_registra_request_sin_resolver(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
return _FakeResponse(200, text="body", headers={"X-Test": "1"})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
_patch_history_ok(monkeypatch, captured)
|
||||
|
||||
result = mod.hoppscotch_run_request(
|
||||
"GET",
|
||||
"<<baseURL>>/api/status",
|
||||
title="Status",
|
||||
variables={"baseURL": "https://h"},
|
||||
access_token="ACCESS-JWT",
|
||||
record_history=True,
|
||||
)
|
||||
|
||||
assert result["recorded"] is True
|
||||
assert result["history_id"] == "hist-42"
|
||||
|
||||
# El POST de History fue al endpoint GraphQL con la cookie access_token.
|
||||
assert captured["history_url"].endswith("/graphql")
|
||||
assert captured["history_kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
|
||||
payload = captured["history_kwargs"]["json"]
|
||||
assert "createUserHistory" in payload["query"]
|
||||
variables = payload["variables"]
|
||||
assert variables["t"] == "REST"
|
||||
|
||||
# reqData es el json string de un HoppRESTRequest v:"2" con la url SIN resolver.
|
||||
req = json.loads(variables["d"])
|
||||
assert req["v"] == "2"
|
||||
assert req["method"] == "GET"
|
||||
assert req["endpoint"] == "<<baseURL>>/api/status"
|
||||
assert req["name"] == "Status"
|
||||
|
||||
# resMetadata minimo: statusCode + duration.
|
||||
res_meta = json.loads(variables["m"])
|
||||
assert res_meta == {"statusCode": 200, "duration": 12}
|
||||
|
||||
|
||||
def test_record_history_false_no_llama_create_user_history(monkeypatch):
|
||||
calls = {"post": 0}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
return _FakeResponse(200)
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
calls["post"] += 1
|
||||
return _FakeGraphQLResponse(200, {"data": {"createUserHistory": {"id": "x"}}})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_run_request(
|
||||
"GET",
|
||||
"https://h/x",
|
||||
access_token="A",
|
||||
record_history=False,
|
||||
)
|
||||
|
||||
assert result["recorded"] is False
|
||||
assert result["history_id"] is None
|
||||
assert calls["post"] == 0
|
||||
|
||||
|
||||
def test_request_exception_status_error(monkeypatch):
|
||||
def fake_request(method, url, **kwargs):
|
||||
raise mod.requests.RequestException("boom")
|
||||
|
||||
# Si llegara a postear seria un fallo del test: no debe.
|
||||
def fake_post(url, **kwargs):
|
||||
raise AssertionError("no debe registrar history si la ejecucion fallo")
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_run_request(
|
||||
"GET", "https://h/x", access_token="A"
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["recorded"] is False
|
||||
assert "boom" in result["error"]
|
||||
|
||||
|
||||
def test_variable_faltante_conserva_literal(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
captured["url"] = url
|
||||
return _FakeResponse(200)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "request", fake_request)
|
||||
_patch_history_ok(monkeypatch, captured)
|
||||
|
||||
mod.hoppscotch_run_request(
|
||||
"GET",
|
||||
"<<baseURL>>/x",
|
||||
variables={"otra": "y"},
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
# baseURL no esta en variables -> el literal se conserva.
|
||||
assert captured["url"] == "<<baseURL>>/x"
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: hoppscotch_set_environment
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_set_environment(team_id: str, name: str, variables: list[dict], *, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
|
||||
description: "Crea o actualiza (idempotente por nombre) un Team Environment de Hoppscotch self-hosted via GraphQL, resolviendo secretos desde pass. Lista los environments de la team y, si ya existe uno con ese name, llama updateTeamEnvironment; si no, createTeamEnvironment. Cualquier variable cuyo value empiece por 'pass:' se resuelve con pass_get_secret y se fuerza secret=True. Los valores secretos nunca se logean ni aparecen en el output: resolved_secrets lista solo los keys. Las mutations estan protegidas por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token."
|
||||
tags: [hoppscotch, flow-replay, http, secret, infra]
|
||||
uses_functions: [pass_get_secret_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, requests]
|
||||
params:
|
||||
- name: team_id
|
||||
desc: "ID de la team duena del environment."
|
||||
- name: name
|
||||
desc: "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."
|
||||
- name: variables
|
||||
desc: "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. Campos secret ausentes se tratan como False."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
|
||||
output: "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 con el key afectado, GraphQL errors, HTTP no JSON, o fallo de transporte). Si una variable pass: no se resuelve, NO se crea/actualiza el environment."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_crea_cuando_no_existe"
|
||||
- "test_actualiza_cuando_existe"
|
||||
- "test_resuelve_secreto_desde_pass"
|
||||
- "test_error_pass_no_llama_mutation"
|
||||
test_file_path: "python/functions/infra/hoppscotch_set_environment_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_set_environment.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_set_environment import hoppscotch_set_environment
|
||||
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
# Una variable normal + una resuelta desde pass (se marca secret=True sola).
|
||||
result = hoppscotch_set_environment(
|
||||
team_id="cmq8kn0v500030xls1nvminjy",
|
||||
name="registry",
|
||||
variables=[
|
||||
{"key": "base_url", "value": "https://api.example.com", "secret": False},
|
||||
{"key": "api_key", "value": "pass:apis/licenseplatedata"},
|
||||
],
|
||||
access_token=token,
|
||||
)
|
||||
print(result)
|
||||
# {"status": "ok", "id": "...", "name": "registry",
|
||||
# "action": "updated", "resolved_secrets": ["api_key"]}
|
||||
# El valor crudo de api_key NUNCA aparece en el output.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieras definir o actualizar las variables de un workspace (team
|
||||
environment) Hoppscotch self-hosted desde el registry, con los secretos
|
||||
resueltos desde `pass` en vez de hardcodearlos. Util en el patron grabar->
|
||||
destilar->reproducir: tras destilar un flujo, dejas sus tokens/credenciales como
|
||||
variables `pass:` de un environment que el humano ve en la GUI, sin que el
|
||||
secreto pase por el codigo. Idempotente por nombre: vuelve a llamarla para
|
||||
actualizar sin duplicar. Primero obten el `access_token` con `hoppscotch_login`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Idempotente por nombre.** Busca un environment con ese `name` en la team: si
|
||||
existe lo actualiza, si no lo crea. Dos teams pueden tener environments con el
|
||||
mismo nombre sin colisionar (la busqueda es por team).
|
||||
- **`pass:` resuelve de pass y fuerza `secret=True`.** Si el `value` empieza por
|
||||
`pass:`, el resto es la ruta de pass; el secreto resuelto reemplaza al value y
|
||||
la variable queda marcada como secreta aunque pasaras `secret=False`.
|
||||
- **Nunca logea secretos.** Ni en stdout ni en el output: `resolved_secrets`
|
||||
contiene solo los KEYS resueltos desde pass, jamas los valores. El valor crudo
|
||||
no aparece en el dict de retorno.
|
||||
- **Falla en pass = no se toca el environment.** Si una variable `pass:` no se
|
||||
puede resolver, la funcion aborta con `{"status": "error"}` y el key afectado
|
||||
ANTES de cualquier mutation: no deja el environment a medias.
|
||||
- **El access_token va como cookie, no como header Authorization.** Las mutations
|
||||
estan protegidas por GqlAuthGuard que lee el JWT de la cookie `access_token`.
|
||||
- **El secreto viaja en claro al backend self-host local por GraphQL.** Hoppscotch
|
||||
recibe el valor resuelto en el campo `variables`. Es aceptable porque el backend
|
||||
de referencia es local; no apuntes esta funcion a un Hoppscotch remoto sin TLS.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Listado + create + update validados contra el self-host
|
||||
vivo el 11/06/2026 (createTeamEnvironment / updateTeamEnvironment / listado via
|
||||
team{ teamEnvironments }). Resolucion `pass:` via pass_get_secret_py_infra.
|
||||
@@ -0,0 +1,175 @@
|
||||
"""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
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Tests para hoppscotch_set_environment.
|
||||
|
||||
Deterministas: monkeypatchean requests.post (capa de red) y pass_get_secret.
|
||||
Verifican crear vs actualizar (idempotencia por nombre), resolucion de secretos
|
||||
`pass:` (fuerza secret=True, key en resolved_secrets, valor crudo fuera del
|
||||
output), y abortar sin llamar la mutation si pass falla.
|
||||
|
||||
Hay un test e2e real marcado skip por defecto (self-host vivo).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import infra.hoppscotch_set_environment # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_set_environment"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def _make_post(call_log, list_envs, mutation_result):
|
||||
"""Construye un fake requests.post que distingue listado de mutation.
|
||||
|
||||
El listado lleva 'teamEnvironments' en la query; cualquier otra es mutation.
|
||||
Cada llamada se registra en call_log para inspeccion.
|
||||
"""
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
payload = kwargs["json"]
|
||||
query = payload["query"]
|
||||
call_log.append({"url": url, "kwargs": kwargs, "query": query})
|
||||
if "teamEnvironments" in query:
|
||||
return _FakeResponse(
|
||||
200, {"data": {"team": {"teamEnvironments": list_envs}}}
|
||||
)
|
||||
# mutation (create o update)
|
||||
field = (
|
||||
"updateTeamEnvironment"
|
||||
if "updateTeamEnvironment" in query
|
||||
else "createTeamEnvironment"
|
||||
)
|
||||
return _FakeResponse(200, {"data": {field: mutation_result}})
|
||||
|
||||
return fake_post
|
||||
|
||||
|
||||
def test_crea_cuando_no_existe(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
mod.requests,
|
||||
"post",
|
||||
_make_post(calls, list_envs=[], mutation_result={"id": "env-1", "name": "test_env"}),
|
||||
)
|
||||
|
||||
result = mod.hoppscotch_set_environment(
|
||||
"team-1",
|
||||
"test_env",
|
||||
[{"key": "foo", "value": "bar", "secret": False}],
|
||||
access_token="ACCESS-JWT",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["id"] == "env-1"
|
||||
assert result["action"] == "created"
|
||||
assert result["resolved_secrets"] == []
|
||||
|
||||
# Segunda llamada = mutation createTeamEnvironment con las variables.
|
||||
mutation = calls[1]
|
||||
assert "createTeamEnvironment" in mutation["query"]
|
||||
assert mutation["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
gql_vars = mutation["kwargs"]["json"]["variables"]
|
||||
assert gql_vars["n"] == "test_env"
|
||||
assert gql_vars["t"] == "team-1"
|
||||
sent_vars = json.loads(gql_vars["v"])
|
||||
assert sent_vars == [{"key": "foo", "value": "bar", "secret": False}]
|
||||
|
||||
|
||||
def test_actualiza_cuando_existe(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
mod.requests,
|
||||
"post",
|
||||
_make_post(
|
||||
calls,
|
||||
list_envs=[{"id": "env-existing", "name": "test_env"}],
|
||||
mutation_result={"id": "env-existing", "name": "test_env"},
|
||||
),
|
||||
)
|
||||
|
||||
result = mod.hoppscotch_set_environment(
|
||||
"team-1",
|
||||
"test_env",
|
||||
[{"key": "foo", "value": "bar"}],
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["id"] == "env-existing"
|
||||
assert result["action"] == "updated"
|
||||
|
||||
mutation = calls[1]
|
||||
assert "updateTeamEnvironment" in mutation["query"]
|
||||
gql_vars = mutation["kwargs"]["json"]["variables"]
|
||||
assert gql_vars["id"] == "env-existing"
|
||||
|
||||
|
||||
def test_resuelve_secreto_desde_pass(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
mod.requests,
|
||||
"post",
|
||||
_make_post(calls, list_envs=[], mutation_result={"id": "env-2", "name": "e"}),
|
||||
)
|
||||
|
||||
def fake_pass(path, **kwargs):
|
||||
assert path == "apis/lpd"
|
||||
return {"status": "ok", "value": "TOP-SECRET-VALUE"}
|
||||
|
||||
monkeypatch.setattr(mod, "pass_get_secret", fake_pass)
|
||||
|
||||
result = mod.hoppscotch_set_environment(
|
||||
"team-1",
|
||||
"e",
|
||||
[
|
||||
{"key": "plain", "value": "visible", "secret": False},
|
||||
{"key": "apikey", "value": "pass:apis/lpd", "secret": False},
|
||||
],
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
# El key resuelto aparece, pero NUNCA el valor crudo.
|
||||
assert result["resolved_secrets"] == ["apikey"]
|
||||
assert "TOP-SECRET-VALUE" not in json.dumps(result)
|
||||
|
||||
# La variable resuelta viaja con el valor real y secret=True forzado.
|
||||
mutation = calls[1]
|
||||
sent_vars = json.loads(mutation["kwargs"]["json"]["variables"]["v"])
|
||||
by_key = {v["key"]: v for v in sent_vars}
|
||||
assert by_key["apikey"]["value"] == "TOP-SECRET-VALUE"
|
||||
assert by_key["apikey"]["secret"] is True
|
||||
assert by_key["plain"]["value"] == "visible"
|
||||
assert by_key["plain"]["secret"] is False
|
||||
|
||||
|
||||
def test_error_pass_no_llama_mutation(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
calls.append(kwargs["json"]["query"])
|
||||
return _FakeResponse(200, {"data": {"team": {"teamEnvironments": []}}})
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
def fake_pass(path, **kwargs):
|
||||
return {"status": "error", "error": "pass not installed"}
|
||||
|
||||
monkeypatch.setattr(mod, "pass_get_secret", fake_pass)
|
||||
|
||||
result = mod.hoppscotch_set_environment(
|
||||
"team-1",
|
||||
"e",
|
||||
[{"key": "apikey", "value": "pass:apis/lpd"}],
|
||||
access_token="A",
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "apikey" in result["error"]
|
||||
# No se hizo ninguna llamada de red (ni listado ni mutation): aborta antes.
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="e2e real contra self-host vivo")
|
||||
def test_e2e_create_then_update_live():
|
||||
"""End-to-end real contra el Hoppscotch self-host vivo.
|
||||
|
||||
login -> set_environment("test_env") -> created -> set_environment de nuevo
|
||||
-> updated. Limpia el env al final con deleteTeamEnvironment.
|
||||
"""
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
import requests
|
||||
|
||||
team_id = "cmq8kn0v500030xls1nvminjy"
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
first = mod.hoppscotch_set_environment(
|
||||
team_id,
|
||||
"test_env",
|
||||
[{"key": "foo", "value": "bar", "secret": False}],
|
||||
access_token=token,
|
||||
)
|
||||
assert first["status"] == "ok"
|
||||
assert first["action"] == "created"
|
||||
env_id = first["id"]
|
||||
|
||||
second = mod.hoppscotch_set_environment(
|
||||
team_id,
|
||||
"test_env",
|
||||
[{"key": "foo", "value": "baz", "secret": False}],
|
||||
access_token=token,
|
||||
)
|
||||
assert second["status"] == "ok"
|
||||
assert second["action"] == "updated"
|
||||
assert second["id"] == env_id
|
||||
|
||||
# Cleanup: borra el env de prueba.
|
||||
del_q = "mutation($id:ID!){ deleteTeamEnvironment(id:$id) }"
|
||||
requests.post(
|
||||
"http://localhost:3170/graphql",
|
||||
json={"query": del_q, "variables": {"id": env_id}},
|
||||
cookies={"access_token": token},
|
||||
timeout=15.0,
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: hoppscotch_update_request
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def hoppscotch_update_request(request_id: str, method: str, url: str, *, title: str | None = None, headers: dict | None = None, body: str | None = None, body_type: str | None = None, access_token: str, backend_url: str = \"http://localhost:3170\") -> dict"
|
||||
description: "Actualiza una request REST existente en Hoppscotch self-hosted via la mutation GraphQL updateRequest. Reconstruye el HoppRESTRequest canonico reusando build_hoppscotch_collection del registry y lo aplica sobre la request identificada por request_id. Protegida por GqlAuthGuard: el JWT de sesion (de hoppscotch_login) viaja en la cookie access_token."
|
||||
tags: [hoppscotch, flow-replay, http, infra, crud]
|
||||
uses_functions: [build_hoppscotch_collection_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [json, requests]
|
||||
params:
|
||||
- name: request_id
|
||||
desc: "ID de la request existente a actualizar."
|
||||
- name: method
|
||||
desc: "metodo HTTP de la request (GET, POST, ...). Se normaliza a mayusculas."
|
||||
- name: url
|
||||
desc: "endpoint completo de la request (con query string si aplica)."
|
||||
- name: title
|
||||
desc: "nuevo nombre visible de la request. None = derivar de method + path."
|
||||
- name: headers
|
||||
desc: "dict name->value de cabeceras de la request. None = sin cabeceras."
|
||||
- name: body
|
||||
desc: "cuerpo de la request como texto YA serializado. None = sin cuerpo."
|
||||
- name: body_type
|
||||
desc: "tipo de cuerpo: 'json' | 'form' | 'raw' | None."
|
||||
- name: access_token
|
||||
desc: "JWT de sesion (de hoppscotch_login). Viaja en la cookie access_token, NO en el header Authorization."
|
||||
- name: backend_url
|
||||
desc: "base del backend Hoppscotch sin barra final. El endpoint GraphQL es {backend_url}/graphql. Default http://localhost:3170."
|
||||
output: "dict. En exito: {status: 'ok', id: str, title: str}. En error (GraphQL errors, respuesta no JSON, sin id, o fallo de transporte): {status: 'error', error: str, data: <cuerpo GraphQL si lo hubo>}. Nunca lanza por errores de red esperables."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_actualiza_request_y_devuelve_id"
|
||||
- "test_error_graphql_errors"
|
||||
test_file_path: "python/functions/infra/hoppscotch_update_request_test.py"
|
||||
file_path: "python/functions/infra/hoppscotch_update_request.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.hoppscotch_login import hoppscotch_login
|
||||
from infra.hoppscotch_update_request import hoppscotch_update_request
|
||||
|
||||
token = hoppscotch_login("admin@example.com")["access_token"]
|
||||
|
||||
# Actualizar una request existente: cambiar metodo, url, titulo y body.
|
||||
result = hoppscotch_update_request(
|
||||
request_id="cmq8lue8l000x0xlsd62bncpi",
|
||||
method="POST",
|
||||
url="https://api.example.com/login",
|
||||
title="Login (actualizado)",
|
||||
body='{"user":"neo"}',
|
||||
body_type="json",
|
||||
access_token=token,
|
||||
)
|
||||
print(result) # {"status": "ok", "id": "...", "title": "Login (actualizado)"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando una request ya existe en una team collection y quieres reescribir su
|
||||
contenido (metodo, url, cabeceras, body o titulo) desde el agente, para que el
|
||||
humano vea el cambio reflejado en vivo en la GUI por subscriptions. Necesitas el
|
||||
`request_id` (de `hoppscotch_list_requests` o de un `hoppscotch_create_request`
|
||||
previo) y un `access_token` fresco de `hoppscotch_login`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **El access_token va como cookie, no como header Authorization.** La mutation
|
||||
esta protegida por GqlAuthGuard que lee el JWT de la cookie `access_token`.
|
||||
- **El token expira (~24h).** Si la llamada devuelve un error de auth, re-loguea
|
||||
con `hoppscotch_login`.
|
||||
- **`request` debe ser un json string de un HoppRESTRequest v:"2".** El campo
|
||||
`request` del input es un string; esta funcion lo serializa con `json.dumps`.
|
||||
- **Reescribe la request entera, no hace patch.** El HoppRESTRequest enviado
|
||||
reemplaza el contenido: pasa todos los campos que quieras conservar, no solo los
|
||||
que cambian.
|
||||
- **Secreto — nunca logear el token en crudo.** Trata el JWT como un secreto.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Validado contra el self-host vivo el 10/06/2026
|
||||
(create -> update -> list confirmo el titulo actualizado).
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Actualiza una request REST existente en Hoppscotch.
|
||||
|
||||
Reconstruye el HoppRESTRequest canonico (reusando build_hoppscotch_collection
|
||||
del registry) y lo aplica sobre una request existente via la mutation GraphQL
|
||||
updateRequest del backend self-hosted. Protegida por GqlAuthGuard: el JWT de
|
||||
sesion viaja en la cookie `access_token`.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
from infra.build_hoppscotch_collection import build_hoppscotch_collection
|
||||
|
||||
_MUTATION = (
|
||||
"mutation($r:ID!,$d:UpdateTeamRequestInput!){"
|
||||
" updateRequest(requestID:$r, data:$d){ id title } }"
|
||||
)
|
||||
|
||||
|
||||
def hoppscotch_update_request(
|
||||
request_id: str,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
headers: dict | None = None,
|
||||
body: str | None = None,
|
||||
body_type: str | None = None,
|
||||
access_token: str,
|
||||
backend_url: str = "http://localhost:3170",
|
||||
) -> dict:
|
||||
"""Actualiza una request existente en Hoppscotch.
|
||||
|
||||
Args:
|
||||
request_id: ID de la request a actualizar.
|
||||
method: metodo HTTP de la request (GET, POST, ...).
|
||||
url: endpoint de la request.
|
||||
title: nombre visible de la request en la GUI. None = derivar de
|
||||
method + path via build_hoppscotch_collection.
|
||||
headers: dict name->value de cabeceras de la request.
|
||||
body: cuerpo de la request como texto ya serializado.
|
||||
body_type: tipo de cuerpo ("json"|"form"|"raw"|None).
|
||||
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).
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "id": str, "title": str}``. En error
|
||||
(GraphQL errors, HTTP no 200, transporte): ``{"status": "error",
|
||||
"error": str, "data": ...}`` con el cuerpo GraphQL si lo hubo.
|
||||
"""
|
||||
spec = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": headers or {},
|
||||
"body": body,
|
||||
"body_type": body_type,
|
||||
}
|
||||
req_names = [title] if title else None
|
||||
req_item = build_hoppscotch_collection([spec], request_names=req_names)[
|
||||
"requests"
|
||||
][0]
|
||||
|
||||
payload = {
|
||||
"query": _MUTATION,
|
||||
"variables": {
|
||||
"r": request_id,
|
||||
"d": {
|
||||
"title": req_item["name"],
|
||||
"request": json.dumps(req_item),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{backend_url}/graphql",
|
||||
json=payload,
|
||||
cookies={"access_token": access_token},
|
||||
timeout=30.0,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return {"status": "error", "error": f"transport error: {exc}"}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"non-JSON response (HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
if data.get("errors"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "graphql errors",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
updated = (data.get("data") or {}).get("updateRequest")
|
||||
if not updated or not updated.get("id"):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "updateRequest returned no id",
|
||||
"data": data,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"id": updated["id"],
|
||||
"title": updated.get("title"),
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests para hoppscotch_update_request.
|
||||
|
||||
Deterministas: monkeypatchean requests.post para no tocar la red. Verifican que
|
||||
el POST GraphQL lleva la mutation updateRequest con requestID, el access_token
|
||||
en la cookie, y que `request` es el json string de un HoppRESTRequest v:"2".
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import infra.hoppscotch_update_request # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.hoppscotch_update_request"]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, json_data=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data
|
||||
|
||||
def json(self):
|
||||
if self._json is None:
|
||||
raise ValueError("no json")
|
||||
return self._json
|
||||
|
||||
|
||||
def test_golden_actualiza_request_y_devuelve_id(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["kwargs"] = kwargs
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"data": {
|
||||
"updateRequest": {"id": "req-7", "title": "New title"}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_update_request(
|
||||
"req-7",
|
||||
"POST",
|
||||
"https://api.example.com/x",
|
||||
title="New title",
|
||||
body="a=1&b=2",
|
||||
body_type="form",
|
||||
access_token="ACCESS-JWT",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["id"] == "req-7"
|
||||
assert result["title"] == "New title"
|
||||
|
||||
assert captured["url"].endswith("/graphql")
|
||||
assert captured["kwargs"]["cookies"] == {"access_token": "ACCESS-JWT"}
|
||||
|
||||
payload = captured["kwargs"]["json"]
|
||||
assert "updateRequest" in payload["query"]
|
||||
variables = payload["variables"]
|
||||
assert variables["r"] == "req-7"
|
||||
assert variables["d"]["title"] == "New title"
|
||||
|
||||
req = json.loads(variables["d"]["request"])
|
||||
assert req["v"] == "2"
|
||||
assert req["method"] == "POST"
|
||||
assert req["body"] == {
|
||||
"contentType": "application/x-www-form-urlencoded",
|
||||
"body": "a=1&b=2",
|
||||
}
|
||||
|
||||
|
||||
def test_error_graphql_errors(monkeypatch):
|
||||
def fake_post(url, **kwargs):
|
||||
return _FakeResponse(
|
||||
200, {"errors": [{"message": "team_req/not_found"}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod.requests, "post", fake_post)
|
||||
|
||||
result = mod.hoppscotch_update_request(
|
||||
"missing", "GET", "https://x", access_token="A"
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "graphql errors"
|
||||
assert "errors" in result["data"]
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: pass_get_secret
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def pass_get_secret(path: str, *, line: int = 1, timeout_s: float = 10.0) -> dict"
|
||||
description: "Lee un secreto del gestor de contrasenas pass (passwordstore.org) ejecutando `pass show <path>` como subproceso (lista de args, nunca shell=True). Devuelve la linea solicitada (1-indexed): line=1 es la contrasena por convencion de pass, line=N es metadata multilinea (usuario, URL, notas). El valor es sensible y la funcion NUNCA lo logea. Maneja errores sin lanzar: pass no instalado, entry inexistente, linea fuera de rango. Solo usa stdlib (subprocess)."
|
||||
tags: [pass, secret, credential, infra, flow-replay]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [subprocess]
|
||||
params:
|
||||
- name: path
|
||||
desc: "ruta del secreto dentro del store (p.ej. 'gitea/dataforge-git-token'). Es el argumento que recibiria `pass show <path>`."
|
||||
- name: line
|
||||
desc: "numero de linea a devolver, 1-indexed. line=1 (default) = primera linea = contrasena por convencion de pass. line=N = linea N para metadata multilinea."
|
||||
- name: timeout_s
|
||||
desc: "timeout del subproceso `pass show` en segundos. Default 10.0."
|
||||
output: "dict. En exito: {status: 'ok', value: str} con la linea pedida sin el salto de linea final. En error (sin lanzar): {status: 'error', error: str} para pass no instalado ('pass not installed'), entry inexistente o fallo de pass (stderr stripeado), o linea fuera de rango ('line N out of range')."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_line_1_devuelve_la_password"
|
||||
- "test_line_2_devuelve_metadata"
|
||||
- "test_returncode_distinto_de_cero_es_error"
|
||||
- "test_pass_no_instalado_es_error"
|
||||
- "test_linea_fuera_de_rango_es_error"
|
||||
- "test_timeout_es_error"
|
||||
test_file_path: "python/functions/infra/pass_get_secret_test.py"
|
||||
file_path: "python/functions/infra/pass_get_secret.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.pass_get_secret import pass_get_secret
|
||||
|
||||
# Primera linea = la contrasena/token (convencion de pass).
|
||||
res = pass_get_secret("gitea/dataforge-git-token")
|
||||
print(res) # {"status": "ok", "value": "ghp_..."} -- NO logear el value en prod
|
||||
|
||||
# Linea 2 = metadata (p.ej. el usuario), si el entry es multilinea.
|
||||
user = pass_get_secret("apis/licenseplatedata", line=2)
|
||||
print(user) # {"status": "ok", "value": "user: neo"}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites resolver un secreto de `pass` para inyectarlo en una config,
|
||||
un header HTTP, una variable de entorno o un body de request sin hardcodearlo en
|
||||
el codigo. Es el lector de secretos del registry en Python: el caller pide la
|
||||
ruta del store y recibe el valor en `value`, listo para enchufar donde haga
|
||||
falta. line=1 para la password; line=N para metadata (usuario, URL, notas).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Requiere `pass` instalado y el GPG agent desbloqueado.** Si `pass` no esta en
|
||||
el PATH devuelve `{"status": "error", "error": "pass not installed"}`. Si el
|
||||
agente GPG esta bloqueado, `pass show` puede colgarse hasta el `timeout_s`.
|
||||
- **El valor es un secreto: no lo logees.** La funcion nunca lo imprime ni lo
|
||||
registra. Trata el campo `value` como sensible aguas arriba (no `print` en
|
||||
produccion, no persistir en claro).
|
||||
- **line=1 es la contrasena.** Por convencion de pass la primera linea es el
|
||||
secreto principal; las lineas siguientes son metadata 1-indexed.
|
||||
- **No usa shell.** Ejecuta `["pass", "show", path]` como lista de args, nunca
|
||||
`shell=True`, asi que `path` no puede inyectar comandos.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v1.0.0 — version inicial. Lector de secretos `pass` para Python, base de la
|
||||
resolucion `pass:` en hoppscotch_set_environment.
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Lee un secreto del gestor de contrasenas `pass` (passwordstore.org).
|
||||
|
||||
Ejecuta `pass show <path>` como subproceso (lista de args, nunca shell=True) y
|
||||
devuelve la linea solicitada del secreto. Por convencion de pass, la primera
|
||||
linea es la contrasena y las lineas siguientes son metadata (usuario, URL,
|
||||
notas, etc.).
|
||||
|
||||
El valor devuelto es sensible: esta funcion NUNCA lo logea. El caller es
|
||||
responsable de tratarlo como secreto (no imprimirlo, no persistirlo en claro).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def pass_get_secret(path: str, *, line: int = 1, timeout_s: float = 10.0) -> dict:
|
||||
"""Lee una linea de un secreto del password store (pass).
|
||||
|
||||
Args:
|
||||
path: ruta del secreto dentro del store (p.ej. "gitea/dataforge-git-token").
|
||||
Es el argumento que recibiria `pass show <path>`.
|
||||
line: numero de linea a devolver, 1-indexed. line=1 (default) es la
|
||||
primera linea = la contrasena por convencion de pass. line=N
|
||||
devuelve la linea N para metadata multilinea.
|
||||
timeout_s: timeout del subproceso en segundos.
|
||||
|
||||
Returns:
|
||||
Dict. En exito: ``{"status": "ok", "value": str}`` con la linea pedida
|
||||
sin el salto de linea final. En error (sin lanzar):
|
||||
``{"status": "error", "error": str}`` para: pass no instalado, entry
|
||||
inexistente / fallo de pass (returncode != 0), o linea fuera de rango.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["pass", "show", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return {"status": "error", "error": "pass not installed"}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "error": f"pass timed out after {timeout_s}s"}
|
||||
|
||||
if proc.returncode != 0:
|
||||
return {"status": "error", "error": (proc.stderr or "").strip()}
|
||||
|
||||
# `pass show` termina con un salto de linea; splitlines lo absorbe.
|
||||
lines = proc.stdout.splitlines()
|
||||
if line < 1 or line > len(lines):
|
||||
return {"status": "error", "error": f"line {line} out of range"}
|
||||
|
||||
return {"status": "ok", "value": lines[line - 1]}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests para pass_get_secret.
|
||||
|
||||
Deterministas: monkeypatchean subprocess.run para no ejecutar `pass` real.
|
||||
Verifican seleccion de linea (1-indexed), errores de pass (returncode != 0),
|
||||
pass no instalado (FileNotFoundError) y linea fuera de rango.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import infra.pass_get_secret # noqa: F401
|
||||
|
||||
# El __init__ rebinds el nombre a la funcion; recuperamos el submodulo real.
|
||||
mod = sys.modules["infra.pass_get_secret"]
|
||||
|
||||
|
||||
class _FakeCompleted:
|
||||
def __init__(self, returncode=0, stdout="", stderr=""):
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def test_line_1_devuelve_la_password(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
assert args == ["pass", "show", "apis/lpd"]
|
||||
assert kwargs.get("shell") is None # nunca shell=True
|
||||
return _FakeCompleted(0, stdout="s3cr3t-pass\nuser: neo\nurl: https://x\n")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/lpd")
|
||||
assert result == {"status": "ok", "value": "s3cr3t-pass"}
|
||||
|
||||
|
||||
def test_line_2_devuelve_metadata(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
return _FakeCompleted(0, stdout="s3cr3t-pass\nuser: neo\nurl: https://x\n")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/lpd", line=2)
|
||||
assert result == {"status": "ok", "value": "user: neo"}
|
||||
|
||||
|
||||
def test_returncode_distinto_de_cero_es_error(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
return _FakeCompleted(1, stdout="", stderr="Error: apis/nope is not in the password store.\n")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/nope")
|
||||
assert result["status"] == "error"
|
||||
assert result["error"] == "Error: apis/nope is not in the password store."
|
||||
|
||||
|
||||
def test_pass_no_instalado_es_error(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
raise FileNotFoundError("no such file: pass")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/lpd")
|
||||
assert result == {"status": "error", "error": "pass not installed"}
|
||||
|
||||
|
||||
def test_linea_fuera_de_rango_es_error(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
return _FakeCompleted(0, stdout="solo-una-linea\n")
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/lpd", line=5)
|
||||
assert result == {"status": "error", "error": "line 5 out of range"}
|
||||
|
||||
|
||||
def test_timeout_es_error(monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd=args, timeout=10.0)
|
||||
|
||||
monkeypatch.setattr(mod.subprocess, "run", fake_run)
|
||||
|
||||
result = mod.pass_get_secret("apis/lpd")
|
||||
assert result["status"] == "error"
|
||||
assert "timed out" in result["error"]
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: sync_chromium_profiles_to_rofi
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "sync_chromium_profiles_to_rofi(local_state_path: str = '', apps_dir: str = '', icons_dir: str = '', chromium_cmd: str = 'chromium') -> dict"
|
||||
description: "Sincroniza los perfiles de Chromium con el lanzador (rofi / menus .desktop). Lee profile.info_cache del Local State y por cada perfil de usuario valido genera un avatar PNG con las iniciales (o reutiliza la foto Gaia) y un archivo .desktop que lanza Chromium en ese perfil con su icono. Limpia los .desktop obsoletos generados por esta funcion (marcador X-RofiChromiumProfile) y refresca la base de datos de aplicaciones con update-desktop-database (best-effort). Excluye System Profile y perfiles con nombre vacio. Idempotente."
|
||||
tags: ["rofi", "chromium", "desktop", "launcher", "xdg", "profiles", "icons"]
|
||||
params:
|
||||
- name: local_state_path
|
||||
desc: "Ruta al archivo 'Local State' de Chromium (JSON con profile.info_cache). Si viene vacio usa ~/.config/chromium-cdp/Local State. El user-data-dir se deriva como su dirname."
|
||||
- name: apps_dir
|
||||
desc: "Directorio XDG de salida de los .desktop. Si viene vacio usa ~/.local/share/applications. rofi escanea este arbol."
|
||||
- name: icons_dir
|
||||
desc: "Directorio de salida de los iconos PNG por perfil. Si viene vacio usa ~/.local/share/icons/chromium-profiles."
|
||||
- name: chromium_cmd
|
||||
desc: "Comando base para el Exec del .desktop. Default 'chromium'. NO se pasa --user-data-dir: el wrapper del sistema (/etc/chromium.d/cdp) lo inyecta. El --profile-directory es relativo a ese user-data-dir."
|
||||
output: "dict con user_data_dir (str absoluto), created (lista de .desktop escritos), removed (lista de .desktop obsoletos borrados), profiles (lista de {dir, name, desktop, icon}), warnings (perfiles saltados por datos raros) y update_desktop_database ({attempted, ok, detail})."
|
||||
uses_functions:
|
||||
- generate_initials_avatar_py_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- Pillow
|
||||
file_path: "python/functions/infra/sync_chromium_profiles_to_rofi.py"
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Ejecucion directa con defaults (lee ~/.config/chromium-cdp/Local State,
|
||||
# escribe .desktop en ~/.local/share/applications y PNGs en
|
||||
# ~/.local/share/icons/chromium-profiles)
|
||||
cd $HOME/fn_registry
|
||||
./fn run sync_chromium_profiles_to_rofi_py_infra
|
||||
```
|
||||
|
||||
```python
|
||||
# Variante Python importando la funcion (PYTHONPATH=python/functions via fn run,
|
||||
# o sys.path.insert al ejecutar suelto).
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.sync_chromium_profiles_to_rofi import sync_chromium_profiles_to_rofi
|
||||
|
||||
# Apuntando a un Local State y directorios de salida de prueba en /tmp
|
||||
res = sync_chromium_profiles_to_rofi(
|
||||
local_state_path="/tmp/chromium-test/Local State",
|
||||
apps_dir="/tmp/chromium-test/apps",
|
||||
icons_dir="/tmp/chromium-test/icons",
|
||||
)
|
||||
print(res["created"], res["removed"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Tras crear, renombrar o eliminar un perfil de Chromium, o a diario via dag_engine,
|
||||
para que los perfiles aparezcan en rofi (y en cualquier menu XDG) con su propio
|
||||
icono y un Exec que abre Chromium directamente en ese perfil. Util tambien al
|
||||
provisionar una maquina nueva donde se han clonado varios perfiles.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El `Exec` del `.desktop` depende del wrapper del sistema que inyecta
|
||||
`--user-data-dir=$HOME/.config/chromium-cdp` (via `/etc/chromium.d/cdp`). Por eso
|
||||
NO se pasa `--user-data-dir` aqui; solo `chromium --profile-directory="<dir>"`. En
|
||||
una maquina sin ese wrapper el `.desktop` abriria el perfil del user-data-dir por
|
||||
defecto de Chromium, no el del directorio `chromium-cdp`.
|
||||
- El marcador `X-RofiChromiumProfile=true` gobierna la limpieza: la funcion solo
|
||||
borra `.desktop` que ella misma genero. NUNCA toca `.desktop` ajenos aunque
|
||||
coincidan con el patron `chromium-*.desktop`.
|
||||
- `update-desktop-database` es best-effort: si el binario no esta en el PATH, la
|
||||
funcion NO falla; lo reporta en `update_desktop_database.detail`. rofi suele
|
||||
funcionar igualmente sin el refresh, pero algunos menus cachean.
|
||||
- Excluye siempre `"System Profile"` (no es un perfil de usuario) y cualquier
|
||||
entrada con `name` vacio.
|
||||
- Si un perfil tiene `gaia_picture_file_name` y el archivo existe en el dir del
|
||||
perfil, se usa esa imagen tal cual como icono (sin recorte circular). Si no, se
|
||||
genera el avatar de iniciales con `generate_initials_avatar`.
|
||||
- El `slug` se deriva de la CLAVE del `info_cache` (el nombre del subdirectorio,
|
||||
ej. `"Profile 1"` -> `profile-1`), no del nombre legible. Asi dos perfiles con el
|
||||
mismo nombre visible pero distinto directorio no colisionan.
|
||||
@@ -0,0 +1,264 @@
|
||||
"""sync_chromium_profiles_to_rofi — sincroniza perfiles de Chromium con el lanzador (rofi / menus .desktop).
|
||||
|
||||
Lee los perfiles de Chromium desde el `Local State` de un user-data-dir y, por
|
||||
cada perfil de usuario valido, genera:
|
||||
|
||||
1. un avatar PNG con las iniciales del perfil (icono distinto por perfil), o
|
||||
reutiliza la foto Gaia del perfil si esta disponible,
|
||||
2. un archivo `.desktop` que lanza Chromium en ese perfil con ese icono.
|
||||
|
||||
Ademas limpia los `.desktop` de perfiles que ya no existen (identificados por el
|
||||
marcador `X-RofiChromiumProfile=true`) y refresca la base de datos de aplicaciones
|
||||
con `update-desktop-database` (best-effort).
|
||||
|
||||
El `Exec` NO pasa `--user-data-dir`: el wrapper del sistema (`/etc/chromium.d/cdp`)
|
||||
inyecta `--user-data-dir=$HOME/.config/chromium-cdp` en cada invocacion de chromium.
|
||||
Por eso basta `chromium --profile-directory="<dir>"`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Resolver el paquete `infra` del registry tanto al ejecutar via `fn run`
|
||||
# (PYTHONPATH=python/functions) como al ejecutar el archivo directo como script.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from infra.generate_initials_avatar import generate_initials_avatar # noqa: E402
|
||||
|
||||
# Marcador que identifica los .desktop generados por ESTA funcion. Gobierna la
|
||||
# limpieza de obsoletos sin tocar .desktop ajenos.
|
||||
MARKER = "X-RofiChromiumProfile=true"
|
||||
|
||||
|
||||
def _slugify(key: str) -> str:
|
||||
"""Convierte la clave del dir del perfil en un slug seguro para nombres de archivo.
|
||||
|
||||
"Profile 1" -> "profile-1", "Default" -> "default", "osint_01" -> "osint-01".
|
||||
"""
|
||||
s = key.strip().lower()
|
||||
s = re.sub(r"[\s_]+", "-", s)
|
||||
s = re.sub(r"[^a-z0-9-]", "", s)
|
||||
s = re.sub(r"-+", "-", s).strip("-")
|
||||
return s
|
||||
|
||||
|
||||
def _desktop_body(name: str, dir_key: str, icon_path: str, chromium_cmd: str) -> str:
|
||||
"""Construye el cuerpo del archivo .desktop para un perfil.
|
||||
|
||||
El em dash en `Name` es intencional. `dir_key` se encierra entre comillas
|
||||
dobles en el Exec porque puede contener espacios ("Profile 1").
|
||||
"""
|
||||
return (
|
||||
"[Desktop Entry]\n"
|
||||
"Version=1.0\n"
|
||||
"Type=Application\n"
|
||||
f"Name=Chromium — {name}\n"
|
||||
"GenericName=Web Browser\n"
|
||||
f"Comment=Chromium en el perfil {name}\n"
|
||||
f'Exec={chromium_cmd} --profile-directory="{dir_key}" %U\n'
|
||||
f"Icon={icon_path}\n"
|
||||
"Terminal=false\n"
|
||||
"Categories=Network;WebBrowser;\n"
|
||||
"StartupWMClass=chromium\n"
|
||||
f"{MARKER}\n"
|
||||
)
|
||||
|
||||
|
||||
def sync_chromium_profiles_to_rofi(
|
||||
local_state_path: str = "",
|
||||
apps_dir: str = "",
|
||||
icons_dir: str = "",
|
||||
chromium_cmd: str = "chromium",
|
||||
) -> dict:
|
||||
"""Sincroniza los perfiles de Chromium con entradas .desktop + iconos para rofi.
|
||||
|
||||
Lee `profile.info_cache` del `Local State` y, por cada perfil de usuario valido
|
||||
(excluye "System Profile" y perfiles con nombre vacio), genera un avatar PNG y
|
||||
un archivo `.desktop` que lanza Chromium en ese perfil. Limpia los `.desktop`
|
||||
obsoletos generados previamente por esta funcion y refresca la base de datos de
|
||||
aplicaciones. Es idempotente: ejecutarla N veces deja el mismo estado.
|
||||
|
||||
Args:
|
||||
local_state_path: Ruta al archivo `Local State` de Chromium. Si viene vacio
|
||||
usa `~/.config/chromium-cdp/Local State`.
|
||||
apps_dir: Directorio de salida de los `.desktop`. Si viene vacio usa
|
||||
`~/.local/share/applications`.
|
||||
icons_dir: Directorio de salida de los iconos PNG. Si viene vacio usa
|
||||
`~/.local/share/icons/chromium-profiles`.
|
||||
chromium_cmd: Comando base para lanzar Chromium en el Exec del `.desktop`.
|
||||
Default "chromium" (el wrapper del sistema inyecta `--user-data-dir`).
|
||||
|
||||
Returns:
|
||||
dict con:
|
||||
user_data_dir: ruta absoluta del user-data-dir derivado del Local State.
|
||||
created: lista de paths de los .desktop escritos/actualizados este run.
|
||||
removed: lista de paths de los .desktop obsoletos borrados.
|
||||
profiles: lista de {dir, name, desktop, icon} por perfil procesado.
|
||||
warnings: lista de mensajes de perfiles saltados por datos raros.
|
||||
update_desktop_database: dict {attempted, ok, detail} con el resultado
|
||||
best-effort de `update-desktop-database`.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: si el `Local State` no existe.
|
||||
ValueError: si el JSON no tiene `profile.info_cache`.
|
||||
"""
|
||||
if not local_state_path:
|
||||
local_state_path = os.path.expanduser("~/.config/chromium-cdp/Local State")
|
||||
if not apps_dir:
|
||||
apps_dir = os.path.expanduser("~/.local/share/applications")
|
||||
if not icons_dir:
|
||||
icons_dir = os.path.expanduser("~/.local/share/icons/chromium-profiles")
|
||||
|
||||
ls_path = Path(local_state_path)
|
||||
if not ls_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Local State no encontrado en {ls_path}. "
|
||||
"Verifica la ruta del user-data-dir de Chromium."
|
||||
)
|
||||
|
||||
user_data_dir = ls_path.parent
|
||||
|
||||
try:
|
||||
state = json.loads(ls_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise ValueError(f"No se pudo parsear el JSON de {ls_path}: {exc}") from exc
|
||||
|
||||
info_cache = state.get("profile", {}).get("info_cache")
|
||||
if not isinstance(info_cache, dict):
|
||||
raise ValueError(
|
||||
f"{ls_path} no contiene profile.info_cache (dict). "
|
||||
"El archivo no parece un Local State de Chromium valido."
|
||||
)
|
||||
|
||||
apps_path = Path(apps_dir)
|
||||
icons_path = Path(icons_dir)
|
||||
icons_path.mkdir(parents=True, exist_ok=True)
|
||||
apps_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
created: list[str] = []
|
||||
profiles: list[dict] = []
|
||||
warnings: list[str] = []
|
||||
# slugs vigentes -> sirve para la limpieza de obsoletos
|
||||
valid_slugs: set[str] = set()
|
||||
|
||||
for dir_key, meta in info_cache.items():
|
||||
try:
|
||||
if dir_key == "System Profile":
|
||||
continue
|
||||
if not isinstance(meta, dict):
|
||||
warnings.append(f"perfil {dir_key!r}: metadata no es dict, saltado")
|
||||
continue
|
||||
|
||||
name = (meta.get("name") or "").strip()
|
||||
if not name:
|
||||
# name vacio -> no es un perfil de usuario util
|
||||
continue
|
||||
|
||||
slug = _slugify(dir_key)
|
||||
if not slug:
|
||||
warnings.append(f"perfil {dir_key!r}: slug vacio tras sanitizar, saltado")
|
||||
continue
|
||||
valid_slugs.add(slug)
|
||||
|
||||
icon_path = icons_path / f"{slug}.png"
|
||||
|
||||
# Preferir la foto Gaia del perfil si existe; si no, avatar de iniciales.
|
||||
gaia = (meta.get("gaia_picture_file_name") or "").strip()
|
||||
gaia_src = user_data_dir / dir_key / gaia if gaia else None
|
||||
if gaia_src is not None and gaia_src.is_file():
|
||||
try:
|
||||
shutil.copyfile(str(gaia_src), str(icon_path))
|
||||
except Exception as exc:
|
||||
warnings.append(
|
||||
f"perfil {dir_key!r}: no se pudo copiar foto Gaia ({exc}), "
|
||||
"usando avatar de iniciales"
|
||||
)
|
||||
generate_initials_avatar(name, str(icon_path))
|
||||
else:
|
||||
generate_initials_avatar(name, str(icon_path))
|
||||
|
||||
desktop_path = apps_path / f"chromium-{slug}.desktop"
|
||||
desktop_path.write_text(
|
||||
_desktop_body(name, dir_key, str(icon_path), chromium_cmd),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
created.append(str(desktop_path))
|
||||
profiles.append(
|
||||
{
|
||||
"dir": dir_key,
|
||||
"name": name,
|
||||
"desktop": str(desktop_path),
|
||||
"icon": str(icon_path),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
warnings.append(f"perfil {dir_key!r}: error inesperado, saltado ({exc})")
|
||||
continue
|
||||
|
||||
# --- Limpieza de obsoletos: .desktop generados por esta funcion cuyo slug ya
|
||||
# no corresponde a ningun perfil vigente. ---
|
||||
removed: list[str] = []
|
||||
for desktop_file in apps_path.glob("chromium-*.desktop"):
|
||||
try:
|
||||
content = desktop_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
if MARKER not in content:
|
||||
# No es nuestro -> no tocar.
|
||||
continue
|
||||
slug = desktop_file.stem[len("chromium-"):]
|
||||
if slug in valid_slugs:
|
||||
continue
|
||||
# Obsoleto: borrar .desktop + su icono asociado.
|
||||
try:
|
||||
desktop_file.unlink()
|
||||
removed.append(str(desktop_file))
|
||||
except Exception as exc:
|
||||
warnings.append(f"no se pudo borrar obsoleto {desktop_file}: {exc}")
|
||||
continue
|
||||
old_icon = icons_path / f"{slug}.png"
|
||||
if old_icon.exists():
|
||||
try:
|
||||
old_icon.unlink()
|
||||
except Exception as exc:
|
||||
warnings.append(f"no se pudo borrar icono obsoleto {old_icon}: {exc}")
|
||||
|
||||
# --- Refrescar base de datos de aplicaciones (best-effort). ---
|
||||
udb = {"attempted": False, "ok": False, "detail": ""}
|
||||
udb_bin = shutil.which("update-desktop-database")
|
||||
if udb_bin:
|
||||
udb["attempted"] = True
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[udb_bin, str(apps_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
udb["ok"] = proc.returncode == 0
|
||||
udb["detail"] = (proc.stderr or proc.stdout or "").strip()
|
||||
except Exception as exc:
|
||||
udb["detail"] = f"error ejecutando update-desktop-database: {exc}"
|
||||
else:
|
||||
udb["detail"] = "update-desktop-database no encontrado en PATH"
|
||||
|
||||
return {
|
||||
"user_data_dir": str(user_data_dir),
|
||||
"created": created,
|
||||
"removed": removed,
|
||||
"profiles": profiles,
|
||||
"warnings": warnings,
|
||||
"update_desktop_database": udb,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = sync_chromium_profiles_to_rofi()
|
||||
print(json.dumps(result, indent=2, default=str, ensure_ascii=False))
|
||||
Reference in New Issue
Block a user