Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,39 +31,86 @@ public class ResponseTemplateParser {

private static final ObjectMapper objectMapper = new ObjectMapper();

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

// Supports {{.}} or {{.xxx}} or {{.xxx.yyy}} multi-level variables
private static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\{\\{\\s*\\.([\\w\\$\\[\\]\\.]*)\\s*}}",
Pattern.DOTALL);

// Detects multi-level path access patterns like {{.xxx.yyy}}
// This regex is fully covered by unit tests in ResponseTemplateParserTest.java
private static final Pattern MULTI_LEVEL_PATTERN = Pattern.compile("\\{\\{\\s*\\.\\w+\\.[\\w\\.]+\\s*}}");

/**
* 处理响应模板
* @param rawResponse 原始响应(JSON或文本)
* @param responseTemplate 模板字符串(可为jsonPath、模板、null/空)
* @return 处理后的字符串
* Process response template
* @param rawResponse raw response (JSON or text)
* @param responseTemplate template string (can be jsonPath, template, null/empty)
* @return processed string
*/
public static String parse(String rawResponse, String responseTemplate) {
if (!StringUtils.hasText(responseTemplate) || "{{.}}".equals(responseTemplate.trim())) {
// 原样输出
// Return raw output
return rawResponse;
}

// jsonPath 提取
// JsonPath extraction
if (responseTemplate.trim().startsWith("$.") || responseTemplate.trim().startsWith("$[")) {
try {
Object result = JsonPath.read(rawResponse, responseTemplate.trim());
return result != null ? result.toString() : "";
}
catch (Exception e) {
// jsonPath 失败,降级为模板处理
// JsonPath failed, fallback to template processing
}
}

// Detect multi-level path access
if (MULTI_LEVEL_PATTERN.matcher(responseTemplate).find()) {
return parseWithHandlebars(rawResponse, responseTemplate);
}

// Simple template variable replacement (maintain backward compatibility)
return parseWithSimpleTemplate(rawResponse, responseTemplate);
}

private static String parseWithHandlebars(String rawResponse, String responseTemplate) {
try {
// 1. Preprocess template: convert syntax to be compatible with Handlebars
String handlebarsTemplateStr = responseTemplate
// Remove dot prefix: {{ .xxx.yyy }} -> {{xxx.yyy}}
.replaceAll("\\{\\{\\s*\\.", "{{")
// Convert array access syntax: {{users.[0].name}} -> {{users.0.name}}
.replaceAll("\\[([0-9]+)\\]", "$1");

// 2. Compile template
Template template = handlebars.compileInline(handlebarsTemplateStr);

Map<String, Object> dataContext;
boolean isJson = rawResponse.trim().startsWith("{") || rawResponse.trim().startsWith("[");
if (isJson) {
dataContext = objectMapper.readValue(rawResponse, new TypeReference<Map<String, Object>>() {
});
}
else {
// Non-JSON data, create a context containing the raw response
dataContext = Map.of("_raw", rawResponse);
}

return template.apply(dataContext);

}
catch (Exception e) {
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,219 @@
/*
* 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;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;

import java.util.regex.Pattern;

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 renders null values as empty strings, which is standard template
// engine behavior
assertEquals("Data: , Info: test", result);
}

// Regex pattern tests to validate MULTI_LEVEL_PATTERN detection
@Test
void shouldDetectMultiLevelPatternsCorrectly() {
Pattern MULTI_LEVEL_PATTERN = Pattern.compile("\\{\\{\\s*\\.\\w+\\.[\\w\\.]+\\s*}}");

// Should match multi-level patterns
assertTrue(MULTI_LEVEL_PATTERN.matcher("{{.user.name}}").find());
assertTrue(MULTI_LEVEL_PATTERN.matcher("{{ .location.city }}").find());
assertTrue(MULTI_LEVEL_PATTERN.matcher("{{.data.user.profile.name}}").find());
assertTrue(MULTI_LEVEL_PATTERN.matcher("{{.api.response.status.code}}").find());

// Should NOT match single-level patterns
assertFalse(MULTI_LEVEL_PATTERN.matcher("{{.}}").find());
assertFalse(MULTI_LEVEL_PATTERN.matcher("{{.status}}").find());
assertFalse(MULTI_LEVEL_PATTERN.matcher("{{.message}}").find());

// Should NOT match non-template strings
assertFalse(MULTI_LEVEL_PATTERN.matcher("plain text").find());
assertFalse(MULTI_LEVEL_PATTERN.matcher("$.location.city").find());
}

@Test
void shouldMatchTemplatePatternCorrectly() {
Pattern TEMPLATE_PATTERN = Pattern.compile("\\{\\{\\s*\\.([\\w\\$\\[\\]\\.]*)\\s*}}", Pattern.DOTALL);

// Should match all template patterns
assertTrue(TEMPLATE_PATTERN.matcher("{{.}}").find());
assertTrue(TEMPLATE_PATTERN.matcher("{{.status}}").find());
assertTrue(TEMPLATE_PATTERN.matcher("{{.user.name}}").find());
assertTrue(TEMPLATE_PATTERN.matcher("{{ .location.city }}").find());
assertTrue(TEMPLATE_PATTERN.matcher("{{.users.[0].name}}").find());
assertTrue(TEMPLATE_PATTERN.matcher("{{.data$}}").find());

// Should NOT match non-template patterns
assertFalse(TEMPLATE_PATTERN.matcher("$.location.city").find());
assertFalse(TEMPLATE_PATTERN.matcher("plain text").find());
assertFalse(TEMPLATE_PATTERN.matcher("{single brace}").find());
}

}
Loading