feat(alerting): MustacheRenderer with literal fallback on missing vars

Sentinel-substitution approach: unresolved {{x.y.z}} tokens are replaced
with a unique NUL-delimited sentinel before Mustache compilation, rendered
as opaque text, then post-replaced with the original {{x.y.z}} literal.
Malformed templates (unclosed {{) are caught and return the raw template.
Never throws. 9 unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 19:27:05 +02:00
parent c53f642838
commit 92a74e7b8d
2 changed files with 169 additions and 0 deletions

View File

@@ -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.
* <p>
* Contract:
* <ul>
* <li>Unresolved {@code {{x.y.z}}} tokens render as the literal {@code {{x.y.z}}} and log WARN.</li>
* <li>Malformed templates (e.g. unclosed {@code {{}) return the original template string and log WARN.</li>
* <li>Never throws on template content.</li>
* </ul>
*/
@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<String, Object> ctx) {
if (template == null) return "";
try {
// 1) Walk all {{path}} tokens. Those unresolved get replaced with a unique sentinel.
Map<String, String> 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<String, String> 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<String, Object> 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<String, Object>) current).get(parts[i]);
}
return current;
}
}

View File

@@ -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.<String, Object>of("name", "production");
assertThat(renderer.render("Env: {{name}}", ctx)).isEqualTo("Env: production");
}
@Test
void nestedPath_rendersValue() {
var ctx = Map.<String, Object>of(
"alert", Map.of("state", "FIRING"));
assertThat(renderer.render("State: {{alert.state}}", ctx)).isEqualTo("State: FIRING");
}
@Test
void missingVariable_rendersLiteralMustache() {
var ctx = Map.<String, Object>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.<String, Object>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.");
}
}