Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions boms/extras/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
<artifactId>a2a-java-extras-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>a2a-java-sdk-http-client-android</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>a2a-java-sdk-http-client-vertx</artifactId>
Expand Down
4 changes: 4 additions & 0 deletions boms/extras/src/it/extras-usage-test/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-extras-common</artifactId>
</dependency>
<dependency>
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-sdk-http-client-android</artifactId>
</dependency>
<dependency>
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-sdk-http-client-vertx</artifactId>
Expand Down
60 changes: 60 additions & 0 deletions extras/http-client-android/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?xml version="1.0"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-sdk-parent</artifactId>
<version>1.0.0.Beta2-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>a2a-java-sdk-http-client-android</artifactId>
<packaging>jar</packaging>

<name>Java SDK A2A HTTP Client: Android</name>
<description>Java SDK for the Agent2Agent Protocol (A2A) - Android HTTP Client</description>

<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>a2a-java-sdk-http-client</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>a2a-java-sdk-spec</artifactId>
</dependency>

<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>a2a-java-sdk-http-client</artifactId>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.mock-server</groupId>
<artifactId>mockserver-netty</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<java.runtime.name>Android Runtime</java.runtime.name>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -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<T extends Builder<T>> implements Builder<T> {
protected String url = "";
protected Map<String, String> 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<String, String> 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
Comment thread
sherryfox marked this conversation as resolved.
for (Map.Entry<String, String> 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) {
Comment on lines +109 to +110
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using reader.readLine() can lead to OutOfMemoryError if the server sends a very long line without a newline character, as readLine() buffers the entire line. Per repository rules, explicit size validation is necessary to prevent Denial of Service (DoS) via OutOfMemoryError. It is safer to read the stream in chunks while maintaining a running count of the total bytes read to ensure the final in-memory representation does not exceed safety limits.

References
  1. When a class is designed to hold the entire content of a file in memory, an explicit size validation is necessary to prevent Denial of Service (DoS) via OutOfMemoryError for arbitrarily large files.

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<String> messageConsumer,
Consumer<Throwable> 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<Void> executeAsyncSSE(
HttpURLConnection connection,
Consumer<String> messageConsumer,
Consumer<Throwable> errorConsumer,
Runnable completeRunnable) {
return CompletableFuture.runAsync(
() -> processSSEResponse(connection, messageConsumer, errorConsumer, completeRunnable),
NET_EXECUTOR);
}
}

private static class AndroidGetBuilder extends AndroidBuilder<GetBuilder> 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<Void> getAsyncSSE(
Consumer<String> messageConsumer,
Consumer<Throwable> errorConsumer,
Runnable completeRunnable)
throws IOException {
HttpURLConnection connection = createConnection("GET", true);
return executeAsyncSSE(connection, messageConsumer, errorConsumer, completeRunnable);
}
}

private static class AndroidPostBuilder extends AndroidBuilder<PostBuilder>
implements PostBuilder {
private String body = "";

@Override
public PostBuilder body(String body) {
this.body = body;
return this;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it should return self(); shouldn't it ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK They do the same thing here. self() is only needed in the base class for generics. In the subclass, return this works perfectly and avoids a method call and cast.

}

@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<Void> postAsyncSSE(
Consumer<String> messageConsumer,
Consumer<Throwable> 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();
}
Comment thread
sherryfox marked this conversation as resolved.
}, NET_EXECUTOR);
}
}

private static class AndroidDeleteBuilder extends AndroidBuilder<DeleteBuilder>
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;
}
}
}
Loading