Skip to content

Commit f02f2ae

Browse files
committed
Add Spring SpEL implementation of TemplateRenderer
Signed-off-by: Yanming Zhou <[email protected]>
1 parent d93ab77 commit f02f2ae

File tree

7 files changed

+565
-0
lines changed

7 files changed

+565
-0
lines changed

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
<module>spring-ai-bom</module>
3535
<module>spring-ai-commons</module>
3636
<module>spring-ai-template-st</module>
37+
<module>spring-ai-template-spel</module>
3738
<module>spring-ai-client-chat</module>
3839
<module>spring-ai-model</module>
3940
<module>spring-ai-test</module>

spring-ai-bom/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@
101101
<version>${project.version}</version>
102102
</dependency>
103103

104+
<dependency>
105+
<groupId>org.springframework.ai</groupId>
106+
<artifactId>spring-ai-template-spel</artifactId>
107+
<version>${project.version}</version>
108+
</dependency>
109+
104110
<!-- Spring AI model -->
105111

106112
<dependency>

spring-ai-model/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@
5353
<version>${project.parent.version}</version>
5454
</dependency>
5555

56+
<dependency>
57+
<groupId>org.springframework.ai</groupId>
58+
<artifactId>spring-ai-template-spel</artifactId>
59+
<version>${project.parent.version}</version>
60+
</dependency>
61+
5662
<dependency>
5763
<groupId>io.micrometer</groupId>
5864
<artifactId>micrometer-observation</artifactId>

spring-ai-template-spel/pom.xml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
~ Copyright 2023-2025 the original author or authors.
4+
~
5+
~ Licensed under the Apache License, Version 2.0 (the "License");
6+
~ you may not use this file except in compliance with the License.
7+
~ You may obtain a copy of the License at
8+
~
9+
~ https://www.apache.org/licenses/LICENSE-2.0
10+
~
11+
~ Unless required by applicable law or agreed to in writing, software
12+
~ distributed under the License is distributed on an "AS IS" BASIS,
13+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
~ See the License for the specific language governing permissions and
15+
~ limitations under the License.
16+
-->
17+
18+
<project xmlns="http://maven.apache.org/POM/4.0.0"
19+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
20+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
21+
<modelVersion>4.0.0</modelVersion>
22+
<parent>
23+
<groupId>org.springframework.ai</groupId>
24+
<artifactId>spring-ai-parent</artifactId>
25+
<version>1.1.0-SNAPSHOT</version>
26+
</parent>
27+
<artifactId>spring-ai-template-spel</artifactId>
28+
<packaging>jar</packaging>
29+
<name>Spring AI Template SpEL</name>
30+
<description>SpEL implementation for Spring AI templating</description>
31+
<url>https://github.com/spring-projects/spring-ai</url>
32+
33+
<scm>
34+
<url>https://github.com/spring-projects/spring-ai</url>
35+
<connection>git://github.com/spring-projects/spring-ai.git</connection>
36+
<developerConnection>[email protected]:spring-projects/spring-ai.git</developerConnection>
37+
</scm>
38+
39+
<properties>
40+
<maven.compiler.target>17</maven.compiler.target>
41+
<maven.compiler.source>17</maven.compiler.source>
42+
</properties>
43+
44+
<dependencies>
45+
<dependency>
46+
<groupId>org.springframework.ai</groupId>
47+
<artifactId>spring-ai-commons</artifactId>
48+
<version>${project.parent.version}</version>
49+
</dependency>
50+
51+
<dependency>
52+
<groupId>org.springframework</groupId>
53+
<artifactId>spring-expression</artifactId>
54+
</dependency>
55+
56+
<!-- Logging -->
57+
<dependency>
58+
<groupId>org.slf4j</groupId>
59+
<artifactId>slf4j-api</artifactId>
60+
</dependency>
61+
62+
<!-- test dependencies -->
63+
<dependency>
64+
<groupId>org.springframework.boot</groupId>
65+
<artifactId>spring-boot-starter-test</artifactId>
66+
<scope>test</scope>
67+
</dependency>
68+
</dependencies>
69+
</project>
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.template.spel;
18+
19+
import java.util.LinkedHashSet;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.Set;
23+
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
26+
27+
import org.springframework.ai.template.TemplateRenderer;
28+
import org.springframework.ai.template.ValidationMode;
29+
import org.springframework.context.expression.MapAccessor;
30+
import org.springframework.expression.EvaluationContext;
31+
import org.springframework.expression.Expression;
32+
import org.springframework.expression.common.CompositeStringExpression;
33+
import org.springframework.expression.common.TemplateParserContext;
34+
import org.springframework.expression.spel.standard.SpelExpression;
35+
import org.springframework.expression.spel.standard.SpelExpressionParser;
36+
import org.springframework.expression.spel.support.DataBindingPropertyAccessor;
37+
import org.springframework.expression.spel.support.StandardEvaluationContext;
38+
import org.springframework.util.Assert;
39+
40+
/**
41+
* Renders a template using the Spring SpEL.
42+
*
43+
* <p>
44+
* This renderer allows customization of delimiters, validation behavior when template
45+
* variables are missing.
46+
*
47+
* <p>
48+
* Use the {@link #builder()} to create and configure instances.
49+
*
50+
* <p>
51+
* <b>Thread safety:</b> This class is safe for concurrent use. Each call to
52+
* {@link #apply(String, Map)} creates a new SpEL Expression instance, and no mutable
53+
* state is shared between threads.
54+
*
55+
* @author Yanming Zhou
56+
* @since 1.1.0
57+
*/
58+
public class SpelTemplateRenderer implements TemplateRenderer {
59+
60+
private static final Logger logger = LoggerFactory.getLogger(SpelTemplateRenderer.class);
61+
62+
private static final String VALIDATION_MESSAGE = "Not all variables were replaced in the template. Missing variable names are: %s.";
63+
64+
private static final char DEFAULT_START_DELIMITER_TOKEN = '{';
65+
66+
private static final char DEFAULT_END_DELIMITER_TOKEN = '}';
67+
68+
private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
69+
70+
private final char startDelimiterToken;
71+
72+
private final char endDelimiterToken;
73+
74+
private final ValidationMode validationMode;
75+
76+
private final EvaluationContext evaluationContext;
77+
78+
/**
79+
* Constructs a new {@code SpelTemplateRenderer} with the specified delimiter tokens,
80+
* validation mode.
81+
* @param startDelimiterToken the character used to denote the start of a template
82+
* variable (e.g., '{')
83+
* @param endDelimiterToken the character used to denote the end of a template
84+
* variable (e.g., '}')
85+
* @param validationMode the mode to use for template variable validation; must not be
86+
* null template
87+
*/
88+
public SpelTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode) {
89+
Assert.notNull(validationMode, "validationMode cannot be null");
90+
this.startDelimiterToken = startDelimiterToken;
91+
this.endDelimiterToken = endDelimiterToken;
92+
this.validationMode = validationMode;
93+
94+
StandardEvaluationContext ctx = new StandardEvaluationContext();
95+
ctx.setPropertyAccessors(List.of(new MapAccessor(false), DataBindingPropertyAccessor.forReadOnlyAccess()));
96+
this.evaluationContext = ctx;
97+
}
98+
99+
@Override
100+
public String apply(String template, Map<String, Object> variables) {
101+
Assert.hasText(template, "template cannot be null or empty");
102+
Assert.notNull(variables, "variables cannot be null");
103+
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
104+
105+
Expression expression = parseExpression(template);
106+
107+
if (this.validationMode != ValidationMode.NONE) {
108+
validate(expression, variables);
109+
}
110+
111+
return String.valueOf(expression.getValue(this.evaluationContext, variables));
112+
}
113+
114+
private Expression parseExpression(String template) {
115+
SpelExpressionParser parser = new SpelExpressionParser();
116+
return parser.parseExpression(template, new TemplateParserContext(String.valueOf(this.startDelimiterToken),
117+
String.valueOf(this.endDelimiterToken)));
118+
}
119+
120+
/**
121+
* Validates that all required template variables are provided in the model. Returns
122+
* the set of missing variables for further handling or logging.
123+
* @param expression the Expression instance
124+
* @param templateVariables the provided variables
125+
* @return set of missing variable names, or empty set if none are missing
126+
*/
127+
private Set<String> validate(Expression expression, Map<String, Object> templateVariables) {
128+
Set<String> templateTokens = getInputVariables(expression);
129+
Set<String> modelKeys = templateVariables.keySet();
130+
Set<String> missingVariables = new LinkedHashSet<>(templateTokens);
131+
missingVariables.removeAll(modelKeys);
132+
133+
if (!missingVariables.isEmpty()) {
134+
if (this.validationMode == ValidationMode.WARN) {
135+
logger.warn(VALIDATION_MESSAGE.formatted(missingVariables));
136+
}
137+
else if (this.validationMode == ValidationMode.THROW) {
138+
throw new IllegalStateException(VALIDATION_MESSAGE.formatted(missingVariables));
139+
}
140+
}
141+
return missingVariables;
142+
}
143+
144+
public Set<String> getInputVariables(Expression expression) {
145+
Set<String> inputVariables = new LinkedHashSet<>();
146+
if (expression instanceof CompositeStringExpression cse) {
147+
for (Expression ex : cse.getExpressions()) {
148+
if (ex instanceof SpelExpression se) {
149+
inputVariables.add(se.getExpressionString());
150+
}
151+
}
152+
}
153+
154+
return inputVariables;
155+
}
156+
157+
public static Builder builder() {
158+
return new Builder();
159+
}
160+
161+
/**
162+
* Builder for configuring and creating {@link SpelTemplateRenderer} instances.
163+
*/
164+
public static final class Builder {
165+
166+
private char startDelimiterToken = DEFAULT_START_DELIMITER_TOKEN;
167+
168+
private char endDelimiterToken = DEFAULT_END_DELIMITER_TOKEN;
169+
170+
private ValidationMode validationMode = DEFAULT_VALIDATION_MODE;
171+
172+
private Builder() {
173+
}
174+
175+
/**
176+
* Sets the character used as the start delimiter for template expressions.
177+
* Default is '{'.
178+
* @param startDelimiterToken The start delimiter character.
179+
* @return This builder instance for chaining.
180+
*/
181+
public Builder startDelimiterToken(char startDelimiterToken) {
182+
this.startDelimiterToken = startDelimiterToken;
183+
return this;
184+
}
185+
186+
/**
187+
* Sets the character used as the end delimiter for template expressions. Default
188+
* is '}'.
189+
* @param endDelimiterToken The end delimiter character.
190+
* @return This builder instance for chaining.
191+
*/
192+
public Builder endDelimiterToken(char endDelimiterToken) {
193+
this.endDelimiterToken = endDelimiterToken;
194+
return this;
195+
}
196+
197+
/**
198+
* Sets the validation mode to control behavior when the provided variables do not
199+
* match the variables required by the template. Default is
200+
* {@link ValidationMode#THROW}.
201+
* @param validationMode The desired validation mode.
202+
* @return This builder instance for chaining.
203+
*/
204+
public Builder validationMode(ValidationMode validationMode) {
205+
this.validationMode = validationMode;
206+
return this;
207+
}
208+
209+
/**
210+
* Builds and returns a new {@link SpelTemplateRenderer} instance with the
211+
* configured settings.
212+
* @return A configured {@link SpelTemplateRenderer}.
213+
*/
214+
public SpelTemplateRenderer build() {
215+
return new SpelTemplateRenderer(this.startDelimiterToken, this.endDelimiterToken, this.validationMode);
216+
}
217+
218+
}
219+
220+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@NonNullApi
18+
@NonNullFields
19+
package org.springframework.ai.template.spel;
20+
21+
import org.springframework.lang.NonNullApi;
22+
import org.springframework.lang.NonNullFields;

0 commit comments

Comments
 (0)