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;
}
}