diff --git a/client/transport/rest/src/main/java/org/a2aproject/sdk/client/transport/rest/RestTransport.java b/client/transport/rest/src/main/java/org/a2aproject/sdk/client/transport/rest/RestTransport.java index b716fc934..1f8f5c3e3 100644 --- a/client/transport/rest/src/main/java/org/a2aproject/sdk/client/transport/rest/RestTransport.java +++ b/client/transport/rest/src/main/java/org/a2aproject/sdk/client/transport/rest/RestTransport.java @@ -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; @@ -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; @@ -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); } 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 entry : payloadAndHeaders.getHeaders().entrySet()) { diff --git a/common/pom.xml b/common/pom.xml index fb3d3a01f..04104dd01 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -21,6 +21,11 @@ org.slf4j slf4j-api + + org.junit.jupiter + junit-jupiter-api + test + \ No newline at end of file diff --git a/common/src/main/java/org/a2aproject/sdk/util/HtmlEscapeUtils.java b/common/src/main/java/org/a2aproject/sdk/util/HtmlEscapeUtils.java new file mode 100644 index 000000000..a36e3058f --- /dev/null +++ b/common/src/main/java/org/a2aproject/sdk/util/HtmlEscapeUtils.java @@ -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 '}. + *

+ * 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", "'"); + } +} diff --git a/common/src/test/java/org/a2aproject/sdk/util/HtmlEscapeUtilsTest.java b/common/src/test/java/org/a2aproject/sdk/util/HtmlEscapeUtilsTest.java new file mode 100644 index 000000000..ff7191e28 --- /dev/null +++ b/common/src/test/java/org/a2aproject/sdk/util/HtmlEscapeUtilsTest.java @@ -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("", 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("&", + 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("")); + } +} diff --git a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java index 6ff2d560d..e4621ba7c 100644 --- a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java +++ b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java @@ -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; @@ -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; @@ -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); } 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 entry : payloadAndHeaders.getHeaders().entrySet()) { diff --git a/compat-0.3/spec-grpc/pom.xml b/compat-0.3/spec-grpc/pom.xml index b591fe3bb..ead70420f 100644 --- a/compat-0.3/spec-grpc/pom.xml +++ b/compat-0.3/spec-grpc/pom.xml @@ -48,6 +48,11 @@ com.google.api.grpc proto-google-common-protos + + com.google.protobuf + protobuf-java-util + provided + diff --git a/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonUtils_v0_3.java b/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonUtils_v0_3.java new file mode 100644 index 000000000..697c22c45 --- /dev/null +++ b/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonUtils_v0_3.java @@ -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. + *

+ * {@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)}. + *

+ * 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)); + } +} diff --git a/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonUtils_v0_3_Test.java b/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonUtils_v0_3_Test.java new file mode 100644 index 000000000..7766c57a2 --- /dev/null +++ b/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonUtils_v0_3_Test.java @@ -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(""))) + .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(""), + "JSON should preserve literal '' 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); + } +} diff --git a/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java b/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java index 8b08c6952..174ba355e 100644 --- a/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java +++ b/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java @@ -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; @@ -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())); @@ -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); diff --git a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtils.java b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtils.java index 2abbe0190..4e0456516 100644 --- a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtils.java +++ b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtils.java @@ -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(); @@ -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(); diff --git a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoJsonUtils.java b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoJsonUtils.java new file mode 100644 index 000000000..42809dda3 --- /dev/null +++ b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoJsonUtils.java @@ -0,0 +1,37 @@ +package org.a2aproject.sdk.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. + *

+ * {@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)}. + */ +public final class ProtoJsonUtils { + + private ProtoJsonUtils() { + } + + /** + * 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)); + } +} diff --git a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java index 4193fa725..67ffc203c 100644 --- a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java +++ b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java @@ -2,18 +2,23 @@ import static org.a2aproject.sdk.grpc.utils.JSONRPCUtils.ERROR_MESSAGE; import static org.a2aproject.sdk.spec.A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.SEND_MESSAGE_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import java.util.Collections; + import com.google.gson.JsonArray; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; +import org.a2aproject.sdk.grpc.SendMessageResponse; import org.a2aproject.sdk.jsonrpc.common.json.InvalidParamsJsonMappingException; import org.a2aproject.sdk.jsonrpc.common.json.JsonMappingException; import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; @@ -24,8 +29,11 @@ import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskPushNotificationConfigResponse; import org.a2aproject.sdk.spec.InvalidParamsError; import org.a2aproject.sdk.spec.JSONParseError; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; import org.a2aproject.sdk.spec.TaskNotFoundError; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TextPart; import org.a2aproject.sdk.util.ErrorDetail; import org.junit.jupiter.api.Test; @@ -485,4 +493,51 @@ public void testToJsonRPCErrorResponse_RoundTrip() throws Exception { assertEquals("Custom message", response.getError().getMessage()); } + @Test + public void testToJsonRPCRequest_doesNotHtmlEscapeAngleBrackets() { + // Reproduce issue #892: HTML special characters in message text were escaped + // to Unicode sequences (< for <, > for >) by JsonFormat.printer(). + MessageSendParams params = new MessageSendParams( + Message.builder() + .role(Message.Role.ROLE_USER) + .parts(Collections.singletonList(new TextPart(""))) + .contextId("context-1234") + .messageId("message-1234") + .build(), + null, null + ); + org.a2aproject.sdk.grpc.SendMessageRequest protoRequest = + ProtoUtils.ToProto.sendMessageRequest(params); + + String json = JSONRPCUtils.toJsonRPCRequest("test-id", SEND_MESSAGE_METHOD, protoRequest); + + assertTrue(json.contains(""), + "JSON should preserve literal '' 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 testToJsonRPCResultResponse_doesNotHtmlEscapeAngleBrackets() { + // Reproduce issue #892: server responses must also send literal angle brackets. + Message message = Message.builder() + .role(Message.Role.ROLE_AGENT) + .parts(Collections.singletonList(new TextPart(""))) + .contextId("context-1234") + .messageId("message-5678") + .build(); + SendMessageResponse protoResponse = ProtoUtils.ToProto.taskOrMessage(message); + + String json = JSONRPCUtils.toJsonRPCResultResponse("test-id", protoResponse); + + assertTrue(json.contains(""), + "JSON should preserve literal '' 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); + } + } diff --git a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/ProtoJsonUtilsTest.java b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/ProtoJsonUtilsTest.java new file mode 100644 index 000000000..961af246d --- /dev/null +++ b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/ProtoJsonUtilsTest.java @@ -0,0 +1,89 @@ +package org.a2aproject.sdk.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.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TextPart; +import org.junit.jupiter.api.Test; + +public class ProtoJsonUtilsTest { + + @Test + public void toJson_doesNotHtmlEscapeAngleBrackets() throws Exception { + MessageSendParams params = new MessageSendParams( + Message.builder() + .role(Message.Role.ROLE_USER) + .parts(Collections.singletonList(new TextPart(""))) + .contextId("context-1234") + .messageId("message-1234") + .build(), + null, null + ); + var proto = ProtoUtils.ToProto.sendMessageRequest(params); + + String json = ProtoJsonUtils.toJson(JsonFormat.printer(), proto); + + assertTrue(json.contains(""), + "JSON should preserve literal '' 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 params = new MessageSendParams( + Message.builder() + .role(Message.Role.ROLE_USER) + .parts(Collections.singletonList(new TextPart("foo&bar"))) + .contextId("context-1234") + .messageId("message-1234") + .build(), + null, null + ); + var proto = ProtoUtils.ToProto.sendMessageRequest(params); + + String json = ProtoJsonUtils.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 params = new MessageSendParams( + Message.builder() + .role(Message.Role.ROLE_USER) + .parts(Collections.singletonList(new TextPart("hello"))) + .contextId("context-1234") + .messageId("message-1234") + .build(), + null, null + ); + var proto = ProtoUtils.ToProto.sendMessageRequest(params); + + String withDefaults = ProtoJsonUtils.toJson( + JsonFormat.printer().alwaysPrintFieldsWithNoPresence(), proto); + String withoutDefaults = ProtoJsonUtils.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.toJson(JsonFormat.printer(), null); + + assertEquals("", result); + } +} diff --git a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java index 84442cc8d..a22fdce7c 100644 --- a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java @@ -25,6 +25,7 @@ import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.util.JsonFormat; import mutiny.zero.ZeroPublisher; +import org.a2aproject.sdk.grpc.utils.ProtoJsonUtils; import org.a2aproject.sdk.grpc.utils.ProtoUtils; import org.a2aproject.sdk.util.ErrorDetail; import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; @@ -666,7 +667,7 @@ private void validate(String json) { private HTTPRestResponse createSuccessResponse(int statusCode, com.google.protobuf.Message.Builder builder) { try { // Include default value fields to ensure empty arrays, zeros, etc. are present in JSON - String jsonBody = JsonFormat.printer().alwaysPrintFieldsWithNoPresence().print(builder); + String jsonBody = ProtoJsonUtils.toJson(JsonFormat.printer().alwaysPrintFieldsWithNoPresence(), builder); return new HTTPRestResponse(statusCode, APPLICATION_JSON, jsonBody); } catch (InvalidProtocolBufferException e) { return createErrorResponse(new InternalError("Failed to serialize response: " + e.getMessage())); @@ -717,7 +718,8 @@ public void onSubscribe(Flow.Subscription subscription) { public void onNext(StreamingEventKind item) { log.log(Level.FINE, "REST: onNext called with event: {0}", item.getClass().getSimpleName()); try { - String payload = JsonFormat.printer().omittingInsignificantWhitespace().print(ProtoUtils.ToProto.taskOrMessageStream(item)); + String payload = ProtoJsonUtils.toJson( + JsonFormat.printer().omittingInsignificantWhitespace(), ProtoUtils.ToProto.taskOrMessageStream(item)); log.log(Level.FINE, "REST: Converted to JSON, sending via tube: {0}", payload.substring(0, Math.min(100, payload.length()))); tube.send(payload); log.log(Level.FINE, "REST: tube.send() completed, requesting next event from EventConsumer");