io.micrometer
micrometer-observation
diff --git a/spring-ai-template-spel/pom.xml b/spring-ai-template-spel/pom.xml
new file mode 100644
index 00000000000..a4a9a454706
--- /dev/null
+++ b/spring-ai-template-spel/pom.xml
@@ -0,0 +1,69 @@
+
+
+
+
+ 4.0.0
+
+ org.springframework.ai
+ spring-ai-parent
+ 1.1.0-SNAPSHOT
+
+ spring-ai-template-spel
+ jar
+ Spring AI Template SpEL
+ SpEL implementation for Spring AI templating
+ https://github.com/spring-projects/spring-ai
+
+
+ https://github.com/spring-projects/spring-ai
+ git://github.com/spring-projects/spring-ai.git
+ git@github.com:spring-projects/spring-ai.git
+
+
+
+ 17
+ 17
+
+
+
+
+ org.springframework.ai
+ spring-ai-commons
+ ${project.parent.version}
+
+
+
+ org.springframework
+ spring-expression
+
+
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
diff --git a/spring-ai-template-spel/src/main/java/org/springframework/ai/template/spel/SpelTemplateRenderer.java b/spring-ai-template-spel/src/main/java/org/springframework/ai/template/spel/SpelTemplateRenderer.java
new file mode 100644
index 00000000000..89f73721586
--- /dev/null
+++ b/spring-ai-template-spel/src/main/java/org/springframework/ai/template/spel/SpelTemplateRenderer.java
@@ -0,0 +1,220 @@
+/*
+ * 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.spel;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.springframework.ai.template.TemplateRenderer;
+import org.springframework.ai.template.ValidationMode;
+import org.springframework.context.expression.MapAccessor;
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.Expression;
+import org.springframework.expression.common.CompositeStringExpression;
+import org.springframework.expression.common.TemplateParserContext;
+import org.springframework.expression.spel.standard.SpelExpression;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.expression.spel.support.DataBindingPropertyAccessor;
+import org.springframework.expression.spel.support.StandardEvaluationContext;
+import org.springframework.util.Assert;
+
+/**
+ * Renders a template using the Spring SpEL.
+ *
+ *
+ * This renderer allows customization of delimiters, validation behavior when template
+ * variables are missing.
+ *
+ *
+ * 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 SpEL Expression instance, and no mutable
+ * state is shared between threads.
+ *
+ * @author Yanming Zhou
+ * @since 1.1.0
+ */
+public class SpelTemplateRenderer implements TemplateRenderer {
+
+ private static final Logger logger = LoggerFactory.getLogger(SpelTemplateRenderer.class);
+
+ private static final String VALIDATION_MESSAGE = "Not all variables were replaced in the template. Missing variable names are: %s.";
+
+ private static final char DEFAULT_START_DELIMITER_TOKEN = '{';
+
+ private static final char DEFAULT_END_DELIMITER_TOKEN = '}';
+
+ private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
+
+ private final char startDelimiterToken;
+
+ private final char endDelimiterToken;
+
+ private final ValidationMode validationMode;
+
+ private final EvaluationContext evaluationContext;
+
+ /**
+ * Constructs a new {@code SpelTemplateRenderer} with the specified delimiter tokens,
+ * validation mode.
+ * @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 template
+ */
+ public SpelTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode) {
+ Assert.notNull(validationMode, "validationMode cannot be null");
+ this.startDelimiterToken = startDelimiterToken;
+ this.endDelimiterToken = endDelimiterToken;
+ this.validationMode = validationMode;
+
+ StandardEvaluationContext ctx = new StandardEvaluationContext();
+ ctx.setPropertyAccessors(List.of(new MapAccessor(false), DataBindingPropertyAccessor.forReadOnlyAccess()));
+ this.evaluationContext = ctx;
+ }
+
+ @Override
+ public String apply(String template, Map variables) {
+ Assert.hasText(template, "template cannot be null or empty");
+ Assert.notNull(variables, "variables cannot be null");
+ Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
+
+ Expression expression = parseExpression(template);
+
+ if (this.validationMode != ValidationMode.NONE) {
+ validate(expression, variables);
+ }
+
+ return String.valueOf(expression.getValue(this.evaluationContext, variables));
+ }
+
+ private Expression parseExpression(String template) {
+ SpelExpressionParser parser = new SpelExpressionParser();
+ return parser.parseExpression(template, new TemplateParserContext(String.valueOf(this.startDelimiterToken),
+ String.valueOf(this.endDelimiterToken)));
+ }
+
+ /**
+ * Validates that all required template variables are provided in the model. Returns
+ * the set of missing variables for further handling or logging.
+ * @param expression the Expression instance
+ * @param templateVariables the provided variables
+ * @return set of missing variable names, or empty set if none are missing
+ */
+ private Set validate(Expression expression, Map templateVariables) {
+ Set templateTokens = getInputVariables(expression);
+ Set modelKeys = templateVariables.keySet();
+ Set missingVariables = new LinkedHashSet<>(templateTokens);
+ missingVariables.removeAll(modelKeys);
+
+ if (!missingVariables.isEmpty()) {
+ if (this.validationMode == ValidationMode.WARN) {
+ logger.warn(VALIDATION_MESSAGE.formatted(missingVariables));
+ }
+ else if (this.validationMode == ValidationMode.THROW) {
+ throw new IllegalStateException(VALIDATION_MESSAGE.formatted(missingVariables));
+ }
+ }
+ return missingVariables;
+ }
+
+ public Set getInputVariables(Expression expression) {
+ Set inputVariables = new LinkedHashSet<>();
+ if (expression instanceof CompositeStringExpression cse) {
+ for (Expression ex : cse.getExpressions()) {
+ if (ex instanceof SpelExpression se) {
+ inputVariables.add(se.getExpressionString());
+ }
+ }
+ }
+
+ return inputVariables;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for configuring and creating {@link SpelTemplateRenderer} instances.
+ */
+ public static final class Builder {
+
+ private char startDelimiterToken = DEFAULT_START_DELIMITER_TOKEN;
+
+ private char endDelimiterToken = DEFAULT_END_DELIMITER_TOKEN;
+
+ private ValidationMode validationMode = DEFAULT_VALIDATION_MODE;
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the character used as the start delimiter for template expressions.
+ * Default is '{'.
+ * @param startDelimiterToken The start delimiter character.
+ * @return This builder instance for chaining.
+ */
+ public Builder startDelimiterToken(char startDelimiterToken) {
+ this.startDelimiterToken = startDelimiterToken;
+ return this;
+ }
+
+ /**
+ * Sets the character used as the end delimiter for template expressions. Default
+ * is '}'.
+ * @param endDelimiterToken The end delimiter character.
+ * @return This builder instance for chaining.
+ */
+ public Builder endDelimiterToken(char endDelimiterToken) {
+ this.endDelimiterToken = endDelimiterToken;
+ return this;
+ }
+
+ /**
+ * Sets the validation mode to control behavior when the provided variables do not
+ * match the variables required by the template. Default is
+ * {@link ValidationMode#THROW}.
+ * @param validationMode The desired validation mode.
+ * @return This builder instance for chaining.
+ */
+ public Builder validationMode(ValidationMode validationMode) {
+ this.validationMode = validationMode;
+ return this;
+ }
+
+ /**
+ * Builds and returns a new {@link SpelTemplateRenderer} instance with the
+ * configured settings.
+ * @return A configured {@link SpelTemplateRenderer}.
+ */
+ public SpelTemplateRenderer build() {
+ return new SpelTemplateRenderer(this.startDelimiterToken, this.endDelimiterToken, this.validationMode);
+ }
+
+ }
+
+}
diff --git a/spring-ai-template-spel/src/main/java/org/springframework/ai/template/spel/package-info.java b/spring-ai-template-spel/src/main/java/org/springframework/ai/template/spel/package-info.java
new file mode 100644
index 00000000000..0bff3eea38d
--- /dev/null
+++ b/spring-ai-template-spel/src/main/java/org/springframework/ai/template/spel/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.spel;
+
+import org.springframework.lang.NonNullApi;
+import org.springframework.lang.NonNullFields;
diff --git a/spring-ai-template-spel/src/test/java/org/springframework/ai/template/spel/SpelTemplateRendererTests.java b/spring-ai-template-spel/src/test/java/org/springframework/ai/template/spel/SpelTemplateRendererTests.java
new file mode 100644
index 00000000000..19be5e94eaa
--- /dev/null
+++ b/spring-ai-template-spel/src/test/java/org/springframework/ai/template/spel/SpelTemplateRendererTests.java
@@ -0,0 +1,241 @@
+/*
+ * 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.spel;
+
+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;
+import org.springframework.expression.ParseException;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link SpelTemplateRenderer}.
+ *
+ * @author Yanming Zhou
+ */
+class SpelTemplateRendererTests {
+
+ @Test
+ void shouldNotAcceptNullValidationMode() {
+ assertThatThrownBy(() -> SpelTemplateRenderer.builder().validationMode(null).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("validationMode cannot be null");
+ }
+
+ @Test
+ void shouldUseDefaultValuesWhenUsingBuilder() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().build();
+
+ assertThat(ReflectionTestUtils.getField(renderer, "startDelimiterToken")).isEqualTo('{');
+ assertThat(ReflectionTestUtils.getField(renderer, "endDelimiterToken")).isEqualTo('}');
+ assertThat(ReflectionTestUtils.getField(renderer, "validationMode")).isEqualTo(ValidationMode.THROW);
+ }
+
+ @Test
+ void shouldRenderTemplateWithSingleVariable() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().build();
+ Map variables = new HashMap<>();
+ variables.put("name", "Spring AI");
+
+ String result = renderer.apply("Hello {name}!", variables);
+
+ assertThat(result).isEqualTo("Hello Spring AI!");
+ }
+
+ @Test
+ void shouldRenderTemplateWithMultipleVariables() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().build();
+ Map variables = new HashMap<>();
+ variables.put("greeting", "Hello");
+ variables.put("name", "Spring AI");
+ variables.put("punctuation", "!");
+
+ String result = renderer.apply("{greeting} {name}{punctuation}", variables);
+
+ assertThat(result).isEqualTo("Hello Spring AI!");
+ }
+
+ @Test
+ void shouldNotRenderEmptyTemplate() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().build();
+ Map variables = new HashMap<>();
+
+ assertThatThrownBy(() -> renderer.apply("", variables)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("template cannot be null or empty");
+ }
+
+ @Test
+ void shouldNotAcceptNullVariables() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().build();
+ assertThatThrownBy(() -> renderer.apply("Hello!", null)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("variables cannot be null");
+ }
+
+ @Test
+ void shouldNotAcceptVariablesWithNullKeySet() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().build();
+ String template = "Hello!";
+ Map variables = new HashMap();
+ variables.put(null, "Spring AI");
+
+ assertThatThrownBy(() -> renderer.apply(template, variables)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("variables keys cannot be null");
+ }
+
+ @Test
+ void shouldThrowExceptionForInvalidTemplateSyntax() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().build();
+ Map variables = new HashMap<>();
+ variables.put("name", "Spring AI");
+
+ assertThatThrownBy(() -> renderer.apply("Hello {name!", variables)).isInstanceOf(ParseException.class)
+ .hasMessageContaining("No ending suffix");
+ }
+
+ @Test
+ void shouldThrowExceptionForMissingVariablesInThrowMode() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().build();
+ Map variables = new HashMap<>();
+ 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]");
+ }
+
+ @Test
+ @Disabled("SpEL does not support missing variables")
+ void shouldContinueRenderingWithMissingVariablesInWarnMode() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().validationMode(ValidationMode.WARN).build();
+ Map variables = new HashMap<>();
+ variables.put("greeting", "Hello");
+
+ String result = renderer.apply("{greeting} {name}!", variables);
+
+ assertThat(result).isEqualTo("Hello !");
+ }
+
+ @Test
+ @Disabled("SpEL does not support missing variables")
+ void shouldRenderWithoutValidationInNoneMode() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().validationMode(ValidationMode.NONE).build();
+ Map variables = new HashMap<>();
+ variables.put("greeting", "Hello");
+
+ String result = renderer.apply("{greeting} {name}!", variables);
+
+ assertThat(result).isEqualTo("Hello !");
+ }
+
+ @Test
+ void shouldRenderWithCustomDelimiters() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder()
+ .startDelimiterToken('<')
+ .endDelimiterToken('>')
+ .build();
+ Map variables = new HashMap<>();
+ variables.put("name", "Spring AI");
+
+ String result = renderer.apply("Hello !", variables);
+
+ assertThat(result).isEqualTo("Hello Spring AI!");
+ }
+
+ @Test
+ void shouldHandleSpecialCharactersAsDelimiters() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder()
+ .startDelimiterToken('$')
+ .endDelimiterToken('$')
+ .build();
+ Map variables = new HashMap<>();
+ variables.put("name", "Spring AI");
+
+ String result = renderer.apply("Hello $name$!", variables);
+
+ assertThat(result).isEqualTo("Hello Spring AI!");
+ }
+
+ /**
+ * Tests that complex multi-line template structures with multiple variables are
+ * rendered correctly with proper whitespace and newline handling.
+ */
+ @Test
+ void shouldHandleComplexTemplateStructures() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().build();
+ Map variables = new HashMap<>();
+ variables.put("header", "Welcome");
+ variables.put("user", "Spring AI");
+ variables.put("items", "one, two, three");
+ variables.put("footer", "Goodbye");
+
+ String result = renderer.apply("""
+ {header}
+ User: {user}
+ Items: {items}
+ {footer}
+ """, variables);
+
+ assertThat(result).isEqualToNormalizingNewlines("""
+ Welcome
+ User: Spring AI
+ Items: one, two, three
+ Goodbye
+ """);
+ }
+
+ /**
+ * Tests that SpEL list variables with separators are correctly handled. Note: Uses
+ * NONE validation mode because the current implementation of getInputVariables
+ * incorrectly treats template options like 'separator' as variables to be resolved.
+ */
+ @Test
+ void shouldHandleListVariables() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().validationMode(ValidationMode.NONE).build();
+
+ Map variables = new HashMap<>();
+ variables.put("items", new String[] { "apple", "banana", "cherry" });
+
+ String result = renderer.apply("Items: {T(java.lang.String).join(', ', items )}", variables);
+
+ assertThat(result).isEqualTo("Items: apple, banana, cherry");
+ }
+
+ /**
+ * Tests that numeric variables (both integer and floating-point) are correctly
+ * converted to strings during template rendering.
+ */
+ @Test
+ void shouldHandleNumericVariables() {
+ SpelTemplateRenderer renderer = SpelTemplateRenderer.builder().build();
+ Map variables = new HashMap<>();
+ variables.put("integer", 42);
+ variables.put("float", 3.14);
+
+ String result = renderer.apply("Integer: {integer}, Float: {float}", variables);
+
+ assertThat(result).isEqualTo("Integer: 42, Float: 3.14");
+ }
+
+}