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
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import com.google.protobuf.NullValue;
import com.google.protobuf.Struct;
import com.google.protobuf.Timestamp;
import com.google.protobuf.Value;
Expand Down Expand Up @@ -166,8 +168,9 @@ default Map<String, Object> structToMap(Struct struct) {
if (struct == null || struct.getFieldsCount() == 0) {
return null;
}
return struct.getFieldsMap().entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> valueToObject(e.getValue())));
Map<String, Object> map = new HashMap<>();
struct.getFieldsMap().forEach((key, value) -> map.put(key, valueToObject(value)));
return map;
}

/**
Expand All @@ -182,7 +185,9 @@ default Map<String, Object> structToMap(Struct struct) {
@SuppressWarnings("unchecked")
default Value objectToValue(Object value) {
Value.Builder valueBuilder = Value.newBuilder();
if (value instanceof String) {
if (value == null) {
valueBuilder.setNullValue(NullValue.NULL_VALUE);
} else if (value instanceof String) {
valueBuilder.setStringValue((String) value);
} else if (value instanceof Number) {
valueBuilder.setNumberValue(((Number) value).doubleValue());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package org.a2aproject.sdk.grpc.mapper;

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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.Arrays;
import java.util.List;
import java.util.Map;

import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.Struct;
import com.google.protobuf.Value;
import com.google.protobuf.util.JsonFormat;

import org.a2aproject.sdk.grpc.utils.ProtoUtils;
Expand All @@ -15,6 +22,95 @@

public class A2ACommonFieldMapperTest {

@Test
void testStructToMap_WithExplicitNull_PreservesKey() throws InvalidProtocolBufferException {
Struct.Builder builder = Struct.newBuilder();
JsonFormat.parser().merge("{\"attributeId\":null,\"source\":\"ID\"}", builder);

Map<String, Object> result = A2ACommonFieldMapper.INSTANCE.structToMap(builder.build());

assertNotNull(result);
assertEquals(2, result.size());
assertTrue(result.containsKey("attributeId"));
assertNull(result.get("attributeId"));
assertEquals("ID", result.get("source"));
}

@Test
void testObjectToValue_WithExplicitNull_SetsNullKind() {
Value result = A2ACommonFieldMapper.INSTANCE.objectToValue(null);

assertEquals(Value.KindCase.NULL_VALUE, result.getKindCase());
}

@Test
void testStructToMap_WithNestedNulls_RoundTrips() throws InvalidProtocolBufferException {
String json = """
{
"nested": {"optional": null},
"items": [null, {"optional": null}, [], {}],
"emptyObject": {},
"emptyList": [],
"text": "",
"number": 0,
"flag": false
}
""";
Struct.Builder builder = Struct.newBuilder();
JsonFormat.parser().merge(json, builder);
Struct original = builder.build();

Map<String, Object> result = A2ACommonFieldMapper.INSTANCE.structToMap(original);

Map<?, ?> nested = assertInstanceOf(Map.class, result.get("nested"));
assertTrue(nested.containsKey("optional"));
assertNull(nested.get("optional"));
List<?> items = assertInstanceOf(List.class, result.get("items"));
assertEquals(4, items.size());
assertNull(items.get(0));
Map<?, ?> item = assertInstanceOf(Map.class, items.get(1));
assertTrue(item.containsKey("optional"));
assertNull(item.get("optional"));
assertEquals(List.of(), items.get(2));
assertEquals(Map.of(), items.get(3));
assertEquals(Map.of(), result.get("emptyObject"));
assertEquals(List.of(), result.get("emptyList"));
assertEquals("", result.get("text"));
assertEquals(0.0, result.get("number"));
assertEquals(false, result.get("flag"));
assertFalse(result.containsKey("absent"));
assertEquals(original, A2ACommonFieldMapper.INSTANCE.mapToStruct(result));
assertEquals(result, A2ACommonFieldMapper.INSTANCE.metadataFromProto(original));
assertEquals(original, A2ACommonFieldMapper.INSTANCE.metadataToProto(result));
}

@Test
void testValueToObject_WithNullListElement_RoundTrips() throws InvalidProtocolBufferException {
Value.Builder builder = Value.newBuilder();
JsonFormat.parser().merge("[null,\"value\",{},[]]", builder);
Value original = builder.build();

List<?> result = assertInstanceOf(List.class, A2ACommonFieldMapper.INSTANCE.valueToObject(original));

assertEquals(Arrays.asList(null, "value", Map.of(), List.of()), result);
assertEquals(original, A2ACommonFieldMapper.INSTANCE.objectToValue(result));
}

@Test
void testStructConversions_WithAbsentOrEmptyValues_PreserveDefaults() {
A2ACommonFieldMapper mapper = A2ACommonFieldMapper.INSTANCE;
Struct empty = Struct.getDefaultInstance();

assertNull(mapper.structToMap(null));
assertNull(mapper.structToMap(empty));
assertEquals(Map.of(), mapper.metadataFromProto(null));
assertEquals(Map.of(), mapper.metadataFromProto(empty));
assertEquals(empty, mapper.mapToStruct(null));
assertEquals(empty, mapper.mapToStruct(Map.of()));
assertEquals(empty, mapper.metadataToProto(null));
assertEquals(empty, mapper.metadataToProto(Map.of()));
}

/**
* Test that valueToObject handles empty struct correctly without throwing NullPointerException.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
package org.a2aproject.sdk.grpc.utils;

import static org.a2aproject.sdk.grpc.utils.JSONRPCUtils.ERROR_MESSAGE;
import static org.a2aproject.sdk.spec.A2AMethods.GET_TASK_METHOD;
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.assertNull;
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 java.util.Map;

import com.google.gson.JsonArray;
import com.google.gson.JsonParser;
Expand All @@ -28,11 +31,15 @@
import org.a2aproject.sdk.jsonrpc.common.wrappers.GetExtendedAgentCardRequest;
import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskPushNotificationConfigRequest;
import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskPushNotificationConfigResponse;
import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskResponse;
import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageRequest;
import org.a2aproject.sdk.spec.DataPart;
import org.a2aproject.sdk.spec.GetExtendedAgentCardParams;
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.Task;
import org.a2aproject.sdk.spec.TaskNotFoundError;
import org.a2aproject.sdk.spec.TaskPushNotificationConfig;
import org.a2aproject.sdk.spec.TextPart;
Expand All @@ -41,6 +48,92 @@

public class JSONRPCUtilsTest {

@Test
public void testSendMessage_WithExplicitNulls_RoundTrips() throws JsonProcessingException {
String json = """
{
"jsonrpc": "2.0",
"id": "null-message",
"method": "SendMessage",
"params": {
"message": {
"messageId": "message-null",
"role": "ROLE_USER",
"parts": [{
"data": {"attributeId": null, "source": "ID"},
"metadata": {"partOptional": null}
}],
"metadata": {"messageOptional": null}
},
"metadata": {"requestOptional": null}
}
}
""";

SendMessageRequest request = assertInstanceOf(SendMessageRequest.class, JSONRPCUtils.parseRequestBody(json, null));
MessageSendParams params = request.getParams();
DataPart part = assertInstanceOf(DataPart.class, params.message().parts().get(0));
Map<?, ?> data = assertInstanceOf(Map.class, part.data());
assertTrue(data.containsKey("attributeId"));
assertNull(data.get("attributeId"));
assertEquals("ID", data.get("source"));
assertEquals(Collections.singletonMap("partOptional", null), part.metadata());
assertEquals(Collections.singletonMap("messageOptional", null), params.message().metadata());
assertEquals(Collections.singletonMap("requestOptional", null), params.metadata());

String serialized = JSONRPCUtils.toJsonRPCRequest(assertInstanceOf(String.class, request.getId()), SEND_MESSAGE_METHOD,
ProtoUtils.ToProto.sendMessageRequest(params));
SendMessageRequest roundTripped = assertInstanceOf(SendMessageRequest.class,
JSONRPCUtils.parseRequestBody(serialized, null));
assertEquals(params, roundTripped.getParams());
}

@Test
public void testGetTask_WithNestedNulls_RoundTrips() throws JsonProcessingException {
String json = """
{
"jsonrpc": "2.0",
"id": "null-task",
"result": {
"id": "task-null",
"contextId": "context-null",
"status": {
"state": "TASK_STATE_COMPLETED",
"timestamp": "2026-09-21T00:00:00Z"
},
"history": [{
"messageId": "message-null",
"role": "ROLE_AGENT",
"parts": [{
"data": {
"nested": {"optional": null},
"items": [null, {"optional": null}, {}, []]
},
"metadata": {"partOptional": null}
}],
"metadata": {"messageOptional": null}
}],
"metadata": {"taskOptional": null}
}
}
""";

GetTaskResponse response = assertInstanceOf(GetTaskResponse.class, JSONRPCUtils.parseResponseBody(json, GET_TASK_METHOD));
Task task = response.getResult();
assertNotNull(task);
assertEquals(Collections.singletonMap("taskOptional", null), task.metadata());
DataPart part = assertInstanceOf(DataPart.class, task.history().get(0).parts().get(0));
Map<?, ?> data = assertInstanceOf(Map.class, part.data());
assertEquals(Collections.singletonMap("optional", null), data.get("nested"));
assertEquals(Collections.singletonMap("partOptional", null), part.metadata());
assertEquals(Collections.singletonMap("messageOptional", null), task.history().get(0).metadata());

String serialized = JSONRPCUtils.toJsonRPCResultResponse(response.getId(), ProtoUtils.ToProto.task(task));
GetTaskResponse roundTripped = assertInstanceOf(GetTaskResponse.class,
JSONRPCUtils.parseResponseBody(serialized, GET_TASK_METHOD));
assertEquals(task, roundTripped.getResult());
}

@Test
public void testParseCreateTaskPushNotificationConfigRequest_ValidProtoFormat() throws JsonProcessingException {
String validRequest = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@
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 java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.google.protobuf.Struct;
import com.google.protobuf.Value;

import org.a2aproject.sdk.grpc.SendMessageConfiguration;
import org.a2aproject.sdk.spec.AgentCapabilities;
import org.a2aproject.sdk.spec.AgentCard;
Expand All @@ -20,6 +26,7 @@
import org.a2aproject.sdk.spec.SecurityRequirement;
import org.a2aproject.sdk.spec.Artifact;
import org.a2aproject.sdk.spec.AuthenticationInfo;
import org.a2aproject.sdk.spec.DataPart;
import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams;
import org.a2aproject.sdk.spec.HTTPAuthSecurityScheme;
import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams;
Expand Down Expand Up @@ -206,6 +213,72 @@ public void convertMessage() {
assertEquals(false, result.getParts(0).hasData());
}

@Test
public void convertMessageWithExplicitNulls() {
Message message = messageWithExplicitNulls();

org.a2aproject.sdk.grpc.Message result = ProtoUtils.ToProto.message(message);

assertEquals(Value.KindCase.NULL_VALUE,
result.getParts(0).getData().getStructValue().getFieldsOrThrow("attributeId").getKindCase());
assertEquals(Value.KindCase.NULL_VALUE,
result.getParts(0).getMetadata().getFieldsOrThrow("optional").getKindCase());
assertEquals(Value.KindCase.NULL_VALUE,
result.getMetadata().getFieldsOrThrow("optional").getKindCase());
assertEquals(message, ProtoUtils.FromProto.message(result));
}

@Test
public void convertMessageWithAbsentOrEmptyMetadata() {
org.a2aproject.sdk.grpc.Message.Builder builder = ProtoUtils.ToProto.message(SIMPLE_MESSAGE)
.toBuilder()
.clearMetadata();

assertNull(ProtoUtils.FromProto.message(builder).metadata());
builder.setMetadata(Struct.getDefaultInstance());
assertEquals(Map.of(), ProtoUtils.FromProto.message(builder).metadata());
}

@Test
public void convertTaskWithExplicitNulls() {
Message message = messageWithExplicitNulls();
Task task = Task.builder()
.id("task-null")
.contextId("context-null")
.status(new TaskStatus(TaskState.TASK_STATE_COMPLETED, message,
OffsetDateTime.parse("2026-09-21T00:00:00Z")))
.history(List.of(message))
.artifacts(List.of(Artifact.builder()
.artifactId("artifact-null")
.parts(message.parts())
.metadata(message.metadata())
.build()))
.metadata(message.metadata())
.build();

org.a2aproject.sdk.grpc.Task result = ProtoUtils.ToProto.task(task);

assertEquals(Value.KindCase.NULL_VALUE,
result.getMetadata().getFieldsOrThrow("optional").getKindCase());
assertEquals(Value.KindCase.NULL_VALUE,
result.getArtifacts(0).getMetadata().getFieldsOrThrow("optional").getKindCase());
assertEquals(task, ProtoUtils.FromProto.task(result));
}

private Message messageWithExplicitNulls() {
Map<String, Object> data = new HashMap<>();
data.put("attributeId", null);
data.put("items", Arrays.asList(null, Collections.singletonMap("optional", null), Map.of(), List.of()));
Map<String, Object> metadata = Collections.singletonMap("optional", null);
return Message.builder()
.messageId("message-null")
.contextId("context-null")
.role(Message.Role.ROLE_USER)
.parts(List.of(new DataPart(data, metadata)))
.metadata(metadata)
.build();
}

@Test
public void convertTaskPushNotificationConfig() {
TaskPushNotificationConfig taskPushConfig = TaskPushNotificationConfig.builder()
Expand Down
Loading