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(); + } + }