-
Notifications
You must be signed in to change notification settings - Fork 147
feat(client): add Android HTTP client and remove fallback in factory #846
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using References
|
||
| 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it should return self(); shouldn't it ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AFAIK They do the same thing here. |
||
| } | ||
|
|
||
| @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(); | ||
| } | ||
|
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; | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.