diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java index c5687add88d..9439c041250 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java @@ -180,6 +180,7 @@ public class OpenAiChatOptions implements ToolCallingChatOptions { * Whether to store the output of this chat completion request for use in our model distillation or evals products. */ private @JsonProperty("store") Boolean store; + /** * Developer-defined tags and values used for filtering completions in the dashboard. */ 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 fbc186aeb9e..a7f3c9c5475 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 @@ -42,6 +42,11 @@ *

* Use the {@link #builder()} to create and configure instances. * + *

+ * Thread safety: This class is safe for concurrent use. Each call to + * {@link #apply(String, Map)} creates a new StringTemplate instance, and no mutable state + * is shared between threads. + * * @author Thomas Vitale * @since 1.0.0 */ @@ -57,7 +62,7 @@ public class StTemplateRenderer implements TemplateRenderer { private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW; - private static final boolean DEFAULT_SUPPORT_ST_FUNCTIONS = false; + private static final boolean DEFAULT_VALIDATE_ST_FUNCTIONS = false; private final char startDelimiterToken; @@ -65,15 +70,27 @@ public class StTemplateRenderer implements TemplateRenderer { private final ValidationMode validationMode; - private final boolean supportStFunctions; + private final boolean validateStFunctions; - StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode, - boolean supportStFunctions) { + /** + * Constructs a new {@code StTemplateRenderer} with the specified delimiter tokens, + * validation mode, and function validation flag. + * @param startDelimiterToken the character used to denote the start of a template + * variable (e.g., '{') + * @param endDelimiterToken the character used to denote the end of a template + * variable (e.g., '}') + * @param validationMode the mode to use for template variable validation; must not be + * null + * @param validateStFunctions whether to validate StringTemplate functions in the + * template + */ + public StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode, + boolean validateStFunctions) { Assert.notNull(validationMode, "validationMode cannot be null"); this.startDelimiterToken = startDelimiterToken; this.endDelimiterToken = endDelimiterToken; this.validationMode = validationMode; - this.supportStFunctions = supportStFunctions; + this.validateStFunctions = validateStFunctions; } @Override @@ -101,20 +118,28 @@ private ST createST(String template) { } } - private void validate(ST st, Map templateVariables) { + /** + * Validates that all required template variables are provided in the model. Returns + * the set of missing variables for further handling or logging. + * @param st the StringTemplate instance + * @param templateVariables the provided variables + * @return set of missing variable names, or empty set if none are missing + */ + private Set validate(ST st, Map templateVariables) { Set templateTokens = getInputVariables(st); Set modelKeys = templateVariables != null ? templateVariables.keySet() : new HashSet<>(); + Set missingVariables = new HashSet<>(templateTokens); + missingVariables.removeAll(modelKeys); - // Check if model provides all keys required by the template - if (!modelKeys.containsAll(templateTokens)) { - templateTokens.removeAll(modelKeys); + if (!missingVariables.isEmpty()) { if (validationMode == ValidationMode.WARN) { - logger.warn(VALIDATION_MESSAGE.formatted(templateTokens)); + logger.warn(VALIDATION_MESSAGE.formatted(missingVariables)); } else if (validationMode == ValidationMode.THROW) { - throw new IllegalStateException(VALIDATION_MESSAGE.formatted(templateTokens)); + throw new IllegalStateException(VALIDATION_MESSAGE.formatted(missingVariables)); } } + return missingVariables; } private Set getInputVariables(ST st) { @@ -125,11 +150,12 @@ private Set getInputVariables(ST st) { for (int i = 0; i < tokens.size(); i++) { Token token = tokens.get(i); + // Handle list variables with option (e.g., {items; separator=", "}) if (token.getType() == STLexer.LDELIM && i + 1 < tokens.size() && tokens.get(i + 1).getType() == STLexer.ID) { if (i + 2 < tokens.size() && tokens.get(i + 2).getType() == STLexer.COLON) { String text = tokens.get(i + 1).getText(); - if (!Compiler.funcs.containsKey(text) || !supportStFunctions) { + if (!Compiler.funcs.containsKey(text) || this.validateStFunctions) { inputVariables.add(text); isInsideList = true; } @@ -138,13 +164,19 @@ private Set getInputVariables(ST st) { else if (token.getType() == STLexer.RDELIM) { isInsideList = false; } + // Only add IDs that are not function calls (i.e., not immediately followed by else if (!isInsideList && token.getType() == STLexer.ID) { - if (!Compiler.funcs.containsKey(token.getText()) || !supportStFunctions) { + boolean isFunctionCall = (i + 1 < tokens.size() && tokens.get(i + 1).getType() == STLexer.LPAREN); + boolean isDotProperty = (i > 0 && tokens.get(i - 1).getType() == STLexer.DOT); + // Only add as variable if: + // - Not a function call + // - Not a built-in function used as property (unless validateStFunctions) + if (!isFunctionCall && (!Compiler.funcs.containsKey(token.getText()) || this.validateStFunctions + || !(isDotProperty && Compiler.funcs.containsKey(token.getText())))) { inputVariables.add(token.getText()); } } } - return inputVariables; } @@ -163,7 +195,7 @@ public static class Builder { private ValidationMode validationMode = DEFAULT_VALIDATION_MODE; - private boolean supportStFunctions = DEFAULT_SUPPORT_ST_FUNCTIONS; + private boolean validateStFunctions = DEFAULT_VALIDATE_ST_FUNCTIONS; private Builder() { } @@ -215,8 +247,8 @@ public Builder validationMode(ValidationMode validationMode) { * ({@link ValidationMode#WARN} or {@link ValidationMode#THROW}). * @return This builder instance for chaining. */ - public Builder supportStFunctions() { - this.supportStFunctions = true; + public Builder validateStFunctions() { + this.validateStFunctions = true; return this; } @@ -226,7 +258,7 @@ public Builder supportStFunctions() { * @return A configured {@link StTemplateRenderer}. */ public StTemplateRenderer build() { - return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode, supportStFunctions); + return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode, validateStFunctions); } } diff --git a/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/package-info.java b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/package-info.java new file mode 100644 index 00000000000..449bd8e0399 --- /dev/null +++ b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +@NonNullApi +@NonNullFields +package org.springframework.ai.template.st; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererEdgeTests.java b/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererEdgeTests.java new file mode 100644 index 00000000000..56feb7a7071 --- /dev/null +++ b/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererEdgeTests.java @@ -0,0 +1,196 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.ai.template.ValidationMode; + +/** + * Additional edge and robustness tests for {@link StTemplateRenderer}. + */ +class StTemplateRendererEdgeTests { + + // --- Built-in Function Handling Tests START --- + + /** + * Built-in functions (first, last) are rendered correctly with variables. + */ + @Test + void shouldHandleMultipleBuiltInFunctionsAndVariables() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + variables.put("list", java.util.Arrays.asList("a", "b", "c")); + variables.put("name", "Mark"); + String template = "{name}: {first(list)}, {last(list)}"; + String result = renderer.apply(template, variables); + assertThat(result).isEqualTo("Mark: a, c"); + } + + /** + * Nested and chained built-in functions are handled when validation is enabled. + */ + /** + * Confirms that ST4 supports valid nested function expressions. + */ + @Test + void shouldSupportValidNestedFunctionExpressionInST4() { + Map variables = new HashMap<>(); + variables.put("words", java.util.Arrays.asList("hello", "WORLD")); + String template = "{first(words)} {last(words)} {length(words)}"; + StTemplateRenderer defaultRenderer = StTemplateRenderer.builder().build(); + String defaultResult = defaultRenderer.apply(template, variables); + assertThat(defaultResult).isEqualTo("hello WORLD 2"); + } + + /** + * Nested and chained built-in functions are handled when validation is enabled. + */ + @Test + void shouldHandleNestedBuiltInFunctions() { + Map variables = new HashMap<>(); + variables.put("words", java.util.Arrays.asList("hello", "WORLD")); + String template = "{first(words)} {last(words)} {length(words)}"; + StTemplateRenderer renderer = StTemplateRenderer.builder().validateStFunctions().build(); + String result = renderer.apply(template, variables); + assertThat(result).isEqualTo("hello WORLD 2"); + } + + /** + * Built-in functions as properties are rendered correctly if supported. + */ + @Test + @Disabled("It is very hard to validate the template expression when using property style access of built-in functions ") + void shouldSupportBuiltInFunctionsAsProperties() { + Map variables = new HashMap<>(); + variables.put("words", java.util.Arrays.asList("hello", "WORLD")); + String template = "{words.first} {words.last} {words.length}"; + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + String result = renderer.apply(template, variables); + assertThat(result).isEqualTo("hello WORLD 2"); + } + + /** + * Built-in functions are not reported as missing variables in THROW mode. + */ + @Test + void shouldNotReportBuiltInFunctionsAsMissingVariablesInThrowMode() { + StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.THROW).build(); + Map variables = new HashMap<>(); + variables.put("memory", "abc"); + String template = "{if(strlen(memory))}ok{endif}"; + String result = renderer.apply(template, variables); + assertThat(result).isEqualTo("ok"); + } + + /** + * Built-in functions are not reported as missing variables in WARN mode. + */ + @Test + void shouldNotReportBuiltInFunctionsAsMissingVariablesInWarnMode() { + StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.WARN).build(); + Map variables = new HashMap<>(); + variables.put("memory", "abc"); + String template = "{if(strlen(memory))}ok{endif}"; + String result = renderer.apply(template, variables); + assertThat(result).isEqualTo("ok"); + } + + /** + * Variables with names similar to built-in functions are treated as normal variables. + */ + @Test + void shouldHandleVariableNamesSimilarToBuiltInFunctions() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + variables.put("lengthy", "foo"); + variables.put("firstName", "bar"); + String template = "{lengthy} {firstName}"; + String result = renderer.apply(template, variables); + assertThat(result).isEqualTo("foo bar"); + } + + // --- Built-in Function Handling Tests END --- + + @Test + void shouldRenderEscapedDelimiters() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + variables.put("x", "y"); + String template = "{x} \\{foo\\}"; + String result = renderer.apply(template, variables); + assertThat(result).isEqualTo("y {foo}"); + } + + @Test + void shouldRenderStaticTextTemplate() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + String template = "Just static text."; + String result = renderer.apply(template, variables); + assertThat(result).isEqualTo("Just static text."); + } + + // Duplicate removed: shouldHandleVariableNamesSimilarToBuiltInFunctions + // (now grouped at the top of the class) + + @Test + void shouldHandleLargeNumberOfVariables() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + StringBuilder template = new StringBuilder(); + for (int i = 0; i < 100; i++) { + String key = "var" + i; + variables.put(key, i); + template.append("{" + key + "} "); + } + String result = renderer.apply(template.toString().trim(), variables); + StringBuilder expected = new StringBuilder(); + for (int i = 0; i < 100; i++) { + expected.append(i).append(" "); + } + assertThat(result).isEqualTo(expected.toString().trim()); + } + + @Test + void shouldRenderUnicodeAndSpecialCharacters() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + variables.put("emoji", "😀"); + variables.put("accented", "Café"); + String template = "{emoji} {accented}"; + String result = renderer.apply(template, variables); + assertThat(result).isEqualTo("😀 Café"); + } + + @Test + void shouldRenderNullVariableValuesAsBlank() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + variables.put("foo", null); + String template = "Value: {foo}"; + String result = renderer.apply(template, variables); + assertThat(result).isEqualTo("Value: "); + } + +} 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 b887feb30d8..7d5f86d71c6 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 @@ -282,11 +282,11 @@ void shouldHandleObjectVariables() { /** * Test whether StringTemplate can correctly render a template containing built-in - * functions when {@code supportStFunctions()} is enabled. It should render properly. + * functions. It should render properly. */ @Test - void shouldRenderTemplateWithSupportStFunctions() { - StTemplateRenderer renderer = StTemplateRenderer.builder().supportStFunctions().build(); + void shouldRenderTemplateWithBuiltInFunctions() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); Map variables = new HashMap<>(); variables.put("memory", "you are a helpful assistant"); String template = "{if(strlen(memory))}Hello!{endif}";