diff --git a/hiero-enterprise-base/src/main/java/org/hiero/base/FileClient.java b/hiero-enterprise-base/src/main/java/org/hiero/base/FileClient.java index 966cf89e..bb7193df 100644 --- a/hiero-enterprise-base/src/main/java/org/hiero/base/FileClient.java +++ b/hiero-enterprise-base/src/main/java/org/hiero/base/FileClient.java @@ -1,6 +1,7 @@ package org.hiero.base; import com.hedera.hashgraph.sdk.FileId; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Objects; import org.jspecify.annotations.NonNull; @@ -92,6 +93,55 @@ default void deleteFile(@NonNull String fileId) throws HieroException { void updateExpirationTime(@NonNull FileId fileId, @NonNull Instant expirationTime) throws HieroException; + /** + * Appends new file content to the end of an existing file. + * + * @param fileId the ID of the file to append + * @param content the content to append + * @throws HieroException if the content could not be appended to the file + */ + default void appendFile(@NonNull String fileId, @NonNull String content) throws HieroException { + Objects.requireNonNull(fileId, "fileId must not be null"); + Objects.requireNonNull(content, "content must not be null"); + + appendFile(FileId.fromString(fileId), content.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Appends new file content to the end of an existing file. + * + * @param fileId the ID of the file to append + * @param content the content to append + * @throws HieroException if the content could not be appended to the file + */ + default void appendFile(@NonNull FileId fileId, @NonNull String content) throws HieroException { + Objects.requireNonNull(fileId, "fileId must not be null"); + Objects.requireNonNull(content, "content must not be null"); + + appendFile(fileId, content.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Appends new file content to the end of an existing file. + * + * @param fileId the ID of the file to append + * @param content the byte content to append + * @throws HieroException if the content could not be appended to the file + */ + default void appendFile(@NonNull String fileId, byte[] content) throws HieroException { + Objects.requireNonNull(fileId, "fileId must not be null"); + appendFile(FileId.fromString(fileId), content); + } + + /** + * Appends new file content to the end of an existing file. + * + * @param fileId the ID of the file to append + * @param content the byte content to append + * @throws HieroException if the content could not be appended to the file + */ + void appendFile(@NonNull FileId fileId, byte[] content) throws HieroException; + /** * Check if a file is deleted. * diff --git a/hiero-enterprise-base/src/main/java/org/hiero/base/implementation/FileClientImpl.java b/hiero-enterprise-base/src/main/java/org/hiero/base/implementation/FileClientImpl.java index 41274566..3802a6a7 100644 --- a/hiero-enterprise-base/src/main/java/org/hiero/base/implementation/FileClientImpl.java +++ b/hiero-enterprise-base/src/main/java/org/hiero/base/implementation/FileClientImpl.java @@ -78,13 +78,7 @@ private FileId createFileImpl( final FileId fileId = result.fileId(); byte[] remaining = Arrays.copyOfRange(contents, FileCreateRequest.FILE_CREATE_MAX_SIZE, contents.length); - while (remaining.length > 0) { - final int length = Math.min(remaining.length, FileCreateRequest.FILE_CREATE_MAX_SIZE); - final byte[] next = Arrays.copyOf(remaining, length); - final FileAppendRequest appendRequest = FileAppendRequest.of(fileId, next); - protocolLayerClient.executeFileAppendRequestTransaction(appendRequest); - remaining = Arrays.copyOfRange(remaining, length, remaining.length); - } + appendRemainingChunks(fileId, remaining); return fileId; } } @@ -139,13 +133,7 @@ public void updateFile(@NonNull final FileId fileId, @NonNull final byte[] conte protocolLayerClient.executeFileUpdateRequestTransaction(request); byte[] remaining = Arrays.copyOfRange(content, FileCreateRequest.FILE_CREATE_MAX_SIZE, content.length); - while (remaining.length > 0) { - final int length = Math.min(remaining.length, FileCreateRequest.FILE_CREATE_MAX_SIZE); - byte[] next = Arrays.copyOf(remaining, length); - final FileAppendRequest appendRequest = FileAppendRequest.of(fileId, next); - protocolLayerClient.executeFileAppendRequestTransaction(appendRequest); - remaining = Arrays.copyOfRange(remaining, length, remaining.length); - } + appendRemainingChunks(fileId, remaining); } } @@ -162,6 +150,43 @@ public void updateExpirationTime( protocolLayerClient.executeFileUpdateRequestTransaction(request); } + @Override + public void appendFile(@NonNull FileId fileId, byte[] content) throws HieroException { + Objects.requireNonNull(fileId, "fileId must not be null"); + Objects.requireNonNull(content, "content must not be null"); + + if (content.length > FileCreateRequest.FILE_MAX_SIZE) { + throw new HieroException( + "File contents must be less than " + FileCreateRequest.FILE_MAX_SIZE + " bytes"); + } + + int initialSize = getSize(fileId); + if (initialSize + content.length > FileCreateRequest.FILE_MAX_SIZE) { + throw new HieroException( + "File contents must be less than " + FileCreateRequest.FILE_MAX_SIZE + " bytes"); + } + + if (content.length <= FileCreateRequest.FILE_CREATE_MAX_SIZE) { + final FileAppendRequest request = FileAppendRequest.of(fileId, content); + protocolLayerClient.executeFileAppendRequestTransaction(request); + } else { + if (log.isDebugEnabled()) { + int appendCount = Math.ceilDiv(content.length, FileCreateRequest.FILE_CREATE_MAX_SIZE); + log.debug( + "Content of size {} is to big for 1 FileAppend transaction. Will append {} FileAppend transactions", + content.length, + appendCount); + } + + byte[] start = Arrays.copyOf(content, FileCreateRequest.FILE_CREATE_MAX_SIZE); + final FileAppendRequest request = FileAppendRequest.of(fileId, start); + protocolLayerClient.executeFileAppendRequestTransaction(request); + byte[] remaining = + Arrays.copyOfRange(content, FileCreateRequest.FILE_CREATE_MAX_SIZE, content.length); + appendRemainingChunks(fileId, remaining); + } + } + @Override public boolean isDeleted(@NonNull final FileId fileId) throws HieroException { Objects.requireNonNull(fileId, "fileId must not be null"); @@ -185,4 +210,15 @@ public Instant getExpirationTime(@NonNull final FileId fileId) throws HieroExcep final FileInfoResponse infoResponse = protocolLayerClient.executeFileInfoQuery(request); return infoResponse.expirationTime(); } + + private void appendRemainingChunks(@NonNull FileId fileId, byte[] remaining) + throws HieroException { + while (remaining.length > 0) { + final int length = Math.min(remaining.length, FileCreateRequest.FILE_CREATE_MAX_SIZE); + final byte[] next = Arrays.copyOf(remaining, length); + final FileAppendRequest appendRequest = FileAppendRequest.of(fileId, next); + protocolLayerClient.executeFileAppendRequestTransaction(appendRequest); + remaining = Arrays.copyOfRange(remaining, length, remaining.length); + } + } } diff --git a/hiero-enterprise-base/src/main/java/org/hiero/base/protocol/data/FileAppendRequest.java b/hiero-enterprise-base/src/main/java/org/hiero/base/protocol/data/FileAppendRequest.java index 5bb4ed04..8dd4a992 100644 --- a/hiero-enterprise-base/src/main/java/org/hiero/base/protocol/data/FileAppendRequest.java +++ b/hiero-enterprise-base/src/main/java/org/hiero/base/protocol/data/FileAppendRequest.java @@ -34,6 +34,7 @@ public record FileAppendRequest( throw new IllegalArgumentException( "File contents must be less than " + FILE_CREATE_MAX_BYTES + " bytes"); } + if (fileMemo != null && fileMemo.length() > 100) { throw new IllegalArgumentException("File memo must be less than 100 characters"); } diff --git a/hiero-enterprise-base/src/test/java/org/hiero/base/test/FileClientImplTest.java b/hiero-enterprise-base/src/test/java/org/hiero/base/test/FileClientImplTest.java index ddcd5426..0b2b9c08 100644 --- a/hiero-enterprise-base/src/test/java/org/hiero/base/test/FileClientImplTest.java +++ b/hiero-enterprise-base/src/test/java/org/hiero/base/test/FileClientImplTest.java @@ -6,12 +6,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.hedera.hashgraph.sdk.FileId; import java.time.Instant; +import java.util.List; import org.hiero.base.HieroException; import org.hiero.base.implementation.FileClientImpl; import org.hiero.base.protocol.ProtocolLayerClient; @@ -28,12 +31,16 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; public class FileClientImplTest { ProtocolLayerClient protocolLayerClient; FileClientImpl fileClientImpl; + ArgumentCaptor fileAppendCaptor = + ArgumentCaptor.forClass(FileAppendRequest.class); + @BeforeEach void setup() { protocolLayerClient = Mockito.mock(ProtocolLayerClient.class); @@ -229,6 +236,136 @@ void testUpdateFileThrowsExceptionForNullArguments() { NullPointerException.class, () -> fileClientImpl.updateFile(null, null)); } + @Test + void testAppendFile() throws HieroException { + // mocks + final FileAppendResult result = Mockito.mock(FileAppendResult.class); + final FileInfoResponse fileInfoResponse = Mockito.mock(FileInfoResponse.class); + + // given + final FileId fileId = FileId.fromString("1.2.3"); + final byte[] content = "Hello, Hiero!".getBytes(); + + // then + when(protocolLayerClient.executeFileAppendRequestTransaction(any(FileAppendRequest.class))) + .thenReturn(result); + when(protocolLayerClient.executeFileInfoQuery(any(FileInfoRequest.class))) + .thenReturn(fileInfoResponse); + when(fileInfoResponse.size()).thenReturn(0); + + fileClientImpl.appendFile(fileId, content); + + verify(protocolLayerClient, times(1)) + .executeFileAppendRequestTransaction(fileAppendCaptor.capture()); + verify(protocolLayerClient, times(1)).executeFileInfoQuery(any(FileInfoRequest.class)); + + final FileAppendRequest captureRequest = fileAppendCaptor.getValue(); + Assertions.assertEquals(fileId, captureRequest.fileId()); + Assertions.assertArrayEquals(content, captureRequest.contents()); + } + + @Test + void testAppendFileThrowsExceptionWhenContentExceedsMaxSize() throws HieroException { + // given + final FileId fileId = FileId.fromString("1.2.3"); + final byte[] content = new byte[FileCreateRequest.FILE_MAX_SIZE + 1]; + + final HieroException err = + Assertions.assertThrows( + HieroException.class, () -> fileClientImpl.appendFile(fileId, content)); + + Assertions.assertEquals( + "File contents must be less than " + FileCreateRequest.FILE_MAX_SIZE + " bytes", + err.getMessage()); + + verifyNoInteractions(protocolLayerClient); + } + + @Test + void testAppendFileThrowsExceptionWhenCombinedSizeExceedsMaxSize() throws HieroException { + // mocks + final FileInfoResponse fileInfoResponse = Mockito.mock(FileInfoResponse.class); + + // given + final FileId fileId = FileId.fromString("1.2.3"); + final byte[] content = new byte[10]; + + when(protocolLayerClient.executeFileInfoQuery(any(FileInfoRequest.class))) + .thenReturn(fileInfoResponse); + when(fileInfoResponse.size()).thenReturn(FileCreateRequest.FILE_MAX_SIZE); + + final HieroException err = + Assertions.assertThrows( + HieroException.class, () -> fileClientImpl.appendFile(fileId, content)); + + Assertions.assertEquals( + "File contents must be less than " + FileCreateRequest.FILE_MAX_SIZE + " bytes", + err.getMessage()); + + verify(protocolLayerClient, times(1)).executeFileInfoQuery(any(FileInfoRequest.class)); + verify(protocolLayerClient, never()) + .executeFileAppendRequestTransaction(any(FileAppendRequest.class)); + } + + @Test + void testAppendFileWithLargeContent() throws HieroException { + // mocks + final FileInfoResponse fileInfoResponse = Mockito.mock(FileInfoResponse.class); + + // given + final FileId fileId = FileId.fromString("1.2.3"); + final byte[] content = new byte[FileCreateRequest.FILE_CREATE_MAX_SIZE * 2]; + + when(protocolLayerClient.executeFileInfoQuery(any(FileInfoRequest.class))) + .thenReturn(fileInfoResponse); + when(fileInfoResponse.size()).thenReturn(1); + + fileClientImpl.appendFile(fileId, content); + + // call for each 2048 chunk + verify(protocolLayerClient, times(2)) + .executeFileAppendRequestTransaction(fileAppendCaptor.capture()); + verify(protocolLayerClient, times(1)).executeFileInfoQuery(any(FileInfoRequest.class)); + + final List capturedRequests = fileAppendCaptor.getAllValues(); + Assertions.assertEquals(fileId, capturedRequests.get(0).fileId()); + Assertions.assertEquals( + FileCreateRequest.FILE_CREATE_MAX_SIZE, capturedRequests.get(0).contents().length); + + Assertions.assertEquals(fileId, capturedRequests.get(1).fileId()); + Assertions.assertEquals( + FileCreateRequest.FILE_CREATE_MAX_SIZE, capturedRequests.get(1).contents().length); + } + + @Test + void testAppendFileWithLargeContentUnevenSize() throws HieroException { + // mocks + final FileInfoResponse fileInfoResponse = Mockito.mock(FileInfoResponse.class); + + // given + final FileId fileId = FileId.fromString("1.2.3"); + final byte[] content = new byte[FileCreateRequest.FILE_CREATE_MAX_SIZE + 2]; + + when(protocolLayerClient.executeFileInfoQuery(any(FileInfoRequest.class))) + .thenReturn(fileInfoResponse); + when(fileInfoResponse.size()).thenReturn(1); + + fileClientImpl.appendFile(fileId, content); + + // call for each chunk 2048 and 2 + verify(protocolLayerClient, times(2)) + .executeFileAppendRequestTransaction(fileAppendCaptor.capture()); + verify(protocolLayerClient, times(1)).executeFileInfoQuery(any(FileInfoRequest.class)); + + final List capturedRequests = fileAppendCaptor.getAllValues(); + Assertions.assertEquals(fileId, capturedRequests.get(0).fileId()); + Assertions.assertEquals( + FileCreateRequest.FILE_CREATE_MAX_SIZE, capturedRequests.get(0).contents().length); + + Assertions.assertEquals(fileId, capturedRequests.get(1).fileId()); + Assertions.assertEquals(2, capturedRequests.get(1).contents().length); + } + @Test void testGetFileSize() throws HieroException { // mocks diff --git a/hiero-enterprise-microprofile/src/test/java/org/hiero/microprofile/test/FileClientTests.java b/hiero-enterprise-microprofile/src/test/java/org/hiero/microprofile/test/FileClientTests.java index 0be95b85..d03fe7a9 100644 --- a/hiero-enterprise-microprofile/src/test/java/org/hiero/microprofile/test/FileClientTests.java +++ b/hiero-enterprise-microprofile/src/test/java/org/hiero/microprofile/test/FileClientTests.java @@ -8,6 +8,8 @@ import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.spi.ConfigProviderResolver; import org.hiero.base.FileClient; +import org.hiero.base.HieroException; +import org.hiero.base.protocol.data.FileCreateRequest; import org.hiero.microprofile.ClientProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; @@ -40,4 +42,41 @@ void testFileClient() throws Exception { // then Assertions.assertNotNull(fileId); } + + @Test + void testAppendFile() throws HieroException { + final byte[] contents = "Hello,".getBytes(); + final FileId fileId = fileClient.createFile(contents); + + Assertions.assertNotNull(fileId); + + fileClient.appendFile(fileId, " Hiero!"); + byte[] bytes = fileClient.readFile(fileId); + + Assertions.assertNotNull(bytes); + Assertions.assertEquals("Hello, Hiero!", new String(bytes)); + } + + @Test + void testAppendFileThrowsExceptionWhenContentExceedMaxSize() throws HieroException { + final byte[] content = new byte[FileCreateRequest.FILE_MAX_SIZE + 1]; + final FileId fileId = fileClient.createFile(new byte[0]); + + Assertions.assertNotNull(fileId); + Assertions.assertThrows(HieroException.class, () -> fileClient.appendFile(fileId, content)); + } + + @Test + void testChunkedAppendFile() throws HieroException { + final String content = "A".repeat(FileCreateRequest.FILE_CREATE_MAX_SIZE * 2); + final FileId fileId = fileClient.createFile(new byte[0]); + + Assertions.assertNotNull(fileId); + + fileClient.appendFile(fileId, content); + byte[] bytes = fileClient.readFile(fileId); + + Assertions.assertNotNull(bytes); + Assertions.assertEquals(content, new String(bytes)); + } } diff --git a/hiero-enterprise-spring/src/test/java/org/hiero/spring/test/FileClientTests.java b/hiero-enterprise-spring/src/test/java/org/hiero/spring/test/FileClientTests.java index a00120dc..0c20c4d9 100644 --- a/hiero-enterprise-spring/src/test/java/org/hiero/spring/test/FileClientTests.java +++ b/hiero-enterprise-spring/src/test/java/org/hiero/spring/test/FileClientTests.java @@ -367,6 +367,44 @@ void testUpdateExpirationTimeThrowsExceptionForNullArgs() throws HieroException NullPointerException.class, () -> fileClient.updateExpirationTime(null, null)); } + @Test + void testAppendFile() throws HieroException { + final byte[] contents = "Hello,".getBytes(); + final FileId fileId = fileClient.createFile(contents); + + Assertions.assertNotNull(fileId); + + fileClient.appendFile(fileId, " Hiero!"); + byte[] bytes = fileClient.readFile(fileId); + + Assertions.assertNotNull(bytes); + Assertions.assertEquals("Hello, Hiero!", new String(bytes)); + } + + @Test + void testAppendFileThrowsExceptionWhenContentExceedMaxSize() throws HieroException { + final byte[] content = new byte[FileCreateRequest.FILE_MAX_SIZE + 1]; + final FileId fileId = fileClient.createFile(new byte[0]); + + Assertions.assertNotNull(fileId); + Assertions.assertThrows(HieroException.class, () -> fileClient.appendFile(fileId, content)); + } + + @Test + void testChunkedAppendFile() throws HieroException { + // 2048 * 2 = 2 chunk + final String content = "A".repeat(FileCreateRequest.FILE_CREATE_MAX_SIZE * 2); + final FileId fileId = fileClient.createFile(new byte[0]); + + Assertions.assertNotNull(fileId); + + fileClient.appendFile(fileId, content); + byte[] bytes = fileClient.readFile(fileId); + + Assertions.assertNotNull(bytes); + Assertions.assertEquals(content, new String(bytes)); + } + @Test void testGetFileSize() throws HieroException { final byte[] contents = "Hello, Hiero!".getBytes();