diff --git a/.github/workflows/run-tck.yml b/.github/workflows/run-tck.yml
index a36f5dc6c..545c1b495 100644
--- a/.github/workflows/run-tck.yml
+++ b/.github/workflows/run-tck.yml
@@ -17,6 +17,8 @@ env:
UV_SYSTEM_PYTHON: 1
# SUT_JSONRPC_URL to use for the TCK and the server agent
SUT_JSONRPC_URL: http://localhost:9999
+ # Slow system on CI
+ TCK_STREAMING_TIMEOUT: 5.0
# Only run the latest job
concurrency:
@@ -55,7 +57,7 @@ jobs:
- name: Build with Maven, skipping tests
run: mvn -B install -DskipTests
- name: Start SUT
- run: SUT_GRPC_URL=${{ env.SUT_JSONRPC_URL }} mvn -B quarkus:dev & #SUT_JSONRPC_URL already set
+ run: SUT_GRPC_URL=${{ env.SUT_JSONRPC_URL }} SUT_REST_URL=${{ env.SUT_JSONRPC_URL }} mvn -B quarkus:dev & #SUT_JSONRPC_URL already set
working-directory: tck
- name: Wait for SUT to start
run: |
@@ -93,5 +95,5 @@ jobs:
- name: Run TCK
run: |
- ./run_tck.py --sut-url ${{ env.SUT_JSONRPC_URL }} --category all --transports jsonrpc,grpc --compliance-report report.json
+ ./run_tck.py --sut-url ${{ env.SUT_JSONRPC_URL }} --category all --transports jsonrpc,grpc,rest --compliance-report report.json
working-directory: tck/a2a-tck
diff --git a/.gitignore b/.gitignore
index d8908d914..04650afea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,4 +44,3 @@ nb-configuration.xml
# TLS Certificates
.certs/
nbproject/
-
diff --git a/README.md b/README.md
index 0f0d7b48e..bb50eba9c 100644
--- a/README.md
+++ b/README.md
@@ -81,7 +81,18 @@ To use the reference implementation with the gRPC protocol, add the following de
Note that you can add more than one of the above dependencies to your project depending on the transports
you'd like to support.
-Support for the HTTP+JSON/REST transport will be coming soon.
+To use the reference implementation with the HTTP+JSON/REST protocol, add the following dependency to your project:
+
+> *⚠️ The `io.github.a2asdk` `groupId` below is temporary and will likely change for future releases.*
+
+```xml
+
+ io.github.a2asdk
+ a2a-java-sdk-reference-rest
+
+ ${io.a2a.sdk.version}
+
+```
### 2. Add a class that creates an A2A Agent Card
@@ -117,7 +128,7 @@ public class WeatherAgentCardProducer {
.tags(Collections.singletonList("weather"))
.examples(List.of("weather in LA, CA"))
.build()))
- .protocolVersion("0.2.5")
+ .protocolVersion("0.3.0")
.build();
}
}
@@ -247,7 +258,7 @@ By default, the sdk-client is coming with the JSONRPC transport dependency. Desp
dependency is included by default, you still need to add the transport to the Client as described in [JSON-RPC Transport section](#json-rpc-transport-configuration).
-If you want to use another transport (such as GRPC or HTTP+JSON), you'll need to add a relevant dependency:
+If you want to use the gRPC transport, you'll need to add a relevant dependency:
----
> *⚠️ The `io.github.a2asdk` `groupId` below is temporary and will likely change for future releases.*
@@ -262,7 +273,21 @@ If you want to use another transport (such as GRPC or HTTP+JSON), you'll need to
```
-Support for the HTTP+JSON/REST transport will be coming soon.
+
+If you want to use the HTTP+JSON/REST transport, you'll need to add a relevant dependency:
+
+----
+> *⚠️ The `io.github.a2asdk` `groupId` below is temporary and will likely change for future releases.*
+----
+
+```xml
+
+ io.github.a2asdk
+ a2a-java-sdk-client-transport-rest
+
+ ${io.a2a.sdk.version}
+
+```
### Sample Usage
@@ -360,6 +385,29 @@ Client client = Client
.build();
```
+
+##### HTTP+JSON/REST Transport Configuration
+
+For the HTTP+JSON/REST transport, if you'd like to use the default `JdkA2AHttpClient`, provide a `RestTransportConfig` created with its default constructor.
+
+To use a custom HTTP client implementation, simply create a `RestTransportConfig` as follows:
+
+```java
+// Create a custom HTTP client
+A2AHttpClient customHttpClient = ...
+
+// Configure the client settings
+ClientConfig clientConfig = new ClientConfig.Builder()
+ .setAcceptedOutputModes(List.of("text"))
+ .build();
+
+Client client = Client
+ .builder(agentCard)
+ .clientConfig(clientConfig)
+ .withTransport(RestTransport.class, new RestTransportConfig(customHttpClient))
+ .build();
+```
+
##### Multiple Transport Configurations
You can specify configuration for multiple transports, the appropriate configuration
@@ -371,6 +419,7 @@ Client client = Client
.builder(agentCard)
.withTransport(GrpcTransport.class, new GrpcTransportConfig(channelFactory))
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig())
+ .withTransport(RestTransport.class, new RestTransportConfig())
.build();
```
diff --git a/client/base/pom.xml b/client/base/pom.xml
index 6df47c1b7..c5b45b583 100644
--- a/client/base/pom.xml
+++ b/client/base/pom.xml
@@ -35,6 +35,11 @@
a2a-java-sdk-client-transport-grpc
test
+
+ ${project.groupId}
+ a2a-java-sdk-client-transport-rest
+ test
+
${project.groupId}
a2a-java-sdk-common
@@ -54,6 +59,11 @@
mockserver-netty
test
+
+ org.slf4j
+ slf4j-jdk14
+ test
+
\ No newline at end of file
diff --git a/client/transport/grpc/pom.xml b/client/transport/grpc/pom.xml
index b910d6ac7..158ae7ea8 100644
--- a/client/transport/grpc/pom.xml
+++ b/client/transport/grpc/pom.xml
@@ -33,6 +33,14 @@
${project.groupId}
a2a-java-sdk-client-transport-spi
+
+ io.grpc
+ grpc-protobuf
+
+
+ io.grpc
+ grpc-stub
+
org.junit.jupiter
junit-jupiter-api
diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java
index 99e5ef151..25de32947 100644
--- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java
+++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java
@@ -2,7 +2,6 @@
import static io.a2a.client.transport.jsonrpc.JsonMessages.AGENT_CARD;
import static io.a2a.client.transport.jsonrpc.JsonMessages.AGENT_CARD_SUPPORTS_EXTENDED;
-import static io.a2a.client.transport.jsonrpc.JsonMessages.AUTHENTICATION_EXTENDED_AGENT_CARD;
import static io.a2a.client.transport.jsonrpc.JsonMessages.CANCEL_TASK_TEST_REQUEST;
import static io.a2a.client.transport.jsonrpc.JsonMessages.CANCEL_TASK_TEST_RESPONSE;
import static io.a2a.client.transport.jsonrpc.JsonMessages.GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST;
@@ -50,7 +49,6 @@
import io.a2a.spec.FilePart;
import io.a2a.spec.FileWithBytes;
import io.a2a.spec.FileWithUri;
-import io.a2a.spec.GetAuthenticatedExtendedCardResponse;
import io.a2a.spec.GetTaskPushNotificationConfigParams;
import io.a2a.spec.Message;
import io.a2a.spec.MessageSendConfiguration;
diff --git a/client/transport/rest/pom.xml b/client/transport/rest/pom.xml
new file mode 100644
index 000000000..5d3be70ae
--- /dev/null
+++ b/client/transport/rest/pom.xml
@@ -0,0 +1,62 @@
+
+
+ 4.0.0
+
+
+ io.github.a2asdk
+ a2a-java-sdk-parent
+ 0.3.0.Beta1-SNAPSHOT
+ ../../../pom.xml
+
+ a2a-java-sdk-client-transport-rest
+ jar
+
+ Java SDK A2A Client Transport: JSON+HTTP/REST
+ Java SDK for the Agent2Agent Protocol (A2A) - JSON+HTTP/REST Client Transport
+
+
+
+ ${project.groupId}
+ a2a-java-sdk-common
+
+
+ ${project.groupId}
+ a2a-java-sdk-spec
+
+
+ ${project.groupId}
+ a2a-java-sdk-spec-grpc
+
+
+ ${project.groupId}
+ a2a-java-sdk-client-transport-spi
+
+
+ io.github.a2asdk
+ a2a-java-sdk-http-client
+
+
+ com.google.protobuf
+ protobuf-java-util
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+
+ org.mock-server
+ mockserver-netty
+ test
+
+
+ org.slf4j
+ slf4j-jdk14
+ test
+
+
+
+
\ No newline at end of file
diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java
new file mode 100644
index 000000000..103f7bb0c
--- /dev/null
+++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java
@@ -0,0 +1,80 @@
+package io.a2a.client.transport.rest;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import io.a2a.client.http.A2AHttpResponse;
+import io.a2a.spec.A2AClientException;
+import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError;
+import io.a2a.spec.ContentTypeNotSupportedError;
+import io.a2a.spec.InternalError;
+import io.a2a.spec.InvalidAgentResponseError;
+import io.a2a.spec.InvalidParamsError;
+import io.a2a.spec.InvalidRequestError;
+import io.a2a.spec.JSONParseError;
+import io.a2a.spec.MethodNotFoundError;
+import io.a2a.spec.PushNotificationNotSupportedError;
+import io.a2a.spec.TaskNotCancelableError;
+import io.a2a.spec.TaskNotFoundError;
+import io.a2a.spec.UnsupportedOperationError;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Utility class to A2AHttpResponse to appropriate A2A error types
+ */
+public class RestErrorMapper {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().registerModule(new JavaTimeModule());
+
+ public static A2AClientException mapRestError(A2AHttpResponse response) {
+ return RestErrorMapper.mapRestError(response.body(), response.status());
+ }
+
+ public static A2AClientException mapRestError(String body, int code) {
+ try {
+ if (body != null && !body.isBlank()) {
+ JsonNode node = OBJECT_MAPPER.readTree(body);
+ String className = node.findValue("error").asText();
+ String errorMessage = node.findValue("message").asText();
+ return mapRestError(className, errorMessage, code);
+ }
+ return mapRestError("", "", code);
+ } catch (JsonProcessingException ex) {
+ Logger.getLogger(RestErrorMapper.class.getName()).log(Level.SEVERE, null, ex);
+ return new A2AClientException("Failed to parse error response: " + ex.getMessage());
+ }
+ }
+
+ public static A2AClientException mapRestError(String className, String errorMessage, int code) {
+ switch (className) {
+ case "io.a2a.spec.TaskNotFoundError":
+ return new A2AClientException(errorMessage, new TaskNotFoundError());
+ case "io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError":
+ return new A2AClientException(errorMessage, new AuthenticatedExtendedCardNotConfiguredError());
+ case "io.a2a.spec.ContentTypeNotSupportedError":
+ return new A2AClientException(errorMessage, new ContentTypeNotSupportedError(null, null, errorMessage));
+ case "io.a2a.spec.InternalError":
+ return new A2AClientException(errorMessage, new InternalError(errorMessage));
+ case "io.a2a.spec.InvalidAgentResponseError":
+ return new A2AClientException(errorMessage, new InvalidAgentResponseError(null, null, errorMessage));
+ case "io.a2a.spec.InvalidParamsError":
+ return new A2AClientException(errorMessage, new InvalidParamsError());
+ case "io.a2a.spec.InvalidRequestError":
+ return new A2AClientException(errorMessage, new InvalidRequestError());
+ case "io.a2a.spec.JSONParseError":
+ return new A2AClientException(errorMessage, new JSONParseError());
+ case "io.a2a.spec.MethodNotFoundError":
+ return new A2AClientException(errorMessage, new MethodNotFoundError());
+ case "io.a2a.spec.PushNotificationNotSupportedError":
+ return new A2AClientException(errorMessage, new PushNotificationNotSupportedError());
+ case "io.a2a.spec.TaskNotCancelableError":
+ return new A2AClientException(errorMessage, new TaskNotCancelableError());
+ case "io.a2a.spec.UnsupportedOperationError":
+ return new A2AClientException(errorMessage, new UnsupportedOperationError());
+ default:
+ return new A2AClientException(errorMessage);
+ }
+ }
+}
diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransport.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransport.java
new file mode 100644
index 000000000..5420600d4
--- /dev/null
+++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransport.java
@@ -0,0 +1,389 @@
+package io.a2a.client.transport.rest;
+
+import static io.a2a.util.Assert.checkNotNullParam;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.MessageOrBuilder;
+import com.google.protobuf.util.JsonFormat;
+import io.a2a.client.http.A2ACardResolver;
+import io.a2a.client.http.A2AHttpClient;
+import io.a2a.client.http.A2AHttpResponse;
+import io.a2a.client.http.JdkA2AHttpClient;
+import io.a2a.client.transport.rest.sse.RestSSEEventListener;
+import io.a2a.client.transport.spi.ClientTransport;
+import io.a2a.client.transport.spi.interceptors.ClientCallContext;
+import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor;
+import io.a2a.client.transport.spi.interceptors.PayloadAndHeaders;
+import io.a2a.grpc.CancelTaskRequest;
+import io.a2a.grpc.CreateTaskPushNotificationConfigRequest;
+import io.a2a.grpc.GetTaskPushNotificationConfigRequest;
+import io.a2a.grpc.GetTaskRequest;
+import io.a2a.grpc.ListTaskPushNotificationConfigRequest;
+import io.a2a.spec.TaskPushNotificationConfig;
+import io.a2a.spec.A2AClientException;
+import io.a2a.spec.AgentCard;
+import io.a2a.spec.DeleteTaskPushNotificationConfigParams;
+import io.a2a.spec.EventKind;
+import io.a2a.spec.GetTaskPushNotificationConfigParams;
+import io.a2a.spec.ListTaskPushNotificationConfigParams;
+import io.a2a.spec.MessageSendParams;
+import io.a2a.spec.StreamingEventKind;
+import io.a2a.spec.Task;
+import io.a2a.spec.TaskIdParams;
+import io.a2a.spec.TaskQueryParams;
+import io.a2a.grpc.utils.ProtoUtils;
+import io.a2a.spec.A2AClientError;
+import io.a2a.spec.SendStreamingMessageRequest;
+import io.a2a.spec.SetTaskPushNotificationConfigRequest;
+import io.a2a.util.Utils;
+import java.io.IOException;
+import java.util.List;
+import java.util.logging.Logger;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+public class RestTransport implements ClientTransport {
+
+ private static final Logger log = Logger.getLogger(RestTransport.class.getName());
+ private final A2AHttpClient httpClient;
+ private final String agentUrl;
+ private final List interceptors;
+ private AgentCard agentCard;
+ private boolean needsExtendedCard = false;
+
+ public RestTransport(String agentUrl) {
+ this(null, null, agentUrl, null);
+ }
+
+ public RestTransport(AgentCard agentCard) {
+ this(null, agentCard, agentCard.url(), null);
+ }
+
+ public RestTransport(A2AHttpClient httpClient, AgentCard agentCard,
+ String agentUrl, List interceptors) {
+ this.httpClient = httpClient == null ? new JdkA2AHttpClient() : httpClient;
+ this.agentCard = agentCard;
+ this.agentUrl = agentUrl.endsWith("/") ? agentUrl.substring(0, agentUrl.length() - 1) : agentUrl;
+ this.interceptors = interceptors;
+ }
+
+ @Override
+ public EventKind sendMessage(MessageSendParams messageSendParams, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("messageSendParams", messageSendParams);
+ io.a2a.grpc.SendMessageRequest.Builder builder = io.a2a.grpc.SendMessageRequest.newBuilder(ProtoUtils.ToProto.sendMessageRequest(messageSendParams));
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(io.a2a.spec.SendMessageRequest.METHOD, builder, agentCard, context);
+ try {
+ String httpResponseBody = sendPostRequest(agentUrl + "/v1/message:send", payloadAndHeaders);
+ io.a2a.grpc.SendMessageResponse.Builder responseBuilder = io.a2a.grpc.SendMessageResponse.newBuilder();
+ JsonFormat.parser().merge(httpResponseBody, responseBuilder);
+ if (responseBuilder.hasMsg()) {
+ return ProtoUtils.FromProto.message(responseBuilder.getMsg());
+ }
+ if (responseBuilder.hasTask()) {
+ return ProtoUtils.FromProto.task(responseBuilder.getTask());
+ }
+ throw new A2AClientException("Failed to send message, wrong response:" + httpResponseBody);
+ } catch (A2AClientException e) {
+ throw e;
+ } catch (IOException | InterruptedException e) {
+ throw new A2AClientException("Failed to send message: " + e, e);
+ }
+ }
+
+ @Override
+ public void sendMessageStreaming(MessageSendParams messageSendParams, Consumer eventConsumer, Consumer errorConsumer, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", messageSendParams);
+ checkNotNullParam("eventConsumer", eventConsumer);
+ checkNotNullParam("messageSendParams", messageSendParams);
+ io.a2a.grpc.SendMessageRequest.Builder builder = io.a2a.grpc.SendMessageRequest.newBuilder(ProtoUtils.ToProto.sendMessageRequest(messageSendParams));
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(SendStreamingMessageRequest.METHOD,
+ builder, agentCard, context);
+ AtomicReference> ref = new AtomicReference<>();
+ RestSSEEventListener sseEventListener = new RestSSEEventListener(eventConsumer, errorConsumer);
+ try {
+ A2AHttpClient.PostBuilder postBuilder = createPostBuilder(agentUrl + "/v1/message:stream", payloadAndHeaders);
+ ref.set(postBuilder.postAsyncSSE(
+ msg -> sseEventListener.onMessage(msg, ref.get()),
+ throwable -> sseEventListener.onError(throwable, ref.get()),
+ () -> {
+ // We don't need to do anything special on completion
+ }));
+ } catch (IOException e) {
+ throw new A2AClientException("Failed to send streaming message request: " + e, e);
+ } catch (InterruptedException e) {
+ throw new A2AClientException("Send streaming message request timed out: " + e, e);
+ }
+ }
+
+ @Override
+ public Task getTask(TaskQueryParams taskQueryParams, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("taskQueryParams", taskQueryParams);
+ GetTaskRequest.Builder builder = GetTaskRequest.newBuilder();
+ builder.setName("tasks/" + taskQueryParams.id());
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(io.a2a.spec.GetTaskRequest.METHOD, builder,
+ agentCard, context);
+ try {
+ String url;
+ if (taskQueryParams.historyLength() != null) {
+ url = agentUrl + String.format("/v1/tasks/%1s?historyLength=%2d", taskQueryParams.id(), taskQueryParams.historyLength());
+ } else {
+ url = agentUrl + String.format("/v1/tasks/%1s", taskQueryParams.id());
+ }
+ A2AHttpClient.GetBuilder getBuilder = httpClient.createGet().url(url);
+ if (payloadAndHeaders.getHeaders() != null) {
+ for (Map.Entry entry : payloadAndHeaders.getHeaders().entrySet()) {
+ getBuilder.addHeader(entry.getKey(), entry.getValue());
+ }
+ }
+ A2AHttpResponse response = getBuilder.get();
+ if (!response.success()) {
+ throw RestErrorMapper.mapRestError(response);
+ }
+ String httpResponseBody = response.body();
+ io.a2a.grpc.Task.Builder responseBuilder = io.a2a.grpc.Task.newBuilder();
+ JsonFormat.parser().merge(httpResponseBody, responseBuilder);
+ return ProtoUtils.FromProto.task(responseBuilder);
+ } catch (A2AClientException e) {
+ throw e;
+ } catch (IOException | InterruptedException e) {
+ throw new A2AClientException("Failed to get task: " + e, e);
+ }
+ }
+
+ @Override
+ public Task cancelTask(TaskIdParams taskIdParams, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("taskIdParams", taskIdParams);
+ CancelTaskRequest.Builder builder = CancelTaskRequest.newBuilder();
+ builder.setName("tasks/" + taskIdParams.id());
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(io.a2a.spec.CancelTaskRequest.METHOD, builder,
+ agentCard, context);
+ try {
+ String httpResponseBody = sendPostRequest(agentUrl + String.format("/v1/tasks/%1s:cancel", taskIdParams.id()), payloadAndHeaders);
+ io.a2a.grpc.Task.Builder responseBuilder = io.a2a.grpc.Task.newBuilder();
+ JsonFormat.parser().merge(httpResponseBody, responseBuilder);
+ return ProtoUtils.FromProto.task(responseBuilder);
+ } catch (A2AClientException e) {
+ throw e;
+ } catch (IOException | InterruptedException e) {
+ throw new A2AClientException("Failed to cancel task: " + e, e);
+ }
+ }
+
+ @Override
+ public TaskPushNotificationConfig setTaskPushNotificationConfiguration(TaskPushNotificationConfig request, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", request);
+ CreateTaskPushNotificationConfigRequest.Builder builder = CreateTaskPushNotificationConfigRequest.newBuilder();
+ builder.setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(request))
+ .setParent("tasks/" + request.taskId());
+ if (request.pushNotificationConfig().id() != null) {
+ builder.setConfigId(request.pushNotificationConfig().id());
+ }
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(SetTaskPushNotificationConfigRequest.METHOD, builder, agentCard, context);
+ try {
+ String httpResponseBody = sendPostRequest(agentUrl + String.format("/v1/tasks/%1s/pushNotificationConfigs", request.taskId()), payloadAndHeaders);
+ io.a2a.grpc.TaskPushNotificationConfig.Builder responseBuilder = io.a2a.grpc.TaskPushNotificationConfig.newBuilder();
+ JsonFormat.parser().merge(httpResponseBody, responseBuilder);
+ return ProtoUtils.FromProto.taskPushNotificationConfig(responseBuilder);
+ } catch (A2AClientException e) {
+ throw e;
+ } catch (IOException | InterruptedException e) {
+ throw new A2AClientException("Failed to set task push notification config: " + e, e);
+ }
+ }
+
+ @Override
+ public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPushNotificationConfigParams request, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", request);
+ GetTaskPushNotificationConfigRequest.Builder builder = GetTaskPushNotificationConfigRequest.newBuilder();
+ builder.setName(String.format("/tasks/%1s/pushNotificationConfigs/%2s", request.id(), request.pushNotificationConfigId()));
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(io.a2a.spec.GetTaskPushNotificationConfigRequest.METHOD, builder,
+ agentCard, context);
+ try {
+ String url = agentUrl + String.format("/v1/tasks/%1s/pushNotificationConfigs/%2s", request.id(), request.pushNotificationConfigId());
+ A2AHttpClient.GetBuilder getBuilder = httpClient.createGet().url(url);
+ if (payloadAndHeaders.getHeaders() != null) {
+ for (Map.Entry entry : payloadAndHeaders.getHeaders().entrySet()) {
+ getBuilder.addHeader(entry.getKey(), entry.getValue());
+ }
+ }
+ A2AHttpResponse response = getBuilder.get();
+ if (!response.success()) {
+ throw RestErrorMapper.mapRestError(response);
+ }
+ String httpResponseBody = response.body();
+ io.a2a.grpc.TaskPushNotificationConfig.Builder responseBuilder = io.a2a.grpc.TaskPushNotificationConfig.newBuilder();
+ JsonFormat.parser().merge(httpResponseBody, responseBuilder);
+ return ProtoUtils.FromProto.taskPushNotificationConfig(responseBuilder);
+ } catch (A2AClientException e) {
+ throw e;
+ } catch (IOException | InterruptedException e) {
+ throw new A2AClientException("Failed to get push notifications: " + e, e);
+ }
+ }
+
+ @Override
+ public List listTaskPushNotificationConfigurations(ListTaskPushNotificationConfigParams request, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", request);
+ ListTaskPushNotificationConfigRequest.Builder builder = ListTaskPushNotificationConfigRequest.newBuilder();
+ builder.setParent(String.format("/tasks/%1s/pushNotificationConfigs", request.id()));
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(io.a2a.spec.ListTaskPushNotificationConfigRequest.METHOD, builder,
+ agentCard, context);
+ try {
+ String url = agentUrl + String.format("/v1/tasks/%1s/pushNotificationConfigs", request.id());
+ A2AHttpClient.GetBuilder getBuilder = httpClient.createGet().url(url);
+ if (payloadAndHeaders.getHeaders() != null) {
+ for (Map.Entry entry : payloadAndHeaders.getHeaders().entrySet()) {
+ getBuilder.addHeader(entry.getKey(), entry.getValue());
+ }
+ }
+ A2AHttpResponse response = getBuilder.get();
+ if (!response.success()) {
+ throw RestErrorMapper.mapRestError(response);
+ }
+ String httpResponseBody = response.body();
+ io.a2a.grpc.ListTaskPushNotificationConfigResponse.Builder responseBuilder = io.a2a.grpc.ListTaskPushNotificationConfigResponse.newBuilder();
+ JsonFormat.parser().merge(httpResponseBody, responseBuilder);
+ return ProtoUtils.FromProto.listTaskPushNotificationConfigParams(responseBuilder);
+ } catch (A2AClientException e) {
+ throw e;
+ } catch (IOException | InterruptedException e) {
+ throw new A2AClientException("Failed to list push notifications: " + e, e);
+ }
+ }
+
+ @Override
+ public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationConfigParams request, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", request);
+ io.a2a.grpc.DeleteTaskPushNotificationConfigRequestOrBuilder builder = io.a2a.grpc.DeleteTaskPushNotificationConfigRequest.newBuilder();
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(io.a2a.spec.DeleteTaskPushNotificationConfigRequest.METHOD, builder,
+ agentCard, context);
+ try {
+ String url = agentUrl + String.format("/v1/tasks/%1s/pushNotificationConfigs/%2s", request.id(), request.pushNotificationConfigId());
+ A2AHttpClient.DeleteBuilder deleteBuilder = httpClient.createDelete().url(url);
+ if (payloadAndHeaders.getHeaders() != null) {
+ for (Map.Entry entry : payloadAndHeaders.getHeaders().entrySet()) {
+ deleteBuilder.addHeader(entry.getKey(), entry.getValue());
+ }
+ }
+ A2AHttpResponse response = deleteBuilder.delete();
+ if (!response.success()) {
+ throw RestErrorMapper.mapRestError(response);
+ }
+ } catch (A2AClientException e) {
+ throw e;
+ } catch (IOException | InterruptedException e) {
+ throw new A2AClientException("Failed to delete push notification config: " + e, e);
+ }
+ }
+
+ @Override
+ public void resubscribe(TaskIdParams request, Consumer eventConsumer,
+ Consumer errorConsumer, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", request);
+ io.a2a.grpc.TaskSubscriptionRequest.Builder builder = io.a2a.grpc.TaskSubscriptionRequest.newBuilder();
+ builder.setName("tasks/" + request.id());
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(io.a2a.spec.TaskResubscriptionRequest.METHOD, builder,
+ agentCard, context);
+ AtomicReference> ref = new AtomicReference<>();
+ RestSSEEventListener sseEventListener = new RestSSEEventListener(eventConsumer, errorConsumer);
+ try {
+ String url = agentUrl + String.format("/v1/tasks/%1s:subscribe", request.id());
+ A2AHttpClient.PostBuilder postBuilder = createPostBuilder(url, payloadAndHeaders);
+ ref.set(postBuilder.postAsyncSSE(
+ msg -> sseEventListener.onMessage(msg, ref.get()),
+ throwable -> sseEventListener.onError(throwable, ref.get()),
+ () -> {
+ // We don't need to do anything special on completion
+ }));
+ } catch (IOException e) {
+ throw new A2AClientException("Failed to send streaming message request: " + e, e);
+ } catch (InterruptedException e) {
+ throw new A2AClientException("Send streaming message request timed out: " + e, e);
+ }
+ }
+
+ @Override
+ public AgentCard getAgentCard(ClientCallContext context) throws A2AClientException {
+ A2ACardResolver resolver;
+ try {
+ if (agentCard == null) {
+ resolver = new A2ACardResolver(httpClient, agentUrl, null, getHttpHeaders(context));
+ agentCard = resolver.getAgentCard();
+ needsExtendedCard = agentCard.supportsAuthenticatedExtendedCard();
+ }
+ if (!needsExtendedCard) {
+ return agentCard;
+ }
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(io.a2a.spec.GetTaskRequest.METHOD, null,
+ agentCard, context);
+ String url = agentUrl + String.format("/v1/card");
+ A2AHttpClient.GetBuilder getBuilder = httpClient.createGet().url(url);
+ if (payloadAndHeaders.getHeaders() != null) {
+ for (Map.Entry entry : payloadAndHeaders.getHeaders().entrySet()) {
+ getBuilder.addHeader(entry.getKey(), entry.getValue());
+ }
+ }
+ A2AHttpResponse response = getBuilder.get();
+ if (!response.success()) {
+ throw RestErrorMapper.mapRestError(response);
+ }
+ String httpResponseBody = response.body();
+ agentCard = Utils.OBJECT_MAPPER.readValue(httpResponseBody, AgentCard.class);
+ needsExtendedCard = false;
+ return agentCard;
+ } catch (IOException | InterruptedException e) {
+ throw new A2AClientException("Failed to get authenticated extended agent card: " + e, e);
+ } catch (A2AClientError e) {
+ throw new A2AClientException("Failed to get agent card: " + e, e);
+ }
+ }
+
+ @Override
+ public void close() {
+ // no-op
+ }
+
+ private PayloadAndHeaders applyInterceptors(String methodName, MessageOrBuilder payload,
+ AgentCard agentCard, ClientCallContext clientCallContext) {
+ PayloadAndHeaders payloadAndHeaders = new PayloadAndHeaders(payload, getHttpHeaders(clientCallContext));
+ if (interceptors != null && !interceptors.isEmpty()) {
+ for (ClientCallInterceptor interceptor : interceptors) {
+ payloadAndHeaders = interceptor.intercept(methodName, payloadAndHeaders.getPayload(),
+ payloadAndHeaders.getHeaders(), agentCard, clientCallContext);
+ }
+ }
+ return payloadAndHeaders;
+ }
+
+ private String sendPostRequest(String url, PayloadAndHeaders payloadAndHeaders) throws IOException, InterruptedException {
+ A2AHttpClient.PostBuilder builder = createPostBuilder(url, payloadAndHeaders);
+ A2AHttpResponse response = builder.post();
+ if (!response.success()) {
+ log.fine("Error on POST processing " + JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload()));
+ throw RestErrorMapper.mapRestError(response);
+ }
+ return response.body();
+ }
+
+ private A2AHttpClient.PostBuilder createPostBuilder(String url, PayloadAndHeaders payloadAndHeaders) throws JsonProcessingException, InvalidProtocolBufferException {
+ log.fine(JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload()));
+ A2AHttpClient.PostBuilder postBuilder = httpClient.createPost()
+ .url(url)
+ .addHeader("Content-Type", "application/json")
+ .body(JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload()));
+
+ if (payloadAndHeaders.getHeaders() != null) {
+ for (Map.Entry entry : payloadAndHeaders.getHeaders().entrySet()) {
+ postBuilder.addHeader(entry.getKey(), entry.getValue());
+ }
+ }
+ return postBuilder;
+ }
+
+ private Map getHttpHeaders(ClientCallContext context) {
+ return context != null ? context.getHeaders() : null;
+ }
+}
diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java
new file mode 100644
index 000000000..bbb583a1b
--- /dev/null
+++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfig.java
@@ -0,0 +1,21 @@
+package io.a2a.client.transport.rest;
+
+import io.a2a.client.http.A2AHttpClient;
+import io.a2a.client.transport.spi.ClientTransportConfig;
+
+public class RestTransportConfig extends ClientTransportConfig {
+
+ private final A2AHttpClient httpClient;
+
+ public RestTransportConfig() {
+ this.httpClient = null;
+ }
+
+ public RestTransportConfig(A2AHttpClient httpClient) {
+ this.httpClient = httpClient;
+ }
+
+ public A2AHttpClient getHttpClient() {
+ return httpClient;
+ }
+}
\ No newline at end of file
diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfigBuilder.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfigBuilder.java
new file mode 100644
index 000000000..30f8b412a
--- /dev/null
+++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportConfigBuilder.java
@@ -0,0 +1,28 @@
+package io.a2a.client.transport.rest;
+
+import io.a2a.client.http.A2AHttpClient;
+import io.a2a.client.http.JdkA2AHttpClient;
+import io.a2a.client.transport.spi.ClientTransportConfigBuilder;
+
+public class RestTransportConfigBuilder extends ClientTransportConfigBuilder {
+
+ private A2AHttpClient httpClient;
+
+ public RestTransportConfigBuilder httpClient(A2AHttpClient httpClient) {
+ this.httpClient = httpClient;
+
+ return this;
+ }
+
+ @Override
+ public RestTransportConfig build() {
+ // No HTTP client provided, fallback to the default one (JDK-based implementation)
+ if (httpClient == null) {
+ httpClient = new JdkA2AHttpClient();
+ }
+
+ RestTransportConfig config = new RestTransportConfig(httpClient);
+ config.setInterceptors(this.interceptors);
+ return config;
+ }
+}
\ No newline at end of file
diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportProvider.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportProvider.java
new file mode 100644
index 000000000..99d155968
--- /dev/null
+++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestTransportProvider.java
@@ -0,0 +1,29 @@
+package io.a2a.client.transport.rest;
+
+import io.a2a.client.http.JdkA2AHttpClient;
+import io.a2a.client.transport.spi.ClientTransportProvider;
+import io.a2a.spec.A2AClientException;
+import io.a2a.spec.AgentCard;
+import io.a2a.spec.TransportProtocol;
+
+public class RestTransportProvider implements ClientTransportProvider {
+
+ @Override
+ public String getTransportProtocol() {
+ return TransportProtocol.HTTP_JSON.asString();
+ }
+
+ @Override
+ public RestTransport create(RestTransportConfig clientTransportConfig, AgentCard agentCard, String agentUrl) throws A2AClientException {
+ RestTransportConfig transportConfig = clientTransportConfig;
+ if (transportConfig == null) {
+ transportConfig = new RestTransportConfig(new JdkA2AHttpClient());
+ }
+ return new RestTransport(clientTransportConfig.getHttpClient(), agentCard, agentUrl, transportConfig.getInterceptors());
+ }
+
+ @Override
+ public Class getTransportProtocolClass() {
+ return RestTransport.class;
+ }
+}
diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/sse/RestSSEEventListener.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/sse/RestSSEEventListener.java
new file mode 100644
index 000000000..c34d615cb
--- /dev/null
+++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/sse/RestSSEEventListener.java
@@ -0,0 +1,72 @@
+package io.a2a.client.transport.rest.sse;
+
+import static io.a2a.grpc.StreamResponse.PayloadCase.ARTIFACT_UPDATE;
+import static io.a2a.grpc.StreamResponse.PayloadCase.MSG;
+import static io.a2a.grpc.StreamResponse.PayloadCase.STATUS_UPDATE;
+import static io.a2a.grpc.StreamResponse.PayloadCase.TASK;
+
+import java.util.concurrent.Future;
+import java.util.function.Consumer;
+import java.util.logging.Logger;
+
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.util.JsonFormat;
+import io.a2a.client.transport.rest.RestErrorMapper;
+import io.a2a.grpc.StreamResponse;
+import io.a2a.grpc.utils.ProtoUtils;
+import io.a2a.spec.StreamingEventKind;
+
+public class RestSSEEventListener {
+
+ private static final Logger log = Logger.getLogger(RestSSEEventListener.class.getName());
+ private final Consumer eventHandler;
+ private final Consumer errorHandler;
+
+ public RestSSEEventListener(Consumer eventHandler,
+ Consumer errorHandler) {
+ this.eventHandler = eventHandler;
+ this.errorHandler = errorHandler;
+ }
+
+ public void onMessage(String message, Future completableFuture) {
+ try {
+ System.out.println("Streaming message received: " + message);
+ io.a2a.grpc.StreamResponse.Builder builder = io.a2a.grpc.StreamResponse.newBuilder();
+ JsonFormat.parser().merge(message, builder);
+ handleMessage(builder.build(), completableFuture);
+ } catch (InvalidProtocolBufferException e) {
+ errorHandler.accept(RestErrorMapper.mapRestError(message, 500));
+ }
+ }
+
+ public void onError(Throwable throwable, Future future) {
+ if (errorHandler != null) {
+ errorHandler.accept(throwable);
+ }
+ future.cancel(true); // close SSE channel
+ }
+
+ private void handleMessage(StreamResponse response, Future future) {
+ StreamingEventKind event;
+ switch (response.getPayloadCase()) {
+ case MSG:
+ event = ProtoUtils.FromProto.message(response.getMsg());
+ break;
+ case TASK:
+ event = ProtoUtils.FromProto.task(response.getTask());
+ break;
+ case STATUS_UPDATE:
+ event = ProtoUtils.FromProto.taskStatusUpdateEvent(response.getStatusUpdate());
+ break;
+ case ARTIFACT_UPDATE:
+ event = ProtoUtils.FromProto.taskArtifactUpdateEvent(response.getArtifactUpdate());
+ break;
+ default:
+ log.warning("Invalid stream response " + response.getPayloadCase());
+ errorHandler.accept(new IllegalStateException("Invalid stream response from server: " + response.getPayloadCase()));
+ return;
+ }
+ eventHandler.accept(event);
+ }
+
+}
diff --git a/client/transport/rest/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider b/client/transport/rest/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider
new file mode 100644
index 000000000..894866aab
--- /dev/null
+++ b/client/transport/rest/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider
@@ -0,0 +1 @@
+io.a2a.client.transport.rest.RestTransportProvider
\ No newline at end of file
diff --git a/client/transport/rest/src/test/java/io/a2a/client/transport/rest/JsonRestMessages.java b/client/transport/rest/src/test/java/io/a2a/client/transport/rest/JsonRestMessages.java
new file mode 100644
index 000000000..e94fcca1f
--- /dev/null
+++ b/client/transport/rest/src/test/java/io/a2a/client/transport/rest/JsonRestMessages.java
@@ -0,0 +1,654 @@
+package io.a2a.client.transport.rest;
+
+/**
+ * Request and response messages used by the tests. These have been created following examples from
+ * the A2A sample messages.
+ */
+public class JsonRestMessages {
+
+ static final String SEND_MESSAGE_TEST_REQUEST = """
+ {
+ "message":
+ {
+ "messageId": "message-1234",
+ "contextId": "context-1234",
+ "role": "ROLE_USER",
+ "content": [{
+ "text": "tell me a joke"
+ }],
+ "metadata": {
+ }
+ }
+ }""";
+
+ static final String SEND_MESSAGE_TEST_RESPONSE = """
+ {
+ "task": {
+ "id": "9b511af4-b27c-47fa-aecf-2a93c08a44f8",
+ "contextId": "context-1234",
+ "status": {
+ "state": "TASK_STATE_SUBMITTED"
+ },
+ "history": [
+ {
+ "messageId": "message-1234",
+ "contextId": "context-1234",
+ "taskId": "9b511af4-b27c-47fa-aecf-2a93c08a44f8",
+ "role": "ROLE_USER",
+ "content": [
+ {
+ "text": "tell me a joke"
+ }
+ ],
+ "metadata": {}
+ }
+ ]
+ }
+ }""";
+
+ static final String CANCEL_TASK_TEST_REQUEST = """
+ {
+ "name": "tasks/de38c76d-d54c-436c-8b9f-4c2703648d64"
+ }""";
+
+ static final String CANCEL_TASK_TEST_RESPONSE = """
+ {
+ "id": "de38c76d-d54c-436c-8b9f-4c2703648d64",
+ "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4",
+ "status": {
+ "state": "TASK_STATE_CANCELLED"
+ },
+ "metadata": {}
+ }""";
+
+ static final String GET_TASK_TEST_RESPONSE = """
+ {
+ "id": "de38c76d-d54c-436c-8b9f-4c2703648d64",
+ "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4",
+ "status": {
+ "state": "TASK_STATE_COMPLETED"
+ },
+ "artifacts": [
+ {
+ "artifactId": "artifact-1",
+ "parts": [
+ {
+ "text": "Why did the chicken cross the road? To get to the other side!"
+ }
+ ]
+ }
+ ],
+ "history": [
+ {
+ "role": "ROLE_USER",
+ "content": [
+ {
+ "text": "tell me a joke"
+ },
+ {
+ "file": {
+ "file_with_uri": "file:///path/to/file.txt",
+ "mimeType": "text/plain"
+ }
+ },
+ {
+ "file": {
+ "file_with_bytes": "aGVsbG8=",
+ "mimeType": "text/plain"
+ }
+ }
+ ],
+ "messageId": "message-123"
+ }
+ ],
+ "metadata": {}
+ }
+ """;
+
+ static final String AGENT_CARD = """
+ {
+ "name": "GeoSpatial Route Planner Agent",
+ "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.",
+ "url": "https://georoute-agent.example.com/a2a/v1",
+ "provider": {
+ "organization": "Example Geo Services Inc.",
+ "url": "https://www.examplegeoservices.com"
+ },
+ "iconUrl": "https://georoute-agent.example.com/icon.png",
+ "version": "1.2.0",
+ "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api",
+ "capabilities": {
+ "streaming": true,
+ "pushNotifications": true,
+ "stateTransitionHistory": false
+ },
+ "securitySchemes": {
+ "google": {
+ "type": "openIdConnect",
+ "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration"
+ }
+ },
+ "security": [{ "google": ["openid", "profile", "email"] }],
+ "defaultInputModes": ["application/json", "text/plain"],
+ "defaultOutputModes": ["application/json", "image/png"],
+ "skills": [
+ {
+ "id": "route-optimizer-traffic",
+ "name": "Traffic-Aware Route Optimizer",
+ "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).",
+ "tags": ["maps", "routing", "navigation", "directions", "traffic"],
+ "examples": [
+ "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.",
+ "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}"
+ ],
+ "inputModes": ["application/json", "text/plain"],
+ "outputModes": [
+ "application/json",
+ "application/vnd.geo+json",
+ "text/html"
+ ]
+ },
+ {
+ "id": "custom-map-generator",
+ "name": "Personalized Map Generator",
+ "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.",
+ "tags": ["maps", "customization", "visualization", "cartography"],
+ "examples": [
+ "Generate a map of my upcoming road trip with all planned stops highlighted.",
+ "Show me a map visualizing all coffee shops within a 1-mile radius of my current location."
+ ],
+ "inputModes": ["application/json"],
+ "outputModes": [
+ "image/png",
+ "image/jpeg",
+ "application/json",
+ "text/html"
+ ]
+ }
+ ],
+ "supportsAuthenticatedExtendedCard": false,
+ "protocolVersion": "0.2.5"
+ }""";
+
+ static final String AGENT_CARD_SUPPORTS_EXTENDED = """
+ {
+ "name": "GeoSpatial Route Planner Agent",
+ "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.",
+ "url": "https://georoute-agent.example.com/a2a/v1",
+ "provider": {
+ "organization": "Example Geo Services Inc.",
+ "url": "https://www.examplegeoservices.com"
+ },
+ "iconUrl": "https://georoute-agent.example.com/icon.png",
+ "version": "1.2.0",
+ "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api",
+ "capabilities": {
+ "streaming": true,
+ "pushNotifications": true,
+ "stateTransitionHistory": false
+ },
+ "securitySchemes": {
+ "google": {
+ "type": "openIdConnect",
+ "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration"
+ }
+ },
+ "security": [{ "google": ["openid", "profile", "email"] }],
+ "defaultInputModes": ["application/json", "text/plain"],
+ "defaultOutputModes": ["application/json", "image/png"],
+ "skills": [
+ {
+ "id": "route-optimizer-traffic",
+ "name": "Traffic-Aware Route Optimizer",
+ "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).",
+ "tags": ["maps", "routing", "navigation", "directions", "traffic"],
+ "examples": [
+ "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.",
+ "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}"
+ ],
+ "inputModes": ["application/json", "text/plain"],
+ "outputModes": [
+ "application/json",
+ "application/vnd.geo+json",
+ "text/html"
+ ]
+ },
+ {
+ "id": "custom-map-generator",
+ "name": "Personalized Map Generator",
+ "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.",
+ "tags": ["maps", "customization", "visualization", "cartography"],
+ "examples": [
+ "Generate a map of my upcoming road trip with all planned stops highlighted.",
+ "Show me a map visualizing all coffee shops within a 1-mile radius of my current location."
+ ],
+ "inputModes": ["application/json"],
+ "outputModes": [
+ "image/png",
+ "image/jpeg",
+ "application/json",
+ "text/html"
+ ]
+ }
+ ],
+ "supportsAuthenticatedExtendedCard": true,
+ "protocolVersion": "0.2.5"
+ }""";
+
+ static final String AUTHENTICATION_EXTENDED_AGENT_CARD = """
+ {
+ "name": "GeoSpatial Route Planner Agent Extended",
+ "description": "Extended description",
+ "url": "https://georoute-agent.example.com/a2a/v1",
+ "provider": {
+ "organization": "Example Geo Services Inc.",
+ "url": "https://www.examplegeoservices.com"
+ },
+ "iconUrl": "https://georoute-agent.example.com/icon.png",
+ "version": "1.2.0",
+ "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api",
+ "capabilities": {
+ "streaming": true,
+ "pushNotifications": true,
+ "stateTransitionHistory": false
+ },
+ "securitySchemes": {
+ "google": {
+ "type": "openIdConnect",
+ "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration"
+ }
+ },
+ "security": [{ "google": ["openid", "profile", "email"] }],
+ "defaultInputModes": ["application/json", "text/plain"],
+ "defaultOutputModes": ["application/json", "image/png"],
+ "skills": [
+ {
+ "id": "route-optimizer-traffic",
+ "name": "Traffic-Aware Route Optimizer",
+ "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).",
+ "tags": ["maps", "routing", "navigation", "directions", "traffic"],
+ "examples": [
+ "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.",
+ "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}"
+ ],
+ "inputModes": ["application/json", "text/plain"],
+ "outputModes": [
+ "application/json",
+ "application/vnd.geo+json",
+ "text/html"
+ ]
+ },
+ {
+ "id": "custom-map-generator",
+ "name": "Personalized Map Generator",
+ "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.",
+ "tags": ["maps", "customization", "visualization", "cartography"],
+ "examples": [
+ "Generate a map of my upcoming road trip with all planned stops highlighted.",
+ "Show me a map visualizing all coffee shops within a 1-mile radius of my current location."
+ ],
+ "inputModes": ["application/json"],
+ "outputModes": [
+ "image/png",
+ "image/jpeg",
+ "application/json",
+ "text/html"
+ ]
+ },
+ {
+ "id": "skill-extended",
+ "name": "Extended Skill",
+ "description": "This is an extended skill.",
+ "tags": ["extended"]
+ }
+ ],
+ "supportsAuthenticatedExtendedCard": true,
+ "protocolVersion": "0.2.5"
+ }""";
+
+ static final String SEND_MESSAGE_TEST_REQUEST_WITH_MESSAGE_RESPONSE = """
+ {
+ "jsonrpc": "2.0",
+ "method": "message/send",
+ "params": {
+ "message": {
+ "role": "user",
+ "parts": [
+ {
+ "kind": "text",
+ "text": "tell me a joke"
+ }
+ ],
+ "messageId": "message-1234",
+ "contextId": "context-1234",
+ "kind": "message"
+ },
+ "configuration": {
+ "acceptedOutputModes": ["text"],
+ "blocking": true
+ },
+ }
+ }""";
+
+ static final String SEND_MESSAGE_TEST_RESPONSE_WITH_MESSAGE_RESPONSE = """
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "role": "agent",
+ "parts": [
+ {
+ "kind": "text",
+ "text": "Why did the chicken cross the road? To get to the other side!"
+ }
+ ],
+ "messageId": "msg-456",
+ "kind": "message"
+ }
+ }""";
+
+ static final String SEND_MESSAGE_WITH_ERROR_TEST_REQUEST = """
+ {
+ "jsonrpc": "2.0",
+ "method": "message/send",
+ "params": {
+ "message": {
+ "role": "user",
+ "parts": [
+ {
+ "kind": "text",
+ "text": "tell me a joke"
+ }
+ ],
+ "messageId": "message-1234",
+ "contextId": "context-1234",
+ "kind": "message"
+ },
+ "configuration": {
+ "acceptedOutputModes": ["text"],
+ "blocking": true
+ },
+ }
+ }""";
+
+ static final String SEND_MESSAGE_ERROR_TEST_RESPONSE = """
+ {
+ "jsonrpc": "2.0",
+ "error": {
+ "code": -32702,
+ "message": "Invalid parameters",
+ "data": "Hello world"
+ }
+ }""";
+
+ static final String GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE = """
+ {
+ "name": "tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs/10",
+ "pushNotificationConfig": {
+ "url": "https://example.com/callback",
+ "authentication": {
+ "schemes": ["jwt"]
+ }
+ }
+ }""";
+ static final String LIST_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE = """
+ {
+ "configs":[
+ {
+ "name": "tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs/10",
+ "pushNotificationConfig": {
+ "url": "https://example.com/callback",
+ "authentication": {
+ "schemes": ["jwt"]
+ }
+ }
+ },
+ {
+ "name": "tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs/5",
+ "pushNotificationConfig": {
+ "url": "https://test.com/callback"
+ }
+ }
+ ]
+ }""";
+
+
+ static final String SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST = """
+ {
+ "parent": "tasks/de38c76d-d54c-436c-8b9f-4c2703648d64",
+ "config": {
+ "name": "tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs",
+ "pushNotificationConfig": {
+ "url": "https://example.com/callback",
+ "authentication": {
+ "schemes": [ "jwt" ]
+ }
+ }
+ }
+ }""";
+
+ static final String SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE = """
+ {
+ "name": "tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs/10",
+ "pushNotificationConfig": {
+ "url": "https://example.com/callback",
+ "authentication": {
+ "schemes": ["jwt"]
+ }
+ }
+ }""";
+
+ static final String SEND_MESSAGE_WITH_FILE_PART_TEST_REQUEST = """
+ {
+ "jsonrpc": "2.0",
+ "method": "message/send",
+ "params": {
+ "message": {
+ "role": "user",
+ "parts": [
+ {
+ "kind": "text",
+ "text": "analyze this image"
+ },
+ {
+ "kind": "file",
+ "file": {
+ "uri": "file:///path/to/image.jpg",
+ "mimeType": "image/jpeg"
+ }
+ }
+ ],
+ "messageId": "message-1234-with-file",
+ "contextId": "context-1234",
+ "kind": "message"
+ },
+ "configuration": {
+ "acceptedOutputModes": ["text"],
+ "blocking": true
+ }
+ }
+ }""";
+
+ static final String SEND_MESSAGE_WITH_FILE_PART_TEST_RESPONSE = """
+ {
+ "jsonrpc": "2.0",
+ "result": {
+ "id": "de38c76d-d54c-436c-8b9f-4c2703648d64",
+ "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4",
+ "status": {
+ "state": "completed"
+ },
+ "artifacts": [
+ {
+ "artifactId": "artifact-1",
+ "name": "image-analysis",
+ "parts": [
+ {
+ "kind": "text",
+ "text": "This is an image of a cat sitting on a windowsill."
+ }
+ ]
+ }
+ ],
+ "metadata": {},
+ "kind": "task"
+ }
+ }""";
+
+ static final String SEND_MESSAGE_WITH_DATA_PART_TEST_REQUEST = """
+ {
+ "jsonrpc": "2.0",
+ "method": "message/send",
+ "params": {
+ "message": {
+ "role": "user",
+ "parts": [
+ {
+ "kind": "text",
+ "text": "process this data"
+ },
+ {
+ "kind": "data",
+ "data": {
+ "temperature": 25.5,
+ "humidity": 60.2,
+ "location": "San Francisco",
+ "timestamp": "2024-01-15T10:30:00Z"
+ }
+ }
+ ],
+ "messageId": "message-1234-with-data",
+ "contextId": "context-1234",
+ "kind": "message"
+ },
+ "configuration": {
+ "acceptedOutputModes": ["text"],
+ "blocking": true
+ }
+ }
+ }""";
+
+ static final String SEND_MESSAGE_WITH_DATA_PART_TEST_RESPONSE = """
+ {
+ "jsonrpc": "2.0",
+ "result": {
+ "id": "de38c76d-d54c-436c-8b9f-4c2703648d64",
+ "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4",
+ "status": {
+ "state": "completed"
+ },
+ "artifacts": [
+ {
+ "artifactId": "artifact-1",
+ "name": "data-analysis",
+ "parts": [
+ {
+ "kind": "text",
+ "text": "Processed weather data: Temperature is 25.5°C, humidity is 60.2% in San Francisco."
+ }
+ ]
+ }
+ ],
+ "metadata": {},
+ "kind": "task"
+ }
+ }""";
+
+ static final String SEND_MESSAGE_WITH_MIXED_PARTS_TEST_REQUEST = """
+ {
+ "jsonrpc": "2.0",
+ "method": "message/send",
+ "params": {
+ "message": {
+ "role": "user",
+ "parts": [
+ {
+ "kind": "text",
+ "text": "analyze this data and image"
+ },
+ {
+ "kind": "file",
+ "file": {
+ "bytes": "aGVsbG8=",
+ "name": "chart.png",
+ "mimeType": "image/png"
+ }
+ },
+ {
+ "kind": "data",
+ "data": {
+ "chartType": "bar",
+ "dataPoints": [10, 20, 30, 40],
+ "labels": ["Q1", "Q2", "Q3", "Q4"]
+ }
+ }
+ ],
+ "messageId": "message-1234-with-mixed",
+ "contextId": "context-1234",
+ "kind": "message"
+ },
+ "configuration": {
+ "acceptedOutputModes": ["text"],
+ "blocking": true
+ }
+ }
+ }""";
+
+ static final String SEND_MESSAGE_WITH_MIXED_PARTS_TEST_RESPONSE = """
+ {
+ "jsonrpc": "2.0",
+ "result": {
+ "id": "de38c76d-d54c-436c-8b9f-4c2703648d64",
+ "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4",
+ "status": {
+ "state": "completed"
+ },
+ "artifacts": [
+ {
+ "artifactId": "artifact-1",
+ "name": "mixed-analysis",
+ "parts": [
+ {
+ "kind": "text",
+ "text": "Analyzed chart image and data: Bar chart showing quarterly data with values [10, 20, 30, 40]."
+ }
+ ]
+ }
+ ],
+ "metadata": {},
+ "kind": "task"
+ }
+ }""";
+
+ public static final String SEND_MESSAGE_STREAMING_TEST_REQUEST = """
+ {
+ "message": {
+ "role": "ROLE_USER",
+ "content": [
+ {
+ "text": "tell me some jokes"
+ }
+ ],
+ "messageId": "message-1234",
+ "contextId": "context-1234"
+ },
+ "configuration": {
+ "acceptedOutputModes": ["text"]
+ }
+ }""";
+ static final String SEND_MESSAGE_STREAMING_TEST_RESPONSE
+ = "event: message\n"
+ + "data: {\"task\":{\"id\":\"2\",\"contextId\":\"context-1234\",\"status\":{\"state\":\"TASK_STATE_SUBMITTED\"},\"artifacts\":[{\"artifactId\":\"artifact-1\",\"name\":\"joke\",\"parts\":[{\"text\":\"Why did the chicken cross the road? To get to the other side!\"}]}],\"metadata\":{}}}\n\n";
+
+ static final String TASK_RESUBSCRIPTION_REQUEST_TEST_RESPONSE
+ = "event: message\n"
+ + "data: {\"task\":{\"id\":\"2\",\"contextId\":\"context-1234\",\"status\":{\"state\":\"TASK_STATE_COMPLETED\"},\"artifacts\":[{\"artifactId\":\"artifact-1\",\"name\":\"joke\",\"parts\":[{\"text\":\"Why did the chicken cross the road? To get to the other side!\"}]}],\"metadata\":{}}}\n\n";
+ public static final String TASK_RESUBSCRIPTION_TEST_REQUEST = """
+ {
+ "jsonrpc": "2.0",
+ "method": "tasks/resubscribe",
+ "params": {
+ "id": "task-1234"
+ }
+ }""";
+}
diff --git a/client/transport/rest/src/test/java/io/a2a/client/transport/rest/RestTransportTest.java b/client/transport/rest/src/test/java/io/a2a/client/transport/rest/RestTransportTest.java
new file mode 100644
index 000000000..fc6fc23a6
--- /dev/null
+++ b/client/transport/rest/src/test/java/io/a2a/client/transport/rest/RestTransportTest.java
@@ -0,0 +1,426 @@
+package io.a2a.client.transport.rest;
+
+
+import static io.a2a.client.transport.rest.JsonRestMessages.CANCEL_TASK_TEST_REQUEST;
+import static io.a2a.client.transport.rest.JsonRestMessages.CANCEL_TASK_TEST_RESPONSE;
+import static io.a2a.client.transport.rest.JsonRestMessages.GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE;
+import static io.a2a.client.transport.rest.JsonRestMessages.GET_TASK_TEST_RESPONSE;
+import static io.a2a.client.transport.rest.JsonRestMessages.LIST_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE;
+import static io.a2a.client.transport.rest.JsonRestMessages.SEND_MESSAGE_STREAMING_TEST_RESPONSE;
+import static io.a2a.client.transport.rest.JsonRestMessages.SEND_MESSAGE_TEST_REQUEST;
+import static io.a2a.client.transport.rest.JsonRestMessages.SEND_MESSAGE_TEST_RESPONSE;
+import static io.a2a.client.transport.rest.JsonRestMessages.SEND_MESSAGE_STREAMING_TEST_REQUEST;
+import static io.a2a.client.transport.rest.JsonRestMessages.SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST;
+import static io.a2a.client.transport.rest.JsonRestMessages.SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE;
+import static io.a2a.client.transport.rest.JsonRestMessages.TASK_RESUBSCRIPTION_REQUEST_TEST_RESPONSE;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+
+import io.a2a.client.transport.spi.interceptors.ClientCallContext;
+import io.a2a.spec.Artifact;
+import io.a2a.spec.DeleteTaskPushNotificationConfigParams;
+import io.a2a.spec.EventKind;
+import io.a2a.spec.FilePart;
+import io.a2a.spec.FileWithBytes;
+import io.a2a.spec.FileWithUri;
+import io.a2a.spec.GetTaskPushNotificationConfigParams;
+import io.a2a.spec.ListTaskPushNotificationConfigParams;
+import io.a2a.spec.Message;
+import io.a2a.spec.MessageSendConfiguration;
+import io.a2a.spec.MessageSendParams;
+import io.a2a.spec.Part;
+import io.a2a.spec.Part.Kind;
+import io.a2a.spec.PushNotificationAuthenticationInfo;
+import io.a2a.spec.PushNotificationConfig;
+import io.a2a.spec.StreamingEventKind;
+import io.a2a.spec.Task;
+import io.a2a.spec.TaskIdParams;
+import io.a2a.spec.TaskPushNotificationConfig;
+import io.a2a.spec.TaskQueryParams;
+import io.a2a.spec.TaskState;
+import io.a2a.spec.TextPart;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import java.util.logging.Logger;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.matchers.MatchType;
+import org.mockserver.model.JsonBody;
+
+public class RestTransportTest {
+
+ private static final Logger log = Logger.getLogger(RestTransportTest.class.getName());
+ private ClientAndServer server;
+
+ @BeforeEach
+ public void setUp() throws IOException {
+ server = new ClientAndServer(4001);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ server.stop();
+ }
+
+ public RestTransportTest() {
+ }
+
+ /**
+ * Test of sendMessage method, of class JSONRestTransport.
+ */
+ @Test
+ public void testSendMessage() throws Exception {
+ Message message = new Message.Builder()
+ .role(Message.Role.USER)
+ .parts(Collections.singletonList(new TextPart("tell me a joke")))
+ .contextId("context-1234")
+ .messageId("message-1234")
+ .taskId("")
+ .build();
+ this.server.when(
+ request()
+ .withMethod("POST")
+ .withPath("/v1/message:send")
+ .withBody(JsonBody.json(SEND_MESSAGE_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS))
+ )
+ .respond(
+ response()
+ .withStatusCode(200)
+ .withBody(SEND_MESSAGE_TEST_RESPONSE)
+ );
+ MessageSendParams messageSendParams = new MessageSendParams(message, null, null);
+ ClientCallContext context = null;
+ RestTransport instance = new RestTransport("http://localhost:4001");
+ EventKind result = instance.sendMessage(messageSendParams, context);
+ assertEquals("task", result.getKind());
+ Task task = (Task) result;
+ assertEquals("9b511af4-b27c-47fa-aecf-2a93c08a44f8", task.getId());
+ assertEquals("context-1234", task.getContextId());
+ assertEquals(TaskState.SUBMITTED, task.getStatus().state());
+ assertNull(task.getStatus().message());
+ assertNull(task.getMetadata());
+ assertEquals(true, task.getArtifacts().isEmpty());
+ assertEquals(1, task.getHistory().size());
+ Message history = task.getHistory().get(0);
+ assertEquals("message", history.getKind());
+ assertEquals(Message.Role.USER, history.getRole());
+ assertEquals("context-1234", history.getContextId());
+ assertEquals("message-1234", history.getMessageId());
+ assertEquals("9b511af4-b27c-47fa-aecf-2a93c08a44f8", history.getTaskId());
+ assertEquals(1, history.getParts().size());
+ assertEquals(Kind.TEXT, history.getParts().get(0).getKind());
+ assertEquals("tell me a joke", ((TextPart) history.getParts().get(0)).getText());
+ assertNull(history.getMetadata());
+ assertNull(history.getReferenceTaskIds());
+ }
+
+ /**
+ * Test of cancelTask method, of class JSONRestTransport.
+ */
+ @Test
+ public void testCancelTask() throws Exception {
+ this.server.when(
+ request()
+ .withMethod("POST")
+ .withPath("/v1/tasks/de38c76d-d54c-436c-8b9f-4c2703648d64:cancel")
+ .withBody(JsonBody.json(CANCEL_TASK_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS))
+ )
+ .respond(
+ response()
+ .withStatusCode(200)
+ .withBody(CANCEL_TASK_TEST_RESPONSE)
+ );
+ ClientCallContext context = null;
+ RestTransport instance = new RestTransport("http://localhost:4001");
+ Task task = instance.cancelTask(new TaskIdParams("de38c76d-d54c-436c-8b9f-4c2703648d64",
+ new HashMap<>()), context);
+ assertEquals("de38c76d-d54c-436c-8b9f-4c2703648d64", task.getId());
+ assertEquals(TaskState.CANCELED, task.getStatus().state());
+ assertNull(task.getStatus().message());
+ assertNull(task.getMetadata());
+ }
+
+ /**
+ * Test of getTask method, of class JSONRestTransport.
+ */
+ @Test
+ public void testGetTask() throws Exception {
+ this.server.when(
+ request()
+ .withMethod("GET")
+ .withPath("/v1/tasks/de38c76d-d54c-436c-8b9f-4c2703648d64")
+ )
+ .respond(
+ response()
+ .withStatusCode(200)
+ .withBody(GET_TASK_TEST_RESPONSE)
+ );
+ ClientCallContext context = null;
+ TaskQueryParams request = new TaskQueryParams("de38c76d-d54c-436c-8b9f-4c2703648d64", 10);
+ RestTransport instance = new RestTransport("http://localhost:4001");
+ Task task = instance.getTask(request, context);
+ assertEquals("de38c76d-d54c-436c-8b9f-4c2703648d64", task.getId());
+ assertEquals(TaskState.COMPLETED, task.getStatus().state());
+ assertNull(task.getStatus().message());
+ assertNull(task.getMetadata());
+ assertEquals(false, task.getArtifacts().isEmpty());
+ assertEquals(1, task.getArtifacts().size());
+ Artifact artifact = task.getArtifacts().get(0);
+ assertEquals("artifact-1", artifact.artifactId());
+ assertEquals("", artifact.name());
+ assertEquals(false, artifact.parts().isEmpty());
+ assertEquals(Kind.TEXT, artifact.parts().get(0).getKind());
+ assertEquals("Why did the chicken cross the road? To get to the other side!", ((TextPart) artifact.parts().get(0)).getText());
+ assertEquals(1, task.getHistory().size());
+ Message history = task.getHistory().get(0);
+ assertEquals("message", history.getKind());
+ assertEquals(Message.Role.USER, history.getRole());
+ assertEquals("message-123", history.getMessageId());
+ assertEquals(3, history.getParts().size());
+ assertEquals(Kind.TEXT, history.getParts().get(0).getKind());
+ assertEquals("tell me a joke", ((TextPart) history.getParts().get(0)).getText());
+ assertEquals(Kind.FILE, history.getParts().get(1).getKind());
+ FilePart part = (FilePart) history.getParts().get(1);
+ assertEquals("text/plain", part.getFile().mimeType());
+ assertEquals("file:///path/to/file.txt", ((FileWithUri) part.getFile()).uri());
+ part = (FilePart) history.getParts().get(2);
+ assertEquals(Kind.FILE, part.getKind());
+ assertEquals("text/plain", part.getFile().mimeType());
+ assertEquals("hello", ((FileWithBytes) part.getFile()).bytes());
+ assertNull(history.getMetadata());
+ assertNull(history.getReferenceTaskIds());
+ }
+
+ /**
+ * Test of sendMessageStreaming method, of class JSONRestTransport.
+ */
+ @Test
+ public void testSendMessageStreaming() throws Exception {
+ this.server.when(
+ request()
+ .withMethod("POST")
+ .withPath("/v1/message:stream")
+ .withBody(JsonBody.json(SEND_MESSAGE_STREAMING_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS))
+ )
+ .respond(
+ response()
+ .withStatusCode(200)
+ .withHeader("Content-Type", "text/event-stream")
+ .withBody(SEND_MESSAGE_STREAMING_TEST_RESPONSE)
+ );
+
+ RestTransport client = new RestTransport("http://localhost:4001");
+ Message message = new Message.Builder()
+ .role(Message.Role.USER)
+ .parts(Collections.singletonList(new TextPart("tell me some jokes")))
+ .contextId("context-1234")
+ .messageId("message-1234")
+ .build();
+ MessageSendConfiguration configuration = new MessageSendConfiguration.Builder()
+ .acceptedOutputModes(List.of("text"))
+ .blocking(false)
+ .build();
+ MessageSendParams params = new MessageSendParams.Builder()
+ .message(message)
+ .configuration(configuration)
+ .build();
+ AtomicReference receivedEvent = new AtomicReference<>();
+ CountDownLatch latch = new CountDownLatch(1);
+ Consumer eventHandler = event -> {
+ receivedEvent.set(event);
+ latch.countDown();
+ };
+ Consumer errorHandler = error -> {
+ };
+ client.sendMessageStreaming(params, eventHandler, errorHandler, null);
+
+ boolean eventReceived = latch.await(10, TimeUnit.SECONDS);
+ assertTrue(eventReceived);
+ assertNotNull(receivedEvent.get());
+ assertEquals("task", receivedEvent.get().getKind());
+ Task task = (Task) receivedEvent.get();
+ assertEquals("2", task.getId());
+ }
+
+ /**
+ * Test of setTaskPushNotificationConfiguration method, of class JSONRestTransport.
+ */
+ @Test
+ public void testSetTaskPushNotificationConfiguration() throws Exception {
+ log.info("Testing setTaskPushNotificationConfiguration");
+ this.server.when(
+ request()
+ .withMethod("POST")
+ .withPath("/v1/tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs")
+ .withBody(JsonBody.json(SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS))
+ )
+ .respond(
+ response()
+ .withStatusCode(200)
+ .withBody(SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE)
+ );
+ RestTransport client = new RestTransport("http://localhost:4001");
+ TaskPushNotificationConfig pushedConfig = new TaskPushNotificationConfig(
+ "de38c76d-d54c-436c-8b9f-4c2703648d64",
+ new PushNotificationConfig.Builder()
+ .url("https://example.com/callback")
+ .authenticationInfo(
+ new PushNotificationAuthenticationInfo(Collections.singletonList("jwt"), null))
+ .build());
+ TaskPushNotificationConfig taskPushNotificationConfig = client.setTaskPushNotificationConfiguration(pushedConfig, null);
+ PushNotificationConfig pushNotificationConfig = taskPushNotificationConfig.pushNotificationConfig();
+ assertNotNull(pushNotificationConfig);
+ assertEquals("https://example.com/callback", pushNotificationConfig.url());
+ PushNotificationAuthenticationInfo authenticationInfo = pushNotificationConfig.authentication();
+ assertEquals(1, authenticationInfo.schemes().size());
+ assertEquals("jwt", authenticationInfo.schemes().get(0));
+ }
+
+ /**
+ * Test of getTaskPushNotificationConfiguration method, of class JSONRestTransport.
+ */
+ @Test
+ public void testGetTaskPushNotificationConfiguration() throws Exception {
+ this.server.when(
+ request()
+ .withMethod("GET")
+ .withPath("/v1/tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs/10")
+ )
+ .respond(
+ response()
+ .withStatusCode(200)
+ .withBody(GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE)
+ );
+
+ RestTransport client = new RestTransport("http://localhost:4001");
+ TaskPushNotificationConfig taskPushNotificationConfig = client.getTaskPushNotificationConfiguration(
+ new GetTaskPushNotificationConfigParams("de38c76d-d54c-436c-8b9f-4c2703648d64", "10",
+ new HashMap<>()), null);
+ PushNotificationConfig pushNotificationConfig = taskPushNotificationConfig.pushNotificationConfig();
+ assertNotNull(pushNotificationConfig);
+ assertEquals("https://example.com/callback", pushNotificationConfig.url());
+ PushNotificationAuthenticationInfo authenticationInfo = pushNotificationConfig.authentication();
+ assertTrue(authenticationInfo.schemes().size() == 1);
+ assertEquals("jwt", authenticationInfo.schemes().get(0));
+ }
+
+ /**
+ * Test of listTaskPushNotificationConfigurations method, of class JSONRestTransport.
+ */
+ @Test
+ public void testListTaskPushNotificationConfigurations() throws Exception {
+ this.server.when(
+ request()
+ .withMethod("GET")
+ .withPath("/v1/tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs")
+ )
+ .respond(
+ response()
+ .withStatusCode(200)
+ .withBody(LIST_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE)
+ );
+
+ RestTransport client = new RestTransport("http://localhost:4001");
+ List taskPushNotificationConfigs = client.listTaskPushNotificationConfigurations(
+ new ListTaskPushNotificationConfigParams("de38c76d-d54c-436c-8b9f-4c2703648d64", new HashMap<>()), null);
+ assertEquals(2, taskPushNotificationConfigs.size());
+ PushNotificationConfig pushNotificationConfig = taskPushNotificationConfigs.get(0).pushNotificationConfig();
+ assertNotNull(pushNotificationConfig);
+ assertEquals("https://example.com/callback", pushNotificationConfig.url());
+ assertEquals("10", pushNotificationConfig.id());
+ PushNotificationAuthenticationInfo authenticationInfo = pushNotificationConfig.authentication();
+ assertTrue(authenticationInfo.schemes().size() == 1);
+ assertEquals("jwt", authenticationInfo.schemes().get(0));
+ assertEquals("", authenticationInfo.credentials());
+ pushNotificationConfig = taskPushNotificationConfigs.get(1).pushNotificationConfig();
+ assertNotNull(pushNotificationConfig);
+ assertEquals("https://test.com/callback", pushNotificationConfig.url());
+ assertEquals("5", pushNotificationConfig.id());
+ authenticationInfo = pushNotificationConfig.authentication();
+ assertNull(authenticationInfo);
+ }
+
+ /**
+ * Test of deleteTaskPushNotificationConfigurations method, of class JSONRestTransport.
+ */
+ @Test
+ public void testDeleteTaskPushNotificationConfigurations() throws Exception {
+ log.info("Testing deleteTaskPushNotificationConfigurations");
+ this.server.when(
+ request()
+ .withMethod("DELETE")
+ .withPath("/v1/tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs/10")
+ )
+ .respond(
+ response()
+ .withStatusCode(200)
+ );
+ ClientCallContext context = null;
+ RestTransport instance = new RestTransport("http://localhost:4001");
+ instance.deleteTaskPushNotificationConfigurations(new DeleteTaskPushNotificationConfigParams("de38c76d-d54c-436c-8b9f-4c2703648d64", "10"), context);
+ }
+
+ /**
+ * Test of resubscribe method, of class JSONRestTransport.
+ */
+ @Test
+ public void testResubscribe() throws Exception {
+ log.info("Testing resubscribe");
+
+ this.server.when(
+ request()
+ .withMethod("POST")
+ .withPath("/v1/tasks/task-1234:subscribe")
+ )
+ .respond(
+ response()
+ .withStatusCode(200)
+ .withHeader("Content-Type", "text/event-stream")
+ .withBody(TASK_RESUBSCRIPTION_REQUEST_TEST_RESPONSE)
+ );
+
+ RestTransport client = new RestTransport("http://localhost:4001");
+ TaskIdParams taskIdParams = new TaskIdParams("task-1234");
+
+ AtomicReference receivedEvent = new AtomicReference<>();
+ CountDownLatch latch = new CountDownLatch(1);
+ Consumer eventHandler = event -> {
+ receivedEvent.set(event);
+ latch.countDown();
+ };
+ Consumer errorHandler = error -> {};
+ client.resubscribe(taskIdParams, eventHandler, errorHandler, null);
+
+ boolean eventReceived = latch.await(10, TimeUnit.SECONDS);
+ assertTrue(eventReceived);
+
+ StreamingEventKind eventKind = receivedEvent.get();;
+ assertNotNull(eventKind);
+ assertInstanceOf(Task.class, eventKind);
+ Task task = (Task) eventKind;
+ assertEquals("2", task.getId());
+ assertEquals("context-1234", task.getContextId());
+ assertEquals(TaskState.COMPLETED, task.getStatus().state());
+ List artifacts = task.getArtifacts();
+ assertEquals(1, artifacts.size());
+ Artifact artifact = artifacts.get(0);
+ assertEquals("artifact-1", artifact.artifactId());
+ assertEquals("joke", artifact.name());
+ Part> part = artifact.parts().get(0);
+ assertEquals(Part.Kind.TEXT, part.getKind());
+ assertEquals("Why did the chicken cross the road? To get to the other side!", ((TextPart) part).getText());
+ }
+}
diff --git a/examples/helloworld/server/pom.xml b/examples/helloworld/server/pom.xml
index b35ffa8a9..03f06158a 100644
--- a/examples/helloworld/server/pom.xml
+++ b/examples/helloworld/server/pom.xml
@@ -23,7 +23,6 @@
io.github.a2asdk
a2a-java-sdk-reference-jsonrpc
- ${project.version}
io.quarkus
diff --git a/http-client/src/main/java/io/a2a/client/http/A2AHttpClient.java b/http-client/src/main/java/io/a2a/client/http/A2AHttpClient.java
index f59e079f2..52c252a8f 100644
--- a/http-client/src/main/java/io/a2a/client/http/A2AHttpClient.java
+++ b/http-client/src/main/java/io/a2a/client/http/A2AHttpClient.java
@@ -1,6 +1,7 @@
package io.a2a.client.http;
import java.io.IOException;
+import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@@ -10,8 +11,11 @@ public interface A2AHttpClient {
PostBuilder createPost();
+ DeleteBuilder createDelete();
+
interface Builder> {
T url(String s);
+ T addHeaders(Map headers);
T addHeader(String name, String value);
}
@@ -31,4 +35,8 @@ CompletableFuture postAsyncSSE(
Consumer errorConsumer,
Runnable completeRunnable) throws IOException, InterruptedException;
}
+
+ interface DeleteBuilder extends Builder {
+ A2AHttpResponse delete() throws IOException, InterruptedException;
+ }
}
diff --git a/http-client/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java b/http-client/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java
index 2cdbb2d37..abcecc8ed 100644
--- a/http-client/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java
+++ b/http-client/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java
@@ -35,6 +35,11 @@ public PostBuilder createPost() {
return new JdkPostBuilder();
}
+ @Override
+ public DeleteBuilder createDelete() {
+ return new JdkDeleteBuilder();
+ }
+
private abstract class JdkBuilder> implements Builder {
private String url;
private Map headers = new HashMap<>();
@@ -51,6 +56,16 @@ public T addHeader(String name, String value) {
return self();
}
+ @Override
+ public T addHeaders(Map headers) {
+ if(headers != null && ! headers.isEmpty()) {
+ for (Map.Entry entry : headers.entrySet()) {
+ addHeader(entry.getKey(), entry.getValue());
+ }
+ }
+ return self();
+ }
+
@SuppressWarnings("unchecked")
T self() {
return (T) this;
@@ -145,6 +160,19 @@ public CompletableFuture getAsyncSSE(
.build();
return super.asyncRequest(request, messageConsumer, errorConsumer, completeRunnable);
}
+
+ }
+
+ private class JdkDeleteBuilder extends JdkBuilder implements A2AHttpClient.DeleteBuilder {
+
+ @Override
+ public A2AHttpResponse delete() throws IOException, InterruptedException {
+ HttpRequest request = super.createRequestBuilder().DELETE().build();
+ HttpResponse response =
+ httpClient.send(request, BodyHandlers.ofString(StandardCharsets.UTF_8));
+ return new JdkHttpResponse(response);
+ }
+
}
private class JdkPostBuilder extends JdkBuilder implements A2AHttpClient.PostBuilder {
diff --git a/http-client/src/test/java/io/a2a/client/http/A2ACardResolverTest.java b/http-client/src/test/java/io/a2a/client/http/A2ACardResolverTest.java
index 0b855007b..99d26adad 100644
--- a/http-client/src/test/java/io/a2a/client/http/A2ACardResolverTest.java
+++ b/http-client/src/test/java/io/a2a/client/http/A2ACardResolverTest.java
@@ -14,6 +14,7 @@
import io.a2a.spec.A2AClientError;
import io.a2a.spec.A2AClientJSONError;
import io.a2a.spec.AgentCard;
+import java.util.Map;
import org.junit.jupiter.api.Test;
public class A2ACardResolverTest {
@@ -126,6 +127,11 @@ public PostBuilder createPost() {
return null;
}
+ @Override
+ public DeleteBuilder createDelete() {
+ return null;
+ }
+
class TestGetBuilder implements A2AHttpClient.GetBuilder {
@Override
@@ -161,7 +167,11 @@ public GetBuilder url(String s) {
@Override
public GetBuilder addHeader(String name, String value) {
+ return this;
+ }
+ @Override
+ public GetBuilder addHeaders(Map headers) {
return this;
}
}
diff --git a/pom.xml b/pom.xml
index d1677324e..cfe777bae 100644
--- a/pom.xml
+++ b/pom.xml
@@ -81,22 +81,22 @@
${project.groupId}
- a2a-java-sdk-client-config
+ a2a-java-sdk-client-transport-spi
${project.version}
${project.groupId}
- a2a-java-sdk-client-transport-spi
+ a2a-java-sdk-client-transport-jsonrpc
${project.version}
${project.groupId}
- a2a-java-sdk-client-transport-jsonrpc
+ a2a-java-sdk-client-transport-grpc
${project.version}
${project.groupId}
- a2a-java-sdk-client-transport-grpc
+ a2a-java-sdk-client-transport-rest
${project.version}
@@ -119,6 +119,46 @@
a2a-java-sdk-spec-grpc
${project.version}
+
+ ${project.groupId}
+ a2a-java-sdk-server-common
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-transport-grpc
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-transport-jsonrpc
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-transport-rest
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-reference-common
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-reference-grpc
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-reference-jsonrpc
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-reference-rest
+ ${project.version}
+
io.grpc
grpc-bom
@@ -212,6 +252,25 @@
${logback.version}
test
+
+ ${project.groupId}
+ a2a-java-sdk-tests-server-common
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-tests-server-common
+ test-jar
+ test
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-server-common
+ test-jar
+ test
+ ${project.version}
+
@@ -302,7 +361,7 @@
sign
-
+
@@ -327,6 +386,7 @@
client/base
client/transport/grpc
client/transport/jsonrpc
+ client/transport/rest
client/transport/spi
common
examples/helloworld
@@ -334,6 +394,7 @@
reference/common
reference/grpc
reference/jsonrpc
+ reference/rest
server-common
spec
spec-grpc
@@ -341,6 +402,7 @@
tests/server-common
transport/jsonrpc
transport/grpc
+ transport/rest
diff --git a/reference/common/pom.xml b/reference/common/pom.xml
index 8b2af244f..2e149a343 100644
--- a/reference/common/pom.xml
+++ b/reference/common/pom.xml
@@ -21,12 +21,10 @@
${project.groupId}
a2a-java-sdk-server-common
- ${project.version}
${project.groupId}
a2a-java-sdk-tests-server-common
- ${project.version}
provided
@@ -34,7 +32,6 @@
a2a-java-sdk-tests-server-common
test-jar
test
- ${project.version}
io.quarkus
diff --git a/reference/grpc/pom.xml b/reference/grpc/pom.xml
index 6a4ec4618..e11adc2b5 100644
--- a/reference/grpc/pom.xml
+++ b/reference/grpc/pom.xml
@@ -18,22 +18,18 @@
${project.groupId}
a2a-java-sdk-reference-common
- ${project.version}
${project.groupId}
a2a-java-sdk-transport-grpc
- ${project.version}
${project.groupId}
a2a-java-sdk-server-common
- ${project.version}
${project.groupId}
a2a-java-sdk-tests-server-common
- ${project.version}
provided
@@ -41,12 +37,10 @@
a2a-java-sdk-tests-server-common
test-jar
test
- ${project.version}
${project.groupId}
a2a-java-sdk-client-transport-grpc
- ${project.version}
test
diff --git a/reference/jsonrpc/pom.xml b/reference/jsonrpc/pom.xml
index a4342f96c..de441f6b3 100644
--- a/reference/jsonrpc/pom.xml
+++ b/reference/jsonrpc/pom.xml
@@ -21,22 +21,18 @@
${project.groupId}
a2a-java-sdk-reference-common
- ${project.version}
${project.groupId}
a2a-java-sdk-transport-jsonrpc
- ${project.version}
${project.groupId}
a2a-java-sdk-server-common
- ${project.version}
${project.groupId}
a2a-java-sdk-tests-server-common
- ${project.version}
provided
@@ -44,7 +40,6 @@
a2a-java-sdk-tests-server-common
test-jar
test
- ${project.version}
io.quarkus
diff --git a/reference/rest/README.md b/reference/rest/README.md
new file mode 100644
index 000000000..2a7f0f902
--- /dev/null
+++ b/reference/rest/README.md
@@ -0,0 +1,7 @@
+# A2A Java SDK Reference Server Integration
+
+This is a reference server for the A2A SDK for Java, that we use to run tests, as well as to demonstrate examples.
+
+It is based on [Quarkus](https://quarkus.io), and makes use of Quarkus's [Reactive Routes](https://quarkus.io/guides/reactive-routes).
+
+It is a great choice if you use Quarkus!
\ No newline at end of file
diff --git a/reference/rest/pom.xml b/reference/rest/pom.xml
new file mode 100644
index 000000000..e96a16971
--- /dev/null
+++ b/reference/rest/pom.xml
@@ -0,0 +1,106 @@
+
+
+ 4.0.0
+
+
+ io.github.a2asdk
+ a2a-java-sdk-parent
+ 0.3.0.Beta1-SNAPSHOT
+ ../../pom.xml
+
+ a2a-java-sdk-reference-rest
+
+ jar
+
+ Java A2A Reference Server: JSON+HTTP/REST
+ Java SDK for the Agent2Agent Protocol (A2A) - A2A JSON+HTTP/REST Reference Server (based on Quarkus)
+
+
+
+ ${project.groupId}
+ a2a-java-sdk-reference-common
+
+
+ ${project.groupId}
+ a2a-java-sdk-transport-rest
+
+
+ ${project.groupId}
+ a2a-java-sdk-server-common
+
+
+ ${project.groupId}
+ a2a-java-sdk-client-transport-rest
+ test
+
+
+ ${project.groupId}
+ a2a-java-sdk-tests-server-common
+ provided
+
+
+ ${project.groupId}
+ a2a-java-sdk-tests-server-common
+ test-jar
+ test
+
+
+ com.google.protobuf
+ protobuf-java-util
+ test
+
+
+ io.quarkus
+ quarkus-reactive-routes
+
+
+ jakarta.enterprise
+ jakarta.enterprise.cdi-api
+
+
+ jakarta.inject
+ jakarta.inject-api
+
+
+ org.slf4j
+ slf4j-api
+
+
+ io.quarkus
+ quarkus-junit5
+ test
+
+
+ io.quarkus
+ quarkus-rest-client-jackson
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ io.rest-assured
+ rest-assured
+ test
+
+
+
+
+
+ maven-surefire-plugin
+ 3.5.3
+
+
+ org.jboss.logmanager.LogManager
+ INFO
+ ${maven.home}
+
+
+
+
+
+
diff --git a/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java b/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java
new file mode 100644
index 000000000..1a90cda75
--- /dev/null
+++ b/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java
@@ -0,0 +1,411 @@
+package io.a2a.server.rest.quarkus;
+
+import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
+import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Flow;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Function;
+
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+
+import io.a2a.server.ServerCallContext;
+import io.a2a.server.auth.UnauthenticatedUser;
+import io.a2a.server.auth.User;
+import io.a2a.server.util.async.Internal;
+import io.a2a.spec.InternalError;
+import io.a2a.spec.InvalidParamsError;
+import io.a2a.spec.JSONRPCError;
+import io.a2a.spec.MethodNotFoundError;
+import io.a2a.transport.rest.handler.RestHandler;
+import io.a2a.transport.rest.handler.RestHandler.HTTPRestResponse;
+import io.a2a.transport.rest.handler.RestHandler.HTTPRestStreamingResponse;
+import io.quarkus.vertx.web.Body;
+import io.quarkus.vertx.web.ReactiveRoutes;
+import io.quarkus.vertx.web.Route;
+import io.smallrye.mutiny.Multi;
+import io.vertx.core.AsyncResult;
+import io.vertx.core.Handler;
+import io.vertx.core.MultiMap;
+import io.vertx.core.buffer.Buffer;
+import io.vertx.core.http.HttpServerResponse;
+import io.vertx.ext.web.RoutingContext;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+@Singleton
+public class A2AServerRoutes {
+
+ @Inject
+ RestHandler jsonRestHandler;
+
+ // Hook so testing can wait until the MultiSseSupport is subscribed.
+ // Without this we get intermittent failures
+ private static volatile Runnable streamingMultiSseSupportSubscribedRunnable;
+
+ @Inject
+ @Internal
+ Executor executor;
+
+ @Inject
+ Instance callContextFactory;
+
+ @Route(regex = "^/v1/message:send$", order = 1, methods = {Route.HttpMethod.POST}, consumes = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING)
+ public void sendMessage(@Body String body, RoutingContext rc) {
+ ServerCallContext context = createCallContext(rc);
+ HTTPRestResponse response = null;
+ try {
+ response = jsonRestHandler.sendMessage(body, context);
+ } catch (Throwable t) {
+ response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage()));
+ } finally {
+ rc.response()
+ .setStatusCode(response.getStatusCode())
+ .putHeader(CONTENT_TYPE, response.getContentType())
+ .end(response.getBody());
+ }
+ }
+
+ @Route(regex = "^/v1/message:stream$", order = 1, methods = {Route.HttpMethod.POST}, consumes = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING)
+ public void sendMessageStreaming(@Body String body, RoutingContext rc) {
+ ServerCallContext context = createCallContext(rc);
+ HTTPRestStreamingResponse streamingResponse = null;
+ HTTPRestResponse error = null;
+ try {
+ HTTPRestResponse response = jsonRestHandler.sendStreamingMessage(body, context);
+ if (response instanceof HTTPRestStreamingResponse) {
+ streamingResponse = (HTTPRestStreamingResponse) response;
+ } else {
+ error = response;
+ }
+ } finally {
+ if (error != null) {
+ rc.response()
+ .setStatusCode(error.getStatusCode())
+ .putHeader(CONTENT_TYPE, APPLICATION_JSON)
+ .end(error.getBody());
+ } else {
+ Multi events = Multi.createFrom().publisher(streamingResponse.getPublisher());
+ executor.execute(() -> {
+ MultiSseSupport.subscribeObject(
+ events.map(i -> (Object) i), rc);
+ });
+ }
+ }
+ }
+
+ @Route(path = "/v1/tasks/:id", order = 1, methods = {Route.HttpMethod.GET}, type = Route.HandlerType.BLOCKING)
+ public void getTask(RoutingContext rc) {
+ String taskId = rc.pathParam("id");
+ ServerCallContext context = createCallContext(rc);
+ HTTPRestResponse response = null;
+ try {
+ Integer historyLength = null;
+ if (rc.request().params().contains("history_length")) {
+ historyLength = Integer.valueOf(rc.request().params().get("history_length"));
+ }
+ response = jsonRestHandler.getTask(taskId, historyLength, context);
+ } catch (NumberFormatException e) {
+ response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad history_length"));
+ } catch (Throwable t) {
+ response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage()));
+ } finally {
+ rc.response()
+ .setStatusCode(response.getStatusCode())
+ .putHeader(CONTENT_TYPE, response.getContentType())
+ .end(response.getBody());
+ }
+ }
+
+ @Route(regex = "^/v1/tasks/([^/]+):cancel$", order = 1, methods = {Route.HttpMethod.POST}, type = Route.HandlerType.BLOCKING)
+ public void cancelTask(RoutingContext rc) {
+ String taskId = rc.pathParam("param0");
+ ServerCallContext context = createCallContext(rc);
+ HTTPRestResponse response = null;
+ try {
+ response = jsonRestHandler.cancelTask(taskId, context);
+ } catch (Throwable t) {
+ if (t instanceof JSONRPCError error) {
+ response = jsonRestHandler.createErrorResponse(error);
+ } else {
+ response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage()));
+ }
+ } finally {
+ rc.response()
+ .setStatusCode(response.getStatusCode())
+ .putHeader(CONTENT_TYPE, response.getContentType())
+ .end(response.getBody());
+ }
+ }
+
+ @Route(regex = "^/v1/tasks/([^/]+):subscribe$", order = 1, methods = {Route.HttpMethod.POST}, type = Route.HandlerType.BLOCKING)
+ public void resubscribeTask(RoutingContext rc) {
+ String taskId = rc.pathParam("param0");
+ ServerCallContext context = createCallContext(rc);
+ HTTPRestStreamingResponse streamingResponse = null;
+ HTTPRestResponse error = null;
+ try {
+ HTTPRestResponse response = jsonRestHandler.resubscribeTask(taskId, context);
+ if (response instanceof HTTPRestStreamingResponse) {
+ streamingResponse = (HTTPRestStreamingResponse) response;
+ } else {
+ error = response;
+ }
+ } finally {
+ if (error != null) {
+ rc.response()
+ .setStatusCode(error.getStatusCode())
+ .putHeader(CONTENT_TYPE, APPLICATION_JSON)
+ .end(error.getBody());
+ } else {
+ Multi events = Multi.createFrom().publisher(streamingResponse.getPublisher());
+ executor.execute(() -> {
+ MultiSseSupport.subscribeObject(
+ events.map(i -> (Object) i), rc);
+ });
+ }
+ }
+ }
+
+ @Route(path = "/v1/tasks/:id/pushNotificationConfigs", order = 1, methods = {Route.HttpMethod.POST}, consumes = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING)
+ public void setTaskPushNotificationConfiguration(@Body String body, RoutingContext rc) {
+ String taskId = rc.pathParam("id");
+ ServerCallContext context = createCallContext(rc);
+ HTTPRestResponse response = null;
+ try {
+ response = jsonRestHandler.setTaskPushNotificationConfiguration(taskId, body, context);
+ } catch (Throwable t) {
+ response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage()));
+ } finally {
+ rc.response()
+ .setStatusCode(response.getStatusCode())
+ .putHeader(CONTENT_TYPE, response.getContentType())
+ .end(response.getBody());
+ }
+ }
+
+ @Route(path = "/v1/tasks/:id/pushNotificationConfigs/:configId", order = 1, methods = {Route.HttpMethod.GET}, type = Route.HandlerType.BLOCKING)
+ public void getTaskPushNotificationConfiguration(RoutingContext rc) {
+ String taskId = rc.pathParam("id");
+ String configId = rc.pathParam("configId");
+ ServerCallContext context = createCallContext(rc);
+ HTTPRestResponse response = null;
+ try {
+ response = jsonRestHandler.getTaskPushNotificationConfiguration(taskId, configId, context);
+ } catch (Throwable t) {
+ response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage()));
+ } finally {
+ rc.response()
+ .setStatusCode(response.getStatusCode())
+ .putHeader(CONTENT_TYPE, response.getContentType())
+ .end(response.getBody());
+ }
+ }
+
+ @Route(path = "/v1/tasks/:id/pushNotificationConfigs", order = 1, methods = {Route.HttpMethod.GET}, type = Route.HandlerType.BLOCKING)
+ public void listTaskPushNotificationConfigurations(RoutingContext rc) {
+ String taskId = rc.pathParam("id");
+ ServerCallContext context = createCallContext(rc);
+ HTTPRestResponse response = null;
+ try {
+ response = jsonRestHandler.listTaskPushNotificationConfigurations(taskId, context);
+ } catch (Throwable t) {
+ response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage()));
+ } finally {
+ rc.response()
+ .setStatusCode(response.getStatusCode())
+ .putHeader(CONTENT_TYPE, response.getContentType())
+ .end(response.getBody());
+ }
+ }
+
+ @Route(path = "/v1/tasks/:id/pushNotificationConfigs/:configId", order = 1, methods = {Route.HttpMethod.DELETE}, type = Route.HandlerType.BLOCKING)
+ public void deleteTaskPushNotificationConfiguration(RoutingContext rc) {
+ String taskId = rc.pathParam("id");
+ String configId = rc.pathParam("configId");
+ ServerCallContext context = createCallContext(rc);
+ HTTPRestResponse response = null;
+ try {
+ response = jsonRestHandler.deleteTaskPushNotificationConfiguration(taskId, configId, context);
+ } catch (Throwable t) {
+ response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage()));
+ } finally {
+ rc.response()
+ .setStatusCode(response.getStatusCode())
+ .putHeader(CONTENT_TYPE, response.getContentType())
+ .end(response.getBody());
+ }
+ }
+
+ /**
+ * /**
+ * Handles incoming GET requests to the agent card endpoint.
+ * Returns the agent card in JSON format.
+ *
+ * @param rc
+ */
+ @Route(path = "/.well-known/agent-card.json", order = 1, methods = Route.HttpMethod.GET, produces = APPLICATION_JSON)
+ public void getAgentCard(RoutingContext rc) {
+ HTTPRestResponse response = jsonRestHandler.getAgentCard();
+ rc.response()
+ .setStatusCode(response.getStatusCode())
+ .putHeader(CONTENT_TYPE, response.getContentType())
+ .end(response.getBody());
+ }
+
+ @Route(path = "/v1/card", order = 1, methods = Route.HttpMethod.GET, produces = APPLICATION_JSON)
+ public void getAuthenticatedExtendedCard(RoutingContext rc) {
+ HTTPRestResponse response = jsonRestHandler.getAuthenticatedExtendedCard();
+ rc.response()
+ .setStatusCode(response.getStatusCode())
+ .putHeader(CONTENT_TYPE, response.getContentType())
+ .end(response.getBody());
+ }
+
+ @Route(path = "^/v1/.*", order = 100, methods = {Route.HttpMethod.DELETE, Route.HttpMethod.GET, Route.HttpMethod.HEAD, Route.HttpMethod.OPTIONS, Route.HttpMethod.POST, Route.HttpMethod.PUT}, produces = APPLICATION_JSON)
+ public void methodNotFoundMessage(RoutingContext rc) {
+ HTTPRestResponse response = jsonRestHandler.createErrorResponse(new MethodNotFoundError());
+ rc.response()
+ .setStatusCode(response.getStatusCode())
+ .putHeader(CONTENT_TYPE, response.getContentType())
+ .end(response.getBody());
+ }
+
+ static void setStreamingMultiSseSupportSubscribedRunnable(Runnable runnable) {
+ streamingMultiSseSupportSubscribedRunnable = runnable;
+ }
+
+ private ServerCallContext createCallContext(RoutingContext rc) {
+
+ if (callContextFactory.isUnsatisfied()) {
+ User user;
+ if (rc.user() == null) {
+ user = UnauthenticatedUser.INSTANCE;
+ } else {
+ user = new User() {
+ @Override
+ public boolean isAuthenticated() {
+ return rc.userContext().authenticated();
+ }
+
+ @Override
+ public String getUsername() {
+ return rc.user().subject();
+ }
+ };
+ }
+ Map state = new HashMap<>();
+ // TODO Python's impl has
+ // state['auth'] = request.auth
+ // in jsonrpc_app.py. Figure out what this maps to in what Vert.X gives us
+
+ Map headers = new HashMap<>();
+ Set headerNames = rc.request().headers().names();
+ headerNames.forEach(name -> headers.put(name, rc.request().getHeader(name)));
+ state.put("headers", headers);
+
+ return new ServerCallContext(user, state);
+ } else {
+ CallContextFactory builder = callContextFactory.get();
+ return builder.build(rc);
+ }
+ }
+
+ // Port of import io.quarkus.vertx.web.runtime.MultiSseSupport, which is considered internal API
+ private static class MultiSseSupport {
+
+ private MultiSseSupport() {
+ // Avoid direct instantiation.
+ }
+
+ private static void initialize(HttpServerResponse response) {
+ if (response.bytesWritten() == 0) {
+ MultiMap headers = response.headers();
+ if (headers.get("content-type") == null) {
+ headers.set("content-type", "text/event-stream");
+ }
+ response.setChunked(true);
+ }
+ }
+
+ private static void onWriteDone(Flow.Subscription subscription, AsyncResult ar, RoutingContext rc) {
+ if (ar.failed()) {
+ rc.fail(ar.cause());
+ } else {
+ subscription.request(1);
+ }
+ }
+
+ public static void write(Multi multi, RoutingContext rc) {
+ HttpServerResponse response = rc.response();
+ multi.subscribe().withSubscriber(new Flow.Subscriber() {
+ Flow.Subscription upstream;
+
+ @Override
+ public void onSubscribe(Flow.Subscription subscription) {
+ this.upstream = subscription;
+ this.upstream.request(1);
+
+ // Notify tests that we are subscribed
+ Runnable runnable = streamingMultiSseSupportSubscribedRunnable;
+ if (runnable != null) {
+ runnable.run();
+ }
+ }
+
+ @Override
+ public void onNext(Buffer item) {
+ initialize(response);
+ response.write(item, new Handler>() {
+ @Override
+ public void handle(AsyncResult ar) {
+ onWriteDone(upstream, ar, rc);
+ }
+ });
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ rc.fail(throwable);
+ }
+
+ @Override
+ public void onComplete() {
+ endOfStream(response);
+ }
+ });
+ }
+
+ public static void subscribeObject(Multi