Skip to content

Commit ebd0921

Browse files
authored
fix!: rewrite PartTypeAdapter to use flat JSON format for Part serialization (a2aproject#700)
Replace the nested "file" wrapper with flat fields (raw, url, filename, mediaType) aligned with the proto Part message schema and A2A spec. Fixes a2aproject#689 🦕 Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com>
1 parent 8c6b50f commit ebd0921

4 files changed

Lines changed: 359 additions & 41 deletions

File tree

jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import static io.a2a.spec.A2AErrorCodes.TASK_NOT_FOUND_ERROR_CODE;
1414
import static io.a2a.spec.A2AErrorCodes.UNSUPPORTED_OPERATION_ERROR_CODE;
1515
import static io.a2a.spec.DataPart.DATA;
16-
import static io.a2a.spec.FilePart.FILE;
1716
import static io.a2a.spec.TextPart.TEXT;
1817
import static java.lang.String.format;
1918
import static java.util.Collections.emptyMap;
@@ -521,33 +520,58 @@ public static Map<String, Object> readMetadata(@Nullable String json) throws Jso
521520
*/
522521
static class PartTypeAdapter extends TypeAdapter<Part<?>> {
523522

524-
private static final Set<String> VALID_KEYS = Set.of(TEXT, FILE, DATA);
523+
private static final String RAW = "raw";
524+
private static final String URL = "url";
525+
private static final String FILENAME = "filename";
526+
private static final String MEDIA_TYPE = "mediaType";
527+
// The oneOf content-type discriminator keys in the flat JSON format.
528+
// Exactly one must be present (and non-null) in each Part object.
529+
private static final Set<String> VALID_KEYS = Set.of(TEXT, RAW, URL, DATA);
525530
private static final Type MAP_TYPE = new TypeToken<Map<String, Object>>(){}.getType();
526531

527532
// Create separate Gson instance without the Part adapter to avoid recursion
528533
private final Gson delegateGson = createBaseGsonBuilder().create();
529534

535+
private void writeMetadata(JsonWriter out, @Nullable Map<String, Object> metadata) throws java.io.IOException {
536+
if (metadata != null && !metadata.isEmpty()) {
537+
out.name("metadata");
538+
delegateGson.toJson(metadata, MAP_TYPE, out);
539+
}
540+
}
541+
542+
/** Writes a string field only when the value is non-null and non-empty. */
543+
private void writeNonEmpty(JsonWriter out, String name, String value) throws java.io.IOException {
544+
if (!value.isEmpty()) {
545+
out.name(name).value(value);
546+
}
547+
}
548+
530549
@Override
531550
public void write(JsonWriter out, Part<?> value) throws java.io.IOException {
532551
if (value == null) {
533552
out.nullValue();
534553
return;
535554
}
536-
// Write wrapper object with member name as discriminator
537555
out.beginObject();
538556

539557
if (value instanceof TextPart textPart) {
540-
// TextPart: { "text": "value" } - direct string value
541-
out.name(TEXT);
542-
out.value(textPart.text());
543-
JsonUtil.writeMetadata(out, textPart.metadata());
558+
out.name(TEXT).value(textPart.text());
559+
writeMetadata(out, textPart.metadata());
544560
} else if (value instanceof FilePart filePart) {
545-
// FilePart: { "file": {...} }
546-
out.name(FILE);
547-
delegateGson.toJson(filePart.file(), FileContent.class, out);
548-
JsonUtil.writeMetadata(out, filePart.metadata());
561+
if (filePart.file() instanceof FileWithBytes withBytes) {
562+
out.name(RAW).value(withBytes.bytes());
563+
writeNonEmpty(out, FILENAME, withBytes.name());
564+
writeNonEmpty(out, MEDIA_TYPE, withBytes.mimeType());
565+
} else if (filePart.file() instanceof FileWithUri withUri) {
566+
out.name(URL).value(withUri.uri());
567+
writeNonEmpty(out, FILENAME, withUri.name());
568+
writeNonEmpty(out, MEDIA_TYPE, withUri.mimeType());
569+
} else {
570+
throw new JsonSyntaxException("Unknown FileContent subclass: " + filePart.file().getClass().getName());
571+
}
572+
writeMetadata(out, filePart.metadata());
573+
549574
} else if (value instanceof DataPart dataPart) {
550-
// DataPart: { "data": <any JSON value> }
551575
out.name(DATA);
552576
delegateGson.toJson(dataPart.data(), Object.class, out);
553577
JsonUtil.writeMetadata(out, dataPart.metadata());
@@ -566,7 +590,6 @@ Part<?> read(JsonReader in) throws java.io.IOException {
566590
return null;
567591
}
568592

569-
// Read the JSON as a tree to inspect the member name discriminator
570593
com.google.gson.JsonElement jsonElement = com.google.gson.JsonParser.parseReader(in);
571594
if (!jsonElement.isJsonObject()) {
572595
throw new JsonSyntaxException("Part must be a JSON object");
@@ -576,34 +599,47 @@ Part<?> read(JsonReader in) throws java.io.IOException {
576599

577600
// Extract metadata if present
578601
Map<String, Object> metadata = JsonUtil.readMetadata(jsonObject);
579-
580-
// Check for member name discriminators (v1.0 protocol)
581602
Set<String> keys = jsonObject.keySet();
582-
if (keys.size() < 1 || keys.size() > 2) {
583-
throw new JsonSyntaxException(format("Part object must have one content key from %s and optionally 'metadata' (found: %s)", VALID_KEYS, keys));
584-
}
585603

586-
// Find the discriminator (should be one of TEXT, FILE, DATA)
604+
// Find the oneOf discriminator, skipping null/empty values to tolerate formats
605+
// where multiple content keys may be present with only one populated
606+
// (e.g., proto serialization with alwaysPrintFieldsWithNoPresence).
607+
// Unknown extra fields are ignored.
587608
String discriminator = keys.stream()
588609
.filter(VALID_KEYS::contains)
610+
.filter(key -> {
611+
com.google.gson.JsonElement el = jsonObject.get(key);
612+
return el != null && !el.isJsonNull();
613+
})
589614
.findFirst()
590615
.orElseThrow(() -> new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, keys)));
591616

592617
return switch (discriminator) {
593618
case TEXT -> new TextPart(jsonObject.get(TEXT).getAsString(), metadata);
594-
case FILE -> new FilePart(delegateGson.fromJson(jsonObject.get(FILE), FileContent.class), metadata);
619+
case RAW -> new FilePart(new FileWithBytes(
620+
stringOrEmpty(jsonObject, MEDIA_TYPE),
621+
stringOrEmpty(jsonObject, FILENAME),
622+
jsonObject.get(RAW).getAsString()), metadata);
623+
case URL -> new FilePart(new FileWithUri(
624+
stringOrEmpty(jsonObject, MEDIA_TYPE),
625+
stringOrEmpty(jsonObject, FILENAME),
626+
jsonObject.get(URL).getAsString()), metadata);
595627
case DATA -> {
596-
// DataPart supports any JSON value: object, array, primitive, or null
597-
Object data = delegateGson.fromJson(
598-
jsonObject.get(DATA),
599-
Object.class
600-
);
628+
Object data = delegateGson.fromJson(jsonObject.get(DATA), Object.class);
601629
yield new DataPart(data, metadata);
602630
}
603-
default ->
604-
throw new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, discriminator));
631+
default -> throw new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, discriminator));
605632
};
606633
}
634+
635+
/** Returns the string value of the field, or an empty string if absent or null. */
636+
private String stringOrEmpty(com.google.gson.JsonObject obj, String key) {
637+
com.google.gson.JsonElement el = obj.get(key);
638+
if (el == null || el.isJsonNull()) {
639+
return "";
640+
}
641+
return el.getAsString();
642+
}
607643
}
608644

609645
/**

jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/TaskSerializationTest.java

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,8 @@ void testTaskWithFilePartBytes() throws JsonProcessingException {
255255
// Serialize
256256
String json = JsonUtil.toJson(task);
257257

258-
// Verify JSON contains file part data (v1.0 format uses member name "file", not "kind")
259-
assertTrue(json.contains("\"file\""));
258+
// Verify JSON contains file part data in flat format (raw/filename/mediaType, not "file" wrapper)
259+
assertTrue(json.contains("\"raw\""));
260260
assertFalse(json.contains("\"kind\""));
261261
assertTrue(json.contains("document.pdf"));
262262
assertTrue(json.contains("application/pdf"));
@@ -492,11 +492,9 @@ void testDeserializeTaskWithFilePartBytesFromJson() throws JsonProcessingExcepti
492492
"artifactId": "file-artifact",
493493
"parts": [
494494
{
495-
"file": {
496-
"mimeType": "application/pdf",
497-
"name": "document.pdf",
498-
"bytes": "base64encodeddata"
499-
}
495+
"raw": "base64encodeddata",
496+
"filename": "document.pdf",
497+
"mediaType": "application/pdf"
500498
}
501499
]
502500
}
@@ -532,11 +530,9 @@ void testDeserializeTaskWithFilePartUriFromJson() throws JsonProcessingException
532530
"artifactId": "uri-artifact",
533531
"parts": [
534532
{
535-
"file": {
536-
"mimeType": "image/png",
537-
"name": "photo.png",
538-
"uri": "https://example.com/photo.png"
539-
}
533+
"url": "https://example.com/photo.png",
534+
"filename": "photo.png",
535+
"mediaType": "image/png"
540536
}
541537
]
542538
}

spec-grpc/src/main/java/io/a2a/grpc/utils/JSONRPCUtils.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ public static String toJsonRPCRequest(@Nullable String requestId, String method,
576576
output.name("method").value(method);
577577
}
578578
if (payload != null) {
579-
String resultValue = JsonFormat.printer().includingDefaultValueFields().omittingInsignificantWhitespace().print(payload);
579+
String resultValue = JsonFormat.printer().alwaysPrintFieldsWithNoPresence().omittingInsignificantWhitespace().print(payload);
580580
output.name("params").jsonValue(resultValue);
581581
}
582582
output.endObject();
@@ -599,7 +599,7 @@ public static String toJsonRPCResultResponse(Object requestId, com.google.protob
599599
output.name("id").value(number.longValue());
600600
}
601601
}
602-
String resultValue = JsonFormat.printer().includingDefaultValueFields().omittingInsignificantWhitespace().print(builder);
602+
String resultValue = JsonFormat.printer().alwaysPrintFieldsWithNoPresence().omittingInsignificantWhitespace().print(builder);
603603
output.name("result").jsonValue(resultValue);
604604
output.endObject();
605605
return result.toString();

0 commit comments

Comments
 (0)