From 0f8ed889948cb748d6c098da17b27d8f97a61c87 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Thu, 20 Nov 2025 18:57:57 +0100 Subject: [PATCH 1/4] Register new File converter and test --- .../registry/FileHttpMessageConverter.java | 48 ++++++++++ .../ai/sdk/prompt/registry/PromptClient.java | 2 +- .../FileHttpMessageConverterTest.java | 89 +++++++++++++++++++ sample-code/spring-app/pom.xml | 5 ++ .../app/controllers/PromptRegistryTest.java | 14 ++- 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/FileHttpMessageConverter.java create mode 100644 core-services/prompt-registry/src/test/java/com/sap/ai/sdk/prompt/registry/FileHttpMessageConverterTest.java 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..a11426b60 --- /dev/null +++ b/core-services/prompt-registry/src/main/java/com/sap/ai/sdk/prompt/registry/FileHttpMessageConverter.java @@ -0,0 +1,48 @@ +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.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.util.StreamUtils; + +class FileHttpMessageConverter extends AbstractHttpMessageConverter { + + FileHttpMessageConverter() { + super(MediaType.APPLICATION_OCTET_STREAM); + } + + @Override + protected boolean supports(@Nonnull final Class clazz) { + return File.class == clazz; + } + + @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; + } + + @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..6a30ad978 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; @@ -11,9 +15,12 @@ import java.io.IOException; 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 +63,12 @@ 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); // cleanup List deletedTemplate = controller.deleteTemplate(); From c4ef94198c49bb244b7647622ef2c54ceb18865f Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Fri, 21 Nov 2025 13:29:17 +0100 Subject: [PATCH 2/4] dele template file after use in e2e --- .../java/com/sap/ai/sdk/app/controllers/PromptRegistryTest.java | 2 ++ 1 file changed, 2 insertions(+) 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 6a30ad978..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 @@ -13,6 +13,7 @@ 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; @@ -69,6 +70,7 @@ void importExportTemplate() throws IOException { 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(); From e495e5afd33529c851b6a0921717d5f3e0f1903a Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Fri, 21 Nov 2025 14:09:33 +0100 Subject: [PATCH 3/4] Add javadoc on FileHttpMessageConverter for --- .../registry/FileHttpMessageConverter.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) 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 index a11426b60..06ce7bea6 100644 --- 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 @@ -10,21 +10,50 @@ 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 {@link HttpMessageConverter} that enables Spring's RestTemplate to read and write {@link + * java.io.File} objects for {@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 temporary file and returns it. + * + *

A temporary file is created in the system temp directory and 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( @@ -37,6 +66,16 @@ protected File readInternal( 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) From ea903a6f01084942a46442f15bb9ad68205ac423 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Fri, 21 Nov 2025 14:12:39 +0100 Subject: [PATCH 4/4] Add javadoc on FileHttpMessageConverter for clarity --- .../sdk/prompt/registry/FileHttpMessageConverter.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 06ce7bea6..fbe48d6a6 100644 --- 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 @@ -16,8 +16,8 @@ import org.springframework.util.StreamUtils; /** - * A custom {@link HttpMessageConverter} that enables Spring's RestTemplate to read and write {@link - * java.io.File} objects for {@code application/octet-stream} payloads. + * 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 */ @@ -41,10 +41,10 @@ protected boolean supports(@Nonnull final Class clazz) { } /** - * Reads the {@link HttpInputMessage} body into a new temporary file and returns it. + * Reads the {@link HttpInputMessage} body into a new file in system's temporary directory. * - *

A temporary file is created in the system temp directory and the response body is streamed - * directly into this file without buffering the entire content in memory. + *

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. *