feat(infra): auto-commit con 88 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 00:16:46 +02:00
parent 6bc97df5c0
commit eb8dbf66a1
126 changed files with 10933 additions and 287 deletions
@@ -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