Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
18 changes: 18 additions & 0 deletions spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@
<artifactId>json-path</artifactId>
</dependency>

<!-- Handlebars Template Engine for multi-level JSON extraction -->
<dependency>
<groupId>com.github.jknack</groupId>
<artifactId>handlebars</artifactId>
<version>4.3.1</version>
</dependency>

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
Expand Down Expand Up @@ -113,6 +120,17 @@
</exclusion>
</exclusions>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@

package com.alibaba.cloud.ai.mcp.gateway.core.jsontemplate;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.jknack.handlebars.Handlebars;
import com.github.jknack.handlebars.Template;
import com.jayway.jsonpath.JsonPath;
import org.springframework.util.StringUtils;

Expand All @@ -28,10 +31,15 @@ public class ResponseTemplateParser {

private static final ObjectMapper objectMapper = new ObjectMapper();

// 支持 {{.}} 或 {{.xxx}} 变量
private static final Handlebars handlebars = new Handlebars();

// 支持 {{.}} 或 {{.xxx}} 或 {{.xxx.yyy}} 等多层级变量
private static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\{\\{\\s*\\.([\\w\\$\\[\\]\\.]*)\\s*}}",
Pattern.DOTALL);

// 检测是否包含多层级路径访问(如 {{.xxx.yyy}})
private static final Pattern MULTI_LEVEL_PATTERN = Pattern.compile("\\{\\{\\s*\\.\\w+\\.[\\w\\.]+\\s*}}");

/**
* 处理响应模板
* @param rawResponse 原始响应(JSON或文本)
Expand All @@ -55,12 +63,62 @@ public static String parse(String rawResponse, String responseTemplate) {
}
}

// 模板变量替换
// 检测是否包含多层级路径访问,如果包含则使用 Handlebars 引擎
if (MULTI_LEVEL_PATTERN.matcher(responseTemplate).find()) {
return parseWithHandlebars(rawResponse, responseTemplate);
}

// 简单模板变量替换(保持原有逻辑以确保向后兼容)
return parseWithSimpleTemplate(rawResponse, responseTemplate);
}

/**
* 使用 Handlebars 引擎处理多层级模板 兼容 higress.cn/ai/mcp-server 的模板语法 {{ .xxx.yyy }}
*/
private static String parseWithHandlebars(String rawResponse, String responseTemplate) {
try {
// 1. 预处理模板:转换语法以兼容 Handlebars
String handlebarsTemplateStr = responseTemplate
// 移除点号前缀:{{ .xxx.yyy }} -> {{xxx.yyy}}
.replaceAll("\\{\\{\\s*\\.", "{{")
// 转换数组访问语法:{{users.[0].name}} -> {{users.0.name}}
.replaceAll("\\[([0-9]+)\\]", "$1");

// 2. 编译模板
Template template = handlebars.compileInline(handlebarsTemplateStr);

// 3. 准备数据上下文:将JSON字符串解析为 Map
Map<String, Object> dataContext;
boolean isJson = rawResponse.trim().startsWith("{") || rawResponse.trim().startsWith("[");
if (isJson) {
dataContext = objectMapper.readValue(rawResponse, new TypeReference<Map<String, Object>>() {
});
}
else {
// 非JSON数据,创建一个包含原始响应的上下文
dataContext = Map.of("_raw", rawResponse);
}

// 4. 应用模板并返回结果
return template.apply(dataContext);

}
catch (Exception e) {
// Handlebars 处理失败,降级为简单模板处理
return parseWithSimpleTemplate(rawResponse, responseTemplate);
}
}

/**
* 简单模板变量替换(原有逻辑)
*/
private static String parseWithSimpleTemplate(String rawResponse, String responseTemplate) {
try {
Map<String, Object> context = null;
boolean isJson = rawResponse.trim().startsWith("{") || rawResponse.trim().startsWith("[");
if (isJson) {
context = objectMapper.readValue(rawResponse, Map.class);
context = objectMapper.readValue(rawResponse, new TypeReference<Map<String, Object>>() {
});
}
StringBuffer sb = new StringBuffer();
Matcher matcher = TEMPLATE_PATTERN.matcher(responseTemplate);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
* Copyright 2024-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 com.alibaba.cloud.ai.mcp.gateway.core.jsontemplate;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class ResponseTemplateParserTest {

@Test
void shouldReturnRawResponseWhenTemplateIsEmpty() {
String rawResponse = "{\"status\": \"success\"}";
String template = "";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals(rawResponse, result);
}

@Test
void shouldReturnRawResponseWhenTemplateIsRootAccess() {
String rawResponse = "{\"status\": \"success\"}";
String template = "{{.}}";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals(rawResponse, result);
}

@Test
void shouldExtractSingleLevelValue() {
String rawResponse = "{\"status\": \"success\", \"code\": 200}";
String template = "Status: {{.status}}";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("Status: success", result);
}

@Test
void shouldExtractNestedValue() {
String rawResponse = "{\"location\": {\"province\": \"Zhejiang\", \"city\": \"Hangzhou\"}, \"code\": 200}";
String template = "The city is {{.location.city}} and the code is {{.code}}.";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("The city is Hangzhou and the code is 200.", result);
}

@Test
void shouldExtractDeeplyNestedValue() {
String rawResponse = "{\"data\": {\"user\": {\"profile\": {\"name\": \"Alice\", \"age\": 30}}, \"timestamp\": 1234567890}}";
String template = "User {{.data.user.profile.name}} is {{.data.user.profile.age}} years old.";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("User Alice is 30 years old.", result);
}

@Test
void shouldHandleSpacesInTemplate() {
String rawResponse = "{\"data\": {\"value\": \"test\"}}";
String template = "The value is {{ .data.value }}.";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("The value is test.", result);
}

@Test
void shouldHandleMultipleNestedReplacements() {
String rawResponse = "{\"weather\": {\"temperature\": 25, \"humidity\": 60}, \"location\": {\"city\": \"Beijing\"}}";
String template = "Temperature in {{.location.city}} is {{.weather.temperature}}°C with {{.weather.humidity}}% humidity.";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("Temperature in Beijing is 25°C with 60% humidity.", result);
}

@Test
void shouldHandleMissingNestedKey() {
String rawResponse = "{\"location\": {\"city\": \"Hangzhou\"}}";
String template = "City: {{.location.city}}, Country: {{.location.country}}";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("City: Hangzhou, Country: ", result);
}

@Test
void shouldHandleArrayAccess() {
String rawResponse = "{\"users\": [{\"name\": \"Alice\"}, {\"name\": \"Bob\"}]}";
String template = "First user: {{.users.0.name}}";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("First user: Alice", result);
}

@Test
void shouldWorkWithJsonPathWhenStartsWithDollar() {
String rawResponse = "{\"location\": {\"city\": \"Hangzhou\"}}";
String template = "$.location.city";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("Hangzhou", result);
}

@Test
void shouldFallbackToSimpleTemplateWhenNoMultiLevel() {
String rawResponse = "{\"status\": \"success\", \"message\": \"OK\"}";
String template = "{{.status}}: {{.message}}";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("success: OK", result);
}

@Test
void shouldHandleNonJsonResponse() {
String rawResponse = "Simple text response";
String template = "Response: {{.}}";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("Response: Simple text response", result);
}

@Test
void shouldHandleComplexNestedStructure() {
String rawResponse = "{\"api\": {\"response\": {\"data\": {\"items\": [{\"id\": 1, \"name\": \"Item1\"}, {\"id\": 2, \"name\": \"Item2\"}], \"total\": 2}, \"status\": {\"code\": 200, \"message\": \"success\"}}}}";
String template = "Status: {{.api.response.status.message}}, Total items: {{.api.response.data.total}}, First item: {{.api.response.data.items.[0].name}}";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("Status: success, Total items: 2, First item: Item1", result);
}

@Test
void shouldHandleNumbersAndBooleans() {
String rawResponse = "{\"config\": {\"enabled\": true, \"maxRetries\": 3, \"timeout\": 30.5}}";
String template = "Enabled: {{.config.enabled}}, Max retries: {{.config.maxRetries}}, Timeout: {{.config.timeout}}";

String result = ResponseTemplateParser.parse(rawResponse, template);

assertEquals("Enabled: true, Max retries: 3, Timeout: 30.5", result);
}

@Test
void shouldHandleEmptyObjectsAndNulls() {
String rawResponse = "{\"data\": null, \"empty\": {}, \"info\": {\"value\": \"test\"}}";
String template = "Data: {{.data}}, Info: {{.info.value}}";

String result = ResponseTemplateParser.parse(rawResponse, template);

// Handlebars 将 null 值渲染为空字符串,这是符合模板引擎标准行为的
assertEquals("Data: , Info: test", result);
}

}
Loading