From 3135d807ec6d3fe484abe97a16e16fa9f3a3f1ff Mon Sep 17 00:00:00 2001 From: Jose Date: Thu, 25 Aug 2022 13:32:02 +0200 Subject: [PATCH] RESTEasy Reactive - Support text/binary conversion for multipart files Assuming we receive the following request: ``` Content-Disposition: form-data; name="textPart"; filename="my_file.txt" Content-Type: application/octet-stream Content-Transfer-Encoding: binary ``` We can load the content of the file using the `FileUpload` class: ```java public class FormData { @FormParam("myFile") public FileUpload myFile; } ``` However, according to what the specs state: ``` 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. ``` Then, we should not enforce the use of `FileUpload` and hence also support reading the whole content of the file into strings, and other binary formats like byte[] and InputStream. ```java public class FormData { @FormParam("myFile") public String myFile; } public class FormData { @FormParam("myFile") @PartType(MediaType.APPLICATION_OCTET_STREAM) public byte[] binaryPart; } public class FormData { @FormParam("myFile") @PartType(MediaType.APPLICATION_OCTET_STREAM) public InputStream streamPart; } ``` Fix https://github.com/quarkusio/quarkus/issues/27083 --- .../multipart/MultipartFilenameTest.java | 99 +++++++++++++++++++ .../MultipartPopulatorGenerator.java | 27 ++++- .../core/ResteasyReactiveRequestContext.java | 13 ++- .../core/multipart/MultipartSupport.java | 39 ++++++++ 4 files changed, 173 insertions(+), 5 deletions(-) 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 333226f0a9bf4e..670ab3e10aa333 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,51 @@ 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") + 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 +166,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 ef0f652e40b12d..a8a951f66486ac 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,27 @@ 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 { + 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/ResteasyReactiveRequestContext.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java index 5a071585efe960..62e81b178d213f 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java @@ -8,6 +8,7 @@ import java.lang.reflect.Type; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -867,12 +868,20 @@ public Object getFormParameter(String name, boolean single, boolean encoded) { } if (single) { FormData.FormValue val = formData.getFirst(name); - if (val == null || val.isFileItem()) { + if (val == null) { return null; } - if (encoded) { + + if (val.isFileItem()) { + try { + return Files.readString(val.getFileItem().getFile()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } else if (encoded) { return Encode.encodeQueryParam(val.getValue()); } + return val.getValue(); } Deque val = formData.get(name); 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 f598d3eaf44737..f255b13bb030f4 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,19 @@ 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.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 +105,42 @@ public static Object convertFormAttribute(String value, Class type, Type generic throw new NotSupportedException("Media type '" + mediaType + "' in multipart request is not supported"); } + 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 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 uploads = getFileUploads(formName, context); if (!uploads.isEmpty()) {