1"""Trigger library + validation endpoints. 2 3Split out of api.py — the library is data (loaded from YAML), the validator 4is a thin wrapper around ``safe_eval``. Neither needs the rest of the 5dashboard's machinery, so they live cleanly in their own module. 6""" 7 8from __future__ import annotations 9 10import logging 11from pathlib import Path 12from typing import Any 13 14import yaml 15from fastapi import APIRouter 16from pydantic import BaseModel 17 18logger = logging.getLogger(__name__) 19 20router = APIRouter() 21 22 23class TriggerValidateRequest(BaseModel): 24 expr: str 25 sample_payload: dict[str, Any] = {} 26 27 28_LIBRARY_CACHE: dict[str, Any] = {"mtime": 0.0, "templates": None} 29 30 31def _library_path() -> Path: 32 """Resolve config/trigger_templates.yaml relative to the project root. 33 34 Falls back to the repo-shipped copy at the package's grandparent 35 when the cwd-based lookup misses, so importing the module from a 36 tools subdirectory still finds the file. 37 """ 38 candidates = [ 39 Path("config/trigger_templates.yaml"), 40 Path(__file__).resolve().parents[4] / "config" / "trigger_templates.yaml", 41 ] 42 for p in candidates: 43 if p.exists(): 44 return p 45 return candidates[0] 46 47 48def _load_library() -> list[dict[str, Any]]: 49 """Read config/trigger_templates.yaml, cached by mtime. 50 51 Hot-reload-friendly: editing the YAML on disk takes effect on the 52 next GET without restarting the dashboard. 53 """ 54 path = _library_path() 55 try: 56 mtime = path.stat().st_mtime 57 except FileNotFoundError: 58 return [] 59 if ( 60 _LIBRARY_CACHE["templates"] is not None 61 and _LIBRARY_CACHE["mtime"] == mtime 62 ): 63 return _LIBRARY_CACHE["templates"] 64 try: 65 loaded = yaml.safe_load(path.read_text()) or [] 66 if not isinstance(loaded, list): 67 logger.warning("trigger_templates.yaml: top-level must be a list, got %s", type(loaded)) 68 loaded = [] 69 except Exception as exc: 70 logger.exception("failed to load trigger_templates.yaml: %s", exc) 71 loaded = [] 72 _LIBRARY_CACHE["mtime"] = mtime 73 _LIBRARY_CACHE["templates"] = loaded 74 return loaded 75 76 77@router.get("/api/triggers/library") 78async def triggers_library() -> dict[str, Any]: 79 """Predefined trigger rules the user can apply to any arena with one click. 80 81 Source of truth is ``config/trigger_templates.yaml`` — edit there to 82 extend the picker, no Python changes needed. Reloads on mtime change. 83 """ 84 return {"templates": _load_library()} 85 86 87@router.post("/api/triggers/validate") 88async def triggers_validate(req: TriggerValidateRequest) -> dict[str, Any]: 89 """Validate a trigger ``when:`` expression with safe_eval. 90 91 Optionally evaluates against a user-supplied sample payload so the 92 user can sanity-check their rule before saving. Returns 93 ``{ok, error, result}`` — never raises. 94 """ 95 from maf.triggers.safe_eval import SafeEvalError, safe_eval 96 try: 97 result = safe_eval(req.expr, {"payload": req.sample_payload or {}}) 98 return {"ok": True, "result": result, "error": None} 99 except SafeEvalError as exc: 100 return {"ok": False, "result": None, "error": f"safe_eval: {exc}"} 101 except Exception as exc: 102 return {"ok": False, "result": None, "error": f"{type(exc).__name__}: {exc}"}