io.micrometer
micrometer-observation
diff --git a/spring-ai-template-jinja/pom.xml b/spring-ai-template-jinja/pom.xml
new file mode 100644
index 00000000000..3eb742a28c9
--- /dev/null
+++ b/spring-ai-template-jinja/pom.xml
@@ -0,0 +1,71 @@
+
+
+
+
+ 4.0.0
+
+ org.springframework.ai
+ spring-ai-parent
+ 1.1.0-SNAPSHOT
+
+ spring-ai-template-jinja
+ jar
+ Spring AI Template Jinja
+ Jinja 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}
+
+
+
+ com.hubspot.jinjava
+ jinjava
+ 2.8.0
+
+
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
diff --git a/spring-ai-template-jinja/src/main/java/org/springframework/ai/template/jinja/JinjaTemplateRenderer.java b/spring-ai-template-jinja/src/main/java/org/springframework/ai/template/jinja/JinjaTemplateRenderer.java
new file mode 100644
index 00000000000..89a3b345ead
--- /dev/null
+++ b/spring-ai-template-jinja/src/main/java/org/springframework/ai/template/jinja/JinjaTemplateRenderer.java
@@ -0,0 +1,172 @@
+/*
+ * 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.jinja;
+
+import com.hubspot.jinjava.Jinjava;
+import com.hubspot.jinjava.JinjavaConfig;
+import com.hubspot.jinjava.tree.parse.ExpressionToken;
+import com.hubspot.jinjava.tree.parse.Token;
+import com.hubspot.jinjava.tree.parse.TokenScanner;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.template.TemplateRenderer;
+import org.springframework.ai.template.ValidationMode;
+import org.springframework.util.Assert;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Renders a template using the Jin-java library.
+ *
+ *
+ * This renderer allows customization of validation behavior.
+ *
+ *
+ * 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 Jin-java instance, and no mutable state is
+ * shared between threads.
+ *
+ * @author Sun Yuhan
+ * @since 1.1.0
+ */
+public class JinjaTemplateRenderer implements TemplateRenderer {
+
+ private static final Logger logger = LoggerFactory.getLogger(JinjaTemplateRenderer.class);
+
+ private static final String VALIDATION_MESSAGE = "Not all variables were replaced in the template. Missing variable names are: %s.";
+
+ private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
+
+ private final ValidationMode validationMode;
+
+ /**
+ * Constructs a new {@code JinjaTemplateRenderer} with the specified validation mode.
+ * @param validationMode the mode to use for template variable validation; must not be
+ * null
+ */
+ public JinjaTemplateRenderer(ValidationMode validationMode) {
+ Assert.notNull(validationMode, "validationMode cannot be null");
+ this.validationMode = validationMode;
+ }
+
+ @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");
+
+ if (this.validationMode != ValidationMode.NONE) {
+ validate(template, variables);
+ }
+ Jinjava jinjava = new Jinjava();
+ String rendered;
+ try {
+ rendered = jinjava.render(template, variables);
+ }
+ catch (Exception ex) {
+ throw new IllegalArgumentException("The template string is not valid.", ex);
+ }
+ return rendered;
+ }
+
+ /**
+ * Validates that all required template variables are provided in the model. Returns
+ * the set of missing variables for further handling or logging.
+ * @param template the template to be rendered
+ * @param templateVariables the provided variables
+ * @return set of missing variable names, or empty set if none are missing
+ */
+ private Set validate(String template, Map templateVariables) {
+ Set templateTokens = getInputVariables(template);
+ Set modelKeys = templateVariables.keySet();
+ Set missingVariables = new HashSet<>(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;
+ }
+
+ /**
+ * Retrieve all variables in the template
+ * @param template the template to be rendered
+ * @return set of variable names
+ */
+ private Set getInputVariables(String template) {
+ Set variables = new HashSet<>();
+ JinjavaConfig config = JinjavaConfig.newBuilder().build();
+ TokenScanner scanner = new TokenScanner(template, config);
+
+ while (scanner.hasNext()) {
+ Token token = scanner.next();
+ if (token instanceof ExpressionToken expressionToken) {
+ String varName = expressionToken.getExpr().trim();
+ variables.add(varName);
+ }
+ }
+ return variables;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for configuring and creating {@link JinjaTemplateRenderer} instances.
+ */
+ public static final class Builder {
+
+ private ValidationMode validationMode = DEFAULT_VALIDATION_MODE;
+
+ private Builder() {
+ }
+
+ /**
+ * 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 JinjaTemplateRenderer} instance with the
+ * configured settings.
+ * @return A configured {@link JinjaTemplateRenderer}.
+ */
+ public JinjaTemplateRenderer build() {
+ return new JinjaTemplateRenderer(this.validationMode);
+ }
+
+ }
+
+}
diff --git a/spring-ai-template-jinja/src/main/java/org/springframework/ai/template/jinja/package-info.java b/spring-ai-template-jinja/src/main/java/org/springframework/ai/template/jinja/package-info.java
new file mode 100644
index 00000000000..a12645a2f34
--- /dev/null
+++ b/spring-ai-template-jinja/src/main/java/org/springframework/ai/template/jinja/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.jinja;
+
+import org.springframework.lang.NonNullApi;
+import org.springframework.lang.NonNullFields;
diff --git a/spring-ai-template-jinja/src/test/java/org/springframework/ai/template/jinja/JinjaTemplateRendererTests.java b/spring-ai-template-jinja/src/test/java/org/springframework/ai/template/jinja/JinjaTemplateRendererTests.java
new file mode 100644
index 00000000000..e4e3e7ceb0f
--- /dev/null
+++ b/spring-ai-template-jinja/src/test/java/org/springframework/ai/template/jinja/JinjaTemplateRendererTests.java
@@ -0,0 +1,179 @@
+/*
+ * 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.jinja;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.template.ValidationMode;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link JinjaTemplateRenderer}.
+ *
+ * @author Sun YuHan
+ */
+class JinjaTemplateRendererTests {
+
+ @Test
+ void shouldNotAcceptNullValidationMode() {
+ assertThatThrownBy(() -> JinjaTemplateRenderer.builder().validationMode(null).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("validationMode cannot be null");
+ }
+
+ @Test
+ void shouldUseDefaultValuesWhenUsingBuilder() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.builder().build();
+
+ assertThat(ReflectionTestUtils.getField(renderer, "validationMode")).isEqualTo(ValidationMode.THROW);
+ }
+
+ @Test
+ void shouldRenderTemplateWithSingleVariable() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.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() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.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() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.builder().build();
+ Map variables = new HashMap<>();
+
+ assertThatThrownBy(() -> renderer.apply("", variables)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("template cannot be null or empty");
+ }
+
+ @Test
+ void shouldNotAcceptNullVariables() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.builder().build();
+ assertThatThrownBy(() -> renderer.apply("Hello!", null)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("variables cannot be null");
+ }
+
+ @Test
+ void shouldNotAcceptVariablesWithNullKeySet() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.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 shouldThrowExceptionForMissingVariablesInThrowMode() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.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
+ void shouldContinueRenderingWithMissingVariablesInWarnMode() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.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
+ void shouldRenderWithoutValidationInNoneMode() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.builder().validationMode(ValidationMode.NONE).build();
+ Map variables = new HashMap<>();
+ variables.put("greeting", "Hello");
+
+ String result = renderer.apply("{{greeting}} {{name}}!", variables);
+
+ assertThat(result).isEqualTo("Hello !");
+ }
+
+ /**
+ * Tests that complex multi-line template structures with multiple variables are
+ * rendered correctly with proper whitespace and newline handling.
+ */
+ @Test
+ void shouldHandleComplexTemplateStructures() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.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 numeric variables (both integer and floating-point) are correctly
+ * converted to strings during template rendering.
+ */
+ @Test
+ void shouldHandleNumericVariables() {
+ JinjaTemplateRenderer renderer = JinjaTemplateRenderer.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");
+ }
+
+}