diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/MustacheRenderer.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/MustacheRenderer.java new file mode 100644 index 00000000..ec208597 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/MustacheRenderer.java @@ -0,0 +1,92 @@ +package com.cameleer.server.app.alerting.notify; + +import com.samskivert.mustache.Mustache; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Renders Mustache templates against a context map. + *

+ * Contract: + *

+ */ +@Component +public class MustacheRenderer { + + private static final Logger log = LoggerFactory.getLogger(MustacheRenderer.class); + + /** Matches {{path}} tokens, capturing the trimmed path. Ignores triple-mustache and comments. */ + private static final Pattern TOKEN = Pattern.compile("\\{\\{\\s*([^#/!>{\\s][^}]*)\\s*\\}\\}"); + + /** Sentinel prefix/suffix to survive Mustache compilation so we can post-replace. */ + private static final String SENTINEL_PREFIX = "\u0000TPL\u0001"; + private static final String SENTINEL_SUFFIX = "\u0001LPT\u0000"; + + public String render(String template, Map ctx) { + if (template == null) return ""; + try { + // 1) Walk all {{path}} tokens. Those unresolved get replaced with a unique sentinel. + Map literals = new LinkedHashMap<>(); + StringBuilder pre = new StringBuilder(); + Matcher m = TOKEN.matcher(template); + int sentinelIdx = 0; + boolean anyUnresolved = false; + while (m.find()) { + String path = m.group(1).trim(); + if (resolvePath(ctx, path) == null) { + anyUnresolved = true; + String sentinelKey = SENTINEL_PREFIX + sentinelIdx++ + SENTINEL_SUFFIX; + literals.put(sentinelKey, "{{" + path + "}}"); + m.appendReplacement(pre, Matcher.quoteReplacement(sentinelKey)); + } + } + m.appendTail(pre); + if (anyUnresolved) { + log.warn("MustacheRenderer: unresolved template variables; rendering as literals. template={}", + template.length() > 200 ? template.substring(0, 200) + "..." : template); + } + + // 2) Compile & render the pre-processed template (sentinels are plain text — not Mustache tags). + String rendered = Mustache.compiler() + .defaultValue("") + .escapeHTML(false) + .compile(pre.toString()) + .execute(ctx); + + // 3) Restore the sentinel placeholders back to their original {{path}} literals. + for (Map.Entry e : literals.entrySet()) { + rendered = rendered.replace(e.getKey(), e.getValue()); + } + return rendered; + } catch (Exception e) { + log.warn("MustacheRenderer: template render failed, returning raw template: {}", e.getMessage()); + return template; + } + } + + /** + * Resolves a dotted path like "alert.state" against a nested Map context. + * Returns null if any segment is missing or the value is null. + */ + @SuppressWarnings("unchecked") + Object resolvePath(Map ctx, String path) { + if (ctx == null || path == null || path.isBlank()) return null; + String[] parts = path.split("\\."); + Object current = ctx.get(parts[0]); + for (int i = 1; i < parts.length; i++) { + if (!(current instanceof Map)) return null; + current = ((Map) current).get(parts[i]); + } + return current; + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/MustacheRendererTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/MustacheRendererTest.java new file mode 100644 index 00000000..e74d1542 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/MustacheRendererTest.java @@ -0,0 +1,77 @@ +package com.cameleer.server.app.alerting.notify; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MustacheRendererTest { + + private MustacheRenderer renderer; + + @BeforeEach + void setUp() { + renderer = new MustacheRenderer(); + } + + @Test + void simpleVariable_rendersValue() { + var ctx = Map.of("name", "production"); + assertThat(renderer.render("Env: {{name}}", ctx)).isEqualTo("Env: production"); + } + + @Test + void nestedPath_rendersValue() { + var ctx = Map.of( + "alert", Map.of("state", "FIRING")); + assertThat(renderer.render("State: {{alert.state}}", ctx)).isEqualTo("State: FIRING"); + } + + @Test + void missingVariable_rendersLiteralMustache() { + var ctx = Map.of("known", "yes"); + String result = renderer.render("{{known}} and {{missing.path}}", ctx); + assertThat(result).isEqualTo("yes and {{missing.path}}"); + } + + @Test + void missingVariable_exactLiteralNoPadding() { + // The rendered literal must be exactly {{x}} — no surrounding whitespace or delimiter residue. + String result = renderer.render("{{unknown}}", Map.of()); + assertThat(result).isEqualTo("{{unknown}}"); + } + + @Test + void malformedTemplate_returnsRawTemplate() { + String broken = "Hello {{unclosed"; + String result = renderer.render(broken, Map.of()); + assertThat(result).isEqualTo(broken); + } + + @Test + void nullTemplate_returnsEmptyString() { + assertThat(renderer.render(null, Map.of())).isEmpty(); + } + + @Test + void emptyTemplate_returnsEmptyString() { + assertThat(renderer.render("", Map.of())).isEmpty(); + } + + @Test + void mixedResolvedAndUnresolved_rendersCorrectly() { + var ctx = Map.of( + "env", Map.of("slug", "prod"), + "alert", Map.of("id", "abc-123")); + String tmpl = "{{env.slug}} / {{alert.id}} / {{alert.resolvedAt}}"; + String result = renderer.render(tmpl, ctx); + assertThat(result).isEqualTo("prod / abc-123 / {{alert.resolvedAt}}"); + } + + @Test + void plainText_noTokens_returnsAsIs() { + assertThat(renderer.render("No tokens here.", Map.of())).isEqualTo("No tokens here."); + } +}