Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.synapxnet.goai.contract;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
Expand Down Expand Up @@ -29,13 +30,15 @@ DelegatedTokenVerifier delegatedTokenVerifier(
* 注册只作用于 Agent v1 API 的上下文过滤器。
*
* @param verifier 委托令牌验证器
* @param objectMapper Spring 统一配置的 JSON 序列化器
* @return Servlet 过滤器注册对象
*/
@Bean
FilterRegistrationBean<AgentRequestContextFilter> agentRequestContextFilter(
DelegatedTokenVerifier verifier) {
DelegatedTokenVerifier verifier,
ObjectMapper objectMapper) {
FilterRegistrationBean<AgentRequestContextFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new AgentRequestContextFilter(verifier));
bean.setFilter(new AgentRequestContextFilter(verifier, objectMapper));
bean.addUrlPatterns("/api/agent/v1/*");
bean.setOrder(-100);
return bean;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package com.synapxnet.goai.contract;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

Expand All @@ -18,14 +22,17 @@ final class AgentRequestContextFilter extends OncePerRequestFilter {

private static final Pattern TOOL_PATH = Pattern.compile("^/api/agent/v1/tools/([^/]+):invoke$");
private final DelegatedTokenVerifier tokenVerifier;
private final ObjectMapper objectMapper;

/**
* 创建请求上下文过滤器。
*
* @param tokenVerifier 短期委托令牌验证器
* @param objectMapper Spring 统一配置的 JSON 序列化器
*/
AgentRequestContextFilter(DelegatedTokenVerifier tokenVerifier) {
AgentRequestContextFilter(DelegatedTokenVerifier tokenVerifier, ObjectMapper objectMapper) {
this.tokenVerifier = tokenVerifier;
this.objectMapper = objectMapper;
}

/**
Expand All @@ -45,23 +52,52 @@ protected void doFilterInternal(
return;
}
try {
String workspaceId = requiredHeader(request, "X-OpenXnet-Workspace-Id");
String incidentId = requiredHeader(request, "X-OpenXnet-Incident-Id");
String traceId = requiredHeader(request, "X-OpenXnet-Trace-Id");
String toolName = requiredHeader(request, "X-OpenXnet-Tool-Name");
String idempotencyKey = requiredHeader(request, "Idempotency-Key");
verifyPathTool(request.getRequestURI(), toolName);
String actorId = tokenVerifier.verify(request.getHeader("Authorization"), workspaceId, toolName);
AgentContract.RequestContext context = new AgentContract.RequestContext(
workspaceId, incidentId, traceId, toolName, idempotencyKey, actorId, null);
request.setAttribute(AgentContract.CONTEXT_ATTRIBUTE, context);
putMdc(context);
try {
String workspaceId = requiredHeader(request, "X-OpenXnet-Workspace-Id");
String incidentId = requiredHeader(request, "X-OpenXnet-Incident-Id");
String traceId = requiredHeader(request, "X-OpenXnet-Trace-Id");
String toolName = requiredHeader(request, "X-OpenXnet-Tool-Name");
String idempotencyKey = requiredHeader(request, "Idempotency-Key");
verifyPathTool(request.getRequestURI(), toolName);
String actorId = tokenVerifier.verify(request.getHeader("Authorization"), workspaceId, toolName);
AgentContract.RequestContext context = new AgentContract.RequestContext(
workspaceId, incidentId, traceId, toolName, idempotencyKey, actorId, null);
request.setAttribute(AgentContract.CONTEXT_ATTRIBUTE, context);
putMdc(context);
} catch (AgentContractException exception) {
writeContractError(response, exception);
return;
}
filterChain.doFilter(request, response);
} finally {
MDC.clear();
}
}

/**
* 将过滤器阶段的鉴权或契约异常写为脱敏公共 JSON 包络。
*
* @param response HTTP 响应
* @param exception 已识别的公共契约异常
* @throws IOException 响应写入失败时抛出
*/
private void writeContractError(
HttpServletResponse response,
AgentContractException exception) throws IOException {
AgentContract.ToolMeta meta = new AgentContract.ToolMeta(
null, null, null, null, null, AgentContract.CONTRACT_VERSION,
Instant.now(), 0L, "agent-contract", null, null);
AgentContract.ToolError error = new AgentContract.ToolError(
exception.getCode(), exception.getMessage(), exception.isRetryable(), exception.getDetails());
AgentContract.ToolResponse<Void> body = new AgentContract.ToolResponse<>(
false, null, error, meta, null);
response.resetBuffer();
response.setStatus(exception.getHttpStatus());
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), body);
}

/**
* 读取并限制公共 Header,避免空值和超长日志污染。
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.synapxnet.goai.contract;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

import static org.assertj.core.api.Assertions.assertThat;

/**
* 验证 Agent 过滤器在控制器执行前返回稳定且脱敏的公共错误包络。
*/
class AgentRequestContextFilterTest {

private static final String SECRET = "goai-filter-test-secret-1234567890";
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();

/**
* 验证缺少公共请求头时直接返回 400 JSON,而不是由容器转换为 500。
*
* @throws Exception 测试请求执行或 JSON 解析失败时抛出
*/
@Test
void returnsStructuredBadRequestWhenHeadersAreMissing() throws Exception {
AgentRequestContextFilter filter = createFilter();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/agent/v1/actions/action-1");
MockHttpServletResponse response = new MockHttpServletResponse();

filter.doFilter(request, response, new MockFilterChain());

JsonNode body = objectMapper.readTree(response.getContentAsByteArray());
assertThat(response.getStatus()).isEqualTo(400);
assertThat(response.getContentType()).startsWith("application/json");
assertThat(body.path("success").asBoolean()).isFalse();
assertThat(body.path("error").path("code").asText()).isEqualTo("INVALID_ARGUMENT");
assertThat(body.path("meta").path("workspaceId").isNull()).isTrue();
}

/**
* 验证公共请求头有效但未携带委托令牌时返回 401 脱敏包络。
*
* @throws Exception 测试请求执行或 JSON 解析失败时抛出
*/
@Test
void returnsStructuredUnauthorizedWhenTokenIsMissing() throws Exception {
AgentRequestContextFilter filter = createFilter();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/agent/v1/actions/action-1");
request.addHeader("X-OpenXnet-Workspace-Id", "ws_goai_demo");
request.addHeader("X-OpenXnet-Incident-Id", "incident-1");
request.addHeader("X-OpenXnet-Trace-Id", "trace-1");
request.addHeader("X-OpenXnet-Tool-Name", "dataops.workflow.instance.get");
request.addHeader("Idempotency-Key", "idem-1");
MockHttpServletResponse response = new MockHttpServletResponse();

filter.doFilter(request, response, new MockFilterChain());

JsonNode body = objectMapper.readTree(response.getContentAsByteArray());
assertThat(response.getStatus()).isEqualTo(401);
assertThat(body.path("error").path("code").asText()).isEqualTo("UNAUTHENTICATED");
assertThat(response.getContentAsString()).doesNotContain(SECRET);
}

/**
* 创建使用测试密钥和标准受众的过滤器实例。
*
* @return 可独立执行的 Agent 请求过滤器
*/
private AgentRequestContextFilter createFilter() {
DelegatedTokenVerifier verifier = new DelegatedTokenVerifier(SECRET, "openxnet-agent-adapter");
return new AgentRequestContextFilter(verifier, objectMapper);
}
}