From 534e4e5c7888c473c1be1bd88ce8f30ed19a756a Mon Sep 17 00:00:00 2001 From: Daria Wieliczko Date: Mon, 4 May 2026 13:06:55 +0000 Subject: [PATCH] feat(client): add Android HTTP client and remove fallback in factory - Port changes from PR 806 to main branch - Add AndroidA2AHttpClient using HttpURLConnection in a new extras module - Update A2AHttpClientFactory to fail fast if no provider is found - Add abstract test classes in http-client and concrete implementations in android module --- boms/extras/pom.xml | 5 + boms/extras/src/it/extras-usage-test/pom.xml | 4 + extras/http-client-android/pom.xml | 60 ++++ .../sdk/client/http/AndroidA2AHttpClient.java | 290 ++++++++++++++++++ .../http/AndroidA2AHttpClientProvider.java | 33 ++ ...ject.sdk.client.http.A2AHttpClientProvider | 1 + .../http/AndroidA2AHttpClientFactoryTest.java | 41 +++ .../AndroidA2AHttpClientIntegrationTest.java | 9 + .../http/AndroidA2AHttpClientSSETest.java | 9 + .../client/http/AndroidA2AHttpClientTest.java | 59 ++++ http-client/pom.xml | 20 ++ .../sdk/client/http/A2AHttpClientFactory.java | 22 +- .../AbstractA2AHttpClientIntegrationTest.java | 216 +++++++++++++ .../http/AbstractA2AHttpClientSSETest.java | 255 +++++++++++++++ pom.xml | 13 + 15 files changed, 1030 insertions(+), 7 deletions(-) create mode 100644 extras/http-client-android/pom.xml create mode 100644 extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java create mode 100644 extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientProvider.java create mode 100644 extras/http-client-android/src/main/resources/META-INF/services/org.a2aproject.sdk.client.http.A2AHttpClientProvider create mode 100644 extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientFactoryTest.java create mode 100644 extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientIntegrationTest.java create mode 100644 extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientSSETest.java create mode 100644 extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientTest.java create mode 100644 http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientIntegrationTest.java create mode 100644 http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientSSETest.java diff --git a/boms/extras/pom.xml b/boms/extras/pom.xml index 799a09dde..2e36f899d 100644 --- a/boms/extras/pom.xml +++ b/boms/extras/pom.xml @@ -34,6 +34,11 @@ a2a-java-extras-common ${project.version} + + ${project.groupId} + a2a-java-sdk-http-client-android + ${project.version} + ${project.groupId} a2a-java-sdk-http-client-vertx diff --git a/boms/extras/src/it/extras-usage-test/pom.xml b/boms/extras/src/it/extras-usage-test/pom.xml index 67792ea5d..bca59ad4f 100644 --- a/boms/extras/src/it/extras-usage-test/pom.xml +++ b/boms/extras/src/it/extras-usage-test/pom.xml @@ -44,6 +44,10 @@ org.a2aproject.sdk a2a-java-extras-common + + org.a2aproject.sdk + a2a-java-sdk-http-client-android + org.a2aproject.sdk a2a-java-sdk-http-client-vertx diff --git a/extras/http-client-android/pom.xml b/extras/http-client-android/pom.xml new file mode 100644 index 000000000..6964df341 --- /dev/null +++ b/extras/http-client-android/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + + org.a2aproject.sdk + a2a-java-sdk-parent + 1.0.0.Beta2-SNAPSHOT + ../../pom.xml + + a2a-java-sdk-http-client-android + jar + + Java SDK A2A HTTP Client: Android + Java SDK for the Agent2Agent Protocol (A2A) - Android HTTP Client + + + + ${project.groupId} + a2a-java-sdk-http-client + + + ${project.groupId} + a2a-java-sdk-spec + + + + ${project.groupId} + a2a-java-sdk-http-client + test-jar + test + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.mock-server + mockserver-netty + test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + Android Runtime + + + + + + diff --git a/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java b/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java new file mode 100644 index 000000000..21e585398 --- /dev/null +++ b/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClient.java @@ -0,0 +1,290 @@ +package org.a2aproject.sdk.client.http; + +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_MULT_CHOICE; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; + +import org.a2aproject.sdk.common.A2AErrorMessages; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +/** Android-specific implementation of {@link A2AHttpClient} using {@link HttpURLConnection}. */ +public class AndroidA2AHttpClient implements A2AHttpClient { + + private static final Executor NET_EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "A2A-Android-Net"); + t.setDaemon(true); + return t; + }); + + @Override + public GetBuilder createGet() { + return new AndroidGetBuilder(); + } + + @Override + public PostBuilder createPost() { + return new AndroidPostBuilder(); + } + + @Override + public DeleteBuilder createDelete() { + return new AndroidDeleteBuilder(); + } + + private abstract static class AndroidBuilder> implements Builder { + protected String url = ""; + protected Map headers = new HashMap<>(); + + @Override + public T url(String url) { + this.url = url; + return self(); + } + + @Override + public T addHeader(String name, String value) { + headers.put(name, value); + return self(); + } + + @Override + public T addHeaders(Map headers) { + if (headers != null) { + this.headers.putAll(headers); + } + return self(); + } + + @SuppressWarnings("unchecked") + protected T self() { + return (T) this; + } + + protected HttpURLConnection createConnection(String method, boolean isSSE) throws IOException { + URL urlObj; + try { + urlObj = new URI(url).toURL(); + } catch (URISyntaxException e) { + throw new MalformedURLException("Invalid URL: " + url); + } + HttpURLConnection connection = (HttpURLConnection) urlObj.openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(15000); // 15 seconds + connection.setReadTimeout(60000); // 60 seconds + for (Map.Entry header : headers.entrySet()) { + connection.setRequestProperty(header.getKey(), header.getValue()); + } + if (isSSE) { + connection.setRequestProperty(A2AHttpClient.ACCEPT, A2AHttpClient.EVENT_STREAM); + } + return connection; + } + + protected static String readStreamWithLimit(InputStream is) throws IOException { + if (is == null) { + return ""; + } + int maxResponseSize = 10 * 1024 * 1024; // 10 MB + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + boolean first = true; + while ((line = reader.readLine()) != null) { + if (sb.length() + line.length() > maxResponseSize) { + throw new IOException("Response size exceeds limit"); + } + if (!first) { + sb.append('\n'); + } + sb.append(line); + first = false; + } + return sb.toString(); + } + } + + protected A2AHttpResponse execute(HttpURLConnection connection) throws IOException { + int status = connection.getResponseCode(); + String body = ""; + try (InputStream is = + (status >= HTTP_OK && status < HTTP_MULT_CHOICE) + ? connection.getInputStream() + : connection.getErrorStream()) { + body = readStreamWithLimit(is); + } + + if (status == HTTP_UNAUTHORIZED) { + throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED); + } else if (status == HTTP_FORBIDDEN) { + throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED); + } + + return new AndroidHttpResponse(status, body); + } + + protected void processSSEResponse( + HttpURLConnection connection, + Consumer messageConsumer, + Consumer errorConsumer, + Runnable completeRunnable) { + try { + int status = connection.getResponseCode(); + if (status != HTTP_OK) { + if (status == HTTP_UNAUTHORIZED) { + errorConsumer.accept(new IOException(A2AErrorMessages.AUTHENTICATION_FAILED)); + return; + } else if (status == HTTP_FORBIDDEN) { + errorConsumer.accept(new IOException(A2AErrorMessages.AUTHORIZATION_FAILED)); + return; + } + + String errorBody = ""; + try (InputStream es = connection.getErrorStream()) { + errorBody = readStreamWithLimit(es); + } + errorConsumer.accept( + new IOException("Request failed with status " + status + ":" + errorBody)); + return; + } + + try (InputStream is = connection.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("data:")) { + String data = line.substring(5).trim(); + if (!data.isEmpty()) { + messageConsumer.accept(data); + } + } + } + completeRunnable.run(); + } + } catch (Exception e) { + errorConsumer.accept(e); + } finally { + connection.disconnect(); + } + } + + protected CompletableFuture executeAsyncSSE( + HttpURLConnection connection, + Consumer messageConsumer, + Consumer errorConsumer, + Runnable completeRunnable) { + return CompletableFuture.runAsync( + () -> processSSEResponse(connection, messageConsumer, errorConsumer, completeRunnable), + NET_EXECUTOR); + } + } + + private static class AndroidGetBuilder extends AndroidBuilder implements GetBuilder { + @Override + public A2AHttpResponse get() throws IOException { + HttpURLConnection connection = createConnection("GET", false); + try { + return execute(connection); + } catch (IOException e) { + connection.disconnect(); + throw e; + } + } + + @Override + public CompletableFuture getAsyncSSE( + Consumer messageConsumer, + Consumer errorConsumer, + Runnable completeRunnable) + throws IOException { + HttpURLConnection connection = createConnection("GET", true); + return executeAsyncSSE(connection, messageConsumer, errorConsumer, completeRunnable); + } + } + + private static class AndroidPostBuilder extends AndroidBuilder + implements PostBuilder { + private String body = ""; + + @Override + public PostBuilder body(String body) { + this.body = body; + return this; + } + + @Override + public A2AHttpResponse post() throws IOException { + HttpURLConnection connection = createConnection("POST", false); + connection.setDoOutput(true); + try { + try (OutputStream os = connection.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + return execute(connection); + } catch (IOException e) { + connection.disconnect(); + throw e; + } + } + + @Override + public CompletableFuture postAsyncSSE( + Consumer messageConsumer, + Consumer errorConsumer, + Runnable completeRunnable) + throws IOException { + HttpURLConnection connection = createConnection("POST", true); + connection.setDoOutput(true); + + return CompletableFuture.runAsync( + () -> { + try { + try (OutputStream os = connection.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + processSSEResponse(connection, messageConsumer, errorConsumer, completeRunnable); + } catch (Exception e) { + errorConsumer.accept(e); + connection.disconnect(); + } + }, NET_EXECUTOR); + } + } + + private static class AndroidDeleteBuilder extends AndroidBuilder + implements DeleteBuilder { + @Override + public A2AHttpResponse delete() throws IOException { + HttpURLConnection connection = createConnection("DELETE", false); + try { + return execute(connection); + } catch (IOException e) { + connection.disconnect(); + throw e; + } + } + } + + private record AndroidHttpResponse(int status, String body) implements A2AHttpResponse { + @Override + public boolean success() { + return status >= HTTP_OK && status < HTTP_MULT_CHOICE; + } + } +} diff --git a/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientProvider.java b/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientProvider.java new file mode 100644 index 000000000..25214a60e --- /dev/null +++ b/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientProvider.java @@ -0,0 +1,33 @@ +package org.a2aproject.sdk.client.http; + +/** + * Service provider for {@link AndroidA2AHttpClient}. + */ +public final class AndroidA2AHttpClientProvider implements A2AHttpClientProvider { + + private static final boolean ANDROID_AVAILABLE = isAndroidAvailable(); + + private static boolean isAndroidAvailable() { + String runtimeName = System.getProperty("java.runtime.name"); + return runtimeName != null && runtimeName.toLowerCase(java.util.Locale.ENGLISH).contains("android"); + } + + @Override + public A2AHttpClient create() { + if (!ANDROID_AVAILABLE) { + throw new IllegalStateException( + "Android classes are not available. This provider is only supported on Android."); + } + return new AndroidA2AHttpClient(); + } + + @Override + public int priority() { + return ANDROID_AVAILABLE ? 110 : -1; // Higher priority than Vert.x on Android + } + + @Override + public String name() { + return "android"; + } +} diff --git a/extras/http-client-android/src/main/resources/META-INF/services/org.a2aproject.sdk.client.http.A2AHttpClientProvider b/extras/http-client-android/src/main/resources/META-INF/services/org.a2aproject.sdk.client.http.A2AHttpClientProvider new file mode 100644 index 000000000..9bf99369a --- /dev/null +++ b/extras/http-client-android/src/main/resources/META-INF/services/org.a2aproject.sdk.client.http.A2AHttpClientProvider @@ -0,0 +1 @@ +org.a2aproject.sdk.client.http.AndroidA2AHttpClientProvider diff --git a/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientFactoryTest.java b/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientFactoryTest.java new file mode 100644 index 000000000..fd5c0be2a --- /dev/null +++ b/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientFactoryTest.java @@ -0,0 +1,41 @@ +package org.a2aproject.sdk.client.http; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import org.junit.jupiter.api.Test; + +public class AndroidA2AHttpClientFactoryTest { + + @Test + public void testCreateReturnsAndroidClient() { + // When both JDK and Android are on classpath, Android should be preferred due to higher priority (100) + A2AHttpClient client = A2AHttpClientFactory.create(); + assertNotNull(client); + assertInstanceOf(AndroidA2AHttpClient.class, client, + "Factory should return AndroidA2AHttpClient when Android provider is available"); + } + + @Test + public void testCreateWithAndroidProviderName() { + A2AHttpClient client = A2AHttpClientFactory.create("android"); + assertNotNull(client); + assertInstanceOf(AndroidA2AHttpClient.class, client, + "Factory should return AndroidA2AHttpClient when 'android' provider is requested"); + } + + @Test + public void testAndroidClientIsUsable() { + A2AHttpClient client = A2AHttpClientFactory.create("android"); + assertNotNull(client); + + // Verify we can create builders + A2AHttpClient.GetBuilder getBuilder = client.createGet(); + assertNotNull(getBuilder, "Should be able to create GET builder"); + + A2AHttpClient.PostBuilder postBuilder = client.createPost(); + assertNotNull(postBuilder, "Should be able to create POST builder"); + + A2AHttpClient.DeleteBuilder deleteBuilder = client.createDelete(); + assertNotNull(deleteBuilder, "Should be able to create DELETE builder"); + } +} diff --git a/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientIntegrationTest.java b/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientIntegrationTest.java new file mode 100644 index 000000000..80d65ab75 --- /dev/null +++ b/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientIntegrationTest.java @@ -0,0 +1,9 @@ +package org.a2aproject.sdk.client.http; + +public class AndroidA2AHttpClientIntegrationTest extends AbstractA2AHttpClientIntegrationTest { + + @Override + protected A2AHttpClient createClient() { + return new AndroidA2AHttpClient(); + } +} diff --git a/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientSSETest.java b/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientSSETest.java new file mode 100644 index 000000000..499310790 --- /dev/null +++ b/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientSSETest.java @@ -0,0 +1,9 @@ +package org.a2aproject.sdk.client.http; + +public class AndroidA2AHttpClientSSETest extends AbstractA2AHttpClientSSETest { + + @Override + protected A2AHttpClient createClient() { + return new AndroidA2AHttpClient(); + } +} diff --git a/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientTest.java b/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientTest.java new file mode 100644 index 000000000..d2b4a97ca --- /dev/null +++ b/extras/http-client-android/src/test/java/org/a2aproject/sdk/client/http/AndroidA2AHttpClientTest.java @@ -0,0 +1,59 @@ +package org.a2aproject.sdk.client.http; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import org.junit.jupiter.api.Test; + +public class AndroidA2AHttpClientTest { + + @Test + public void testNoArgsConstructor() { + AndroidA2AHttpClient client = new AndroidA2AHttpClient(); + assertNotNull(client); + } + + @Test + public void testCreateGet() { + AndroidA2AHttpClient client = new AndroidA2AHttpClient(); + A2AHttpClient.GetBuilder builder = client.createGet(); + assertNotNull(builder); + } + + @Test + public void testCreatePost() { + AndroidA2AHttpClient client = new AndroidA2AHttpClient(); + A2AHttpClient.PostBuilder builder = client.createPost(); + assertNotNull(builder); + } + + @Test + public void testCreateDelete() { + AndroidA2AHttpClient client = new AndroidA2AHttpClient(); + A2AHttpClient.DeleteBuilder builder = client.createDelete(); + assertNotNull(builder); + } + + @Test + public void testBuilderUrlSetting() { + AndroidA2AHttpClient client = new AndroidA2AHttpClient(); + A2AHttpClient.GetBuilder builder = client.createGet(); + A2AHttpClient.GetBuilder result = builder.url("https://example.com"); + assertSame(builder, result, "Builder should return itself for method chaining"); + } + + @Test + public void testBuilderHeaderSetting() { + AndroidA2AHttpClient client = new AndroidA2AHttpClient(); + A2AHttpClient.GetBuilder builder = client.createGet(); + A2AHttpClient.GetBuilder result = builder.addHeader("Accept", "application/json"); + assertSame(builder, result, "Builder should return itself for method chaining"); + } + + @Test + public void testPostBuilderBody() { + AndroidA2AHttpClient client = new AndroidA2AHttpClient(); + A2AHttpClient.PostBuilder builder = client.createPost(); + A2AHttpClient.PostBuilder result = builder.body("{\"key\":\"value\"}"); + assertSame(builder, result, "Builder should return itself for method chaining"); + } +} diff --git a/http-client/pom.xml b/http-client/pom.xml index c41097374..468432f9d 100644 --- a/http-client/pom.xml +++ b/http-client/pom.xml @@ -43,4 +43,24 @@ + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + **/Abstract*.class + + + + + + + \ No newline at end of file diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpClientFactory.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpClientFactory.java index 97ace7996..068a47046 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpClientFactory.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2AHttpClientFactory.java @@ -1,7 +1,9 @@ package org.a2aproject.sdk.client.http; import java.util.Comparator; +import java.util.List; import java.util.ServiceLoader; +import java.util.stream.Collectors; import java.util.stream.StreamSupport; /** @@ -9,8 +11,8 @@ * *

* This factory discovers available {@link A2AHttpClientProvider} implementations at runtime - * and selects the one with the highest priority. If no providers are found, it falls back - * to creating a {@link JdkA2AHttpClient}. + * and selects the one with the highest priority. If no providers are found, it throws + * an {@link IllegalStateException}. * *

Usage

*
{@code
@@ -40,6 +42,13 @@
  */
 public final class A2AHttpClientFactory {
 
+    private static final List PROVIDERS;
+
+    static {
+        PROVIDERS = StreamSupport.stream(ServiceLoader.load(A2AHttpClientProvider.class).spliterator(), false)
+                .collect(Collectors.toList());
+    }
+
     private A2AHttpClientFactory() {
         // Utility class
     }
@@ -49,17 +58,16 @@ private A2AHttpClientFactory() {
      *
      * 

* This method uses the ServiceLoader mechanism to discover providers at runtime. - * If no providers are found, it falls back to creating a {@link JdkA2AHttpClient}. + * If no providers are found, it throws an {@link IllegalStateException}. * * @return a new A2AHttpClient instance + * @throws IllegalStateException if no providers are found */ public static A2AHttpClient create() { - ServiceLoader loader = ServiceLoader.load(A2AHttpClientProvider.class); - - return StreamSupport.stream(loader.spliterator(), false) + return PROVIDERS.stream() .max(Comparator.comparingInt(A2AHttpClientProvider::priority)) .map(A2AHttpClientProvider::create) - .orElseGet(JdkA2AHttpClient::new); + .orElseThrow(() -> new IllegalStateException("No A2AHttpClientProvider found")); } /** diff --git a/http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientIntegrationTest.java b/http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientIntegrationTest.java new file mode 100644 index 000000000..e0b45f9b3 --- /dev/null +++ b/http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientIntegrationTest.java @@ -0,0 +1,216 @@ +package org.a2aproject.sdk.client.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +import org.a2aproject.sdk.common.A2AErrorMessages; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.integration.ClientAndServer; + +import java.io.IOException; + +public abstract class AbstractA2AHttpClientIntegrationTest { + + private ClientAndServer mockServer; + private A2AHttpClient client; + + protected abstract A2AHttpClient createClient(); + + @BeforeEach + public void setup() { + mockServer = ClientAndServer.startClientAndServer(0); + client = createClient(); + } + + @AfterEach + public void teardown() { + if (mockServer != null) { + mockServer.stop(); + } + } + + private String getBaseUrl() { + return "http://localhost:" + mockServer.getPort(); + } + + @Test + public void testGetRequestSuccess() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/test")) + .respond(response().withStatusCode(200).withBody("success")); + + A2AHttpResponse response = client.createGet() + .url(getBaseUrl() + "/test") + .get(); + + assertEquals(200, response.status()); + assertTrue(response.success()); + assertEquals("success", response.body()); + } + + @Test + public void testPostRequestSuccess() throws Exception { + mockServer + .when(request() + .withMethod("POST") + .withPath("/test") + .withBody("{\"key\":\"value\"}")) + .respond(response().withStatusCode(201).withBody("created")); + + A2AHttpResponse response = client.createPost() + .url(getBaseUrl() + "/test") + .body("{\"key\":\"value\"}") + .post(); + + assertEquals(201, response.status()); + assertTrue(response.success()); + assertEquals("created", response.body()); + } + + @Test + public void testDeleteRequestSuccess() throws Exception { + mockServer + .when(request().withMethod("DELETE").withPath("/test")) + .respond(response().withStatusCode(204)); + + A2AHttpResponse response = client.createDelete() + .url(getBaseUrl() + "/test") + .delete(); + + assertEquals(204, response.status()); + assertTrue(response.success()); + } + + @Test + public void test401AuthenticationErrorOnGet() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/test")) + .respond(response().withStatusCode(401)); + + Exception exception = assertThrows(IOException.class, () -> { + client.createGet() + .url(getBaseUrl() + "/test") + .get(); + }); + + assertEquals(A2AErrorMessages.AUTHENTICATION_FAILED, exception.getMessage()); + } + + @Test + public void test403AuthorizationErrorOnGet() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/test")) + .respond(response().withStatusCode(403)); + + Exception exception = assertThrows(IOException.class, () -> { + client.createGet() + .url(getBaseUrl() + "/test") + .get(); + }); + + assertEquals(A2AErrorMessages.AUTHORIZATION_FAILED, exception.getMessage()); + } + + @Test + public void test401AuthenticationErrorOnPost() throws Exception { + mockServer + .when(request().withMethod("POST").withPath("/test")) + .respond(response().withStatusCode(401)); + + Exception exception = assertThrows(IOException.class, () -> { + client.createPost() + .url(getBaseUrl() + "/test") + .body("{}") + .post(); + }); + + assertEquals(A2AErrorMessages.AUTHENTICATION_FAILED, exception.getMessage()); + } + + @Test + public void test403AuthorizationErrorOnPost() throws Exception { + mockServer + .when(request().withMethod("POST").withPath("/test")) + .respond(response().withStatusCode(403)); + + Exception exception = assertThrows(IOException.class, () -> { + client.createPost() + .url(getBaseUrl() + "/test") + .body("{}") + .post(); + }); + + assertEquals(A2AErrorMessages.AUTHORIZATION_FAILED, exception.getMessage()); + } + + @Test + public void test401AuthenticationErrorOnDelete() throws Exception { + mockServer + .when(request().withMethod("DELETE").withPath("/test")) + .respond(response().withStatusCode(401)); + + Exception exception = assertThrows(IOException.class, () -> { + client.createDelete() + .url(getBaseUrl() + "/test") + .delete(); + }); + + assertEquals(A2AErrorMessages.AUTHENTICATION_FAILED, exception.getMessage()); + } + + @Test + public void testHeaderPropagation() throws Exception { + mockServer + .when(request() + .withMethod("GET") + .withPath("/test") + .withHeader("Authorization", "Bearer token") + .withHeader("X-Custom-Header", "custom-value")) + .respond(response().withStatusCode(200).withBody("ok")); + + A2AHttpResponse response = client.createGet() + .url(getBaseUrl() + "/test") + .addHeader("Authorization", "Bearer token") + .addHeader("X-Custom-Header", "custom-value") + .get(); + + assertEquals(200, response.status()); + assertEquals("ok", response.body()); + } + + @Test + public void testNonSuccessStatusCode() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/test")) + .respond(response().withStatusCode(500).withBody("Internal Server Error")); + + A2AHttpResponse response = client.createGet() + .url(getBaseUrl() + "/test") + .get(); + + assertEquals(500, response.status()); + assertFalse(response.success()); + assertEquals("Internal Server Error", response.body()); + } + + @Test + public void test404NotFound() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/test")) + .respond(response().withStatusCode(404).withBody("Not Found")); + + A2AHttpResponse response = client.createGet() + .url(getBaseUrl() + "/test") + .get(); + + assertEquals(404, response.status()); + assertFalse(response.success()); + assertEquals("Not Found", response.body()); + } +} diff --git a/http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientSSETest.java b/http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientSSETest.java new file mode 100644 index 000000000..30a9a244f --- /dev/null +++ b/http-client/src/test/java/org/a2aproject/sdk/client/http/AbstractA2AHttpClientSSETest.java @@ -0,0 +1,255 @@ +package org.a2aproject.sdk.client.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 org.a2aproject.sdk.common.A2AErrorMessages; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.integration.ClientAndServer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public abstract class AbstractA2AHttpClientSSETest { + + private ClientAndServer mockServer; + private A2AHttpClient client; + + protected abstract A2AHttpClient createClient(); + + @BeforeEach + public void setup() { + mockServer = ClientAndServer.startClientAndServer(0); + client = createClient(); + } + + @AfterEach + public void teardown() { + if (mockServer != null) { + mockServer.stop(); + } + } + + private String getBaseUrl() { + return "http://localhost:" + mockServer.getPort(); + } + + @Test + public void testGetAsyncSSE() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/sse")) + .respond(response() + .withStatusCode(200) + .withHeader("Content-Type", "text/event-stream") + .withBody("data: event1\n\ndata: event2\n\ndata: event3\n\n")); + + CountDownLatch latch = new CountDownLatch(1); + List events = new ArrayList<>(); + AtomicReference error = new AtomicReference<>(); + + client.createGet() + .url(getBaseUrl() + "/sse") + .getAsyncSSE( + events::add, + error::set, + latch::countDown + ); + + assertTrue(latch.await(5, TimeUnit.SECONDS), "Expected completion handler to be called"); + assertNull(error.get(), "Expected no errors"); + assertEquals(3, events.size(), "Expected to receive 3 events"); + assertTrue(events.contains("event1")); + assertTrue(events.contains("event2")); + assertTrue(events.contains("event3")); + } + + @Test + public void testPostAsyncSSE() throws Exception { + mockServer + .when(request() + .withMethod("POST") + .withPath("/sse") + .withBody("{\"subscribe\":true}")) + .respond(response() + .withStatusCode(200) + .withHeader("Content-Type", "text/event-stream") + .withBody("data: message1\n\ndata: message2\n\n")); + + CountDownLatch latch = new CountDownLatch(1); + List events = new ArrayList<>(); + AtomicReference error = new AtomicReference<>(); + + client.createPost() + .url(getBaseUrl() + "/sse") + .body("{\"subscribe\":true}") + .postAsyncSSE( + events::add, + error::set, + latch::countDown + ); + + assertTrue(latch.await(5, TimeUnit.SECONDS), "Expected completion handler to be called"); + assertNull(error.get(), "Expected no errors"); + assertEquals(2, events.size(), "Expected to receive 2 events"); + assertTrue(events.contains("message1")); + assertTrue(events.contains("message2")); + } + + @Test + public void testSSEDataPrefixStripping() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/sse")) + .respond(response() + .withStatusCode(200) + .withHeader("Content-Type", "text/event-stream") + .withBody("data: content here\n\ndata:no space\n\ndata: extra spaces \n\n")); + + CountDownLatch latch = new CountDownLatch(1); + List events = new ArrayList<>(); + AtomicReference error = new AtomicReference<>(); + + client.createGet() + .url(getBaseUrl() + "/sse") + .getAsyncSSE( + events::add, + error::set, + latch::countDown + ); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + assertNull(error.get()); + assertTrue(events.contains("content here"), "Should have stripped 'data: ' prefix"); + assertTrue(events.contains("no space"), "Should handle 'data:' without space"); + assertTrue(events.contains("extra spaces"), "Should trim whitespace"); + } + + @Test + public void testSSEAuthenticationError() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/sse")) + .respond(response().withStatusCode(401)); + + CountDownLatch errorLatch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + AtomicBoolean completed = new AtomicBoolean(false); + + client.createGet() + .url(getBaseUrl() + "/sse") + .getAsyncSSE( + msg -> {}, + e -> { + error.set(e); + errorLatch.countDown(); + }, + () -> completed.set(true) + ); + + assertTrue(errorLatch.await(5, TimeUnit.SECONDS), "Expected error handler to be called"); + assertNotNull(error.get(), "Expected an error"); + assertTrue(error.get() instanceof IOException, "Expected IOException"); + assertTrue(error.get().getMessage().contains(A2AErrorMessages.AUTHENTICATION_FAILED), + "Expected authentication error message but got: " + error.get().getMessage()); + assertFalse(completed.get(), "Should not call completion handler on error"); + } + + @Test + public void testSSEAuthorizationError() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/sse")) + .respond(response().withStatusCode(403)); + + CountDownLatch errorLatch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + AtomicBoolean completed = new AtomicBoolean(false); + + client.createGet() + .url(getBaseUrl() + "/sse") + .getAsyncSSE( + msg -> {}, + e -> { + error.set(e); + errorLatch.countDown(); + }, + () -> completed.set(true) + ); + + assertTrue(errorLatch.await(5, TimeUnit.SECONDS), "Expected error handler to be called"); + assertNotNull(error.get(), "Expected an error"); + assertTrue(error.get() instanceof IOException, "Expected IOException"); + assertTrue(error.get().getMessage().contains(A2AErrorMessages.AUTHORIZATION_FAILED), + "Expected authorization error message but got: " + error.get().getMessage()); + assertFalse(completed.get(), "Should not call completion handler on error"); + } + + @Test + public void testSSEEmptyLinesIgnored() throws Exception { + mockServer + .when(request().withMethod("GET").withPath("/sse")) + .respond(response() + .withStatusCode(200) + .withHeader("Content-Type", "text/event-stream") + .withBody("data: first\n\n\n\ndata: second\n\ndata: \n\ndata: third\n\n")); + + CountDownLatch latch = new CountDownLatch(1); + List events = new ArrayList<>(); + AtomicReference error = new AtomicReference<>(); + + client.createGet() + .url(getBaseUrl() + "/sse") + .getAsyncSSE( + events::add, + error::set, + latch::countDown + ); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + assertNull(error.get()); + assertEquals(3, events.size(), "Should have received 3 non-empty events"); + assertTrue(events.contains("first")); + assertTrue(events.contains("second")); + assertTrue(events.contains("third")); + } + + @Test + public void testSSEHeaderPropagation() throws Exception { + mockServer + .when(request() + .withMethod("GET") + .withPath("/sse") + .withHeader("Accept", "text/event-stream") + .withHeader("Authorization", "Bearer token")) + .respond(response() + .withStatusCode(200) + .withHeader("Content-Type", "text/event-stream") + .withBody("data: authenticated\n\n")); + + CountDownLatch latch = new CountDownLatch(1); + List events = new ArrayList<>(); + AtomicReference error = new AtomicReference<>(); + + client.createGet() + .url(getBaseUrl() + "/sse") + .addHeader("Authorization", "Bearer token") + .getAsyncSSE( + events::add, + error::set, + latch::countDown + ); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + assertNull(error.get()); + assertTrue(events.contains("authenticated")); + } +} diff --git a/pom.xml b/pom.xml index 48e69372c..bfe5ed8b8 100644 --- a/pom.xml +++ b/pom.xml @@ -120,6 +120,18 @@ a2a-java-sdk-http-client ${project.version} + + ${project.groupId} + a2a-java-sdk-http-client + ${project.version} + test-jar + test + + + ${project.groupId} + a2a-java-sdk-http-client-android + ${project.version} + ${project.groupId} a2a-java-sdk-http-client-vertx @@ -560,6 +572,7 @@ extras/push-notification-config-store-database-jpa extras/queue-manager-replicated extras/http-client-vertx + extras/http-client-android http-client jsonrpc-common integrations/microprofile-config