From 6967fbc1c7d46ef1342c3d332ac85ecd6f87f73c Mon Sep 17 00:00:00 2001 From: Jose Date: Wed, 26 Oct 2022 08:28:25 +0200 Subject: [PATCH] Support programmatic `multipart/form-data` responses With these changes, users can also manually append the parts of the form using the class `MultipartFormDataOutput` as: ```java import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.core.MediaType; import org.jboss.resteasy.reactive.server.core.multipart.MultipartFormDataOutput; @Path("multipart") public class Endpoint { @GET @Produces(MediaType.MULTIPART_FORM_DATA) @Path("file") public MultipartFormDataOutput getFile() { MultipartFormDataOutput form = new MultipartFormDataOutput(); form.addFormData("person", new Person("John"), MediaType.APPLICATION_JSON_TYPE); form.addFormData("status", "a status", MediaType.TEXT_PLAIN_TYPE) .getHeaders().putSingle("extra-header", "extra-value"); return form; } } ``` Fix https://github.com/quarkusio/quarkus/issues/28631 This last approach allows you adding extra headers to the output part. --- docs/src/main/asciidoc/resteasy-reactive.adoc | 29 ++ .../deployment/test/MultipartOutputTest.java | 1 - .../FormDataOutputMapperGenerator.java | 265 ------------------ .../QuarkusMultipartReturnTypeHandler.java | 1 + .../multipart/MultipartOutputResource.java | 15 + ...ipartOutputUsingBlockingEndpointsTest.java | 16 ++ .../FormDataOutputMapperGenerator.java | 41 ++- .../server/core/ServerSerialisers.java | 4 + .../multipart/MultipartFormDataOutput.java | 25 ++ .../multipart/MultipartMessageBodyWriter.java | 71 +++-- .../MultipartOutputInjectionTarget.java | 4 +- .../server/core/multipart/PartItem.java | 56 ++-- .../multipart/MultipartOutputResource.java | 16 ++ ...ipartOutputUsingBlockingEndpointsTest.java | 16 ++ 14 files changed, 221 insertions(+), 339 deletions(-) delete mode 100644 extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/FormDataOutputMapperGenerator.java create mode 100644 independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartFormDataOutput.java diff --git a/docs/src/main/asciidoc/resteasy-reactive.adoc b/docs/src/main/asciidoc/resteasy-reactive.adoc index 2b2609cfeef64..ff07bba6af0a0 100644 --- a/docs/src/main/asciidoc/resteasy-reactive.adoc +++ b/docs/src/main/asciidoc/resteasy-reactive.adoc @@ -578,6 +578,35 @@ public class Endpoint { } ---- +Additionally, you can also manually append the parts of the form using the class `MultipartFormDataOutput` as: + +[source,java] +---- +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +import org.jboss.resteasy.reactive.server.core.multipart.MultipartFormDataOutput; + +@Path("multipart") +public class Endpoint { + + @GET + @Produces(MediaType.MULTIPART_FORM_DATA) + @Path("file") + public MultipartFormDataOutput getFile() { + MultipartFormDataOutput form = new MultipartFormDataOutput(); + form.addFormData("person", new Person("John"), MediaType.APPLICATION_JSON_TYPE); + form.addFormData("status", "a status", MediaType.TEXT_PLAIN_TYPE) + .getHeaders().putSingle("extra-header", "extra-value"); + return form; + } +} +---- + +This last approach allows you adding extra headers to the output part. + WARNING: For the time being, returning Multipart data is limited to be blocking endpoints. ==== Handling malformed input diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jsonb/deployment/src/test/java/io/quarkus/resteasy/reactive/jsonb/deployment/test/MultipartOutputTest.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jsonb/deployment/src/test/java/io/quarkus/resteasy/reactive/jsonb/deployment/test/MultipartOutputTest.java index 3a4e2977ed6d0..0a6a7cfcd8f2b 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jsonb/deployment/src/test/java/io/quarkus/resteasy/reactive/jsonb/deployment/test/MultipartOutputTest.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jsonb/deployment/src/test/java/io/quarkus/resteasy/reactive/jsonb/deployment/test/MultipartOutputTest.java @@ -38,7 +38,6 @@ public void testSimple() { .then() .contentType(ContentType.MULTIPART) .statusCode(200) - .log().all() .extract().asString(); assertContains(response, "name", MediaType.TEXT_PLAIN, EXPECTED_RESPONSE_NAME); diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/FormDataOutputMapperGenerator.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/FormDataOutputMapperGenerator.java deleted file mode 100644 index e591725ae5ed6..0000000000000 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/FormDataOutputMapperGenerator.java +++ /dev/null @@ -1,265 +0,0 @@ -package io.quarkus.resteasy.reactive.server.deployment; - -import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.FORM_PARAM; -import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.REST_FORM_PARAM; - -import java.lang.reflect.Modifier; -import java.util.ArrayList; -import java.util.List; - -import javax.ws.rs.core.MediaType; - -import org.jboss.jandex.AnnotationInstance; -import org.jboss.jandex.AnnotationValue; -import org.jboss.jandex.ClassInfo; -import org.jboss.jandex.DotName; -import org.jboss.jandex.FieldInfo; -import org.jboss.jandex.IndexView; -import org.jboss.jandex.MethodInfo; -import org.jboss.jandex.Type; -import org.jboss.logging.Logger; -import org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames; -import org.jboss.resteasy.reactive.server.core.multipart.MultipartMessageBodyWriter; -import org.jboss.resteasy.reactive.server.core.multipart.MultipartOutputInjectionTarget; -import org.jboss.resteasy.reactive.server.core.multipart.PartItem; - -import io.quarkus.deployment.bean.JavaBeanUtil; -import io.quarkus.gizmo.AssignableResultHandle; -import io.quarkus.gizmo.ClassCreator; -import io.quarkus.gizmo.ClassOutput; -import io.quarkus.gizmo.FieldDescriptor; -import io.quarkus.gizmo.MethodCreator; -import io.quarkus.gizmo.MethodDescriptor; -import io.quarkus.gizmo.ResultHandle; - -final class FormDataOutputMapperGenerator { - - private static final Logger LOGGER = Logger.getLogger(FormDataOutputMapperGenerator.class); - - private static final String TRANSFORM_METHOD_NAME = "mapFrom"; - private static final String ARRAY_LIST_ADD_METHOD_NAME = "add"; - - private FormDataOutputMapperGenerator() { - } - - /** - * Returns true whether the returning type uses either {@link org.jboss.resteasy.reactive.RestForm} - * or {@link org.jboss.resteasy.reactive.server.core.multipart.FormData} annotations. - */ - public static boolean isReturnTypeCompatible(ClassInfo returnTypeClassInfo, IndexView index) { - // go up the class hierarchy until we reach Object - ClassInfo currentClassInHierarchy = returnTypeClassInfo; - while (true) { - List fields = currentClassInHierarchy.fields(); - for (FieldInfo field : fields) { - if (Modifier.isStatic(field.flags())) { // nothing we need to do about static fields - continue; - } - - if (field.annotation(REST_FORM_PARAM) != null || field.annotation(FORM_PARAM) != null) { - // Found either @RestForm or @FormParam in returning class, it's compatible. - return true; - } - } - - DotName superClassDotName = currentClassInHierarchy.superName(); - if (superClassDotName.equals(DotNames.OBJECT_NAME)) { - break; - } - ClassInfo newCurrentClassInHierarchy = index.getClassByName(superClassDotName); - if (newCurrentClassInHierarchy == null) { - printWarningMessageForMissingJandexIndex(currentClassInHierarchy, superClassDotName); - break; - } - - currentClassInHierarchy = newCurrentClassInHierarchy; - } - - // if we reach this point then the returning type is not compatible. - return false; - } - - /** - * Generates a class that map a Pojo into {@link PartItem} that is then used by {@link MultipartMessageBodyWriter}. - * - *

- * For example for a pojo like: - * - *

-     * public class FormData {
-     *
-     *     @RestForm
-     *     @PartType(MediaType.TEXT_PLAIN)
-     *     private String text;
-     *
-     *     @RestForm
-     *     @PartType(MediaType.APPLICATION_OCTET_STREAM)
-     *     public File file;
-     *
-     *     public String getText() {
-     *         return text;
-     *     }
-     *
-     *     public void setText(String text) {
-     *         this.text = text;
-     *     }
-     *
-     *     public File getFile() {
-     *         return file;
-     *     }
-     *
-     *     public void setFile(File file) {
-     *         this.file = file;
-     *     }
-     * }
-     * 
- * - *

- * - * The generated mapper would look like: - * - *

-     * public class FormData_generated_mapper implements MultipartOutputInjectionTarget {
-     *
-     *     public FormDataOutput mapFrom(Object var1) {
-     *         FormDataOutput var2 = new FormDataOutput();
-     *         FormData var4 = (FormData) var1;
-     *         File var3 = var4.data;
-     *         MultipartSupport.addPartItemToFormDataOutput(var2, "file", "application/octet-stream", var3);
-     *         File var5 = var4.text;
-     *         MultipartSupport.addPartItemToFormDataOutput(var2, "text", "text/plain", var5);
-     *         return var2;
-     *     }
-     * }
-     * 
- */ - static String generate(ClassInfo returnTypeClassInfo, ClassOutput classOutput, IndexView index) { - String returnClassName = returnTypeClassInfo.name().toString(); - String generateClassName = MultipartMessageBodyWriter.getGeneratedMapperClassNameFor(returnClassName); - String interfaceClassName = MultipartOutputInjectionTarget.class.getName(); - try (ClassCreator cc = new ClassCreator(classOutput, generateClassName, null, Object.class.getName(), - interfaceClassName)) { - MethodCreator populate = cc.getMethodCreator(TRANSFORM_METHOD_NAME, List.class.getName(), - Object.class); - populate.setModifiers(Modifier.PUBLIC); - - ResultHandle listPartItemListInstanceHandle = populate.newInstance(MethodDescriptor.ofConstructor(ArrayList.class)); - ResultHandle inputInstanceHandle = populate.checkCast(populate.getMethodParam(0), returnClassName); - - // go up the class hierarchy until we reach Object - ClassInfo currentClassInHierarchy = returnTypeClassInfo; - while (true) { - List fields = currentClassInHierarchy.fields(); - for (FieldInfo field : fields) { - if (Modifier.isStatic(field.flags())) { // nothing we need to do about static fields - continue; - } - - AnnotationInstance formParamInstance = field.annotation(REST_FORM_PARAM); - if (formParamInstance == null) { - formParamInstance = field.annotation(FORM_PARAM); - } - if (formParamInstance == null) { // fields not annotated with @RestForm or @FormParam are completely ignored - continue; - } - - boolean useFieldAccess = false; - String getterName = JavaBeanUtil.getGetterName(field.name(), field.type().name()); - Type fieldType = field.type(); - DotName fieldDotName = fieldType.name(); - MethodInfo getter = currentClassInHierarchy.method(getterName); - if (getter == null) { - // even if the field is private, it will be transformed to be made public - useFieldAccess = true; - } - if (!useFieldAccess && !Modifier.isPublic(getter.flags())) { - throw new IllegalArgumentException( - "Getter '" + getterName + "' of class '" + returnTypeClassInfo + "' must be public"); - } - - String formAttrName = field.name(); - AnnotationValue formParamValue = formParamInstance.value(); - if (formParamValue != null) { - formAttrName = formParamValue.asString(); - } - - // TODO: not sure if this is correct, but it seems to be what RESTEasy does and it also makes most sense in the context of a POJO - String partType = MediaType.TEXT_PLAIN; - AnnotationInstance partTypeInstance = field.annotation(ResteasyReactiveDotNames.PART_TYPE_NAME); - if (partTypeInstance != null) { - AnnotationValue partTypeValue = partTypeInstance.value(); - if (partTypeValue != null) { - partType = partTypeValue.asString(); - } - } - - // Cast part type to MediaType. - AssignableResultHandle partTypeHandle = populate.createVariable(MediaType.class); - populate.assign(partTypeHandle, - populate.invokeStaticMethod( - MethodDescriptor.ofMethod(MediaType.class, "valueOf", MediaType.class, String.class), - populate.load(partType))); - - // Continue with the value - AssignableResultHandle resultHandle = populate.createVariable(Object.class); - - if (useFieldAccess) { - populate.assign(resultHandle, - populate.readInstanceField( - FieldDescriptor.of(currentClassInHierarchy.name().toString(), field.name(), - fieldDotName.toString()), - inputInstanceHandle)); - } else { - populate.assign(resultHandle, - populate.invokeVirtualMethod( - MethodDescriptor.ofMethod(currentClassInHierarchy.name().toString(), - getterName, fieldDotName.toString()), - inputInstanceHandle)); - } - - // Get parameterized type if field type is a parameterized class - String firstParamType = ""; - if (fieldType.kind() == Type.Kind.PARAMETERIZED_TYPE) { - List argumentTypes = fieldType.asParameterizedType().arguments(); - if (argumentTypes.size() > 0) { - firstParamType = argumentTypes.get(0).name().toString(); - } - } - - // Create Part Item instance - ResultHandle partItemInstanceHandle = populate.newInstance( - MethodDescriptor.ofConstructor(PartItem.class, - String.class, MediaType.class, Object.class, String.class), - populate.load(formAttrName), partTypeHandle, resultHandle, populate.load(firstParamType)); - - // Add it to the list - populate.invokeVirtualMethod( - MethodDescriptor.ofMethod(ArrayList.class, ARRAY_LIST_ADD_METHOD_NAME, boolean.class, Object.class), - listPartItemListInstanceHandle, partItemInstanceHandle); - } - - DotName superClassDotName = currentClassInHierarchy.superName(); - if (superClassDotName.equals(DotNames.OBJECT_NAME)) { - break; - } - ClassInfo newCurrentClassInHierarchy = index.getClassByName(superClassDotName); - if (newCurrentClassInHierarchy == null) { - printWarningMessageForMissingJandexIndex(currentClassInHierarchy, superClassDotName); - break; - } - currentClassInHierarchy = newCurrentClassInHierarchy; - } - - populate.returnValue(listPartItemListInstanceHandle); - } - return generateClassName; - } - - private static void printWarningMessageForMissingJandexIndex(ClassInfo currentClassInHierarchy, DotName superClassDotName) { - if (!superClassDotName.toString().startsWith("java.")) { - LOGGER.warn("Class '" + superClassDotName + "' which is a parent class of '" - + currentClassInHierarchy.name() - + "' is not part of the Jandex index so its fields will be ignored. If you intended to include these fields, consider making the dependency part of the Jandex index by following the advice at: https://quarkus.io/guides/cdi-reference#bean_discovery"); - } - } -} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/QuarkusMultipartReturnTypeHandler.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/QuarkusMultipartReturnTypeHandler.java index 6c3c095096f20..5c309395931ee 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/QuarkusMultipartReturnTypeHandler.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/QuarkusMultipartReturnTypeHandler.java @@ -11,6 +11,7 @@ import org.jboss.resteasy.reactive.common.processor.AdditionalWriters; import org.jboss.resteasy.reactive.common.processor.EndpointIndexer; import org.jboss.resteasy.reactive.server.core.multipart.MultipartMessageBodyWriter; +import org.jboss.resteasy.reactive.server.processor.generation.multipart.FormDataOutputMapperGenerator; import io.quarkus.deployment.GeneratedClassGizmoAdaptor; import io.quarkus.deployment.annotations.BuildProducer; diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MultipartOutputResource.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MultipartOutputResource.java index 6ebcdeab6174c..f1ce3eb26dd5e 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MultipartOutputResource.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MultipartOutputResource.java @@ -11,6 +11,7 @@ import javax.ws.rs.core.MediaType; import org.jboss.resteasy.reactive.RestResponse; +import org.jboss.resteasy.reactive.server.core.multipart.MultipartFormDataOutput; @Path("/multipart/output") public class MultipartOutputResource { @@ -51,6 +52,20 @@ public RestResponse restResponse() { return RestResponse.ResponseBuilder.ok(response).header("foo", "bar").build(); } + @GET + @Path("/with-form-data") + @Produces(MediaType.MULTIPART_FORM_DATA) + public RestResponse withFormDataOutput() { + MultipartFormDataOutput form = new MultipartFormDataOutput(); + form.addFormData("name", RESPONSE_NAME, MediaType.TEXT_PLAIN_TYPE); + form.addFormData("custom-surname", RESPONSE_SURNAME, MediaType.TEXT_PLAIN_TYPE); + form.addFormData("custom-status", RESPONSE_STATUS, MediaType.TEXT_PLAIN_TYPE) + .getHeaders().putSingle("extra-header", "extra-value"); + form.addFormData("values", RESPONSE_VALUES, MediaType.TEXT_PLAIN_TYPE); + form.addFormData("active", RESPONSE_ACTIVE, MediaType.TEXT_PLAIN_TYPE); + return RestResponse.ResponseBuilder.ok(form).header("foo", "bar").build(); + } + @GET @Path("/string") @Produces(MediaType.MULTIPART_FORM_DATA) diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MultipartOutputUsingBlockingEndpointsTest.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MultipartOutputUsingBlockingEndpointsTest.java index 5795599df878c..3e8bb225da17f 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MultipartOutputUsingBlockingEndpointsTest.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/multipart/MultipartOutputUsingBlockingEndpointsTest.java @@ -63,6 +63,22 @@ public void testRestResponse() { assertContainsValue(response, "num", MediaType.TEXT_PLAIN, "0"); } + @Test + public void testWithFormData() { + String response = RestAssured.get("/multipart/output/with-form-data") + .then() + .log().all() + .contentType(ContentType.MULTIPART) + .statusCode(200) + .extract().asString(); + + assertContainsValue(response, "name", MediaType.TEXT_PLAIN, MultipartOutputResource.RESPONSE_NAME); + assertContainsValue(response, "custom-surname", MediaType.TEXT_PLAIN, MultipartOutputResource.RESPONSE_SURNAME); + assertContainsValue(response, "custom-status", MediaType.TEXT_PLAIN, MultipartOutputResource.RESPONSE_STATUS); + assertContainsValue(response, "active", MediaType.TEXT_PLAIN, MultipartOutputResource.RESPONSE_ACTIVE); + assertContainsValue(response, "values", MediaType.TEXT_PLAIN, "[one, two]"); + } + @Test public void testString() { RestAssured.get("/multipart/output/string") diff --git a/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/generation/multipart/FormDataOutputMapperGenerator.java b/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/generation/multipart/FormDataOutputMapperGenerator.java index 7374f891a8fd4..40bab59df07e9 100644 --- a/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/generation/multipart/FormDataOutputMapperGenerator.java +++ b/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/generation/multipart/FormDataOutputMapperGenerator.java @@ -4,7 +4,6 @@ import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.REST_FORM_PARAM; import java.lang.reflect.Modifier; -import java.util.ArrayList; import java.util.List; import javax.ws.rs.core.MediaType; @@ -19,6 +18,7 @@ import org.jboss.jandex.Type; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames; +import org.jboss.resteasy.reactive.server.core.multipart.MultipartFormDataOutput; import org.jboss.resteasy.reactive.server.core.multipart.MultipartMessageBodyWriter; import org.jboss.resteasy.reactive.server.core.multipart.MultipartOutputInjectionTarget; import org.jboss.resteasy.reactive.server.core.multipart.PartItem; @@ -32,12 +32,12 @@ import io.quarkus.gizmo.MethodDescriptor; import io.quarkus.gizmo.ResultHandle; -final class FormDataOutputMapperGenerator { +public final class FormDataOutputMapperGenerator { private static final Logger LOGGER = Logger.getLogger(FormDataOutputMapperGenerator.class); private static final String TRANSFORM_METHOD_NAME = "mapFrom"; - private static final String ARRAY_LIST_ADD_METHOD_NAME = "add"; + private static final String ADD_FORM_DATA_METHOD_NAME = "addFormData"; private FormDataOutputMapperGenerator() { } @@ -121,29 +121,30 @@ public static boolean isReturnTypeCompatible(ClassInfo returnTypeClassInfo, Inde *
      * public class FormData_generated_mapper implements MultipartOutputInjectionTarget {
      *
-     *     public FormDataOutput mapFrom(Object var1) {
-     *         FormDataOutput var2 = new FormDataOutput();
+     *     public MultipartFormDataOutput mapFrom(Object var1) {
+     *         MultipartFormDataOutput var2 = new MultipartFormDataOutput();
      *         FormData var4 = (FormData) var1;
      *         File var3 = var4.data;
-     *         MultipartSupport.addPartItemToFormDataOutput(var2, "file", "application/octet-stream", var3);
+     *         var2.addFormData("file", var3, "application/octet-stream");
      *         File var5 = var4.text;
-     *         MultipartSupport.addPartItemToFormDataOutput(var2, "text", "text/plain", var5);
+     *         var2.addFormData("text", var5, "text/plain");
      *         return var2;
      *     }
      * }
      * 
*/ - static String generate(ClassInfo returnTypeClassInfo, ClassOutput classOutput, IndexView index) { + public static String generate(ClassInfo returnTypeClassInfo, ClassOutput classOutput, IndexView index) { String returnClassName = returnTypeClassInfo.name().toString(); String generateClassName = MultipartMessageBodyWriter.getGeneratedMapperClassNameFor(returnClassName); String interfaceClassName = MultipartOutputInjectionTarget.class.getName(); try (ClassCreator cc = new ClassCreator(classOutput, generateClassName, null, Object.class.getName(), interfaceClassName)) { - MethodCreator populate = cc.getMethodCreator(TRANSFORM_METHOD_NAME, List.class.getName(), + MethodCreator populate = cc.getMethodCreator(TRANSFORM_METHOD_NAME, MultipartFormDataOutput.class.getName(), Object.class); populate.setModifiers(Modifier.PUBLIC); - ResultHandle listPartItemListInstanceHandle = populate.newInstance(MethodDescriptor.ofConstructor(ArrayList.class)); + ResultHandle formDataInstanceHandle = populate.newInstance(MethodDescriptor + .ofConstructor(MultipartFormDataOutput.class)); ResultHandle inputInstanceHandle = populate.checkCast(populate.getMethodParam(0), returnClassName); // go up the class hierarchy until we reach Object @@ -218,24 +219,20 @@ static String generate(ClassInfo returnTypeClassInfo, ClassOutput classOutput, I } // Get parameterized type if field type is a parameterized class - String firstParamType = ""; + String genericType = ""; if (fieldType.kind() == Type.Kind.PARAMETERIZED_TYPE) { List argumentTypes = fieldType.asParameterizedType().arguments(); if (argumentTypes.size() > 0) { - firstParamType = argumentTypes.get(0).name().toString(); + genericType = argumentTypes.get(0).name().toString(); } } - // Create Part Item instance - ResultHandle partItemInstanceHandle = populate.newInstance( - MethodDescriptor.ofConstructor(PartItem.class, - String.class, MediaType.class, Object.class, String.class), - populate.load(formAttrName), partTypeHandle, resultHandle, populate.load(firstParamType)); - - // Add it to the list + // Add it to the form data object populate.invokeVirtualMethod( - MethodDescriptor.ofMethod(ArrayList.class, ARRAY_LIST_ADD_METHOD_NAME, boolean.class, Object.class), - listPartItemListInstanceHandle, partItemInstanceHandle); + MethodDescriptor.ofMethod(MultipartFormDataOutput.class, ADD_FORM_DATA_METHOD_NAME, PartItem.class, + String.class, Object.class, String.class, MediaType.class), + formDataInstanceHandle, + populate.load(formAttrName), resultHandle, populate.load(genericType), partTypeHandle); } DotName superClassDotName = currentClassInHierarchy.superName(); @@ -250,7 +247,7 @@ static String generate(ClassInfo returnTypeClassInfo, ClassOutput classOutput, I currentClassInHierarchy = newCurrentClassInHierarchy; } - populate.returnValue(listPartItemListInstanceHandle); + populate.returnValue(formDataInstanceHandle); } return generateClassName; } diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ServerSerialisers.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ServerSerialisers.java index 7959c0f364e06..0a27fbc05fa8c 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ServerSerialisers.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ServerSerialisers.java @@ -43,6 +43,8 @@ import org.jboss.resteasy.reactive.common.util.MediaTypeHelper; import org.jboss.resteasy.reactive.common.util.QuarkusMultivaluedHashMap; import org.jboss.resteasy.reactive.common.util.QuarkusMultivaluedMap; +import org.jboss.resteasy.reactive.server.core.multipart.MultipartFormDataOutput; +import org.jboss.resteasy.reactive.server.core.multipart.MultipartMessageBodyWriter; import org.jboss.resteasy.reactive.server.core.serialization.EntityWriter; import org.jboss.resteasy.reactive.server.core.serialization.FixedEntityWriterArray; import org.jboss.resteasy.reactive.server.jaxrs.WriterInterceptorContextImpl; @@ -134,6 +136,8 @@ public void accept(ResteasyReactiveRequestContext context) { MediaType.WILDCARD), new Serialisers.BuiltinWriter(FilePart.class, ServerFilePartBodyHandler.class, MediaType.WILDCARD), + new Serialisers.BuiltinWriter(MultipartFormDataOutput.class, MultipartMessageBodyWriter.class, + MediaType.MULTIPART_FORM_DATA), new Serialisers.BuiltinWriter(java.nio.file.Path.class, ServerPathBodyHandler.class, MediaType.WILDCARD), new Serialisers.BuiltinWriter(PathPart.class, ServerPathPartBodyHandler.class, diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartFormDataOutput.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartFormDataOutput.java new file mode 100644 index 0000000000000..9b8acc4b02d38 --- /dev/null +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartFormDataOutput.java @@ -0,0 +1,25 @@ +package org.jboss.resteasy.reactive.server.core.multipart; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import javax.ws.rs.core.MediaType; + +public class MultipartFormDataOutput { + private final Map parts = new HashMap<>(); + + public Map getFormData() { + return Collections.unmodifiableMap(parts); + } + + public PartItem addFormData(String key, Object entity, MediaType mediaType) { + return addFormData(key, entity, null, mediaType); + } + + public PartItem addFormData(String key, Object entity, String genericType, MediaType mediaType) { + PartItem part = new PartItem(entity, genericType, mediaType); + parts.put(key, part); + return part; + } +} diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartMessageBodyWriter.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartMessageBodyWriter.java index b963504d5cd5b..3ea5826e7673b 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartMessageBodyWriter.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartMessageBodyWriter.java @@ -1,5 +1,6 @@ package org.jboss.resteasy.reactive.server.core.multipart; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.OutputStream; @@ -9,7 +10,9 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import javax.ws.rs.RuntimeType; import javax.ws.rs.WebApplicationException; @@ -56,33 +59,39 @@ private void writeMultiformData(Object o, MediaType mediaType, OutputStream outp String boundary = generateBoundary(); appendBoundaryIntoMediaType(requestContext, boundary, mediaType); - List formData = toFormData(o); + MultipartFormDataOutput formData; + if (o instanceof MultipartFormDataOutput) { + formData = (MultipartFormDataOutput) o; + } else { + formData = toFormData(o); + } write(formData, boundary, outputStream, requestContext); } - private List toFormData(Object o) { + private MultipartFormDataOutput toFormData(Object o) { String transformer = getGeneratedMapperClassNameFor(o.getClass().getName()); BeanFactory.BeanInstance instance = new ReflectionBeanFactoryCreator().apply(transformer).createInstance(); return ((MultipartOutputInjectionTarget) instance.getInstance()).mapFrom(o); } - private void write(List parts, String boundary, OutputStream outputStream, + private void write(MultipartFormDataOutput formDataOutput, String boundary, OutputStream outputStream, ResteasyReactiveRequestContext requestContext) throws IOException { Charset charset = requestContext.getDeployment().getRuntimeConfiguration().body().defaultCharset(); String boundaryLine = "--" + boundary; - for (PartItem part : parts) { - Object partValue = part.getValue(); + Map parts = formDataOutput.getFormData(); + for (Map.Entry entry : parts.entrySet()) { + String partName = entry.getKey(); + PartItem part = entry.getValue(); + Object partValue = part.getEntity(); if (partValue != null) { if (isListOf(part, File.class) || isListOf(part, FileDownload.class)) { - List list = (List) part.getValue(); + List list = (List) partValue; for (int i = 0; i < list.size(); i++) { - writePart(part.getName(), list.get(i), part.getType(), - boundaryLine, charset, outputStream, requestContext); + writePart(partName, list.get(i), part, boundaryLine, charset, outputStream, requestContext); } } else { - writePart(part.getName(), part.getValue(), part.getType(), - boundaryLine, charset, outputStream, requestContext); + writePart(partName, partValue, part, boundaryLine, charset, outputStream, requestContext); } } } @@ -93,12 +102,13 @@ private void write(List parts, String boundary, OutputStream outputStr private void writePart(String partName, Object partValue, - MediaType partType, + PartItem part, String boundaryLine, Charset charset, OutputStream outputStream, ResteasyReactiveRequestContext requestContext) throws IOException { + MediaType partType = part.getMediaType(); if (partValue instanceof FileDownload) { FileDownload fileDownload = (FileDownload) partValue; partValue = fileDownload.filePath().toFile(); @@ -110,11 +120,9 @@ private void writePart(String partName, // write boundary: --... writeLine(outputStream, boundaryLine, charset); - // write content disposition header - writeLine(outputStream, HttpHeaders.CONTENT_DISPOSITION + ": form-data; name=\"" + partName + "\"" - + getFileNameIfFile(partValue), charset); - // write content content type - writeLine(outputStream, HttpHeaders.CONTENT_TYPE + ": " + partType, charset); + // write headers + writeHeaders(partName, partValue, part, charset, outputStream); + // extra line writeLine(outputStream, charset); @@ -124,11 +132,24 @@ private void writePart(String partName, writeLine(outputStream, charset); } - private String getFileNameIfFile(Object value) { + private void writeHeaders(String partName, Object partValue, PartItem part, Charset charset, OutputStream outputStream) + throws IOException { + part.getHeaders().put(HttpHeaders.CONTENT_DISPOSITION, List.of("form-data; name=\"" + partName + "\"" + + getFileNameIfFile(partValue, part.getFilename()))); + part.getHeaders().put(HttpHeaders.CONTENT_TYPE, List.of(part.getMediaType())); + for (Map.Entry> entry : part.getHeaders().entrySet()) { + writeLine(outputStream, entry.getKey() + ": " + entry.getValue().stream().map(String::valueOf) + .collect(Collectors.joining("; ")), charset); + } + } + + private String getFileNameIfFile(Object value, String partFileName) { if (value instanceof File) { return "; filename=\"" + ((File) value).getName() + "\""; } else if (value instanceof FileDownload) { return "; filename=\"" + ((FileDownload) value).fileName() + "\""; + } else if (partFileName != null) { + return partFileName; } return ""; @@ -163,10 +184,14 @@ private void writeEntity(OutputStream os, Object entity, MediaType mediaType, Re boolean wrote = false; for (MessageBodyWriter writer : writers) { if (writer.isWriteable(entityClass, entityType, Serialisers.NO_ANNOTATION, mediaType)) { - // FIXME: spec doesn't really say what headers we should use here - writer.writeTo(entity, entityClass, entityType, Serialisers.NO_ANNOTATION, mediaType, - Serialisers.EMPTY_MULTI_MAP, os); - wrote = true; + try (ByteArrayOutputStream writerOutput = new ByteArrayOutputStream()) { + // FIXME: spec doesn't really say what headers we should use here + writer.writeTo(entity, entityClass, entityType, Serialisers.NO_ANNOTATION, mediaType, + Serialisers.EMPTY_MULTI_MAP, writerOutput); + writerOutput.writeTo(os); + wrote = true; + } + break; } } @@ -191,10 +216,10 @@ private boolean isNotEmpty(String str) { } private boolean isListOf(PartItem part, Class paramType) { - if (!(part.getValue() instanceof Collection)) { + if (!(part.getEntity() instanceof Collection)) { return false; } - return paramType.getName().equals(part.getFirstParamType()); + return paramType.getName().equals(part.getGenericType()); } } diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartOutputInjectionTarget.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartOutputInjectionTarget.java index b8bdf2cd1af8f..d7f8b29feb3ff 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartOutputInjectionTarget.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/MultipartOutputInjectionTarget.java @@ -1,7 +1,5 @@ package org.jboss.resteasy.reactive.server.core.multipart; -import java.util.List; - public interface MultipartOutputInjectionTarget { - List mapFrom(Object pojo); + MultipartFormDataOutput mapFrom(Object pojo); } diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/PartItem.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/PartItem.java index 99c135ec2150d..491343164f30b 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/PartItem.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/PartItem.java @@ -1,40 +1,46 @@ package org.jboss.resteasy.reactive.server.core.multipart; import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; + +import org.jboss.resteasy.reactive.common.util.QuarkusMultivaluedHashMap; public final class PartItem { - private final String name; - private final MediaType type; - private final Object value; - private final String firstParamType; - - public PartItem(String name, MediaType type, Object value, String firstParamType) { - this.name = name; - this.type = type; - this.value = value; - this.firstParamType = firstParamType; + private MultivaluedMap headers; + private Object entity; + private String genericType; + private MediaType mediaType; + private String filename; + + public PartItem(Object entity, String genericType, MediaType mediaType) { + this(entity, genericType, mediaType, null); + } + + public PartItem(Object entity, String genericType, MediaType mediaType, String filename) { + this.headers = new QuarkusMultivaluedHashMap<>(); + this.entity = entity; + this.genericType = genericType; + this.mediaType = mediaType; + this.filename = filename; + } + + public MultivaluedMap getHeaders() { + return headers; } - public String getName() { - return name; + public Object getEntity() { + return entity; } - public MediaType getType() { - return type; + public String getGenericType() { + return genericType; } - public Object getValue() { - return value; + public MediaType getMediaType() { + return mediaType; } - /** - * If the value is a parameterized class like a List, it will return the raw representation of the first parameter type. - * For example, if it's a List, it will return "java.lang.String". - * If the value is not a parameterized class, it will return an empty string. - * - * @return the raw representation of the first parameter type in parameterized classes. Otherwise, empty string. - */ - public String getFirstParamType() { - return firstParamType; + public String getFilename() { + return filename; } } diff --git a/independent-projects/resteasy-reactive/server/vertx/src/test/java/org/jboss/resteasy/reactive/server/vertx/test/multipart/MultipartOutputResource.java b/independent-projects/resteasy-reactive/server/vertx/src/test/java/org/jboss/resteasy/reactive/server/vertx/test/multipart/MultipartOutputResource.java index 8dcfe45abb88f..a6786f98ff551 100644 --- a/independent-projects/resteasy-reactive/server/vertx/src/test/java/org/jboss/resteasy/reactive/server/vertx/test/multipart/MultipartOutputResource.java +++ b/independent-projects/resteasy-reactive/server/vertx/src/test/java/org/jboss/resteasy/reactive/server/vertx/test/multipart/MultipartOutputResource.java @@ -8,6 +8,8 @@ import javax.ws.rs.Produces; import javax.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.server.core.multipart.MultipartFormDataOutput; + @Path("/multipart/output") public class MultipartOutputResource { @@ -49,4 +51,18 @@ public MultipartOutputFileResponse complex() { return response; } + @GET + @Path("/with-form-data") + @Produces(MediaType.MULTIPART_FORM_DATA) + public MultipartFormDataOutput withFormDataOutput() { + MultipartFormDataOutput form = new MultipartFormDataOutput(); + form.addFormData("name", RESPONSE_NAME, MediaType.TEXT_PLAIN_TYPE); + form.addFormData("custom-surname", RESPONSE_SURNAME, MediaType.TEXT_PLAIN_TYPE); + form.addFormData("custom-status", RESPONSE_STATUS, MediaType.TEXT_PLAIN_TYPE) + .getHeaders().putSingle("extra-header", "extra-value"); + form.addFormData("values", RESPONSE_VALUES, MediaType.TEXT_PLAIN_TYPE); + form.addFormData("active", RESPONSE_ACTIVE, MediaType.TEXT_PLAIN_TYPE); + return form; + } + } diff --git a/independent-projects/resteasy-reactive/server/vertx/src/test/java/org/jboss/resteasy/reactive/server/vertx/test/multipart/MultipartOutputUsingBlockingEndpointsTest.java b/independent-projects/resteasy-reactive/server/vertx/src/test/java/org/jboss/resteasy/reactive/server/vertx/test/multipart/MultipartOutputUsingBlockingEndpointsTest.java index ace8b84d8a096..14c35bfdf92a7 100644 --- a/independent-projects/resteasy-reactive/server/vertx/src/test/java/org/jboss/resteasy/reactive/server/vertx/test/multipart/MultipartOutputUsingBlockingEndpointsTest.java +++ b/independent-projects/resteasy-reactive/server/vertx/src/test/java/org/jboss/resteasy/reactive/server/vertx/test/multipart/MultipartOutputUsingBlockingEndpointsTest.java @@ -44,6 +44,22 @@ public void testSimple() { assertContainsValue(response, "num", MediaType.TEXT_PLAIN, "0"); } + @Test + public void testWithFormData() { + String response = RestAssured.get("/multipart/output/with-form-data") + .then() + .log().all() + .contentType(ContentType.MULTIPART) + .statusCode(200) + .extract().asString(); + + assertContainsValue(response, "name", MediaType.TEXT_PLAIN, MultipartOutputResource.RESPONSE_NAME); + assertContainsValue(response, "custom-surname", MediaType.TEXT_PLAIN, MultipartOutputResource.RESPONSE_SURNAME); + assertContainsValue(response, "custom-status", MediaType.TEXT_PLAIN, MultipartOutputResource.RESPONSE_STATUS); + assertContainsValue(response, "active", MediaType.TEXT_PLAIN, MultipartOutputResource.RESPONSE_ACTIVE); + assertContainsValue(response, "values", MediaType.TEXT_PLAIN, "[one, two]"); + } + @Test public void testString() { RestAssured.get("/multipart/output/string")