Skip to content

Commit d419029

Browse files
authored
feat(workflow): Mustache lambda helpers for missing-key defaults (fn.*) (#5684)
1 parent 5890e8c commit d419029

5 files changed

Lines changed: 76 additions & 0 deletions

File tree

keep-ui/entities/workflows/lib/mustache.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,8 @@ export function extractMustacheVariables(yamlString: string): string[] {
1515
.map((match) => match[1])
1616
// TODO: more sophisticated validation
1717
.filter((variable) => variable.length > 0 && !variable.endsWith("."))
18+
// Skip Mustache sigil tokens: section open (#), close (/), inverted (^),
19+
// comment (!), partial (>) — these are not variable references.
20+
.filter((variable) => !/^[#/^!>]/.test(variable))
1821
);
1922
}

keep-ui/entities/workflows/lib/validate-mustache-ui-builder.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export const validateMustacheVariableForUIBuilderStep = (
3535
if (!cleanedVariableName) {
3636
return "Empty mustache variable.";
3737
}
38+
// Mustache sigil tokens (#, /, ^, !, >) are section/lambda syntax, not
39+
// variable references — skip validation entirely.
40+
if (/^[#/^!>]/.test(cleanedVariableName)) {
41+
return null;
42+
}
3843
if (cleanedVariableName === ".") {
3944
if (currentStep.parentId) {
4045
return null;

keep-ui/entities/workflows/lib/validate-mustache-yaml.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export const validateMustacheVariableForYAMLStep = (
3535
if (!cleanedVariableName) {
3636
return ["Empty mustache variable.", "warning"];
3737
}
38+
// Mustache sigil tokens (#, /, ^, !, >) are section/lambda syntax, not
39+
// variable references — skip validation entirely.
40+
if (/^[#/^!>]/.test(cleanedVariableName)) {
41+
return null;
42+
}
3843
if (cleanedVariableName === ".") {
3944
if (currentStep.foreach) {
4045
return null;

keep/iohandler/iohandler.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@
1818
from keep.contextmanager.contextmanager import ContextManager
1919
from keep.step.step_provider_parameter import StepProviderParameter
2020

21+
# Mustache lambda helpers injected into every render context.
22+
# Usage in workflow YAML: {{#fn.na}}{{ alert.someOptionalField }}{{/fn.na}}
23+
# When a referenced field is missing or empty the helper returns the default
24+
# instead of raising RenderException (safe mode is disabled automatically
25+
# when fn.* sections are detected — see _render()).
26+
WORKFLOW_HELPERS = {
27+
"fn": {
28+
"default": lambda text, render: render(text) or "",
29+
"na": lambda text, render: render(text) or "N/A",
30+
"upper": lambda text, render: render(text).upper(),
31+
"lower": lambda text, render: render(text).lower(),
32+
"strip": lambda text, render: render(text).strip(),
33+
}
34+
}
35+
2136

2237
class RenderException(Exception):
2338
def __init__(self, message, missing_keys=None):
@@ -429,11 +444,19 @@ def _render(self, key: str, safe=False, default="", additional_context=None):
429444
)
430445
safe = False
431446

447+
# fn.* helper sections explicitly handle missing/empty keys — the lambda
448+
# returns a default value so RenderException must not be raised.
449+
if "{{#fn." in key or "{{ #fn." in key:
450+
safe = False
451+
432452
context = self.context_manager.get_full_context(exclude_providers=True)
433453

434454
if additional_context:
435455
context.update(additional_context)
436456

457+
# Inject workflow helper lambdas so fn.* sections are resolvable.
458+
context.update(WORKFLOW_HELPERS)
459+
437460
stderr_capture = io.StringIO()
438461
original_stderr = sys.stderr
439462
sys.stderr = stderr_capture

tests/test_iohandler.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,3 +1023,43 @@ def render_template(thread_id):
10231023
t.join(timeout=30)
10241024

10251025
assert not errors, f"Concurrent render raised errors: {errors}"
1026+
1027+
1028+
# ── fn.* Mustache lambda helper tests ────────────────────────────────────────
1029+
1030+
1031+
def test_fn_na_on_missing_key(mocked_context_manager):
1032+
"""fn.na renders 'N/A' when the wrapped field is absent from the context."""
1033+
mocked_context_manager.get_full_context.return_value = {
1034+
"alert": {"name": "test-alert"}, # no 'slack_timestamp' field
1035+
}
1036+
iohandler = IOHandler(mocked_context_manager)
1037+
result = iohandler.render("ts={{#fn.na}}{{ alert.slack_timestamp }}{{/fn.na}}")
1038+
assert result == "ts=N/A", f"Expected 'ts=N/A', got '{result}'"
1039+
1040+
1041+
def test_fn_default_on_missing_key(mocked_context_manager):
1042+
"""fn.default renders an empty string when the wrapped field is absent."""
1043+
mocked_context_manager.get_full_context.return_value = {
1044+
"alert": {"name": "test-alert"}, # no 'silenceURL' field
1045+
}
1046+
iohandler = IOHandler(mocked_context_manager)
1047+
result = iohandler.render("url={{#fn.default}}{{ alert.silenceURL }}{{/fn.default}}")
1048+
assert result == "url=", f"Expected 'url=', got '{result}'"
1049+
1050+
1051+
def test_fn_upper_lower_strip_on_present_value(mocked_context_manager):
1052+
"""fn.upper, fn.lower, and fn.strip transform present field values correctly."""
1053+
mocked_context_manager.get_full_context.return_value = {
1054+
"alert": {"env": " Production "},
1055+
}
1056+
iohandler = IOHandler(mocked_context_manager)
1057+
1058+
upper = iohandler.render("{{#fn.upper}}{{ alert.env }}{{/fn.upper}}")
1059+
assert upper == " PRODUCTION ", f"fn.upper got '{upper}'"
1060+
1061+
lower = iohandler.render("{{#fn.lower}}{{ alert.env }}{{/fn.lower}}")
1062+
assert lower == " production ", f"fn.lower got '{lower}'"
1063+
1064+
strip = iohandler.render("{{#fn.strip}}{{ alert.env }}{{/fn.strip}}")
1065+
assert strip == "Production", f"fn.strip got '{strip}'"

0 commit comments

Comments
 (0)