From d60c65e8cd8a0d9cae092067cc4f96df43c9f378 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Mon, 7 Sep 2026 16:48:04 +0800 Subject: [PATCH] feat(spring): add slash action endpoint aliases Signed-off-by: Zhoujie628 --- docs/en/API_REFERENCE.md | 17 +++ docs/en/INTEGRATION_GUIDE.md | 27 ++++ docs/zh/API_REFERENCE.md | 15 +++ docs/zh/INTEGRATION_GUIDE.md | 26 ++++ .../engine/spring/A2AAutoConfiguration.java | 14 +++ .../workflow/engine/spring/A2AProperties.java | 14 +++ .../spring/A2ASlashActionAliasController.java | 79 ++++++++++++ .../spring/A2AAutoConfigurationTest.java | 116 ++++++++++++++++++ 8 files changed, 308 insertions(+) create mode 100644 spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2ASlashActionAliasController.java diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index f3db2ca..6c7f349 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -620,6 +620,7 @@ Configuration properties prefixed with `a2at.server`: |----------------------------------------------|----------------------------|------------------------------------------------------------------------| | `a2at.server.agent-card` | `classpath:agentcard.json` | Path to the AgentCard JSON file (classpath: or file: prefix supported) | | `a2at.server.path-prefix` | `/a2a/json` | URL path prefix for A2A endpoints | +| `a2at.server.slash-action-aliases-enabled` | `false` | Expose slash-style action aliases for gateways that reject colons | | `a2at.server.agent-timeout-seconds` | `30` | Agent execution timeout in seconds | | `a2at.server.consumption-timeout-seconds` | `5` | Consumption timeout in seconds | | `a2at.server.reconciliation-timeout-seconds` | `1` | Reconciliation wait timeout in seconds | @@ -633,6 +634,7 @@ a2at: server: agent-card: classpath:agentcard/my_agent.json path-prefix: /a2a/json + slash-action-aliases-enabled: false agent-timeout-seconds: 30 executor-core-size: 8 executor-max-size: 16 @@ -656,6 +658,7 @@ Auto-configures the following beans (all `@ConditionalOnMissingBean`, so you can | `requestHandler` | `RequestHandler` | Default request handler | | `restHandler` | `RestHandler` | REST protocol handler | | `a2aController` | `A2AController` | Spring MVC controller for message and task endpoints | +| `a2aSlashActionAliasController` | internal controller | Optional slash-style aliases for action endpoints | ### A2AController @@ -668,6 +671,20 @@ Spring MVC controller that exposes A2A REST endpoints: - `POST {path-prefix}/tasks/{id}:cancel` — cancel a task - `POST {path-prefix}/tasks/{id}:subscribe` — subscribe to task updates over SSE +The colon-style routes are the canonical A2A HTTP+JSON endpoints and are always enabled. When +`a2at.server.slash-action-aliases-enabled=true`, the starter additionally exposes compatibility aliases that delegate +to the same controller and therefore have identical authentication, request bodies, responses, errors, streaming and +lifecycle behavior: + +- `POST {path-prefix}/message/send` +- `POST {path-prefix}/message/stream` +- `POST {path-prefix}/tasks/{id}/cancel` +- `POST {path-prefix}/tasks/{id}/subscribe` + +Prefer an API-gateway rewrite from slash-style public paths to canonical colon-style backend paths. Enable the aliases +only when the gateway cannot rewrite the backend path. The aliases do not change the AgentCard schema or make operation +suffixes configurable in the A2A Java REST client. + HTTP+JSON responses use `application/a2a+json`; successful streaming responses use `text/event-stream`. When a stream closes, the controller cancels its publisher subscription and notifies the SDK event consumer so that server-side polling does not remain active. diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index 18c3ffd..183f2f0 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -549,6 +549,33 @@ Cleanup is enabled and fail-fast by default so stale tasks cannot silently lead the demo may cancel active tasks created by another installation; use an isolated identity or disable cleanup after providing an equivalent ownership-aware policy. +### 13.4 API gateways that reject colon-style paths + +A2A HTTP+JSON defines action endpoints such as `message:send`, `message:stream`, `tasks/{id}:cancel` and +`tasks/{id}:subscribe`. Keep these canonical routes enabled. Prefer configuring the API gateway to publish slash-style +paths and rewrite them to the canonical backend paths, for example: + +```text +/gateway/service/a2a/json/message/send -> /a2a/json/message:send +/gateway/service/a2a/json/message/stream -> /a2a/json/message:stream +``` + +If the gateway cannot rewrite the backend path, enable the starter's aliases: + +```yaml +a2at: + server: + path-prefix: /a2a/json + slash-action-aliases-enabled: true +``` + +The gateway can then forward `/message/send` and `/message/stream` unchanged. The same option also exposes +`/tasks/{id}/cancel` and `/tasks/{id}/subscribe`. Do not put the gateway-owned public prefix into `path-prefix` when the +gateway removes it before forwarding; add it only when the application actually receives that prefix. The A2A-T SDK +does not send HTTP requests, and the A2A Java REST transport appends canonical operation suffixes to the AgentCard base +URL. A caller that must emit slash-style paths therefore needs gateway rewriting or a transport adapter; changing the +AgentCard base URL alone is insufficient. + ## 14. A2A errors and task failures Keep failures before and after task creation separate. A request rejected before a task is created uses a diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index 96b0c50..ede9390 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -586,6 +586,7 @@ classpath 时,自动将所有 A2A SDK 服务端组件注册为 Spring Bean。 |----------------------------------------------|----------------------------|----------------------------------------------------------| | `a2at.server.agent-card` | `classpath:agentcard.json` | AgentCard JSON 文件路径(支持 classpath: 或 file: 前缀) | | `a2at.server.path-prefix` | `/a2a/json` | A2A 端点的 URL 路径前缀 | +| `a2at.server.slash-action-aliases-enabled` | `false` | 为拒绝冒号路径的网关暴露斜杠动作别名 | | `a2at.server.agent-timeout-seconds` | `30` | Agent 执行超时(秒) | | `a2at.server.consumption-timeout-seconds` | `5` | 消费超时(秒) | | `a2at.server.reconciliation-timeout-seconds` | `1` | 协调等待超时(秒) | @@ -599,6 +600,7 @@ a2at: server: agent-card: classpath:agentcard/my_agent.json path-prefix: /a2a/json + slash-action-aliases-enabled: false agent-timeout-seconds: 30 executor-core-size: 8 executor-max-size: 16 @@ -622,6 +624,7 @@ a2at: | `requestHandler` | `RequestHandler` | 默认请求处理器 | | `restHandler` | `RestHandler` | REST 协议处理器 | | `a2aController` | `A2AController` | 消息和任务端点的 Spring MVC 控制器 | +| `a2aSlashActionAliasController` | 内部控制器 | 可选的斜杠形式动作端点别名 | ### A2AController @@ -634,6 +637,18 @@ a2at: - `POST {path-prefix}/tasks/{id}:cancel` — 取消任务 - `POST {path-prefix}/tasks/{id}:subscribe` — 通过 SSE 订阅任务更新 +冒号形式是 A2A HTTP+JSON 标准端点,始终启用。配置 +`a2at.server.slash-action-aliases-enabled=true` 后,starter 还会暴露以下兼容别名;它们委托给同一个控制器, +认证、请求体、响应、错误、流式处理和生命周期行为完全相同: + +- `POST {path-prefix}/message/send` +- `POST {path-prefix}/message/stream` +- `POST {path-prefix}/tasks/{id}/cancel` +- `POST {path-prefix}/tasks/{id}/subscribe` + +优先在 API 网关把公开的斜杠路径重写到后端标准冒号路径;只有网关不能重写后端路径时才开启别名。 +别名不会改变 AgentCard 结构,也不会让 A2A Java REST 客户端的操作后缀变成可配置项。 + HTTP+JSON 响应使用 `application/a2a+json`,成功建立的流式响应使用 `text/event-stream`。连接结束时,控制器会取消 Publisher 订阅并通知 SDK 停止事件消费,避免服务端轮询继续占用资源。 diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index bfb215e..188d04c 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -514,6 +514,32 @@ Demo 在打开独立协议通道和启动工作流之前,先查询每个被调 `A2A_TASK_CLEANUP_MAX_TASKS` 配置。任务查询受认证身份权限约束;若多个实例共用同一身份,Demo 可能取消其他实例创建的活跃任务。 应使用隔离身份,或在提供等价的任务归属清理策略后关闭该功能。 +### 13.4 不接受冒号路径的 API 网关 + +A2A HTTP+JSON 定义了 `message:send`、`message:stream`、`tasks/{id}:cancel` 和 +`tasks/{id}:subscribe` 等动作端点,标准端点应始终保留。首选由 API 网关发布斜杠形式的公开路径, +并重写到后端标准路径,例如: + +```text +/gateway/service/a2a/json/message/send -> /a2a/json/message:send +/gateway/service/a2a/json/message/stream -> /a2a/json/message:stream +``` + +如果网关不能重写后端路径,可开启 starter 的兼容别名: + +```yaml +a2at: + server: + path-prefix: /a2a/json + slash-action-aliases-enabled: true +``` + +此时网关可以原样转发 `/message/send` 和 `/message/stream`;同一配置还会开放 +`/tasks/{id}/cancel` 和 `/tasks/{id}/subscribe`。如果网关转发前会去掉它管理的公开前缀, +不要把该前缀写进 `path-prefix`;只有应用实际收到该前缀时才需要配置。A2A-T SDK 不发送 HTTP 请求, +A2A Java REST transport 会在 AgentCard 基础 URL 后追加标准动作后缀。因此,必须发出斜杠路径的调用方 +需要使用网关重写或传输适配器,仅修改 AgentCard 基础 URL 不够。 + ## 14. A2A 错误与任务失败 任务创建前和创建后的失败必须分开处理。请求在任务创建前被拒绝时,返回非 2xx HTTP 状态和标准 diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java index 03514a6..31bbbf4 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java @@ -67,6 +67,7 @@ *
  • {@link MainEventBusProcessor} - the event bus *
  • {@link A2AController} - the Spring MVC controller (message send/stream plus task * query, list, cancel, and subscribe endpoints) + *
  • Optional slash-style aliases for gateways that cannot publish colon-style action paths * * *

    The partner only needs to provide an {@link AgentExecutor} implementation as a @@ -214,4 +215,17 @@ public A2AController a2aController( RestHandler restHandler, RequestHandler requestHandler, AgentCard agentCard) { return new A2AController(restHandler, requestHandler, agentCard); } + + /** + * Adds non-standard slash-style action aliases without changing the canonical A2A endpoints. + * This compatibility surface is disabled unless explicitly enabled for a constrained gateway. + */ + @Bean + @ConditionalOnMissingBean(A2ASlashActionAliasController.class) + @ConditionalOnProperty( + name = "a2at.server.slash-action-aliases-enabled", + havingValue = "true") + A2ASlashActionAliasController a2aSlashActionAliasController(A2AController delegate) { + return new A2ASlashActionAliasController(delegate); + } } diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java index c6fa7e0..af986f1 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java @@ -39,6 +39,12 @@ public class A2AProperties { /** URL path prefix for A2A endpoints; all controller mappings are anchored under it. */ private String pathPrefix = "/a2a/json"; + /** + * Whether to expose slash-style aliases for A2A action endpoints for gateways that reject + * colons in published paths. Standard colon-style endpoints always remain available. + */ + private boolean slashActionAliasesEnabled = false; + /** Timeout for blocking agent execution. */ private int agentTimeoutSeconds = 30; @@ -89,6 +95,14 @@ public void setPathPrefix(String pathPrefix) { this.pathPrefix = pathPrefix; } + public boolean isSlashActionAliasesEnabled() { + return slashActionAliasesEnabled; + } + + public void setSlashActionAliasesEnabled(boolean slashActionAliasesEnabled) { + this.slashActionAliasesEnabled = slashActionAliasesEnabled; + } + public int getAgentTimeoutSeconds() { return agentTimeoutSeconds; } diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2ASlashActionAliasController.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2ASlashActionAliasController.java new file mode 100644 index 0000000..51d8683 --- /dev/null +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2ASlashActionAliasController.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 OpenAN. All rights reserved. + * + * 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 + * + * http://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 dev.openan.workflow.engine.spring; + +import jakarta.servlet.http.HttpServletRequest; +import org.a2aproject.sdk.spec.A2AError; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * Optional slash-style aliases for A2A action endpoints. + * + *

    Every operation delegates to {@link A2AController}; the aliases therefore retain the + * canonical endpoint's authentication, A2A error envelope, streaming, and cleanup behavior. + */ +@RestController +@ConditionalOnProperty( + name = "a2at.server.slash-action-aliases-enabled", + havingValue = "true") +final class A2ASlashActionAliasController { + + private final A2AController delegate; + + A2ASlashActionAliasController(A2AController delegate) { + this.delegate = delegate; + } + + @PostMapping("${a2at.server.path-prefix}/message/send") + ResponseEntity sendMessage(HttpServletRequest request, @RequestBody String body) { + return delegate.sendMessage(request, body); + } + + @PostMapping( + value = "${a2at.server.path-prefix}/message/stream", + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + SseEmitter streamMessage(HttpServletRequest request, @RequestBody String body) { + return delegate.streamMessage(request, body); + } + + @PostMapping("${a2at.server.path-prefix}/tasks/{id}/cancel") + ResponseEntity cancelTask( + HttpServletRequest request, + @PathVariable("id") String taskId, + @RequestBody(required = false) String body) { + return delegate.cancelTask(request, taskId, body); + } + + @PostMapping( + value = "${a2at.server.path-prefix}/tasks/{id}/subscribe", + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + SseEmitter subscribeToTask(HttpServletRequest request, @PathVariable("id") String taskId) { + return delegate.subscribeToTask(request, taskId); + } + + @ExceptionHandler(A2AError.class) + ResponseEntity a2aError(A2AError error) { + return delegate.a2aError(error); + } +} diff --git a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java index aa030a4..3fbdb3c 100644 --- a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java +++ b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java @@ -26,6 +26,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -41,6 +42,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.auth.TaskOperation; import org.a2aproject.sdk.server.config.A2AConfigProvider; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; @@ -311,6 +313,120 @@ void streamingCapabilityIsCheckedBeforeInvokingBusinessHandler() throws Exceptio verifyNoInteractions(requestHandler); } + @Test + void slashActionAliasesDelegateToTheCanonicalController() throws Exception { + RestHandler restHandler = mock(RestHandler.class); + when(restHandler.sendMessage(any(), eq(""), eq("{}"))) + .thenReturn(new RestHandler.HTTPRestResponse(200, "application/json", "{}")); + RequestHandler requestHandler = mock(RequestHandler.class); + AgentCard agentCard = mock(AgentCard.class); + A2AController canonical = new A2AController(restHandler, requestHandler, agentCard); + var aliases = new A2ASlashActionAliasController(canonical); + MockMvc mockMvc = + MockMvcBuilders.standaloneSetup(aliases) + .addPlaceholderValue("a2at.server.path-prefix", "/a2a/json") + .build(); + + mockMvc + .perform(post("/a2a/json/message/send").content("{}")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/a2a+json")); + + verify(restHandler).sendMessage(any(), eq(""), eq("{}")); + } + + @Test + void everySlashActionAliasUsesTheConfiguredPrefix() throws Exception { + String placeholder = "${a2at.server.path-prefix}"; + Class aliases = A2ASlashActionAliasController.class; + + assertEquals( + placeholder + "/message/send", + aliases + .getDeclaredMethod( + "sendMessage", jakarta.servlet.http.HttpServletRequest.class, String.class) + .getAnnotation(PostMapping.class) + .value()[0]); + assertEquals( + placeholder + "/message/stream", + aliases + .getDeclaredMethod( + "streamMessage", jakarta.servlet.http.HttpServletRequest.class, String.class) + .getAnnotation(PostMapping.class) + .value()[0]); + assertEquals( + placeholder + "/tasks/{id}/cancel", + aliases + .getDeclaredMethod( + "cancelTask", + jakarta.servlet.http.HttpServletRequest.class, + String.class, + String.class) + .getAnnotation(PostMapping.class) + .value()[0]); + assertEquals( + placeholder + "/tasks/{id}/subscribe", + aliases + .getDeclaredMethod( + "subscribeToTask", jakarta.servlet.http.HttpServletRequest.class, String.class) + .getAnnotation(PostMapping.class) + .value()[0]); + } + + @Test + void slashStreamingAliasUsesTheCanonicalA2aErrorEnvelope() throws Exception { + RestHandler restHandler = mock(RestHandler.class); + when(restHandler.createErrorResponse(any())) + .thenReturn( + new RestHandler.HTTPRestResponse( + 501, + "application/a2a+json", + "{\"error\":{\"code\":-32004,\"status\":\"UNIMPLEMENTED\"}}")); + RequestHandler requestHandler = mock(RequestHandler.class); + AgentCard agentCard = mock(AgentCard.class); + when(agentCard.capabilities()).thenReturn(AgentCapabilities.builder().streaming(false).build()); + A2AController canonical = new A2AController(restHandler, requestHandler, agentCard); + var aliases = new A2ASlashActionAliasController(canonical); + MockMvc mockMvc = + MockMvcBuilders.standaloneSetup(aliases) + .addPlaceholderValue("a2at.server.path-prefix", "/a2a/json") + .build(); + + mockMvc + .perform(post("/a2a/json/message/stream").content("{}")) + .andExpect(status().is(501)) + .andExpect(content().contentTypeCompatibleWith("application/a2a+json")); + + verifyNoInteractions(requestHandler); + } + + @Test + void slashActionAliasesAreOptIn() { + A2AController canonical = mock(A2AController.class); + WebApplicationContextRunner runner = + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(A2AAutoConfiguration.class)) + .withBean(AgentCard.class, () -> mock(AgentCard.class)) + .withBean(AgentExecutor.class, () -> mock(AgentExecutor.class)) + .withBean(RequestHandler.class, () -> mock(RequestHandler.class)) + .withBean(RestHandler.class, () -> mock(RestHandler.class)) + .withBean(A2AController.class, () -> canonical); + + runner.run( + context -> assertFalse(context.containsBean("a2aSlashActionAliasController"))); + runner + .withPropertyValues("a2at.server.slash-action-aliases-enabled=true") + .run(context -> assertTrue(context.containsBean("a2aSlashActionAliasController"))); + } + + @Test + void slashActionAliasPropertyDefaultsToDisabled() { + A2AProperties properties = new A2AProperties(); + assertFalse(properties.isSlashActionAliasesEnabled()); + properties.setSlashActionAliasesEnabled(true); + assertTrue(properties.isSlashActionAliasesEnabled()); + } + @Test void blockingTimeoutsComeFromProperties() { A2AProperties properties = new A2AProperties();