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 @@ -13,7 +13,6 @@
import static io.a2a.spec.A2AErrorCodes.TASK_NOT_FOUND_ERROR_CODE;
import static io.a2a.spec.A2AErrorCodes.UNSUPPORTED_OPERATION_ERROR_CODE;
import static io.a2a.spec.DataPart.DATA;
import static io.a2a.spec.FilePart.FILE;
import static io.a2a.spec.TextPart.TEXT;
import static java.lang.String.format;
import static java.util.Collections.emptyMap;
Expand Down Expand Up @@ -521,33 +520,58 @@ public static Map<String, Object> readMetadata(@Nullable String json) throws Jso
*/
static class PartTypeAdapter extends TypeAdapter<Part<?>> {

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

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

private void writeMetadata(JsonWriter out, @Nullable Map<String, Object> metadata) throws java.io.IOException {
if (metadata != null && !metadata.isEmpty()) {
out.name("metadata");
delegateGson.toJson(metadata, MAP_TYPE, out);
}
}

/** Writes a string field only when the value is non-null and non-empty. */
private void writeNonEmpty(JsonWriter out, String name, String value) throws java.io.IOException {
if (!value.isEmpty()) {
out.name(name).value(value);
}
}

@Override
public void write(JsonWriter out, Part<?> value) throws java.io.IOException {
if (value == null) {
out.nullValue();
return;
}
// Write wrapper object with member name as discriminator
out.beginObject();

if (value instanceof TextPart textPart) {
// TextPart: { "text": "value" } - direct string value
out.name(TEXT);
out.value(textPart.text());
JsonUtil.writeMetadata(out, textPart.metadata());
out.name(TEXT).value(textPart.text());
writeMetadata(out, textPart.metadata());
} else if (value instanceof FilePart filePart) {
// FilePart: { "file": {...} }
out.name(FILE);
delegateGson.toJson(filePart.file(), FileContent.class, out);
JsonUtil.writeMetadata(out, filePart.metadata());
if (filePart.file() instanceof FileWithBytes withBytes) {
out.name(RAW).value(withBytes.bytes());
writeNonEmpty(out, FILENAME, withBytes.name());
writeNonEmpty(out, MEDIA_TYPE, withBytes.mimeType());
} else if (filePart.file() instanceof FileWithUri withUri) {
out.name(URL).value(withUri.uri());
writeNonEmpty(out, FILENAME, withUri.name());
writeNonEmpty(out, MEDIA_TYPE, withUri.mimeType());
} else {
throw new JsonSyntaxException("Unknown FileContent subclass: " + filePart.file().getClass().getName());
}
writeMetadata(out, filePart.metadata());

} else if (value instanceof DataPart dataPart) {
// DataPart: { "data": <any JSON value> }
out.name(DATA);
delegateGson.toJson(dataPart.data(), Object.class, out);
JsonUtil.writeMetadata(out, dataPart.metadata());
Expand All @@ -566,7 +590,6 @@ Part<?> read(JsonReader in) throws java.io.IOException {
return null;
}

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

// Extract metadata if present
Map<String, Object> metadata = JsonUtil.readMetadata(jsonObject);

// Check for member name discriminators (v1.0 protocol)
Set<String> keys = jsonObject.keySet();
if (keys.size() < 1 || keys.size() > 2) {
throw new JsonSyntaxException(format("Part object must have one content key from %s and optionally 'metadata' (found: %s)", VALID_KEYS, keys));
}

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

return switch (discriminator) {
case TEXT -> new TextPart(jsonObject.get(TEXT).getAsString(), metadata);
case FILE -> new FilePart(delegateGson.fromJson(jsonObject.get(FILE), FileContent.class), metadata);
case RAW -> new FilePart(new FileWithBytes(
stringOrEmpty(jsonObject, MEDIA_TYPE),
stringOrEmpty(jsonObject, FILENAME),
jsonObject.get(RAW).getAsString()), metadata);
case URL -> new FilePart(new FileWithUri(
stringOrEmpty(jsonObject, MEDIA_TYPE),
stringOrEmpty(jsonObject, FILENAME),
jsonObject.get(URL).getAsString()), metadata);
case DATA -> {
// DataPart supports any JSON value: object, array, primitive, or null
Object data = delegateGson.fromJson(
jsonObject.get(DATA),
Object.class
);
Object data = delegateGson.fromJson(jsonObject.get(DATA), Object.class);
yield new DataPart(data, metadata);
}
default ->
throw new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, discriminator));
default -> throw new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, discriminator));
};
}

/** Returns the string value of the field, or an empty string if absent or null. */
private String stringOrEmpty(com.google.gson.JsonObject obj, String key) {
com.google.gson.JsonElement el = obj.get(key);
if (el == null || el.isJsonNull()) {
return "";
}
return el.getAsString();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,8 @@ void testTaskWithFilePartBytes() throws JsonProcessingException {
// Serialize
String json = JsonUtil.toJson(task);

// Verify JSON contains file part data (v1.0 format uses member name "file", not "kind")
assertTrue(json.contains("\"file\""));
// Verify JSON contains file part data in flat format (raw/filename/mediaType, not "file" wrapper)
assertTrue(json.contains("\"raw\""));
assertFalse(json.contains("\"kind\""));
assertTrue(json.contains("document.pdf"));
assertTrue(json.contains("application/pdf"));
Expand Down Expand Up @@ -492,11 +492,9 @@ void testDeserializeTaskWithFilePartBytesFromJson() throws JsonProcessingExcepti
"artifactId": "file-artifact",
"parts": [
{
"file": {
"mimeType": "application/pdf",
"name": "document.pdf",
"bytes": "base64encodeddata"
}
"raw": "base64encodeddata",
"filename": "document.pdf",
"mediaType": "application/pdf"
}
]
}
Expand Down Expand Up @@ -532,11 +530,9 @@ void testDeserializeTaskWithFilePartUriFromJson() throws JsonProcessingException
"artifactId": "uri-artifact",
"parts": [
{
"file": {
"mimeType": "image/png",
"name": "photo.png",
"uri": "https://example.com/photo.png"
}
"url": "https://example.com/photo.png",
"filename": "photo.png",
"mediaType": "image/png"
}
]
}
Expand Down
4 changes: 2 additions & 2 deletions spec-grpc/src/main/java/io/a2a/grpc/utils/JSONRPCUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ public static String toJsonRPCRequest(@Nullable String requestId, String method,
output.name("method").value(method);
}
if (payload != null) {
String resultValue = JsonFormat.printer().includingDefaultValueFields().omittingInsignificantWhitespace().print(payload);
String resultValue = JsonFormat.printer().alwaysPrintFieldsWithNoPresence().omittingInsignificantWhitespace().print(payload);
output.name("params").jsonValue(resultValue);
}
output.endObject();
Expand All @@ -599,7 +599,7 @@ public static String toJsonRPCResultResponse(Object requestId, com.google.protob
output.name("id").value(number.longValue());
}
}
String resultValue = JsonFormat.printer().includingDefaultValueFields().omittingInsignificantWhitespace().print(builder);
String resultValue = JsonFormat.printer().alwaysPrintFieldsWithNoPresence().omittingInsignificantWhitespace().print(builder);
output.name("result").jsonValue(resultValue);
output.endObject();
return result.toString();
Expand Down
Loading