Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.google.protobuf.InvalidProtocolBufferException;
Expand All @@ -36,6 +37,7 @@
import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext;
import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor;
import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders;
import org.a2aproject.sdk.grpc.utils.ProtoJsonUtils;
import org.a2aproject.sdk.grpc.utils.ProtoUtils;
import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException;
import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil;
Expand Down Expand Up @@ -431,19 +433,24 @@ private String sendPostRequest(String url, PayloadAndHeaders payloadAndHeaders)
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()));
if (log.isLoggable(Level.FINE)) {
log.fine("Error on POST processing " + ProtoJsonUtils.toJson(JsonFormat.printer(), (MessageOrBuilder) payloadAndHeaders.getPayload()));
}
throw RestErrorMapper.mapRestError(response);
}
Comment on lines 435 to 440

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.

medium

Calling printProtoAsJson inside log.fine causes the expensive double-serialization and parsing of the protobuf payload to run eagerly on every failed request, even when FINE logging is disabled. Additionally, if printProtoAsJson throws an InvalidProtocolBufferException during serialization, it will propagate that exception instead of the actual mapped REST error (RestErrorMapper.mapRestError(response)).

To fix both issues, wrap the logging statement in an if (log.isLoggable(java.util.logging.Level.FINE)) block and handle any potential serialization exceptions within it.

Suggested change
if (!response.success()) {
log.fine("Error on POST processing " + JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload()));
log.fine("Error on POST processing " + printProtoAsJson((MessageOrBuilder) payloadAndHeaders.getPayload()));
throw RestErrorMapper.mapRestError(response);
}
if (!response.success()) {
if (log.isLoggable(java.util.logging.Level.FINE)) {
try {
log.fine("Error on POST processing " + printProtoAsJson((MessageOrBuilder) payloadAndHeaders.getPayload()));
} catch (InvalidProtocolBufferException e) {
log.log(java.util.logging.Level.FINE, "Failed to serialize payload for logging", e);
}
}
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()));
String body = ProtoJsonUtils.toJson(JsonFormat.printer(), (MessageOrBuilder) payloadAndHeaders.getPayload());
if (log.isLoggable(Level.FINE)) {
log.fine(body);
}
A2AHttpClient.PostBuilder postBuilder = httpClient.createPost()
.url(url)
.addHeader("Content-Type", "application/json")
.addHeader(A2AHeaders.A2A_VERSION, AgentInterface.CURRENT_PROTOCOL_VERSION)
.body(JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload()));
.body(body);

if (payloadAndHeaders.getHeaders() != null) {
for (Map.Entry<String, String> entry : payloadAndHeaders.getHeaders().entrySet()) {
Expand Down
5 changes: 5 additions & 0 deletions common/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>
31 changes: 31 additions & 0 deletions common/src/main/java/org/a2aproject/sdk/util/HtmlEscapeUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.a2aproject.sdk.util;

/**
* Utilities for removing HTML escaping applied by Gson's {@link com.google.gson.stream.JsonWriter}
* when {@code htmlSafe} is enabled (the default).
*/
public final class HtmlEscapeUtils {

private HtmlEscapeUtils() {
}

/**
* Removes HTML escaping applied by Gson's {@link com.google.gson.stream.JsonWriter} when
* {@code htmlSafe} is enabled (the default). Restores literal
* {@code <}, {@code >}, {@code &}, {@code =}, and {@code '}.
* <p>
* Gson also escapes U+2028 (line separator) and U+2029 (paragraph separator) in HTML-safe
* mode, but those are left as-is because they are valid JSON encodings that preserve the
* original characters without data corruption.
*
* @param json the JSON string potentially containing HTML-escaped sequences
* @return the JSON string with literal characters restored
*/
public static String removeHtmlEscaping(String json) {
return json.replace("\\u003c", "<")
.replace("\\u003e", ">")
.replace("\\u0026", "&")
.replace("\\u003d", "=")
.replace("\\u0027", "'");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.a2aproject.sdk.util;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

public class HtmlEscapeUtilsTest {

@Test
public void removeHtmlEscaping_restoresAngleBrackets() {
assertEquals("<event-topic>", HtmlEscapeUtils.removeHtmlEscaping("\\u003cevent-topic\\u003e"));
}

@Test
public void removeHtmlEscaping_restoresAmpersand() {
assertEquals("foo&bar", HtmlEscapeUtils.removeHtmlEscaping("foo\\u0026bar"));
}

@Test
public void removeHtmlEscaping_restoresEquals() {
assertEquals("a=b", HtmlEscapeUtils.removeHtmlEscaping("a\\u003db"));
}

@Test
public void removeHtmlEscaping_restoresApostrophe() {
assertEquals("it's", HtmlEscapeUtils.removeHtmlEscaping("it\\u0027s"));
}

@Test
public void removeHtmlEscaping_handlesMultipleEscapes() {
assertEquals("<tag>&</tag>",
HtmlEscapeUtils.removeHtmlEscaping("\\u003ctag\\u003e\\u0026\\u003c/tag\\u003e"));
}

@Test
public void removeHtmlEscaping_leavesRegularJsonUntouched() {
String json = "{\"key\": \"value\", \"num\": 42}";
assertEquals(json, HtmlEscapeUtils.removeHtmlEscaping(json));
}

@Test
public void removeHtmlEscaping_handlesEmptyString() {
assertEquals("", HtmlEscapeUtils.removeHtmlEscaping(""));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import org.a2aproject.sdk.compat03.spec.Task_v0_3;
import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3;
import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3;
import org.a2aproject.sdk.compat03.grpc.utils.ProtoJsonUtils_v0_3;
import org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_v0_3;
import org.a2aproject.sdk.compat03.spec.A2AClientError_v0_3;
import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3;
Expand All @@ -49,6 +50,7 @@
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
Expand Down Expand Up @@ -375,18 +377,23 @@ private String sendPostRequest(String url, PayloadAndHeaders_v0_3 payloadAndHead
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()));
if (log.isLoggable(Level.FINE)) {
log.fine("Error on POST processing " + ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), (MessageOrBuilder) payloadAndHeaders.getPayload()));
}
throw RestErrorMapper_v0_3.mapRestError(response);
}
Comment on lines 379 to 384

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.

medium

Calling printProtoAsJson inside log.fine causes the expensive double-serialization and parsing of the protobuf payload to run eagerly on every failed request, even when FINE logging is disabled. Additionally, if printProtoAsJson throws an InvalidProtocolBufferException during serialization, it will propagate that exception instead of the actual mapped REST error (RestErrorMapper_v0_3.mapRestError(response)).

To fix both issues, wrap the logging statement in an if (log.isLoggable(java.util.logging.Level.FINE)) block and handle any potential serialization exceptions within it.

Suggested change
if (!response.success()) {
log.fine("Error on POST processing " + JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload()));
log.fine("Error on POST processing " + printProtoAsJson((MessageOrBuilder) payloadAndHeaders.getPayload()));
throw RestErrorMapper_v0_3.mapRestError(response);
}
if (!response.success()) {
if (log.isLoggable(java.util.logging.Level.FINE)) {
try {
log.fine("Error on POST processing " + printProtoAsJson((MessageOrBuilder) payloadAndHeaders.getPayload()));
} catch (InvalidProtocolBufferException e) {
log.log(java.util.logging.Level.FINE, "Failed to serialize payload for logging", e);
}
}
throw RestErrorMapper_v0_3.mapRestError(response);
}

return response.body();
}

private A2AHttpClient.PostBuilder createPostBuilder(String url, PayloadAndHeaders_v0_3 payloadAndHeaders) throws JsonProcessingException_v0_3, InvalidProtocolBufferException {
log.fine(JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload()));
String body = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), (MessageOrBuilder) payloadAndHeaders.getPayload());
if (log.isLoggable(Level.FINE)) {
log.fine(body);
}
A2AHttpClient.PostBuilder postBuilder = httpClient.createPost()
.url(url)
.addHeader("Content-Type", "application/json")
.body(JsonFormat.printer().print((MessageOrBuilder) payloadAndHeaders.getPayload()));
.body(body);

if (payloadAndHeaders.getHeaders() != null) {
for (Map.Entry<String, String> entry : payloadAndHeaders.getHeaders().entrySet()) {
Expand Down
5 changes: 5 additions & 0 deletions compat-0.3/spec-grpc/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
<groupId>com.google.api.grpc</groupId>
<artifactId>proto-google-common-protos</artifactId>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<scope>provided</scope>
</dependency>

<!-- Annotation dependency for generated code -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.a2aproject.sdk.compat03.grpc.utils;

import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.MessageOrBuilder;
import com.google.protobuf.util.JsonFormat;
import org.a2aproject.sdk.util.HtmlEscapeUtils;
import org.jspecify.annotations.Nullable;

/**
* Protobuf-to-JSON serialization without HTML escaping.
* <p>
* {@link JsonFormat#printer()} delegates to Gson's {@link com.google.gson.stream.JsonWriter}
* which HTML-escapes {@code <}, {@code >}, and {@code &} by default. This utility
* removes those escape sequences via {@link HtmlEscapeUtils#removeHtmlEscaping(String)}.
* <p>
* Intentionally duplicated from {@code org.a2aproject.sdk.grpc.utils.ProtoJsonUtils}
* to maintain v0.3 module isolation (compat-0.3/spec-grpc cannot depend on spec-grpc).
*/
public final class ProtoJsonUtils_v0_3 {

private ProtoJsonUtils_v0_3() {
}

/**
* Serializes a protobuf message to JSON using the supplied printer,
* then removes HTML escaping.
*
* @param printer the configured {@link JsonFormat.Printer} (callers choose options
* such as {@code alwaysPrintFieldsWithNoPresence()} or
* {@code omittingInsignificantWhitespace()})
* @param proto the protobuf message to serialize, or {@code null}
* @return JSON string without HTML-escaped characters, or empty string if proto is null
*/
public static String toJson(JsonFormat.Printer printer, @Nullable MessageOrBuilder proto) throws InvalidProtocolBufferException {
if (proto == null) {
return "";
}
return HtmlEscapeUtils.removeHtmlEscaping(printer.print(proto));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package org.a2aproject.sdk.compat03.grpc.utils;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.Collections;

import com.google.protobuf.util.JsonFormat;

import org.a2aproject.sdk.compat03.spec.Message_v0_3;
import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3;
import org.a2aproject.sdk.compat03.spec.TextPart_v0_3;
import org.junit.jupiter.api.Test;

public class ProtoJsonUtils_v0_3_Test {

@Test
public void toJson_doesNotHtmlEscapeAngleBrackets() throws Exception {
MessageSendParams_v0_3 params = new MessageSendParams_v0_3(
new Message_v0_3.Builder()
.role(Message_v0_3.Role.USER)
.parts(Collections.singletonList(new TextPart_v0_3("<event-topic>")))
.contextId("context-1234")
.messageId("message-1234")
.build(),
null, null
);
var proto = ProtoUtils_v0_3.ToProto.sendMessageRequest(params);

String json = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), proto);

assertTrue(json.contains("<event-topic>"),
"JSON should preserve literal '<event-topic>' but got: " + json);
assertFalse(json.contains("\\u003c"),
"JSON must not contain HTML-escaped '<' (\\u003c) but got: " + json);
assertFalse(json.contains("\\u003e"),
"JSON must not contain HTML-escaped '>' (\\u003e) but got: " + json);
}

@Test
public void toJson_doesNotHtmlEscapeAmpersand() throws Exception {
MessageSendParams_v0_3 params = new MessageSendParams_v0_3(
new Message_v0_3.Builder()
.role(Message_v0_3.Role.USER)
.parts(Collections.singletonList(new TextPart_v0_3("foo&bar")))
.contextId("context-1234")
.messageId("message-1234")
.build(),
null, null
);
var proto = ProtoUtils_v0_3.ToProto.sendMessageRequest(params);

String json = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), proto);

assertTrue(json.contains("foo&bar"),
"JSON should preserve literal '&' but got: " + json);
assertFalse(json.contains("\\u0026"),
"JSON must not contain HTML-escaped '&' (\\u0026) but got: " + json);
}

@Test
public void toJson_respectsAlwaysPrintFieldsWithNoPresence() throws Exception {
MessageSendParams_v0_3 params = new MessageSendParams_v0_3(
new Message_v0_3.Builder()
.role(Message_v0_3.Role.USER)
.parts(Collections.singletonList(new TextPart_v0_3("hello")))
.contextId("context-1234")
.messageId("message-1234")
.build(),
null, null
);
var proto = ProtoUtils_v0_3.ToProto.sendMessageRequest(params);

String withDefaults = ProtoJsonUtils_v0_3.toJson(
JsonFormat.printer().alwaysPrintFieldsWithNoPresence(), proto);
String withoutDefaults = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), proto);

assertTrue(withDefaults.length() > withoutDefaults.length(),
"alwaysPrintFieldsWithNoPresence should produce more fields, got:\n with: " + withDefaults + "\n without: " + withoutDefaults);
}

@Test
public void toJson_returnsEmptyStringForNull() throws Exception {
String result = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), null);

assertEquals("", result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.google.gson.JsonSyntaxException;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.util.JsonFormat;
import org.a2aproject.sdk.compat03.grpc.utils.ProtoJsonUtils_v0_3;
import org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_v0_3;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
Expand Down Expand Up @@ -267,7 +268,7 @@ private void validate(String json) {

private HTTPRestResponse createSuccessResponse(int statusCode, com.google.protobuf.Message.Builder builder) {
try {
String jsonBody = JsonFormat.printer().print(builder);
String jsonBody = ProtoJsonUtils_v0_3.toJson(JsonFormat.printer(), builder);
return new HTTPRestResponse(statusCode, "application/json", jsonBody);
} catch (InvalidProtocolBufferException e) {
return createErrorResponse(new InternalError_v0_3("Failed to serialize response: " + e.getMessage()));
Expand Down Expand Up @@ -307,7 +308,8 @@ public void onSubscribe(Flow.Subscription subscription) {
@Override
public void onNext(StreamingEventKind_v0_3 item) {
try {
String payload = JsonFormat.printer().omittingInsignificantWhitespace().print(ProtoUtils_v0_3.ToProto.taskOrMessageStream(item));
String payload = ProtoJsonUtils_v0_3.toJson(
JsonFormat.printer().omittingInsignificantWhitespace(), ProtoUtils_v0_3.ToProto.taskOrMessageStream(item));
tube.send(payload);
if (subscription != null) {
subscription.request(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,8 @@ public static String toJsonRPCRequest(@Nullable String requestId, String method,
output.name("method").value(method);
}
if (payload != null) {
String resultValue = JsonFormat.printer().alwaysPrintFieldsWithNoPresence().omittingInsignificantWhitespace().print(payload);
String resultValue = ProtoJsonUtils.toJson(
JsonFormat.printer().alwaysPrintFieldsWithNoPresence().omittingInsignificantWhitespace(), payload);
output.name("params").jsonValue(resultValue);
}
output.endObject();
Expand All @@ -593,7 +594,8 @@ public static String toJsonRPCResultResponse(@Nullable Object requestId, com.goo
output.beginObject();
output.name("jsonrpc").value("2.0");
JsonUtil.writeJsonRpcId(output, requestId);
String resultValue = JsonFormat.printer().alwaysPrintFieldsWithNoPresence().omittingInsignificantWhitespace().print(builder);
String resultValue = ProtoJsonUtils.toJson(
JsonFormat.printer().alwaysPrintFieldsWithNoPresence().omittingInsignificantWhitespace(), builder);
output.name("result").jsonValue(resultValue);
output.endObject();
return result.toString();
Expand Down
Loading
Loading