Repository: quarkusio/quarkus
Issue: RESTEasy Reactive - Server side multipart implementation is not being respected

### Describe the bug

We have a multipart endpoint which accepts two parts handled as Strings such as this:

```
public class MultipartUpload {

    @RestForm
    @PartType(MediaType.TEXT_PLAIN)
    public String textPart;

    @RestForm
    @PartType(MediaType.APPLICATION_OCTET_STREAM)
    public String anotherTextPart;

}
```

This is after we migrated to RESTEasy Reactive. With the exception of anotherTextPart being of type InputStream instead nothing has really changed since then, so essentially a multipart where we're not interested in working with the parts as files.

What we weren't aware of was that some of our users are sending us parts with a filename provided, and whenever they do these two parts end up as null.
I've tried looking into the implementation and it kind of looks like the MultipartParser will store everything into a file whenever a filename is provided. Haven't really understood the code completely but my guess is that the implementation will then fail to retrieve the data that was read unless the multipart is defined with File types. But I might be wrong here though.

Looking at the [RFC-2183](https://datatracker.ietf.org/doc/html/rfc2183#section-2.3) for the Content-Disposition header, this behavior does not seem in line with the specification:

> The presence of the filename parameter does not force an
>    implementation to write the entity to a separate file. It is
>    perfectly acceptable for implementations to leave the entity as part
>    of the normal mail stream unless the user requests otherwise. As a
>    consequence, the parameter may be used on any MIME entity, even
>    `inline' ones. These will not normally be written to files, but the
>    parameter could be used to provide a filename if the receiving user
>    should choose to write the part to a file.


The users haven't changed anything in their implementation so it seems pretty evident that this has changed between Classic and Reactive.

### Expected behavior

The server side implementation should be respected regardless of what the client sends.
Unless a RESTEasy server multipart has implemented it, don't write parts of a multipart request to file, even if the Content-Disposition includes a filename. 
The request should still work as if the client had not provided a file name for the part in question, and such is the case in Classic.

### Actual behavior

If a client sends a part with filename in the Content-Disposition header, the receiving end implementation writes the data to a file regardless of what the server side multipart implementation looks like. However, if the server side does not implement the part as a file it will not be able to retrieve the data for further use, i.e the part becomes null.

### How to Reproduce?

_No response_

### Output of `uname -a` or `ver`

_No response_

### Output of `java -version`

_No response_

### GraalVM version (if different from Java)

_No response_

### Quarkus version or git rev

_No response_

### Build tool (ie. output of `mvnw --version` or `gradlew --version`)

_No response_

### Additional information

_No response_

Changed files:
independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/generation/multipart/MultipartPopulatorGenerator.java
independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartSupport.java
extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/multipart/MultipartFilenameTest.java

diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/multipart/MultipartFilenameTest.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/multipart/MultipartFilenameTest.java
index 333226f0a9b..22c7a845d41 100644
--- a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/multipart/MultipartFilenameTest.java
+++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/multipart/MultipartFilenameTest.java
@@ -2,9 +2,15 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 
+import java.io.BufferedReader;
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.stream.Collectors;
 
 import javax.enterprise.context.ApplicationScoped;
 import javax.ws.rs.Consumes;
@@ -57,6 +63,45 @@ void shouldUseFileNameFromAnnotation() throws IOException {
         assertThat(client.postMultipartWithPartFilename(form)).isEqualTo(ClientForm2.FILE_NAME);
     }
 
+    @Test
+    void shouldCopyFileContentToString() throws IOException {
+        Client client = RestClientBuilder.newBuilder().baseUri(baseUri).build(Client.class);
+
+        File file = File.createTempFile("MultipartTest", ".txt");
+        Files.writeString(file.toPath(), "content!");
+        file.deleteOnExit();
+
+        ClientForm form = new ClientForm();
+        form.file = file;
+        assertThat(client.postMultipartWithFileContent(form)).isEqualTo("content!");
+    }
+
+    @Test
+    void shouldCopyFileContentToBytes() throws IOException {
+        Client client = RestClientBuilder.newBuilder().baseUri(baseUri).build(Client.class);
+
+        File file = File.createTempFile("MultipartTest", ".txt");
+        Files.writeString(file.toPath(), "content!");
+        file.deleteOnExit();
+
+        ClientForm form = new ClientForm();
+        form.file = file;
+        assertThat(client.postMultipartWithFileContentAsBytes(form)).isEqualTo("content!");
+    }
+
+    @Test
+    void shouldCopyFileContentToInputStream() throws IOException {
+        Client client = RestClientBuilder.newBuilder().baseUri(baseUri).build(Client.class);
+
+        File file = File.createTempFile("MultipartTest", ".txt");
+        Files.writeString(file.toPath(), "content!");
+        file.deleteOnExit();
+
+        ClientForm form = new ClientForm();
+        form.file = file;
+        assertThat(client.postMultipartWithFileContentAsInputStream(form)).isEqualTo("content!");
+    }
+
     @Path("/multipart")
     @ApplicationScoped
     public static class Resource {
@@ -65,12 +110,52 @@ public static class Resource {
         public String upload(@MultipartForm FormData form) {
             return form.myFile.fileName();
         }
+
+        @POST
+        @Path("/file-content")
+        @Consumes(MediaType.MULTIPART_FORM_DATA)
+        public String uploadWithFileContent(@MultipartForm FormDataWithFileContent form) {
+            return form.fileContent;
+        }
+
+        @POST
+        @Path("/file-content-as-bytes")
+        @Consumes(MediaType.MULTIPART_FORM_DATA)
+        public String uploadWithFileContentAsBytes(@MultipartForm FormDataWithBytes form) {
+            return new String(form.fileContentAsBytes);
+        }
+
+        @POST
+        @Path("/file-content-as-inputstream")
+        @Consumes(MediaType.MULTIPART_FORM_DATA)
+        public String uploadWithFileContentAsInputStream(@MultipartForm FormDataWithInputStream form) {
+            return new BufferedReader(new InputStreamReader(form.fileContentAsInputStream, StandardCharsets.UTF_8))
+                    .lines()
+                    .collect(Collectors.joining(System.lineSeparator()));
+        }
     }
 
     public static class FormData {
         @FormParam("myFile")
         public FileUpload myFile;
+    }
 
+    public static class FormDataWithFileContent {
+        @FormParam("myFile")
+        @PartType(MediaType.APPLICATION_OCTET_STREAM)
+        public String fileContent;
+    }
+
+    public static class FormDataWithBytes {
+        @FormParam("myFile")
+        @PartType(MediaType.APPLICATION_OCTET_STREAM)
+        public byte[] fileContentAsBytes;
+    }
+
+    public static class FormDataWithInputStream {
+        @FormParam("myFile")
+        @PartType(MediaType.APPLICATION_OCTET_STREAM)
+        public InputStream fileContentAsInputStream;
     }
 
     @Path("/multipart")
@@ -82,6 +167,21 @@ public interface Client {
         @POST
         @Consumes(MediaType.MULTIPART_FORM_DATA)
         String postMultipartWithPartFilename(@MultipartForm ClientForm2 clientForm);
+
+        @POST
+        @Path("/file-content")
+        @Consumes(MediaType.MULTIPART_FORM_DATA)
+        String postMultipartWithFileContent(@MultipartForm ClientForm clientForm);
+
+        @POST
+        @Path("/file-content-as-bytes")
+        @Consumes(MediaType.MULTIPART_FORM_DATA)
+        String postMultipartWithFileContentAsBytes(@MultipartForm ClientForm clientForm);
+
+        @POST
+        @Path("/file-content-as-inputstream")
+        @Consumes(MediaType.MULTIPART_FORM_DATA)
+        String postMultipartWithFileContentAsInputStream(@MultipartForm ClientForm clientForm);
     }
 
     public static class ClientForm {
diff --git a/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/generation/multipart/MultipartPopulatorGenerator.java b/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/generation/multipart/MultipartPopulatorGenerator.java
index ef0f652e40b..68418ada79c 100644
--- a/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/generation/multipart/MultipartPopulatorGenerator.java
+++ b/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/generation/multipart/MultipartPopulatorGenerator.java
@@ -13,6 +13,7 @@
 import io.quarkus.gizmo.MethodDescriptor;
 import io.quarkus.gizmo.ResultHandle;
 import java.io.File;
+import java.io.InputStream;
 import java.lang.reflect.Modifier;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -241,11 +242,10 @@ public static String generate(ClassInfo multipartClassInfo, ClassOutput classOut
                                 "Setter '" + setterName + "' of class '" + multipartClassInfo + "' must be public");
                     }
 
-                    if (fieldDotName.equals(DotNames.INPUT_STREAM_NAME)
-                            || fieldDotName.equals(DotNames.INPUT_STREAM_READER_NAME)) {
+                    if (fieldDotName.equals(DotNames.INPUT_STREAM_READER_NAME)) {
                         // don't support InputStream as it's too easy to get into trouble
                         throw new IllegalArgumentException(
-                                "InputStream and InputStreamReader are not supported as a field type of a Multipart POJO class. Offending field is '"
+                                "InputStreamReader are not supported as a field type of a Multipart POJO class. Offending field is '"
                                         + field.name() + "' of class '" + multipartClassName + "'");
                     }
 
@@ -332,6 +332,33 @@ public static String generate(ClassInfo multipartClassInfo, ClassOutput classOut
                                     rrCtxHandle);
                             populate.assign(resultVariableHandle, allFileUploadsHandle);
                         }
+                    } else if (partType.equals(MediaType.APPLICATION_OCTET_STREAM)) {
+                        if (fieldType.kind() == Type.Kind.ARRAY
+                                && fieldType.asArrayType().component().name().equals(DotNames.BYTE_NAME)) {
+                            populate.assign(resultVariableHandle,
+                                    populate.invokeStaticMethod(MethodDescriptor.ofMethod(MultipartSupport.class,
+                                            "getSingleFileUploadAsArrayBytes", byte[].class, String.class,
+                                            ResteasyReactiveRequestContext.class),
+                                            formAttrNameHandle, rrCtxHandle));
+                        } else if (fieldDotName.equals(DotNames.INPUT_STREAM_NAME)) {
+                            populate.assign(resultVariableHandle,
+                                    populate.invokeStaticMethod(MethodDescriptor.ofMethod(MultipartSupport.class,
+                                            "getSingleFileUploadAsInputStream", InputStream.class, String.class,
+                                            ResteasyReactiveRequestContext.class),
+                                            formAttrNameHandle, rrCtxHandle));
+                        } else if (fieldDotName.equals(DotNames.STRING_NAME)) {
+                            populate.assign(resultVariableHandle,
+                                    populate.invokeStaticMethod(MethodDescriptor.ofMethod(MultipartSupport.class,
+                                            "getSingleFileUploadAsString", String.class, String.class,
+                                            ResteasyReactiveRequestContext.class),
+                                            formAttrNameHandle, rrCtxHandle));
+                        } else {
+                            throw new IllegalArgumentException(
+                                    "Unsupported type to read multipart file contents. Offending field is '"
+                                            + field.name() + "' of class '"
+                                            + field.declaringClass().name()
+                                            + "'. If you need to read the contents of the uploaded file, use 'Path' or 'File' as the field type and use File IO APIs to read the bytes, while making sure you annotate the endpoint with '@Blocking'");
+                        }
                     } else {
                         // this is a common enough mistake, so let's provide a good error message
                         failIfFileTypeUsedAsGenericType(field, fieldType, fieldDotName);
diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartSupport.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartSupport.java
index f598d3eaf44..5af3b1b30a7 100644
--- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartSupport.java
+++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartSupport.java
@@ -2,16 +2,20 @@
 
 import java.io.ByteArrayInputStream;
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.Type;
+import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import javax.ws.rs.BadRequestException;
 import javax.ws.rs.NotSupportedException;
 import javax.ws.rs.RuntimeType;
 import javax.ws.rs.core.MediaType;
@@ -102,6 +106,55 @@ public static Object convertFormAttribute(String value, Class type, Type generic
         throw new NotSupportedException("Media type '" + mediaType + "' in multipart request is not supported");
     }
 
+    public static String getSingleFileUploadAsString(String formName, ResteasyReactiveRequestContext context) {
+        DefaultFileUpload upload = getSingleFileUpload(formName, context);
+        if (upload != null) {
+            try {
+                return Files.readString(upload.filePath(), Charset.defaultCharset());
+            } catch (IOException e) {
+                throw new MultipartPartReadingException(e);
+            }
+        }
+
+        return null;
+    }
+
+    public static byte[] getSingleFileUploadAsArrayBytes(String formName, ResteasyReactiveRequestContext context) {
+        DefaultFileUpload upload = getSingleFileUpload(formName, context);
+        if (upload != null) {
+            try {
+                return Files.readAllBytes(upload.filePath());
+            } catch (IOException e) {
+                throw new MultipartPartReadingException(e);
+            }
+        }
+
+        return null;
+    }
+
+    public static InputStream getSingleFileUploadAsInputStream(String formName, ResteasyReactiveRequestContext context) {
+        DefaultFileUpload upload = getSingleFileUpload(formName, context);
+        if (upload != null) {
+            try {
+                return new FileInputStream(upload.filePath().toFile());
+            } catch (IOException e) {
+                throw new MultipartPartReadingException(e);
+            }
+        }
+
+        return null;
+    }
+
+    public static DefaultFileUpload getSingleFileUpload(String formName, ResteasyReactiveRequestContext context) {
+        List<DefaultFileUpload> uploads = getFileUploads(formName, context);
+        if (uploads.size() > 1) {
+            throw new BadRequestException("Found more than one files for attribute '" + formName + "'. Expected only one file");
+        } else if (uploads.size() == 1) {
+            return uploads.get(0);
+        }
+        return null;
+    }
+
     public static DefaultFileUpload getFileUpload(String formName, ResteasyReactiveRequestContext context) {
         List<DefaultFileUpload> uploads = getFileUploads(formName, context);
         if (!uploads.isEmpty()) {