Skip to content

Commit ec5994a

Browse files
authored
fix(grpc): preserve explicit JSON nulls in value mappings (#1174)
Fixes #1159 Fixes #1160
1 parent 10d1bf6 commit ec5994a

4 files changed

Lines changed: 270 additions & 3 deletions

File tree

‎spec-grpc/src/main/java/org/a2aproject/sdk/grpc/mapper/A2ACommonFieldMapper.java‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import java.time.OffsetDateTime;
55
import java.time.ZoneOffset;
66
import java.util.Collections;
7+
import java.util.HashMap;
78
import java.util.List;
89
import java.util.Map;
910
import java.util.stream.Collectors;
1011

12+
import com.google.protobuf.NullValue;
1113
import com.google.protobuf.Struct;
1214
import com.google.protobuf.Timestamp;
1315
import com.google.protobuf.Value;
@@ -166,8 +168,9 @@ default Map<String, Object> structToMap(Struct struct) {
166168
if (struct == null || struct.getFieldsCount() == 0) {
167169
return null;
168170
}
169-
return struct.getFieldsMap().entrySet().stream()
170-
.collect(Collectors.toMap(Map.Entry::getKey, e -> valueToObject(e.getValue())));
171+
Map<String, Object> map = new HashMap<>();
172+
struct.getFieldsMap().forEach((key, value) -> map.put(key, valueToObject(value)));
173+
return map;
171174
}
172175

173176
/**
@@ -182,7 +185,9 @@ default Map<String, Object> structToMap(Struct struct) {
182185
@SuppressWarnings("unchecked")
183186
default Value objectToValue(Object value) {
184187
Value.Builder valueBuilder = Value.newBuilder();
185-
if (value instanceof String) {
188+
if (value == null) {
189+
valueBuilder.setNullValue(NullValue.NULL_VALUE);
190+
} else if (value instanceof String) {
186191
valueBuilder.setStringValue((String) value);
187192
} else if (value instanceof Number) {
188193
valueBuilder.setNumberValue(((Number) value).doubleValue());

‎spec-grpc/src/test/java/org/a2aproject/sdk/grpc/mapper/A2ACommonFieldMapperTest.java‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
package org.a2aproject.sdk.grpc.mapper;
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
45
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
56
import static org.junit.jupiter.api.Assertions.assertNotNull;
7+
import static org.junit.jupiter.api.Assertions.assertNull;
8+
import static org.junit.jupiter.api.Assertions.assertTrue;
69

10+
import java.util.Arrays;
11+
import java.util.List;
712
import java.util.Map;
813

914
import com.google.protobuf.InvalidProtocolBufferException;
15+
import com.google.protobuf.Struct;
16+
import com.google.protobuf.Value;
1017
import com.google.protobuf.util.JsonFormat;
1118

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

1623
public class A2ACommonFieldMapperTest {
1724

25+
@Test
26+
void testStructToMap_WithExplicitNull_PreservesKey() throws InvalidProtocolBufferException {
27+
Struct.Builder builder = Struct.newBuilder();
28+
JsonFormat.parser().merge("{\"attributeId\":null,\"source\":\"ID\"}", builder);
29+
30+
Map<String, Object> result = A2ACommonFieldMapper.INSTANCE.structToMap(builder.build());
31+
32+
assertNotNull(result);
33+
assertEquals(2, result.size());
34+
assertTrue(result.containsKey("attributeId"));
35+
assertNull(result.get("attributeId"));
36+
assertEquals("ID", result.get("source"));
37+
}
38+
39+
@Test
40+
void testObjectToValue_WithExplicitNull_SetsNullKind() {
41+
Value result = A2ACommonFieldMapper.INSTANCE.objectToValue(null);
42+
43+
assertEquals(Value.KindCase.NULL_VALUE, result.getKindCase());
44+
}
45+
46+
@Test
47+
void testStructToMap_WithNestedNulls_RoundTrips() throws InvalidProtocolBufferException {
48+
String json = """
49+
{
50+
"nested": {"optional": null},
51+
"items": [null, {"optional": null}, [], {}],
52+
"emptyObject": {},
53+
"emptyList": [],
54+
"text": "",
55+
"number": 0,
56+
"flag": false
57+
}
58+
""";
59+
Struct.Builder builder = Struct.newBuilder();
60+
JsonFormat.parser().merge(json, builder);
61+
Struct original = builder.build();
62+
63+
Map<String, Object> result = A2ACommonFieldMapper.INSTANCE.structToMap(original);
64+
65+
Map<?, ?> nested = assertInstanceOf(Map.class, result.get("nested"));
66+
assertTrue(nested.containsKey("optional"));
67+
assertNull(nested.get("optional"));
68+
List<?> items = assertInstanceOf(List.class, result.get("items"));
69+
assertEquals(4, items.size());
70+
assertNull(items.get(0));
71+
Map<?, ?> item = assertInstanceOf(Map.class, items.get(1));
72+
assertTrue(item.containsKey("optional"));
73+
assertNull(item.get("optional"));
74+
assertEquals(List.of(), items.get(2));
75+
assertEquals(Map.of(), items.get(3));
76+
assertEquals(Map.of(), result.get("emptyObject"));
77+
assertEquals(List.of(), result.get("emptyList"));
78+
assertEquals("", result.get("text"));
79+
assertEquals(0.0, result.get("number"));
80+
assertEquals(false, result.get("flag"));
81+
assertFalse(result.containsKey("absent"));
82+
assertEquals(original, A2ACommonFieldMapper.INSTANCE.mapToStruct(result));
83+
assertEquals(result, A2ACommonFieldMapper.INSTANCE.metadataFromProto(original));
84+
assertEquals(original, A2ACommonFieldMapper.INSTANCE.metadataToProto(result));
85+
}
86+
87+
@Test
88+
void testValueToObject_WithNullListElement_RoundTrips() throws InvalidProtocolBufferException {
89+
Value.Builder builder = Value.newBuilder();
90+
JsonFormat.parser().merge("[null,\"value\",{},[]]", builder);
91+
Value original = builder.build();
92+
93+
List<?> result = assertInstanceOf(List.class, A2ACommonFieldMapper.INSTANCE.valueToObject(original));
94+
95+
assertEquals(Arrays.asList(null, "value", Map.of(), List.of()), result);
96+
assertEquals(original, A2ACommonFieldMapper.INSTANCE.objectToValue(result));
97+
}
98+
99+
@Test
100+
void testStructConversions_WithAbsentOrEmptyValues_PreserveDefaults() {
101+
A2ACommonFieldMapper mapper = A2ACommonFieldMapper.INSTANCE;
102+
Struct empty = Struct.getDefaultInstance();
103+
104+
assertNull(mapper.structToMap(null));
105+
assertNull(mapper.structToMap(empty));
106+
assertEquals(Map.of(), mapper.metadataFromProto(null));
107+
assertEquals(Map.of(), mapper.metadataFromProto(empty));
108+
assertEquals(empty, mapper.mapToStruct(null));
109+
assertEquals(empty, mapper.mapToStruct(Map.of()));
110+
assertEquals(empty, mapper.metadataToProto(null));
111+
assertEquals(empty, mapper.metadataToProto(Map.of()));
112+
}
113+
18114
/**
19115
* Test that valueToObject handles empty struct correctly without throwing NullPointerException.
20116
*

‎spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
package org.a2aproject.sdk.grpc.utils;
22

33
import static org.a2aproject.sdk.grpc.utils.JSONRPCUtils.ERROR_MESSAGE;
4+
import static org.a2aproject.sdk.spec.A2AMethods.GET_TASK_METHOD;
45
import static org.a2aproject.sdk.spec.A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD;
56
import static org.a2aproject.sdk.spec.A2AMethods.SEND_MESSAGE_METHOD;
67
import static org.a2aproject.sdk.spec.A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD;
78
import static org.junit.jupiter.api.Assertions.assertEquals;
89
import static org.junit.jupiter.api.Assertions.assertFalse;
910
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
1011
import static org.junit.jupiter.api.Assertions.assertNotNull;
12+
import static org.junit.jupiter.api.Assertions.assertNull;
1113
import static org.junit.jupiter.api.Assertions.assertThrows;
1214
import static org.junit.jupiter.api.Assertions.assertTrue;
1315
import static org.junit.jupiter.api.Assertions.fail;
1416

1517
import java.util.Collections;
18+
import java.util.Map;
1619

1720
import com.google.gson.JsonArray;
1821
import com.google.gson.JsonParser;
@@ -28,11 +31,15 @@
2831
import org.a2aproject.sdk.jsonrpc.common.wrappers.GetExtendedAgentCardRequest;
2932
import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskPushNotificationConfigRequest;
3033
import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskPushNotificationConfigResponse;
34+
import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskResponse;
35+
import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageRequest;
36+
import org.a2aproject.sdk.spec.DataPart;
3137
import org.a2aproject.sdk.spec.GetExtendedAgentCardParams;
3238
import org.a2aproject.sdk.spec.InvalidParamsError;
3339
import org.a2aproject.sdk.spec.JSONParseError;
3440
import org.a2aproject.sdk.spec.Message;
3541
import org.a2aproject.sdk.spec.MessageSendParams;
42+
import org.a2aproject.sdk.spec.Task;
3643
import org.a2aproject.sdk.spec.TaskNotFoundError;
3744
import org.a2aproject.sdk.spec.TaskPushNotificationConfig;
3845
import org.a2aproject.sdk.spec.TextPart;
@@ -41,6 +48,92 @@
4148

4249
public class JSONRPCUtilsTest {
4350

51+
@Test
52+
public void testSendMessage_WithExplicitNulls_RoundTrips() throws JsonProcessingException {
53+
String json = """
54+
{
55+
"jsonrpc": "2.0",
56+
"id": "null-message",
57+
"method": "SendMessage",
58+
"params": {
59+
"message": {
60+
"messageId": "message-null",
61+
"role": "ROLE_USER",
62+
"parts": [{
63+
"data": {"attributeId": null, "source": "ID"},
64+
"metadata": {"partOptional": null}
65+
}],
66+
"metadata": {"messageOptional": null}
67+
},
68+
"metadata": {"requestOptional": null}
69+
}
70+
}
71+
""";
72+
73+
SendMessageRequest request = assertInstanceOf(SendMessageRequest.class, JSONRPCUtils.parseRequestBody(json, null));
74+
MessageSendParams params = request.getParams();
75+
DataPart part = assertInstanceOf(DataPart.class, params.message().parts().get(0));
76+
Map<?, ?> data = assertInstanceOf(Map.class, part.data());
77+
assertTrue(data.containsKey("attributeId"));
78+
assertNull(data.get("attributeId"));
79+
assertEquals("ID", data.get("source"));
80+
assertEquals(Collections.singletonMap("partOptional", null), part.metadata());
81+
assertEquals(Collections.singletonMap("messageOptional", null), params.message().metadata());
82+
assertEquals(Collections.singletonMap("requestOptional", null), params.metadata());
83+
84+
String serialized = JSONRPCUtils.toJsonRPCRequest(assertInstanceOf(String.class, request.getId()), SEND_MESSAGE_METHOD,
85+
ProtoUtils.ToProto.sendMessageRequest(params));
86+
SendMessageRequest roundTripped = assertInstanceOf(SendMessageRequest.class,
87+
JSONRPCUtils.parseRequestBody(serialized, null));
88+
assertEquals(params, roundTripped.getParams());
89+
}
90+
91+
@Test
92+
public void testGetTask_WithNestedNulls_RoundTrips() throws JsonProcessingException {
93+
String json = """
94+
{
95+
"jsonrpc": "2.0",
96+
"id": "null-task",
97+
"result": {
98+
"id": "task-null",
99+
"contextId": "context-null",
100+
"status": {
101+
"state": "TASK_STATE_COMPLETED",
102+
"timestamp": "2026-09-21T00:00:00Z"
103+
},
104+
"history": [{
105+
"messageId": "message-null",
106+
"role": "ROLE_AGENT",
107+
"parts": [{
108+
"data": {
109+
"nested": {"optional": null},
110+
"items": [null, {"optional": null}, {}, []]
111+
},
112+
"metadata": {"partOptional": null}
113+
}],
114+
"metadata": {"messageOptional": null}
115+
}],
116+
"metadata": {"taskOptional": null}
117+
}
118+
}
119+
""";
120+
121+
GetTaskResponse response = assertInstanceOf(GetTaskResponse.class, JSONRPCUtils.parseResponseBody(json, GET_TASK_METHOD));
122+
Task task = response.getResult();
123+
assertNotNull(task);
124+
assertEquals(Collections.singletonMap("taskOptional", null), task.metadata());
125+
DataPart part = assertInstanceOf(DataPart.class, task.history().get(0).parts().get(0));
126+
Map<?, ?> data = assertInstanceOf(Map.class, part.data());
127+
assertEquals(Collections.singletonMap("optional", null), data.get("nested"));
128+
assertEquals(Collections.singletonMap("partOptional", null), part.metadata());
129+
assertEquals(Collections.singletonMap("messageOptional", null), task.history().get(0).metadata());
130+
131+
String serialized = JSONRPCUtils.toJsonRPCResultResponse(response.getId(), ProtoUtils.ToProto.task(task));
132+
GetTaskResponse roundTripped = assertInstanceOf(GetTaskResponse.class,
133+
JSONRPCUtils.parseResponseBody(serialized, GET_TASK_METHOD));
134+
assertEquals(task, roundTripped.getResult());
135+
}
136+
44137
@Test
45138
public void testParseCreateTaskPushNotificationConfigRequest_ValidProtoFormat() throws JsonProcessingException {
46139
String validRequest = """

‎spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/ToProtoTest.java‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@
66
import static org.junit.jupiter.api.Assertions.assertEquals;
77
import static org.junit.jupiter.api.Assertions.assertFalse;
88
import static org.junit.jupiter.api.Assertions.assertNotNull;
9+
import static org.junit.jupiter.api.Assertions.assertNull;
910

1011
import java.time.OffsetDateTime;
12+
import java.util.Arrays;
1113
import java.util.Collections;
14+
import java.util.HashMap;
1215
import java.util.List;
1316
import java.util.Map;
1417

18+
import com.google.protobuf.Struct;
19+
import com.google.protobuf.Value;
20+
1521
import org.a2aproject.sdk.grpc.SendMessageConfiguration;
1622
import org.a2aproject.sdk.spec.AgentCapabilities;
1723
import org.a2aproject.sdk.spec.AgentCard;
@@ -20,6 +26,7 @@
2026
import org.a2aproject.sdk.spec.SecurityRequirement;
2127
import org.a2aproject.sdk.spec.Artifact;
2228
import org.a2aproject.sdk.spec.AuthenticationInfo;
29+
import org.a2aproject.sdk.spec.DataPart;
2330
import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams;
2431
import org.a2aproject.sdk.spec.HTTPAuthSecurityScheme;
2532
import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams;
@@ -206,6 +213,72 @@ public void convertMessage() {
206213
assertEquals(false, result.getParts(0).hasData());
207214
}
208215

216+
@Test
217+
public void convertMessageWithExplicitNulls() {
218+
Message message = messageWithExplicitNulls();
219+
220+
org.a2aproject.sdk.grpc.Message result = ProtoUtils.ToProto.message(message);
221+
222+
assertEquals(Value.KindCase.NULL_VALUE,
223+
result.getParts(0).getData().getStructValue().getFieldsOrThrow("attributeId").getKindCase());
224+
assertEquals(Value.KindCase.NULL_VALUE,
225+
result.getParts(0).getMetadata().getFieldsOrThrow("optional").getKindCase());
226+
assertEquals(Value.KindCase.NULL_VALUE,
227+
result.getMetadata().getFieldsOrThrow("optional").getKindCase());
228+
assertEquals(message, ProtoUtils.FromProto.message(result));
229+
}
230+
231+
@Test
232+
public void convertMessageWithAbsentOrEmptyMetadata() {
233+
org.a2aproject.sdk.grpc.Message.Builder builder = ProtoUtils.ToProto.message(SIMPLE_MESSAGE)
234+
.toBuilder()
235+
.clearMetadata();
236+
237+
assertNull(ProtoUtils.FromProto.message(builder).metadata());
238+
builder.setMetadata(Struct.getDefaultInstance());
239+
assertEquals(Map.of(), ProtoUtils.FromProto.message(builder).metadata());
240+
}
241+
242+
@Test
243+
public void convertTaskWithExplicitNulls() {
244+
Message message = messageWithExplicitNulls();
245+
Task task = Task.builder()
246+
.id("task-null")
247+
.contextId("context-null")
248+
.status(new TaskStatus(TaskState.TASK_STATE_COMPLETED, message,
249+
OffsetDateTime.parse("2026-09-21T00:00:00Z")))
250+
.history(List.of(message))
251+
.artifacts(List.of(Artifact.builder()
252+
.artifactId("artifact-null")
253+
.parts(message.parts())
254+
.metadata(message.metadata())
255+
.build()))
256+
.metadata(message.metadata())
257+
.build();
258+
259+
org.a2aproject.sdk.grpc.Task result = ProtoUtils.ToProto.task(task);
260+
261+
assertEquals(Value.KindCase.NULL_VALUE,
262+
result.getMetadata().getFieldsOrThrow("optional").getKindCase());
263+
assertEquals(Value.KindCase.NULL_VALUE,
264+
result.getArtifacts(0).getMetadata().getFieldsOrThrow("optional").getKindCase());
265+
assertEquals(task, ProtoUtils.FromProto.task(result));
266+
}
267+
268+
private Message messageWithExplicitNulls() {
269+
Map<String, Object> data = new HashMap<>();
270+
data.put("attributeId", null);
271+
data.put("items", Arrays.asList(null, Collections.singletonMap("optional", null), Map.of(), List.of()));
272+
Map<String, Object> metadata = Collections.singletonMap("optional", null);
273+
return Message.builder()
274+
.messageId("message-null")
275+
.contextId("context-null")
276+
.role(Message.Role.ROLE_USER)
277+
.parts(List.of(new DataPart(data, metadata)))
278+
.metadata(metadata)
279+
.build();
280+
}
281+
209282
@Test
210283
public void convertTaskPushNotificationConfig() {
211284
TaskPushNotificationConfig taskPushConfig = TaskPushNotificationConfig.builder()

0 commit comments

Comments
 (0)