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 @@ -605,6 +605,9 @@ public void write(JsonWriter out, Part<?> value) throws java.io.IOException {

if (value instanceof TextPart textPart) {
out.name(TEXT).value(textPart.text());
if (textPart.mediaType() != null) {
writeNonEmpty(out, MEDIA_TYPE, textPart.mediaType());
}
writeMetadata(out, textPart.metadata());
} else if (value instanceof FilePart filePart) {
if (filePart.file() instanceof FileWithBytes withBytes) {
Expand Down Expand Up @@ -633,6 +636,9 @@ public void write(JsonWriter out, Part<?> value) throws java.io.IOException {
} else {
delegateGson.toJson(dataPart.data(), Object.class, out);
}
if (dataPart.mediaType() != null) {
writeNonEmpty(out, MEDIA_TYPE, dataPart.mediaType());
}
JsonUtil.writeMetadata(out, dataPart.metadata());
} else {
throw new JsonSyntaxException("Unknown Part subclass: " + value.getClass().getName());
Expand All @@ -658,6 +664,7 @@ Part<?> read(JsonReader in) throws java.io.IOException {

// Extract metadata if present
Map<String, Object> metadata = JsonUtil.readMetadata(jsonObject);
String mediaType = stringOrEmpty(jsonObject, MEDIA_TYPE);
Set<String> keys = jsonObject.keySet();

// A producer that emits every content key, leaving the unset ones as "", would otherwise
Expand All @@ -675,7 +682,7 @@ Part<?> read(JsonReader in) throws java.io.IOException {
.orElseThrow(() -> new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, keys)));

return switch (discriminator) {
case TEXT -> new TextPart(requireString(jsonObject, TEXT), metadata);
case TEXT -> new TextPart(requireString(jsonObject, TEXT), metadata, mediaType.isEmpty() ? null : mediaType);
case RAW -> new FilePart(new FileWithBytes(
stringOrEmpty(jsonObject, MEDIA_TYPE),
stringOrEmpty(jsonObject, FILENAME),
Expand All @@ -686,7 +693,7 @@ Part<?> read(JsonReader in) throws java.io.IOException {
requireString(jsonObject, URL)), metadata);
case DATA -> {
Object data = delegateGson.fromJson(jsonObject.get(DATA), Object.class);
yield new DataPart(data, metadata);
yield new DataPart(data, metadata, mediaType.isEmpty() ? null : mediaType);
}
default -> throw new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, discriminator));
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import java.util.Map;

import com.google.gson.JsonParser;
import org.a2aproject.sdk.spec.DataPart;
import org.a2aproject.sdk.spec.FilePart;
import org.a2aproject.sdk.spec.FileWithBytes;
Expand All @@ -21,6 +22,24 @@
*/
public class PartSerializationTest {

@ParameterizedTest
@ValueSource(strings = {
"{\"text\":\"Hello\",\"mediaType\":\"text/markdown\"}",
"{\"data\":{\"answer\":\"42\"},\"mediaType\":\"application/vnd.example+json\"}"
})
void testMediaTypeSurvivesJsonRoundTrip(String json) throws JsonProcessingException {
Part<?> part = JsonUtil.fromJson(json, Part.class);
assertEquals(JsonParser.parseString(json), JsonParser.parseString(JsonUtil.toJson(part)));
}

@Test
void testConstructedPartsSerializeMediaType() throws JsonProcessingException {
assertEquals(JsonParser.parseString("{\"text\":\"Hello\",\"mediaType\":\"text/markdown\"}"),
JsonParser.parseString(JsonUtil.toJson(new TextPart("Hello", null, "text/markdown"))));
assertEquals(JsonParser.parseString("{\"data\":null,\"mediaType\":\"application/json\"}"),
JsonParser.parseString(JsonUtil.toJson(new DataPart(null, null, "application/json"))));
}

@Test
void testPopulatedDataWinsOverEmptyTextPlaceholder() throws JsonProcessingException {
String json = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ default org.a2aproject.sdk.grpc.Part toProto(Part<?> domain) {

if (domain instanceof TextPart textPart) {
builder.setText(textPart.text());
if (textPart.mediaType() != null) {
builder.setMediaType(textPart.mediaType());
}
builder.setMetadata(A2ACommonFieldMapper.INSTANCE.metadataToProto(textPart.metadata()));
} else if (domain instanceof FilePart filePart) {
FileContent fileContent = filePart.file();
Expand Down Expand Up @@ -75,6 +78,9 @@ default org.a2aproject.sdk.grpc.Part toProto(Part<?> domain) {
// Map data to google.protobuf.Value (supports object, array, primitive, or null)
Value dataValue = A2ACommonFieldMapper.INSTANCE.objectToValue(dataPart.data());
builder.setData(dataValue);
if (dataPart.mediaType() != null) {
builder.setMediaType(dataPart.mediaType());
}
builder.setMetadata(A2ACommonFieldMapper.INSTANCE.metadataToProto(dataPart.metadata()));
}

Expand All @@ -90,8 +96,9 @@ default Part<?> fromProto(org.a2aproject.sdk.grpc.Part proto) {
return null;
}
Map<String, Object> metadata = A2ACommonFieldMapper.INSTANCE.metadataFromProto(proto.getMetadata());
String mediaType = proto.getMediaType().isEmpty() ? null : proto.getMediaType();
if (proto.hasText()) {
return new TextPart(proto.getText(), metadata);
return new TextPart(proto.getText(), metadata, mediaType);
} else if (proto.hasRaw()) {
// raw bytes → FilePart(FileWithBytes)
String bytes = Base64.getEncoder().encodeToString(proto.getRaw().toByteArray());
Expand All @@ -108,7 +115,7 @@ default Part<?> fromProto(org.a2aproject.sdk.grpc.Part proto) {
// data (google.protobuf.Value containing any JSON value) → DataPart
Value dataValue = proto.getData();
Object data = A2ACommonFieldMapper.INSTANCE.valueToObject(dataValue);
return new DataPart(data, metadata);
return new DataPart(data, metadata, mediaType);
}

throw new InvalidRequestError();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.a2aproject.sdk.grpc.mapper;

import static org.junit.jupiter.api.Assertions.assertEquals;

import com.google.protobuf.Value;
import org.a2aproject.sdk.grpc.Part;
import org.a2aproject.sdk.spec.DataPart;
import org.a2aproject.sdk.spec.TextPart;
import org.junit.jupiter.api.Test;

class PartMapperTest {

@Test
void constructedPartsMapMediaTypesToProto() {
assertEquals("text/markdown", PartMapper.INSTANCE.toProto(new TextPart("Hello", null, "text/markdown")).getMediaType());
assertEquals("application/json", PartMapper.INSTANCE.toProto(new DataPart(null, null, "application/json")).getMediaType());
}

@Test
void textMediaTypeSurvivesProtoRoundTrip() {
Part original = Part.newBuilder().setText("Hello").setMediaType("text/markdown").build();
Part result = PartMapper.INSTANCE.toProto(PartMapper.INSTANCE.fromProto(original));
assertEquals("text/markdown", result.getMediaType());
assertEquals("Hello", result.getText());
}

@Test
void dataMediaTypeSurvivesProtoRoundTrip() {
Part original = Part.newBuilder().setData(Value.newBuilder().setStringValue("answer"))
.setMediaType("application/vnd.example+json").build();
Part result = PartMapper.INSTANCE.toProto(PartMapper.INSTANCE.fromProto(original));
assertEquals("application/vnd.example+json", result.getMediaType());
assertEquals("answer", result.getData().getStringValue());
}
}
11 changes: 9 additions & 2 deletions spec/src/main/java/org/a2aproject/sdk/spec/DataPart.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,12 @@
*
* @param data the structured data (supports JSON objects, arrays, primitives, and null)
* @param metadata additional metadata for the part
* @param mediaType the optional MIME type of the content
* @see Part
* @see Message
* @see Artifact
*/
public record DataPart(@Nullable Object data, @Nullable Map<String, Object> metadata) implements Part<Object> {
public record DataPart(@Nullable Object data, @Nullable Map<String, Object> metadata, @Nullable String mediaType) implements Part<Object> {

/**
* The JSON member name discriminator for data parts: "data".
Expand All @@ -69,9 +70,15 @@ public record DataPart(@Nullable Object data, @Nullable Map<String, Object> meta
* @param data the structured data (supports JSON objects, arrays, primitives, and null)
* @param metadata additional metadata for the part
*/
public DataPart (@Nullable Object data, @Nullable Map<String, Object> metadata) {
public DataPart (@Nullable Object data, @Nullable Map<String, Object> metadata, @Nullable String mediaType) {
this.metadata = CollectionCopies.unmodifiableNullableShallowMap(metadata);
this.data = defensivelyCopy(data);
this.mediaType = mediaType;
}

/** Creates a data part with metadata and no media type. */
public DataPart(@Nullable Object data, @Nullable Map<String, Object> metadata) {
this(data, metadata, null);
}

/**
Expand Down
11 changes: 9 additions & 2 deletions spec/src/main/java/org/a2aproject/sdk/spec/TextPart.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@
*
* @param text the text content (required, must not be null)
* @param metadata additional metadata for the part
* @param mediaType the optional MIME type of the content
* @see Part
* @see Message
* @see Artifact
*/
public record TextPart(String text, @Nullable Map<String, Object> metadata) implements Part<String> {
public record TextPart(String text, @Nullable Map<String, Object> metadata, @Nullable String mediaType) implements Part<String> {

/**
* The JSON member name discriminator for text parts: "text".
Expand All @@ -44,10 +45,16 @@ public record TextPart(String text, @Nullable Map<String, Object> metadata) impl
* @param text the text content (required, must not be null)
* @throws IllegalArgumentException if text is null
*/
public TextPart (String text, @Nullable Map<String, Object> metadata) {
public TextPart (String text, @Nullable Map<String, Object> metadata, @Nullable String mediaType) {
Assert.checkNotNullParam("text", text);
this.metadata = CollectionCopies.unmodifiableNullableShallowMap(metadata);
this.text = text;
this.mediaType = mediaType;
}

/** Creates a text part with metadata and no media type. */
public TextPart(String text, @Nullable Map<String, Object> metadata) {
this(text, metadata, null);
}

/**
Expand Down
Loading