From 31db4d29fa61fedb934831523c212aaedec608cb Mon Sep 17 00:00:00 2001 From: renechoi <115696395+renechoi@users.noreply.github.com> Date: Sun, 29 Jun 2025 09:48:59 +0900 Subject: [PATCH] feat(template-st): log StringTemplate compile/runtime errors via SLF4J MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes GH-3604 (https://github.com/spring-projects/spring-ai/issues/3604) ### Why StringTemplate’s default ErrorManager writes diagnostics to stderr, which is invisible in most Spring deployments. This change redirects those messages to SLF4J so they reach Logstash / CloudWatch / etc. ### What * Add `Slf4jStErrorListener` (STErrorListener -> SLF4J). * Create STGroup with listener inside `StTemplateRenderer#createST`. * JUnit `malformedTemplateShouldLogErrorViaSlf4j`. * Javadoc updates. ### Impact No API changes; applications automatically get structured error logs. Signed-off-by: Your Name Signed-off-by: renechoi <115696395+renechoi@users.noreply.github.com> --- .../ai/template/st/Slf4jStErrorListener.java | 51 +++++++++++++ .../ai/template/st/StTemplateRenderer.java | 9 ++- .../template/st/StTemplateRendererTests.java | 73 ++++++++++++++----- 3 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 spring-ai-template-st/src/main/java/org/springframework/ai/template/st/Slf4jStErrorListener.java diff --git a/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/Slf4jStErrorListener.java b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/Slf4jStErrorListener.java new file mode 100644 index 00000000000..96a3ed30709 --- /dev/null +++ b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/Slf4jStErrorListener.java @@ -0,0 +1,51 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.template.st; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.stringtemplate.v4.STErrorListener; +import org.stringtemplate.v4.misc.STMessage; + +/** + * {@link STErrorListener} implementation that logs errors using SLF4J. + */ +public class Slf4jStErrorListener implements STErrorListener { + + private static final Logger logger = LoggerFactory.getLogger(StTemplateRenderer.class); + + @Override + public void compileTimeError(STMessage msg) { + logger.error("StringTemplate compile error: {}", msg); + } + + @Override + public void runTimeError(STMessage msg) { + logger.error("StringTemplate runtime error: {}", msg); + } + + @Override + public void IOError(STMessage msg) { + logger.error("StringTemplate IO error: {}", msg); + } + + @Override + public void internalError(STMessage msg) { + logger.error("StringTemplate internal error: {}", msg); + } + +} diff --git a/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java index 3780b948a09..7cdf5c6e0c9 100644 --- a/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java +++ b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java @@ -25,6 +25,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.stringtemplate.v4.ST; +import org.stringtemplate.v4.STErrorListener; +import org.stringtemplate.v4.STGroup; import org.stringtemplate.v4.compiler.Compiler; import org.stringtemplate.v4.compiler.STLexer; @@ -73,6 +75,8 @@ public class StTemplateRenderer implements TemplateRenderer { private final boolean validateStFunctions; + private final STErrorListener stErrorListener = new Slf4jStErrorListener(); + /** * Constructs a new {@code StTemplateRenderer} with the specified delimiter tokens, * validation mode, and function validation flag. @@ -112,13 +116,16 @@ public String apply(String template, Map variables) { private ST createST(String template) { try { - return new ST(template, this.startDelimiterToken, this.endDelimiterToken); + STGroup group = new STGroup(this.startDelimiterToken, this.endDelimiterToken); + group.setListener(this.stErrorListener); + return new ST(group, template); } catch (Exception ex) { throw new IllegalArgumentException("The template string is not valid.", ex); } } + /** * Validates that all required template variables are provided in the model. Returns * the set of missing variables for further handling or logging. diff --git a/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java b/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java index 4d4e979e869..94c75990cea 100644 --- a/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java +++ b/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java @@ -16,16 +16,23 @@ package org.springframework.ai.template.st; +import static org.assertj.core.api.Assertions.*; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; - +import org.slf4j.LoggerFactory; import org.springframework.ai.template.ValidationMode; import org.springframework.test.util.ReflectionTestUtils; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; /** * Unit tests for {@link StTemplateRenderer}. @@ -37,8 +44,8 @@ class StTemplateRendererTests { @Test void shouldNotAcceptNullValidationMode() { assertThatThrownBy(() -> StTemplateRenderer.builder().validationMode(null).build()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("validationMode cannot be null"); + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("validationMode cannot be null"); } @Test @@ -80,14 +87,14 @@ void shouldNotRenderEmptyTemplate() { Map variables = new HashMap<>(); assertThatThrownBy(() -> renderer.apply("", variables)).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("template cannot be null or empty"); + .hasMessageContaining("template cannot be null or empty"); } @Test void shouldNotAcceptNullVariables() { StTemplateRenderer renderer = StTemplateRenderer.builder().build(); assertThatThrownBy(() -> renderer.apply("Hello!", null)).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("variables cannot be null"); + .hasMessageContaining("variables cannot be null"); } @Test @@ -98,7 +105,7 @@ void shouldNotAcceptVariablesWithNullKeySet() { variables.put(null, "Spring AI"); assertThatThrownBy(() -> renderer.apply(template, variables)).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("variables keys cannot be null"); + .hasMessageContaining("variables keys cannot be null"); } @Test @@ -108,7 +115,7 @@ void shouldThrowExceptionForInvalidTemplateSyntax() { variables.put("name", "Spring AI"); assertThatThrownBy(() -> renderer.apply("Hello {name!", variables)).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("The template string is not valid."); + .hasMessageContaining("The template string is not valid."); } @Test @@ -118,9 +125,9 @@ void shouldThrowExceptionForMissingVariablesInThrowMode() { variables.put("greeting", "Hello"); assertThatThrownBy(() -> renderer.apply("{greeting} {name}!", variables)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining( - "Not all variables were replaced in the template. Missing variable names are: [name]"); + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "Not all variables were replaced in the template. Missing variable names are: [name]"); } @Test @@ -148,9 +155,9 @@ void shouldRenderWithoutValidationInNoneMode() { @Test void shouldRenderWithCustomDelimiters() { StTemplateRenderer renderer = StTemplateRenderer.builder() - .startDelimiterToken('<') - .endDelimiterToken('>') - .build(); + .startDelimiterToken('<') + .endDelimiterToken('>') + .build(); Map variables = new HashMap<>(); variables.put("name", "Spring AI"); @@ -162,9 +169,9 @@ void shouldRenderWithCustomDelimiters() { @Test void shouldHandleSpecialCharactersAsDelimiters() { StTemplateRenderer renderer = StTemplateRenderer.builder() - .startDelimiterToken('$') - .endDelimiterToken('$') - .build(); + .startDelimiterToken('$') + .endDelimiterToken('$') + .build(); Map variables = new HashMap<>(); variables.put("name", "Spring AI"); @@ -297,4 +304,34 @@ void shouldRenderTemplateWithBuiltInFunctions() { assertThat(result).isEqualTo("Hello!"); } + @Test + void malformedTemplateShouldLogErrorViaSlf4j() { + Logger logger = (Logger) LoggerFactory.getLogger(StTemplateRenderer.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + + PrintStream originalErr = System.err; + ByteArrayOutputStream err = new ByteArrayOutputStream(); + System.setErr(new PrintStream(err)); + try { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + variables.put("name", "Spring AI"); + assertThatThrownBy(() -> renderer.apply("Hello {name!", variables)) + .isInstanceOf(IllegalArgumentException.class); + } + finally { + System.setErr(originalErr); + logger.detachAppender(appender); + appender.stop(); + } + + assertThat(appender.list).isNotEmpty(); + ILoggingEvent event = appender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.ERROR); + assertThat(event.getFormattedMessage()).contains("StringTemplate compile error"); + assertThat(err.toString(StandardCharsets.UTF_8)).isEmpty(); + } + }