Skip to content

Commit a2c541a

Browse files
committed
feat: add convenience constructors to FileWithBytes
Add three constructors to FileWithBytes that accept a File, a Path, or a byte[] and handle base64 encoding internally, removing the need for callers to encode content before construction. Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com>
1 parent 566978c commit a2c541a

File tree

8 files changed

+774
-13
lines changed

8 files changed

+774
-13
lines changed

client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package io.a2a.client.transport.jsonrpc;
22

3-
import static io.a2a.spec.AgentInterface.CURRENT_PROTOCOL_VERSION;
4-
53
/**
64
* Request and response messages used by the tests. These have been created following examples from
75
* the <a href="https://google.github.io/A2A/specification/sample-messages">A2A sample messages</a>.
@@ -266,7 +264,8 @@ public class JsonMessages {
266264
},
267265
{
268266
"raw":"aGVsbG8=",
269-
"filename":"hello.txt"
267+
"filename":"hello.txt",
268+
"mediaType": "text/plain"
270269
}
271270
],
272271
"messageId":"message-123"

client/transport/rest/src/test/java/io/a2a/client/transport/rest/JsonRestMessages.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ public class JsonRestMessages {
9191
},
9292
{
9393
"raw": "aGVsbG8=",
94+
"filename":"hello.txt",
9495
"mediaType": "text/plain"
9596
}
9697
],

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

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,9 +746,11 @@ StreamingEventKind read(JsonReader in) throws java.io.IOException {
746746
*/
747747
static class FileContentTypeAdapter extends TypeAdapter<FileContent> {
748748

749-
// Create separate Gson instance without the FileContent adapter to avoid recursion
749+
// Create separate Gson instance without the FileContent adapter to avoid recursion,
750+
// but with an explicit FileWithBytes adapter to prevent field/path leakage.
750751
private final Gson delegateGson = new GsonBuilder()
751752
.registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter())
753+
.registerTypeAdapter(FileWithBytes.class, new FileWithBytesTypeAdapter())
752754
.create();
753755

754756
@Override
@@ -788,6 +790,56 @@ FileContent read(JsonReader in) throws java.io.IOException {
788790
}
789791
}
790792

793+
/**
794+
* Gson TypeAdapter for serializing and deserializing {@link FileWithBytes}.
795+
* <p>
796+
* Explicitly maps only the three protocol fields ({@code mimeType}, {@code name}, {@code bytes})
797+
* to and from JSON. This prevents internal implementation fields (such as the lazy-loading
798+
* {@code source} or the {@code cachedBytes} soft reference) from leaking into serialized output,
799+
* and ensures correct round-trip deserialization via the canonical
800+
* {@link FileWithBytes#FileWithBytes(String, String, String)} constructor.
801+
*/
802+
static class FileWithBytesTypeAdapter extends TypeAdapter<FileWithBytes> {
803+
804+
@Override
805+
public void write(JsonWriter out, FileWithBytes value) throws java.io.IOException {
806+
if (value == null) {
807+
out.nullValue();
808+
return;
809+
}
810+
out.beginObject();
811+
out.name("mimeType").value(value.mimeType());
812+
out.name("name").value(value.name());
813+
out.name("bytes").value(value.bytes());
814+
out.endObject();
815+
}
816+
817+
@Override
818+
public @Nullable FileWithBytes read(JsonReader in) throws java.io.IOException {
819+
if (in.peek() == JsonToken.NULL) {
820+
in.nextNull();
821+
return null;
822+
}
823+
String mimeType = null;
824+
String name = null;
825+
String bytes = null;
826+
in.beginObject();
827+
while (in.hasNext()) {
828+
switch (in.nextName()) {
829+
case "mimeType" -> mimeType = in.nextString();
830+
case "name" -> name = in.nextString();
831+
case "bytes" -> bytes = in.nextString();
832+
default -> in.skipValue();
833+
}
834+
}
835+
in.endObject();
836+
return new FileWithBytes(
837+
mimeType != null ? mimeType : "",
838+
name != null ? name : "",
839+
bytes != null ? bytes : "");
840+
}
841+
}
842+
791843
/**
792844
* Gson TypeAdapter for serializing and deserializing {@link APIKeySecurityScheme.Location} enum.
793845
* <p>

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
44
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
56
import static org.junit.jupiter.api.Assertions.assertNotNull;
67
import static org.junit.jupiter.api.Assertions.assertNull;
78
import static org.junit.jupiter.api.Assertions.assertTrue;
89

10+
import java.io.IOException;
11+
import java.nio.file.Files;
12+
import java.nio.file.Path;
913
import java.time.OffsetDateTime;
14+
import java.util.Base64;
1015
import java.util.List;
1116
import java.util.Map;
1217

1318
import io.a2a.spec.Artifact;
1419
import io.a2a.spec.DataPart;
20+
import io.a2a.spec.FileContent;
1521
import io.a2a.spec.FilePart;
1622
import io.a2a.spec.FileWithBytes;
1723
import io.a2a.spec.FileWithUri;
@@ -22,6 +28,7 @@
2228
import io.a2a.spec.TaskStatus;
2329
import io.a2a.spec.TextPart;
2430
import org.junit.jupiter.api.Test;
31+
import org.junit.jupiter.api.io.TempDir;
2532

2633
/**
2734
* Tests for Task serialization and deserialization using Gson.
@@ -706,4 +713,74 @@ void testTaskWithMixedPartTypes() throws JsonProcessingException {
706713
assertTrue(parts.get(2) instanceof DataPart);
707714
assertTrue(parts.get(3) instanceof FilePart);
708715
}
716+
717+
// ========== FileContentTypeAdapter tests ==========
718+
719+
@TempDir
720+
Path tempDir;
721+
722+
@Test
723+
void testFileWithBytesSerializationDoesNotLeakInternalFields() throws Exception {
724+
FileWithBytes fwb = new FileWithBytes("application/pdf", "doc.pdf", "base64data");
725+
726+
String json = JsonUtil.toJson(fwb);
727+
728+
// Must contain the three protocol fields
729+
assertTrue(json.contains("\"mimeType\""), "missing mimeType: " + json);
730+
assertTrue(json.contains("\"name\""), "missing name: " + json);
731+
assertTrue(json.contains("\"bytes\""), "missing bytes: " + json);
732+
// Must NOT contain internal implementation fields
733+
assertFalse(json.contains("\"source\""), "internal source field leaked: " + json);
734+
assertFalse(json.contains("\"cachedBytes\""), "internal cachedBytes field leaked: " + json);
735+
}
736+
737+
@Test
738+
void testFileWithBytesRoundTripViaFileContentTypeAdapter() throws Exception {
739+
FileWithBytes original = new FileWithBytes("image/png", "photo.png", "abc123");
740+
741+
String json = JsonUtil.toJson(original);
742+
FileContent deserialized = JsonUtil.fromJson(json, FileContent.class);
743+
744+
assertInstanceOf(FileWithBytes.class, deserialized);
745+
FileWithBytes result = (FileWithBytes) deserialized;
746+
assertEquals("image/png", result.mimeType());
747+
assertEquals("photo.png", result.name());
748+
assertEquals("abc123", result.bytes());
749+
}
750+
751+
@Test
752+
void testPathBackedFileWithBytesDoesNotLeakFilePath() throws Exception {
753+
byte[] content = "hello".getBytes();
754+
Path file = tempDir.resolve("secret.txt");
755+
Files.write(file, content);
756+
757+
FileWithBytes fwb = new FileWithBytes("text/plain", file);
758+
759+
String json = JsonUtil.toJson(fwb);
760+
761+
// File path must not appear in the serialized JSON
762+
assertFalse(json.contains(file.toString()), "file path leaked in JSON: " + json);
763+
assertFalse(json.contains(tempDir.toString()), "temp dir path leaked in JSON: " + json);
764+
// Must contain the three protocol fields, not internal implementation fields
765+
assertTrue(json.contains("\"bytes\""), "missing bytes field: " + json);
766+
assertFalse(json.contains("\"source\""), "internal source field leaked: " + json);
767+
}
768+
769+
@Test
770+
void testPathBackedFileWithBytesRoundTrip() throws Exception {
771+
byte[] content = "round-trip".getBytes();
772+
Path file = tempDir.resolve("data.bin");
773+
Files.write(file, content);
774+
775+
FileWithBytes original = new FileWithBytes("application/octet-stream", file);
776+
777+
String json = JsonUtil.toJson(original);
778+
FileContent deserialized = JsonUtil.fromJson(json, FileContent.class);
779+
780+
assertInstanceOf(FileWithBytes.class, deserialized);
781+
FileWithBytes result = (FileWithBytes) deserialized;
782+
assertEquals("application/octet-stream", result.mimeType());
783+
assertEquals("data.bin", result.name());
784+
assertEquals(Base64.getEncoder().encodeToString(content), result.bytes());
785+
}
709786
}

spec-grpc/src/test/java/io/a2a/grpc/utils/PartTypeAdapterTest.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,19 @@
2222
import io.a2a.spec.FileWithUri;
2323
import io.a2a.spec.Part;
2424
import io.a2a.spec.TextPart;
25+
import java.io.IOException;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
2528
import java.util.Base64;
2629
import org.junit.jupiter.api.Test;
30+
import org.junit.jupiter.api.io.TempDir;
2731

2832

2933
public class PartTypeAdapterTest {
3034

35+
@TempDir
36+
Path tempDir;
37+
3138
// -------------------------------------------------------------------------
3239
// TextPart
3340
// -------------------------------------------------------------------------
@@ -134,6 +141,36 @@ public void shouldRoundTripFilePartWithBytes() throws JsonProcessingException {
134141
assertEquals("AAEC", bytes.bytes());
135142
}
136143

144+
@Test
145+
public void shouldRoundTripFilePartWithBytesFromRealFile() throws JsonProcessingException, IOException {
146+
// Create a temporary file with some content
147+
Path testFile = tempDir.resolve("test-file.txt");
148+
String fileContent = "This is test content for lazy loading verification";
149+
Files.writeString(testFile, fileContent);
150+
151+
// Create FileWithBytes from the file path (lazy loading)
152+
FileWithBytes fileWithBytes = new FileWithBytes("text/plain", testFile);
153+
FilePart original = new FilePart(fileWithBytes);
154+
155+
// Serialize to JSON (this triggers lazy loading)
156+
String json = JsonUtil.toJson(original);
157+
158+
// Deserialize and verify
159+
Part<?> deserialized = JsonUtil.fromJson(json, Part.class);
160+
assertInstanceOf(FilePart.class, deserialized);
161+
FilePart result = (FilePart) deserialized;
162+
assertInstanceOf(FileWithBytes.class, result.file());
163+
FileWithBytes bytes = (FileWithBytes) result.file();
164+
165+
assertEquals("text/plain", bytes.mimeType());
166+
assertEquals("test-file.txt", bytes.name());
167+
168+
// Verify the content by decoding the base64
169+
byte[] decodedBytes = Base64.getDecoder().decode(bytes.bytes());
170+
String decodedContent = new String(decodedBytes);
171+
assertEquals(fileContent, decodedContent);
172+
}
173+
137174
// -------------------------------------------------------------------------
138175
// FilePart – FileWithUri
139176
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)