diff --git a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/FileHttpMessageConverter.java b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/FileHttpMessageConverter.java new file mode 100644 index 000000000..fbe48d6a6 --- /dev/null +++ b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/FileHttpMessageConverter.java @@ -0,0 +1,87 @@ +package com.sap.ai.sdk.prompt.registry; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import javax.annotation.Nonnull; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.util.StreamUtils; + +/** + * A custom implementation {@link HttpMessageConverter} for Spring's RestTemplate to read and write + * {@link java.io.File} objects in {@code application/octet-stream} payloads. + * + * @see org.springframework.http.converter.AbstractHttpMessageConverter + */ +class FileHttpMessageConverter extends AbstractHttpMessageConverter { + + FileHttpMessageConverter() { + super(MediaType.APPLICATION_OCTET_STREAM); + } + + /** + * Indicates whether this converter supports the given class. + * + *

This implementation supports only {@link File}. + * + * @param clazz the target class to check + * @return {@code true} if and only if {@code clazz} is {@link File} + */ + @Override + protected boolean supports(@Nonnull final Class clazz) { + return File.class == clazz; + } + + /** + * Reads the {@link HttpInputMessage} body into a new file in system's temporary directory. + * + *

The response body is streamed directly into this file without buffering the entire content + * in memory. + * + *

The caller is responsible for deleting the returned file. + * + * @param clazz the expected target class (always {@link File} + * @param inputMessage the HTTP message containing the response body + * @return a {@link File} containing the streamed response data + * @throws IOException if file creation or streaming fails + * @throws HttpMessageNotReadableException if the message cannot be read + */ + @Nonnull + @Override + protected File readInternal( + @Nonnull final Class clazz, @Nonnull final HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + final var tempFile = File.createTempFile("tmp", ".bin"); + try (OutputStream out = Files.newOutputStream(tempFile.toPath())) { + StreamUtils.copy(inputMessage.getBody(), out); + } + return tempFile; + } + + /** + * Writes the contents of a {@link File} into the HTTP request body. + * + *

The file is streamed directly into the output message, avoiding unnecessary buffering. + * + * @param file the file whose contents should be written + * @param outputMessage the HTTP message whose body should be written to + * @throws IOException if reading the file or writing the body fails + * @throws HttpMessageNotWritableException if the message cannot be written + */ + @Override + protected void writeInternal( + @Nonnull final File file, @Nonnull final HttpOutputMessage outputMessage) + throws IOException, HttpMessageNotWritableException { + try (InputStream in = Files.newInputStream(file.toPath())) { + StreamUtils.copy(in, outputMessage.getBody()); + } + } +} diff --git a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java index 56326c8db..dfea63f91 100644 --- a/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java +++ b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/PromptClient.java @@ -65,7 +65,7 @@ private static ApiClient addMixin(@Nonnull final AiCoreService service) { JacksonMixin.ResponseFormat.class))); rt.setRequestFactory(new BufferingClientHttpRequestFactory(httpRequestFactory)); - + rt.getMessageConverters().add(new FileHttpMessageConverter()); return new ApiClient(rt).setBasePath(destination.asHttp().getUri().toString()); } diff --git a/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/FileHttpMessageConverterTest.java b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/FileHttpMessageConverterTest.java new file mode 100644 index 000000000..a48761376 --- /dev/null +++ b/core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/FileHttpMessageConverterTest.java @@ -0,0 +1,89 @@ +package com.sap.ai.sdk.prompt.registry; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.annotation.Nonnull; +import jdk.jfr.Description; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; + +class FileHttpMessageConverterTest { + + @Test + void testSupports() { + final var converter = new FileHttpMessageConverter(); + + assertThat(converter.supports(File.class)).isTrue(); + assertThat(converter.supports(String.class)).isFalse(); + } + + @Test + @Description("Test conversion from HttpInputMessage to File") + void testReadInternal() throws IOException { + final var converter = new FileHttpMessageConverter(); + final var messageContent = "Hello, World!".getBytes(); + + final var inputMessage = + new HttpInputMessage() { + @Nonnull + @Override + public InputStream getBody() { + return new ByteArrayInputStream(messageContent); + } + + @Nonnull + @Override + public HttpHeaders getHeaders() { + return new HttpHeaders(); + } + }; + + final var generatedFile = converter.readInternal(File.class, inputMessage); + try { + assertThat(generatedFile).exists().isFile(); + assertThat(Files.readAllBytes(generatedFile.toPath())).isEqualTo(messageContent); + } finally { + Files.deleteIfExists(generatedFile.toPath()); + } + } + + @Test + @Description("Test conversion from File to HttpOutputMessage") + void testWriteInternal(@TempDir final Path tempDir) throws IOException { + final var converter = new FileHttpMessageConverter(); + final var fileContent = "Hello, World!".getBytes(); + final var tempFilePath = tempDir.resolve("testFile.txt"); + Files.write(tempFilePath, fileContent); + + final var outputStream = new ByteArrayOutputStream(); + final var outputMessage = + new HttpOutputMessage() { + + @Nonnull + @Override + public HttpHeaders getHeaders() { + return new HttpHeaders(); + } + + @Nonnull + @Override + public OutputStream getBody() { + return outputStream; + } + }; + + converter.writeInternal(tempFilePath.toFile(), outputMessage); + assertThat(outputStream.toByteArray()).isEqualTo(fileContent); + } +} diff --git a/sample-code/spring-app/pom.xml b/sample-code/spring-app/pom.xml index c82279d6c..85a11e90f 100644 --- a/sample-code/spring-app/pom.xml +++ b/sample-code/spring-app/pom.xml @@ -232,6 +232,11 @@ assertj-core test + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + test + diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/PromptRegistryTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/PromptRegistryTest.java index e52422d4b..fb13c7e10 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/PromptRegistryTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/PromptRegistryTest.java @@ -1,7 +1,11 @@ package com.sap.ai.sdk.app.controllers; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.sap.ai.sdk.prompt.registry.model.PromptTemplate; import com.sap.ai.sdk.prompt.registry.model.PromptTemplateDeleteResponse; import com.sap.ai.sdk.prompt.registry.model.PromptTemplateListResponse; @@ -9,11 +13,15 @@ import com.sap.ai.sdk.prompt.registry.model.PromptTemplateSubstitutionResponse; import com.sap.ai.sdk.prompt.registry.model.SingleChatTemplate; import java.io.IOException; +import java.nio.file.Files; import java.util.List; import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; public class PromptRegistryTest { + static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + @Test void listTemplates() { var controller = new PromptRegistryController(); @@ -56,7 +64,13 @@ void importExportTemplate() throws IOException { PromptTemplatePostResponse template = controller.importTemplate(); assertThat(template.getMessage()).contains("successful"); - // export TODO: NOT WORKING + // export + final var exportedTemplate = controller.exportTemplate(); + + final var resource = new ClassPathResource("prompt-template.yaml"); + final JsonNode expectedYaml = YAML_MAPPER.readTree(resource.getContentAsString(UTF_8)); + assertThat(YAML_MAPPER.readTree(exportedTemplate)).isEqualTo(expectedYaml); + Files.deleteIfExists(exportedTemplate.toPath()); // cleanup List deletedTemplate = controller.deleteTemplate();