Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<module>spring-ai-bom</module>
<module>spring-ai-commons</module>
<module>spring-ai-template-st</module>
<module>spring-ai-template-jinja</module>
<module>spring-ai-client-chat</module>
<module>spring-ai-model</module>
<module>spring-ai-test</module>
Expand Down
6 changes: 6 additions & 0 deletions spring-ai-bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-template-jinja</artifactId>
<version>${project.version}</version>
</dependency>

<!-- Spring AI model -->

<dependency>
Expand Down
6 changes: 6 additions & 0 deletions spring-ai-model/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@
<version>${project.parent.version}</version>
</dependency>

<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-template-jinja</artifactId>
<version>${project.parent.version}</version>
</dependency>

<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-observation</artifactId>
Expand Down
71 changes: 71 additions & 0 deletions spring-ai-template-jinja/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ 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.
-->

<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-parent</artifactId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<artifactId>spring-ai-template-jinja</artifactId>
<packaging>jar</packaging>
<name>Spring AI Template Jinja</name>
<description>Jinja implementation for Spring AI templating</description>
<url>https://github.com/spring-projects/spring-ai</url>

<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>[email protected]:spring-projects/spring-ai.git</developerConnection>
</scm>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-commons</artifactId>
<version>${project.parent.version}</version>
</dependency>

<dependency>
<groupId>com.hubspot.jinjava</groupId>
<artifactId>jinjava</artifactId>
<version>2.8.0</version>
</dependency>

<!-- Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>

<!-- test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* This renderer allows customization of validation behavior.
*
* <p>
* Use the {@link #builder()} to create and configure instances.
*
* <p>
* <b>Thread safety:</b> 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<String, Object> 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<String> validate(String template, Map<String, Object> templateVariables) {
Set<String> templateTokens = getInputVariables(template);
Set<String> modelKeys = templateVariables.keySet();
Set<String> 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<String> getInputVariables(String template) {
Set<String> 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);
}

}

}
Original file line number Diff line number Diff line change
@@ -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;
Loading