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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user