From 3453f79743c02cdada6a1fb096d5c638df134b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Szynkiewicz?= Date: Wed, 5 Jan 2022 16:32:54 +0100 Subject: [PATCH] Multipart download in REST Client Reactive refs #21440 --- .../main/asciidoc/rest-client-reactive.adoc | 48 +- .../JaxrsClientReactiveProcessor.java | 289 +++- ...rsClientReactiveClientContextResolver.java | 10 + .../runtime/JaxrsClientReactiveRecorder.java | 15 + .../impl/MultipartResponseDataBase.java | 22 + .../multipart/MultipartResponseTest.java | 280 ++++ .../ClientResponseCompleteRestHandler.java | 87 +- .../handlers/ClientSendRequestHandler.java | 50 +- .../reactive/client/impl/ClientImpl.java | 2 +- .../client/impl/DefaultClientContext.java | 7 + .../reactive/client/impl/HandlerChain.java | 7 +- .../client/impl/RestClientRequestContext.java | 21 + .../multipart/CaseIgnoringComparator.java | 59 + .../impl/multipart/FileDownloadImpl.java | 50 + .../multipart/QuarkusHttpPostBodyUtil.java | 78 + .../QuarkusMultipartResponseDataFactory.java | 334 ++++ .../QuarkusMultipartResponseDecoder.java | 1379 +++++++++++++++++ .../reactive/client/spi/ClientContext.java | 3 + .../reactive/client/spi/FieldFiller.java | 43 + .../client/spi/MultipartResponseData.java | 9 + .../resteasy/reactive/MultipartForm.java | 2 +- .../reactive/multipart/FileDownload.java | 4 + .../resteasy/reactive/multipart/FilePart.java | 39 + .../reactive/multipart/FileUpload.java | 37 +- .../core/multipart/DefaultFileUpload.java | 2 +- 25 files changed, 2818 insertions(+), 59 deletions(-) create mode 100644 extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/impl/MultipartResponseDataBase.java create mode 100644 extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/multipart/MultipartResponseTest.java create mode 100644 independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/CaseIgnoringComparator.java create mode 100644 independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/FileDownloadImpl.java create mode 100644 independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusMultipartResponseDataFactory.java create mode 100644 independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusMultipartResponseDecoder.java create mode 100644 independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/FieldFiller.java create mode 100644 independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/MultipartResponseData.java create mode 100644 independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FileDownload.java create mode 100644 independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FilePart.java diff --git a/docs/src/main/asciidoc/rest-client-reactive.adoc b/docs/src/main/asciidoc/rest-client-reactive.adoc index 1e67b5df361eb..c4b572222090d 100644 --- a/docs/src/main/asciidoc/rest-client-reactive.adoc +++ b/docs/src/main/asciidoc/rest-client-reactive.adoc @@ -450,7 +450,7 @@ public interface ExtensionsService { // ... @GET - Uni> getByIdAsUni(@QueryParam("id") String id) + Uni> getByIdAsUni(@QueryParam("id") String id); } ---- @@ -470,7 +470,7 @@ import java.util.Set; import java.util.concurrent.CompletionStage; @Path("/extension") -public class ExtensionsResource +public class ExtensionsResource { @RestClient ExtensionsService extensionsService; @@ -654,7 +654,11 @@ org.eclipse.microprofile.rest.client.propagateHeaders=Authorization,Proxy-Author == Multipart Form support -Rest Client Reactive allows sending data as multipart forms. This way you can for example +REST Client Reactive support multipart messages. + +=== Sending Multipart messages + +REST Client Reactive allows sending data as multipart forms. This way you can for example send files efficiently. To send data as a multipart form, you need to create a class that would encapsulate all the fields @@ -662,7 +666,7 @@ to be sent, e.g. [source, java] ---- -class FormDto { +public class FormDto { @FormParam("file") @PartType(MediaType.APPLICATION_OCTET_STREAM) public File file; @@ -697,6 +701,42 @@ by specifying `quarkus.rest-client.multipart-post-encoder-mode` in your clients created with the `@RegisterRestClient` annotation. All the available modes are described in the link:https://netty.io/4.1/api/io/netty/handler/codec/http/multipart/HttpPostRequestEncoder.EncoderMode.html[Netty documentation] +=== Receiving Multipart Messages +REST Client Reactive also supports receiving multipart messages. +As with sending, to parse a multipart response, you need to create a class that describes the response data, e.g. + +[source,java] +---- +public class FormDto { + @RestForm // <1> + @PartType(MediaType.APPLICATION_OCTET_STREAM) + public File file; + + @FormParam("otherField") // <2> + @PartType(MediaType.TEXT_PLAIN) + public String textProperty; +} +---- +<1> uses the shorthand `@RestForm` annotation to make a field as a part of a multipart form +<2> the standard `@FormParam` can also be used. It allows to override the name of the multipart part. + +Then, create an interface method that corresponds to the call and make it return the `FormDto`: +[source,java] +---- + @GET + @Produces(MediaType.MULTIPART_FORM_DATA) + @Path("/get-file") + FormDto data sendMultipart(); +---- + +At the moment, multipart response support is subject to the following limitations: + +- files sent in multipart responses can only be parsed to `File`, `Path` and `FileDownload` +- each field of the response type has to be annotated with `@PartType` - fields without this annotation are ignored + +REST Client Reactive needs to know the classes used as multipart return types upfront. If you have an interface method that produces `multipart/form-data`, the return type will be discovered automatically. However, if you intend to use the `ClientBuilder` API to parse a response as multipart, you need to annotate your DTO class with `@MultipartForm`. + +WARNING: The files you download are not automatically removed and can take up a lot of disk space. Consider removing the files when you are done working with them. == Proxy support REST Client Reactive supports sending requests through a proxy. diff --git a/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java b/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java index 6444298af3a19..97203302b1012 100644 --- a/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java +++ b/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java @@ -1,13 +1,18 @@ package io.quarkus.jaxrs.client.reactive.deployment; import static io.quarkus.deployment.Feature.JAXRS_CLIENT_REACTIVE; +import static org.jboss.jandex.Type.Kind.ARRAY; import static org.jboss.jandex.Type.Kind.CLASS; import static org.jboss.jandex.Type.Kind.PARAMETERIZED_TYPE; +import static org.jboss.jandex.Type.Kind.PRIMITIVE; import static org.jboss.resteasy.reactive.common.processor.EndpointIndexer.extractProducesConsumesValues; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.COMPLETION_STAGE; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.CONSUMES; +import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.FORM_PARAM; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.MULTI; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.OBJECT; +import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.PART_TYPE_NAME; +import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.REST_FORM_PARAM; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.UNI; import java.io.Closeable; @@ -19,8 +24,10 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -45,9 +52,11 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.ext.ParamConverterProvider; +import org.apache.http.entity.ContentType; import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.ConfigProvider; import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; import org.jboss.jandex.AnnotationValue; import org.jboss.jandex.ClassInfo; import org.jboss.jandex.DotName; @@ -76,6 +85,8 @@ import org.jboss.resteasy.reactive.client.processor.beanparam.QueryParamItem; import org.jboss.resteasy.reactive.client.processor.scanning.ClientEndpointIndexer; import org.jboss.resteasy.reactive.client.spi.ClientRestHandler; +import org.jboss.resteasy.reactive.client.spi.FieldFiller; +import org.jboss.resteasy.reactive.client.spi.MultipartResponseData; import org.jboss.resteasy.reactive.common.core.GenericTypeMapping; import org.jboss.resteasy.reactive.common.core.ResponseBuilderFactory; import org.jboss.resteasy.reactive.common.core.Serialisers; @@ -93,6 +104,7 @@ import org.jboss.resteasy.reactive.common.processor.HashUtil; import org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames; import org.jboss.resteasy.reactive.common.processor.scanning.ResourceScanningResult; +import org.jboss.resteasy.reactive.multipart.FileDownload; import org.objectweb.asm.Opcodes; import io.quarkus.arc.deployment.BeanArchiveIndexBuildItem; @@ -131,6 +143,7 @@ import io.quarkus.jaxrs.client.reactive.runtime.JaxrsClientReactiveRecorder; import io.quarkus.jaxrs.client.reactive.runtime.RestClientBase; import io.quarkus.jaxrs.client.reactive.runtime.ToObjectArray; +import io.quarkus.jaxrs.client.reactive.runtime.impl.MultipartResponseDataBase; import io.quarkus.resteasy.reactive.common.deployment.ApplicationResultBuildItem; import io.quarkus.resteasy.reactive.common.deployment.QuarkusFactoryCreator; import io.quarkus.resteasy.reactive.common.deployment.QuarkusResteasyReactiveDotNames; @@ -171,6 +184,8 @@ public class JaxrsClientReactiveProcessor { private static final Set ASYNC_RETURN_TYPES = Set.of(COMPLETION_STAGE, UNI, MULTI); public static final DotName BYTE = DotName.createSimple(Byte.class.getName()); + public static final MethodDescriptor MULTIPART_RESPONSE_DATA_ADD_FILLER = MethodDescriptor + .ofMethod(MultipartResponseDataBase.class, "addFiller", void.class, FieldFiller.class); @BuildStep void addFeature(BuildProducer features) { @@ -275,6 +290,16 @@ public void accept(EndpointIndexer.ResourceMethodCallbackData entry) { Map, ?>>> clientImplementations = new HashMap<>(); Map failures = new HashMap<>(); + + Set multipartResponseTypes = new HashSet<>(); + // collect classes annotated with MultipartForm and add classes that are used in rest client interfaces as return + // types for multipart responses + for (AnnotationInstance annotation : index.getAnnotations(ResteasyReactiveDotNames.MULTI_PART_FORM_PARAM)) { + if (annotation.target().kind() == AnnotationTarget.Kind.CLASS) { + multipartResponseTypes.add(annotation.target().asClass()); + } + } + for (Map.Entry i : result.getClientInterfaces().entrySet()) { ClassInfo clazz = index.getClassByName(i.getKey()); //these interfaces can also be clients @@ -287,7 +312,7 @@ public void accept(EndpointIndexer.ResourceMethodCallbackData entry) { RuntimeValue, ?>> proxyProvider = generateClientInvoker( recorderContext, clientProxy, enricherBuildItems, generatedClassBuildItemBuildProducer, clazz, index, defaultConsumesType, - result.getHttpAnnotationToMethod(), observabilityIntegrationNeeded); + result.getHttpAnnotationToMethod(), observabilityIntegrationNeeded, multipartResponseTypes); if (proxyProvider != null) { clientImplementations.put(clientProxy.getClassName(), proxyProvider); } @@ -325,6 +350,225 @@ public void accept(EndpointIndexer.ResourceMethodCallbackData entry) { .produce(new ReflectiveClassBuildItem(true, false, false, writerClass)); } + Map> responsesData = new HashMap<>(); + for (ClassInfo multipartResponseType : multipartResponseTypes) { + responsesData.put(multipartResponseType.toString(), createMultipartResponseData(multipartResponseType, + generatedClassBuildItemBuildProducer, recorderContext)); + } + recorder.setMultipartResponsesData(responsesData); + } + + /** + * Scan `multipartResponseTypeInfo` for fields and setters/getters annotated with @PartType and prepares + * {@link MultipartResponseData} class for it. This class is later used to create a new instance of the response type + * and to provide {@link FieldFiller field fillers} that are responsible for setting values for each of the fields and + * setters + * + * @param multipartResponseTypeInfo a class to scan + * @param generatedClasses build producer for generating classes + * @param context recorder context to instantiate the newly created class + * + * @return a runtime value with an instance of the MultipartResponseData corresponding to the given class + */ + private RuntimeValue createMultipartResponseData(ClassInfo multipartResponseTypeInfo, + BuildProducer generatedClasses, RecorderContext context) { + String multipartResponseType = multipartResponseTypeInfo.toString(); + String dataClassName = multipartResponseType + "$$MultipartData"; + try (ClassCreator c = new ClassCreator(new GeneratedClassGizmoAdaptor(generatedClasses, true), + dataClassName, null, MultipartResponseDataBase.class.getName())) { + + // implement {@link org.jboss.resteasy.reactive.client.spi.MultipartResponseData#newInstance} + // method that returns a new instance of the response type + MethodCreator newInstance = c.getMethodCreator("newInstance", Object.class); + newInstance.returnValue( + newInstance.newInstance(MethodDescriptor.ofConstructor(multipartResponseType))); + + // scan for public fields and public setters annotated with @PartType. + // initialize appropriate collections of FieldFillers in the constructor + + MethodCreator constructor = c.getMethodCreator(MethodDescriptor.ofConstructor(multipartResponseType)); + constructor.invokeSpecialMethod(MethodDescriptor.ofConstructor(MultipartResponseDataBase.class), + constructor.getThis()); + + Map nonPublicPartTypeFields = new HashMap<>(); + // 1. public fields + for (FieldInfo field : multipartResponseTypeInfo.fields()) { + AnnotationInstance partType = field.annotation(ResteasyReactiveDotNames.PART_TYPE_NAME); + if (partType == null) { + log.debugf("Skipping field %s.%s from multipart mapping because it is not annotated with " + + "@PartType", multipartResponseType, field.name()); + } else if (!Modifier.isPublic(field.flags())) { + // the field is not public, let's memorize its name in case it has a getter + nonPublicPartTypeFields.put(field.name(), partType); + } else { + String partName = extractPartName(partType.target(), field.name()); + String fillerName = createFieldFillerForField(partType, field, partName, generatedClasses, dataClassName); + constructor.invokeVirtualMethod(MULTIPART_RESPONSE_DATA_ADD_FILLER, constructor.getThis(), + constructor.newInstance(MethodDescriptor.ofConstructor(fillerName))); + } + } + for (MethodInfo method : multipartResponseTypeInfo.methods()) { + String methodName = method.name(); + if (methodName.startsWith("set") && method.parameters().size() == 1) { + AnnotationInstance partType; + String fieldName = setterToFieldName(methodName); + if ((partType = partTypeFromGetterOrSetter(method)) != null + || (partType = nonPublicPartTypeFields.get(fieldName)) != null) { + String partName = extractPartName(partType.target(), fieldName); + String fillerName = createFieldFillerForSetter(partType, method, partName, generatedClasses, + dataClassName); + constructor.invokeVirtualMethod(MULTIPART_RESPONSE_DATA_ADD_FILLER, constructor.getThis(), + constructor.newInstance(MethodDescriptor.ofConstructor(fillerName))); + } else { + log.debugf("Ignoring possible setter " + methodName + ", no part type annotation found"); + } + } + } + constructor.returnValue(null); + } + return context.newInstance(dataClassName); + } + + private String extractPartName(AnnotationTarget target, String fieldName) { + AnnotationInstance restForm; + AnnotationInstance formParam; + switch (target.kind()) { + case FIELD: + restForm = target.asField().annotation(REST_FORM_PARAM); + formParam = target.asField().annotation(FORM_PARAM); + break; + case METHOD: + restForm = target.asMethod().annotation(REST_FORM_PARAM); + formParam = target.asMethod().annotation(FORM_PARAM); + break; + default: + throw new IllegalArgumentException( + "PartType annotation is only supported on fields and (setter/getter) methods for multipart responses, found one on " + + target); + } + return getAnnotationValueOrDefault(fieldName, restForm, formParam); + } + + private String getAnnotationValueOrDefault(String fieldName, AnnotationInstance restForm, AnnotationInstance formParam) { + if (restForm != null) { + AnnotationValue restFormValue = restForm.value(); + return restFormValue == null ? fieldName : restFormValue.asString(); + } else if (formParam != null) { + return formParam.value().asString(); + } else { + return fieldName; + } + } + + private String createFieldFillerForSetter(AnnotationInstance partType, MethodInfo setter, String partName, + BuildProducer generatedClasses, String dataClassName) { + String fillerClassName = dataClassName + "$$" + setter.name(); + try (ClassCreator c = new ClassCreator(new GeneratedClassGizmoAdaptor(generatedClasses, true), + fillerClassName, null, FieldFiller.class.getName())) { + Type parameter = setter.parameters().get(0); + createFieldFillerConstructor(partType, parameter, partName, fillerClassName, c); + + MethodCreator set = c + .getMethodCreator( + MethodDescriptor.ofMethod(fillerClassName, "set", void.class, Object.class, Object.class)); + + ResultHandle value = set.getMethodParam(1); + value = performValueConversion(parameter, set, value); + + set.invokeVirtualMethod(setter, set.getMethodParam(0), value); + + set.returnValue(null); + } + return fillerClassName; + } + + private ResultHandle performValueConversion(Type parameter, MethodCreator set, ResultHandle value) { + if (parameter.kind() == CLASS) { + if (parameter.asClassType().name().equals(FILE)) { + // we should get a FileDownload type, let's convert it to File + value = set.invokeStaticMethod(MethodDescriptor.ofMethod(FieldFiller.class, "fileDownloadToFile", + File.class, FileDownload.class), value); + } else if (parameter.asClassType().name().equals(PATH)) { + // we should get a FileDownload type, let's convert it to Path + value = set.invokeStaticMethod(MethodDescriptor.ofMethod(FieldFiller.class, "fileDownloadToPath", + Path.class, FileDownload.class), value); + } + } + return value; + } + + private String createFieldFillerForField(AnnotationInstance partType, FieldInfo field, String partName, + BuildProducer generatedClasses, String dataClassName) { + String fillerClassName = dataClassName + "$$" + field.name(); + try (ClassCreator c = new ClassCreator(new GeneratedClassGizmoAdaptor(generatedClasses, true), + fillerClassName, null, FieldFiller.class.getName())) { + createFieldFillerConstructor(partType, field.type(), partName, fillerClassName, c); + + MethodCreator set = c + .getMethodCreator( + MethodDescriptor.ofMethod(fillerClassName, "set", void.class, Object.class, Object.class)); + + ResultHandle value = set.getMethodParam(1); + value = performValueConversion(field.type(), set, value); + set.writeInstanceField(field, set.getMethodParam(0), value); + + set.returnValue(null); + } + return fillerClassName; + } + + private void createFieldFillerConstructor(AnnotationInstance partType, Type type, String partName, + String fillerClassName, ClassCreator c) { + MethodCreator ctor = c.getMethodCreator(MethodDescriptor.ofConstructor(fillerClassName)); + + ResultHandle genericType; + if (type.kind() == PARAMETERIZED_TYPE) { + genericType = createGenericTypeFromParameterizedType(ctor, type.asParameterizedType()); + } else if (type.kind() == CLASS) { + genericType = ctor.newInstance( + MethodDescriptor.ofConstructor(GenericType.class, java.lang.reflect.Type.class), + ctor.loadClass(type.asClassType().name().toString())); + } else if (type.kind() == ARRAY) { + genericType = ctor.newInstance( + MethodDescriptor.ofConstructor(GenericType.class, java.lang.reflect.Type.class), + ctor.loadClass(type.asArrayType().name().toString())); + } else if (type.kind() == PRIMITIVE) { + throw new IllegalArgumentException("Primitive types are not supported for multipart response mapping. " + + "Please use a wrapper class instead"); + } else { + throw new IllegalArgumentException("Unsupported field type for multipart response mapping: " + + type + ". Only classes, arrays and parameterized types are supported"); + } + + ctor.invokeSpecialMethod( + MethodDescriptor.ofConstructor(FieldFiller.class, GenericType.class, String.class, String.class), + ctor.getThis(), genericType, ctor.load(partName), ctor.load(partType.value().asString())); + ctor.returnValue(null); + } + + private AnnotationInstance partTypeFromGetterOrSetter(MethodInfo setter) { + AnnotationInstance partTypeAnno = setter.annotation(PART_TYPE_NAME); + if (partTypeAnno != null) { + return partTypeAnno; + } + + String getterName = setter.name().replaceFirst("s", "g"); + MethodInfo getter = setter.declaringClass().method(getterName); + if (getter != null && null != (partTypeAnno = getter.annotation(PART_TYPE_NAME))) { + return partTypeAnno; + } + + return null; + } + + private String setterToFieldName(String methodName) { + if (methodName.length() <= 3) { + return ""; + } else { + char[] nameArray = methodName.toCharArray(); + nameArray[3] = Character.toLowerCase(nameArray[3]); + return new String(nameArray, 3, nameArray.length - 3); + } } private org.jboss.resteasy.reactive.common.ResteasyReactiveConfig createRestReactiveConfig(ResteasyReactiveConfig config) { @@ -481,7 +725,7 @@ A more full example of generated client (with sub-resource) can is at the bottom RestClientInterface restClientInterface, List enrichers, BuildProducer generatedClasses, ClassInfo interfaceClass, IndexView index, String defaultMediaType, Map httpAnnotationToMethod, - boolean observabilityIntegrationNeeded) { + boolean observabilityIntegrationNeeded, Set multipartResponseTypes) { String name = restClientInterface.getClassName() + "$$QuarkusRestClientInterface"; MethodDescriptor ctorDesc = MethodDescriptor.ofConstructor(name, WebTarget.class.getName(), List.class); @@ -539,9 +783,13 @@ A more full example of generated client (with sub-resource) can is at the bottom handleSubResourceMethod(enrichers, generatedClasses, interfaceClass, index, defaultMediaType, httpAnnotationToMethod, name, c, constructor, - baseTarget, methodIndex, webTargets, method, javaMethodParameters, jandexMethod); + baseTarget, methodIndex, webTargets, method, javaMethodParameters, jandexMethod, + multipartResponseTypes); } else { + // if the response is multipart, let's add it's class to the appropriate collection: + addResponseTypeIfMultipart(multipartResponseTypes, jandexMethod, index); + // constructor: initializing the immutable part of the method-specific web target FieldDescriptor webTargetForMethod = FieldDescriptor.of(name, "target" + methodIndex, WebTargetImpl.class); c.getFieldCreator(webTargetForMethod).setModifiers(Modifier.FINAL); @@ -722,11 +970,38 @@ A more full example of generated client (with sub-resource) can is at the bottom } + private void addResponseTypeIfMultipart(Set multipartResponseTypes, MethodInfo method, IndexView index) { + AnnotationInstance produces = method.annotation(ResteasyReactiveDotNames.PRODUCES); + if (produces == null) { + produces = method.annotation(ResteasyReactiveDotNames.PRODUCES); + } + if (produces != null) { + String[] producesValues = produces.value().asStringArray(); + for (String producesValue : producesValues) { + if (producesValue.toLowerCase(Locale.ROOT) + .startsWith(ContentType.MULTIPART_FORM_DATA.getMimeType())) { + multipartResponseTypes.add(returnTypeAsClass(method, index)); + } + } + } + } + + private ClassInfo returnTypeAsClass(MethodInfo jandexMethod, IndexView index) { + Type result = jandexMethod.returnType(); + if (result.kind() == CLASS) { + return index.getClassByName(result.asClassType().name()); + } else { + throw new IllegalArgumentException("multipart responses can only be mapped to non-generic classes, " + + "got " + result + " of type: " + result.kind()); + } + } + private void handleSubResourceMethod(List enrichers, BuildProducer generatedClasses, ClassInfo interfaceClass, IndexView index, String defaultMediaType, Map httpAnnotationToMethod, String name, ClassCreator c, MethodCreator constructor, AssignableResultHandle baseTarget, int methodIndex, List webTargets, - ResourceMethod method, String[] javaMethodParameters, MethodInfo jandexMethod) { + ResourceMethod method, String[] javaMethodParameters, MethodInfo jandexMethod, + Set multipartResponseTypes) { Type returnType = jandexMethod.returnType(); if (returnType.kind() != CLASS) { // sort of sub-resource method that returns a thing that isn't a class @@ -1025,8 +1300,12 @@ private void handleSubResourceMethod(List handleSubResourceMethod(enrichers, generatedClasses, subResourceInterface, index, defaultMediaType, httpAnnotationToMethod, subName, sub, subMethodCreator, - methodTarget, subMethodIndex, webTargets, subMethod, subJavaMethodParameters, jandexSubMethod); + methodTarget, subMethodIndex, webTargets, subMethod, subJavaMethodParameters, jandexSubMethod, + multipartResponseTypes); } else { + // if the response is multipart, let's add it's class to the appropriate collection: + addResponseTypeIfMultipart(multipartResponseTypes, jandexSubMethod, index); + AssignableResultHandle builder = subMethodCreator.createVariable(Invocation.Builder.class); if (method.getProduces() == null || method.getProduces().length == 0) { // this should never happen! subMethodCreator.assign(builder, subMethodCreator.invokeInterfaceMethod( diff --git a/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/JaxrsClientReactiveClientContextResolver.java b/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/JaxrsClientReactiveClientContextResolver.java index fed488e444ec4..eb8c5ceff02c4 100644 --- a/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/JaxrsClientReactiveClientContextResolver.java +++ b/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/JaxrsClientReactiveClientContextResolver.java @@ -1,11 +1,13 @@ package io.quarkus.jaxrs.client.reactive.runtime; +import java.util.Map; import java.util.function.Supplier; import org.jboss.resteasy.reactive.client.impl.ClientProxies; import org.jboss.resteasy.reactive.client.impl.DefaultClientContext; import org.jboss.resteasy.reactive.client.spi.ClientContext; import org.jboss.resteasy.reactive.client.spi.ClientContextResolver; +import org.jboss.resteasy.reactive.client.spi.MultipartResponseData; import org.jboss.resteasy.reactive.common.core.GenericTypeMapping; import org.jboss.resteasy.reactive.common.core.Serialisers; @@ -51,6 +53,14 @@ public ClientProxies getClientProxies() { } return clientProxies; } + + @Override + public Map, MultipartResponseData> getMultipartResponsesData() { + Map, MultipartResponseData> result = JaxrsClientReactiveRecorder.getMultipartResponsesData(); + return result == null + ? DefaultClientContext.INSTANCE.getMultipartResponsesData() + : result; + } }; } } diff --git a/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/JaxrsClientReactiveRecorder.java b/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/JaxrsClientReactiveRecorder.java index 013e77739c8de..f67b99b9ce37a 100644 --- a/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/JaxrsClientReactiveRecorder.java +++ b/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/JaxrsClientReactiveRecorder.java @@ -12,6 +12,7 @@ import org.jboss.resteasy.reactive.client.impl.ClientProxies; import org.jboss.resteasy.reactive.client.impl.ClientSerialisers; +import org.jboss.resteasy.reactive.client.spi.MultipartResponseData; import org.jboss.resteasy.reactive.common.core.GenericTypeMapping; import org.jboss.resteasy.reactive.common.core.Serialisers; @@ -24,6 +25,7 @@ public class JaxrsClientReactiveRecorder extends ResteasyReactiveCommonRecorder private static volatile Serialisers serialisers; private static volatile GenericTypeMapping genericTypeMapping; + private static volatile Map, MultipartResponseData> multipartResponsesData; private static volatile ClientProxies clientProxies = new ClientProxies(Collections.emptyMap(), Collections.emptyMap()); @@ -39,6 +41,19 @@ public static GenericTypeMapping getGenericTypeMapping() { return genericTypeMapping; } + public static Map, MultipartResponseData> getMultipartResponsesData() { + return multipartResponsesData; + } + + public void setMultipartResponsesData(Map> multipartResponsesData) { + Map, MultipartResponseData> runtimeMap = new HashMap<>(); + for (Map.Entry> multipartData : multipartResponsesData.entrySet()) { + runtimeMap.put(loadClass(multipartData.getKey()), multipartData.getValue().getValue()); + } + + JaxrsClientReactiveRecorder.multipartResponsesData = runtimeMap; + } + public void setupClientProxies( Map, ?>>> clientImplementations, Map failures) { diff --git a/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/impl/MultipartResponseDataBase.java b/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/impl/MultipartResponseDataBase.java new file mode 100644 index 0000000000000..310d1751bf36e --- /dev/null +++ b/extensions/resteasy-reactive/jaxrs-client-reactive/runtime/src/main/java/io/quarkus/jaxrs/client/reactive/runtime/impl/MultipartResponseDataBase.java @@ -0,0 +1,22 @@ +package io.quarkus.jaxrs.client.reactive.runtime.impl; + +import java.util.ArrayList; +import java.util.List; + +import org.jboss.resteasy.reactive.client.spi.FieldFiller; +import org.jboss.resteasy.reactive.client.spi.MultipartResponseData; + +public abstract class MultipartResponseDataBase implements MultipartResponseData { + + private final List fillers = new ArrayList<>(); + + @Override + public List getFieldFillers() { + return fillers; + } + + @SuppressWarnings("unused") // used in generated classes + public void addFiller(FieldFiller filler) { + fillers.add(filler); + } +} diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/multipart/MultipartResponseTest.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/multipart/MultipartResponseTest.java new file mode 100644 index 0000000000000..4c73daa59c3be --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/multipart/MultipartResponseTest.java @@ -0,0 +1,280 @@ +package io.quarkus.rest.client.reactive.multipart; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Fail.fail; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +import javax.ws.rs.FormParam; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.core.MediaType; + +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.jboss.resteasy.reactive.ClientWebApplicationException; +import org.jboss.resteasy.reactive.MultipartForm; +import org.jboss.resteasy.reactive.PartType; +import org.jboss.resteasy.reactive.RestForm; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; + +public class MultipartResponseTest { + + public static final String WOO_HOO_WOO_HOO_HOO = "Woo hoo, woo hoo hoo"; + @TestHTTPResource + URI baseUri; + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest(); + + @Test + void shouldParseMultipartResponse() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri).build(Client.class); + MultipartData data = client.getFile(); + assertThat(data.file).exists(); + verifyWooHooFile(data.file); + assertThat(data.name).isEqualTo("foo"); + assertThat(data.panda.weight).isEqualTo("huge"); + assertThat(data.panda.height).isEqualTo("medium"); + assertThat(data.panda.mood).isEqualTo("happy"); + assertThat(data.number).isEqualTo(1984); + assertThat(data.numberz).containsSequence(2008, 2011, 2014); + } + + @Test + void shouldParseMultipartResponseWithNulls() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri).build(Client.class); + MultipartData data = client.getFileEmpty(); + assertThat(data.file).isNull(); + assertThat(data.name).isNull(); + assertThat(data.panda).isNull(); + } + + @Test + void shouldParseMultipartResponseWithSetters() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri).build(Client.class); + MultipartDataWithSetters data = client.getFileWithSetters(); + assertThat(data.file).exists(); + assertThat(data.name).isEqualTo("foo"); + assertThat(data.panda.weight).isEqualTo("huge"); + assertThat(data.panda.height).isEqualTo("medium"); + assertThat(data.panda.mood).isEqualTo("happy"); + } + + @Test + void shouldBeSaneOnServerError() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri).build(Client.class); + assertThatThrownBy(client::error).isInstanceOf(ClientWebApplicationException.class); + } + + @Test + void shouldParseMultipartResponseWithClientBuilderApi() { + javax.ws.rs.client.Client client = ClientBuilder.newBuilder().build(); + MultipartDataForClientBuilder data = client.target(baseUri) + .path("/give-me-file") + .request(MediaType.MULTIPART_FORM_DATA) + .get(MultipartDataForClientBuilder.class); + assertThat(data.file).exists(); + assertThat(data.name).isEqualTo("foo"); + assertThat(data.panda.weight).isEqualTo("huge"); + assertThat(data.panda.height).isEqualTo("medium"); + assertThat(data.panda.mood).isEqualTo("happy"); + assertThat(data.numbers).containsSequence(2008, 2011, 2014); + } + + void verifyWooHooFile(File file) { + int position = 0; + try (FileReader reader = new FileReader(file)) { + int read; + while ((read = reader.read()) > 0) { + assertThat((char) read).isEqualTo(WOO_HOO_WOO_HOO_HOO.charAt(position % WOO_HOO_WOO_HOO_HOO.length())); + position++; + } + assertThat(position).isEqualTo(WOO_HOO_WOO_HOO_HOO.length() * 10000); + } catch (IOException e) { + fail("failed to read provided file", e); + } + } + + @Path("/give-me-file") + public interface Client { + @GET + @Produces(MediaType.MULTIPART_FORM_DATA) + MultipartData getFile(); + + @GET + @Produces(MediaType.MULTIPART_FORM_DATA) + @Path("/empty") + MultipartData getFileEmpty(); + + @GET + @Produces(MediaType.MULTIPART_FORM_DATA) + MultipartDataWithSetters getFileWithSetters(); + + @GET + @Produces(MediaType.MULTIPART_FORM_DATA) + @Path("/error") + MultipartData error(); + } + + @Path("/give-me-file") + public static class Resource { + + @GET + @Produces(MediaType.MULTIPART_FORM_DATA) + public MultipartData getFile() throws IOException { + File file = File.createTempFile("toDownload", ".txt"); + file.deleteOnExit(); + // let's write Woo hoo, woo hoo hoo 10k times + try (FileOutputStream out = new FileOutputStream(file)) { + for (int i = 0; i < 10000; i++) { + out.write(WOO_HOO_WOO_HOO_HOO.getBytes(StandardCharsets.UTF_8)); + } + } + return new MultipartData("foo", file, new Panda("huge", "medium", "happy"), + 1984, new int[] { 2008, 2011, 2014 }); + } + + @GET + @Produces(MediaType.MULTIPART_FORM_DATA) + @Path("/empty") + public MultipartData getEmptyData() { + return new MultipartData(null, null, null, 0, null); + } + + @GET + @Produces(MediaType.MULTIPART_FORM_DATA) + @Path("/error") + public MultipartData throwError() { + throw new RuntimeException("forced error"); + } + } + + public static class MultipartData { + + @RestForm + @PartType(MediaType.TEXT_PLAIN) + public String name; + + @RestForm + @PartType(MediaType.APPLICATION_OCTET_STREAM) + public File file; + + @RestForm + @PartType(MediaType.APPLICATION_JSON) + public Panda panda; + + @RestForm + @PartType(MediaType.TEXT_PLAIN) + public Integer number; + + @RestForm + @PartType(MediaType.APPLICATION_JSON) + public int[] numberz; + + public MultipartData() { + } + + public MultipartData(String name, File file, Panda panda, int number, int[] numberz) { + this.name = name; + this.file = file; + this.panda = panda; + this.number = number; + this.numberz = numberz; + } + } + + @MultipartForm + public static class MultipartDataForClientBuilder { + @RestForm + @PartType(MediaType.TEXT_PLAIN) + public String name; + + @RestForm + @PartType(MediaType.APPLICATION_OCTET_STREAM) + public File file; + + @RestForm + @PartType(MediaType.APPLICATION_JSON) + public Panda panda; + + @RestForm + @PartType(MediaType.TEXT_PLAIN) + public Integer number; + + @FormParam("numberz") + @PartType(MediaType.APPLICATION_JSON) + public int[] numbers; + + public MultipartDataForClientBuilder() { + } + + public MultipartDataForClientBuilder(String name, File file, Panda panda, int number, int[] numberz) { + this.name = name; + this.file = file; + this.panda = panda; + this.number = number; + this.numbers = numberz; + } + } + + public static class MultipartDataWithSetters { + @RestForm + @PartType(MediaType.TEXT_PLAIN) + private String name; + @RestForm + @PartType(MediaType.APPLICATION_OCTET_STREAM) + File file; + + @RestForm + @PartType(MediaType.APPLICATION_JSON) + private Panda panda; + + public MultipartDataWithSetters() { + } + + public MultipartDataWithSetters(String name, File file, Panda panda) { + this.name = name; + this.file = file; + this.panda = panda; + } + + public void setName(String name) { + this.name = name; + } + + public void setFile(File file) { + this.file = file; + } + + public void setPanda(Panda panda) { + this.panda = panda; + } + } + + public static class Panda { + public String weight; + public String height; + public String mood; + + public Panda() { + } + + public Panda(String weight, String height, String mood) { + this.weight = weight; + this.height = height; + this.mood = mood; + } + } +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientResponseCompleteRestHandler.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientResponseCompleteRestHandler.java index 7879d8e2e088e..1f8c69a425b66 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientResponseCompleteRestHandler.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientResponseCompleteRestHandler.java @@ -1,12 +1,25 @@ package org.jboss.resteasy.reactive.client.handlers; +import io.netty.handler.codec.http.multipart.Attribute; +import io.netty.handler.codec.http.multipart.FileUpload; +import io.netty.handler.codec.http.multipart.InterfaceHttpData; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import javax.ws.rs.core.GenericType; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import org.jboss.resteasy.reactive.client.impl.ClientResponseBuilderImpl; import org.jboss.resteasy.reactive.client.impl.ClientResponseContextImpl; import org.jboss.resteasy.reactive.client.impl.RestClientRequestContext; +import org.jboss.resteasy.reactive.client.impl.multipart.FileDownloadImpl; import org.jboss.resteasy.reactive.client.spi.ClientRestHandler; +import org.jboss.resteasy.reactive.client.spi.FieldFiller; +import org.jboss.resteasy.reactive.client.spi.MultipartResponseData; import org.jboss.resteasy.reactive.common.jaxrs.ResponseImpl; public class ClientResponseCompleteRestHandler implements ClientRestHandler { @@ -16,31 +29,85 @@ public void handle(RestClientRequestContext context) throws Exception { context.getResult().complete(mapToResponse(context, true)); } - public static ResponseImpl mapToResponse(RestClientRequestContext context, boolean parseContent) + public static ResponseImpl mapToResponse(RestClientRequestContext context, + boolean parseContent) throws IOException { + Map, MultipartResponseData> multipartDataMap = context.getMultipartResponsesData(); ClientResponseContextImpl responseContext = context.getOrCreateClientResponseContext(); ClientResponseBuilderImpl builder = new ClientResponseBuilderImpl(); builder.status(responseContext.getStatus(), responseContext.getReasonPhrase()); builder.setAllHeaders(responseContext.getHeaders()); builder.invocationState(context); + InputStream entityStream = responseContext.getEntityStream(); if (context.isResponseTypeSpecified() // when we are returning a RestResponse, we don't want to do any parsing && (Response.Status.Family.familyOf(context.getResponseStatus()) == Response.Status.Family.SUCCESSFUL) && parseContent) { // this case means that a specific response type was requested - Object entity = context.readEntity(responseContext.getEntityStream(), - context.getResponseType(), - responseContext.getMediaType(), - // FIXME: we have strings, it wants objects, perhaps there's - // an Object->String conversion too many - (MultivaluedMap) responseContext.getHeaders()); - if (entity != null) { - builder.entity(entity); + if (context.getResponseMultipartParts() != null) { + GenericType responseType = context.getResponseType(); + if (!(responseType.getType() instanceof Class)) { + throw new IllegalArgumentException("Not supported return type for a multipart message, " + + "expected a non-generic class got : " + responseType.getType()); + } + Class responseClass = (Class) responseType.getType(); + MultipartResponseData multipartData = multipartDataMap.get(responseClass); + if (multipartData == null) { + throw new IllegalStateException("Failed to find multipart data for class " + responseClass + ". " + + "If it's meant to be used as multipart response type, consider annotating it with @MultipartForm"); + } + Object result = multipartData.newInstance(); + builder.entity(result); + List parts = context.getResponseMultipartParts(); + for (FieldFiller fieldFiller : multipartData.getFieldFillers()) { + InterfaceHttpData httpData = getPartForName(parts, fieldFiller.getPartName()); + if (httpData == null) { + continue; + } else if (httpData instanceof Attribute) { + // TODO: get rid of ByteArrayInputStream + // TODO: maybe we could extract something closer to input stream from attribute + ByteArrayInputStream in = new ByteArrayInputStream( + ((Attribute) httpData).getValue().getBytes(StandardCharsets.UTF_8)); + Object fieldValue = context.readEntity(in, + fieldFiller.getFieldType(), + MediaType.valueOf(fieldFiller.getMediaType()), + // FIXME: we have strings, it wants objects, perhaps there's + // an Object->String conversion too many + (MultivaluedMap) responseContext.getHeaders()); + if (fieldValue != null) { + fieldFiller.set(result, fieldValue); + } + } else if (httpData instanceof FileUpload) { + fieldFiller.set(result, new FileDownloadImpl((FileUpload) httpData)); + } else { + throw new IllegalArgumentException("Unsupported multipart message element type. " + + "Expected FileAttribute or Attribute, got: " + httpData.getClass()); + } + } + } else { + Object entity = context.readEntity(entityStream, + context.getResponseType(), + responseContext.getMediaType(), + // FIXME: we have strings, it wants objects, perhaps there's + // an Object->String conversion too many + (MultivaluedMap) responseContext.getHeaders()); + if (entity != null) { + builder.entity(entity); + } } } else { // in this case no specific response type was requested so we just prepare the stream // the users of the response are meant to use readEntity - builder.entityStream(responseContext.getEntityStream()); + builder.entityStream(entityStream); } return builder.build(); } + + private static InterfaceHttpData getPartForName(List parts, String partName) { + for (InterfaceHttpData part : parts) { + if (partName.equals(part.getName())) { + return part; + } + } + return null; + } } diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSendRequestHandler.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSendRequestHandler.java index 7595861b54d11..02b6dd69918b4 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSendRequestHandler.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSendRequestHandler.java @@ -1,5 +1,7 @@ package org.jboss.resteasy.reactive.client.handlers; +import io.netty.handler.codec.http.LastHttpContent; +import io.netty.handler.codec.http.multipart.InterfaceHttpData; import io.smallrye.mutiny.Uni; import io.smallrye.stork.ServiceInstance; import io.smallrye.stork.Stork; @@ -19,6 +21,7 @@ import java.io.IOException; import java.net.URI; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; @@ -26,6 +29,7 @@ import javax.ws.rs.ProcessingException; import javax.ws.rs.client.Entity; import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Variant; import org.jboss.logging.Logger; @@ -38,20 +42,26 @@ import org.jboss.resteasy.reactive.client.impl.multipart.PausableHttpPostRequestEncoder; import org.jboss.resteasy.reactive.client.impl.multipart.QuarkusMultipartForm; import org.jboss.resteasy.reactive.client.impl.multipart.QuarkusMultipartFormUpload; +import org.jboss.resteasy.reactive.client.impl.multipart.QuarkusMultipartResponseDecoder; import org.jboss.resteasy.reactive.client.spi.ClientRestHandler; +import org.jboss.resteasy.reactive.client.spi.MultipartResponseData; import org.jboss.resteasy.reactive.common.core.Serialisers; public class ClientSendRequestHandler implements ClientRestHandler { private static final Logger log = Logger.getLogger(ClientSendRequestHandler.class); + public static final String CONTENT_TYPE = "Content-Type"; private final boolean followRedirects; private final LoggingScope loggingScope; private final ClientLogger clientLogger; + private final Map, MultipartResponseData> multipartResponseDataMap; - public ClientSendRequestHandler(boolean followRedirects, LoggingScope loggingScope, ClientLogger logger) { + public ClientSendRequestHandler(boolean followRedirects, LoggingScope loggingScope, ClientLogger logger, + Map, MultipartResponseData> multipartResponseDataMap) { this.followRedirects = followRedirects; this.loggingScope = loggingScope; this.clientLogger = logger; + this.multipartResponseDataMap = multipartResponseDataMap; } @Override @@ -147,7 +157,29 @@ public void handle(HttpClientResponse clientResponse) { reportFinish(System.nanoTime() - startTime, null, requestContext); } } - if (!requestContext.isRegisterBodyHandler()) { + + if (isResponseMultipart(requestContext)) { + QuarkusMultipartResponseDecoder multipartDecoder = new QuarkusMultipartResponseDecoder( + clientResponse); + + clientResponse.handler(multipartDecoder::offer); + + clientResponse.endHandler(new Handler<>() { + @Override + public void handle(Void event) { + multipartDecoder.offer(LastHttpContent.EMPTY_LAST_CONTENT); + + List datas = multipartDecoder.getBodyHttpDatas(); + requestContext.setResponseMultipartParts(datas); + + if (loggingScope != LoggingScope.NONE) { + clientLogger.logResponse(clientResponse, false); + } + + requestContext.resume(); + } + }); + } else if (!requestContext.isRegisterBodyHandler()) { clientResponse.pause(); if (loggingScope != LoggingScope.NONE) { clientLogger.logResponse(clientResponse, false); @@ -206,6 +238,19 @@ public void accept(Throwable event) { }); } + private boolean isResponseMultipart(RestClientRequestContext requestContext) { + MultivaluedMap responseHeaders = requestContext.getResponseHeaders(); + List contentTypes = responseHeaders.get(CONTENT_TYPE); + if (contentTypes != null) { + for (String contentType : contentTypes) { + if (contentType.toLowerCase(Locale.ROOT).startsWith(MediaType.MULTIPART_FORM_DATA)) { + return true; + } + } + } + return false; + } + private void reportFinish(long timeInNs, Throwable throwable, RestClientRequestContext requestContext) { ServiceInstance serviceInstance = requestContext.getCallStatsCollector(); if (serviceInstance != null) { @@ -218,6 +263,7 @@ public Uni createRequest(RestClientRequestContext state) { URI uri = state.getUri(); Object readTimeout = state.getConfiguration().getProperty(QuarkusRestClientProperties.READ_TIMEOUT); Uni requestOptions; + state.setMultipartResponsesData(multipartResponseDataMap); if (uri.getScheme().startsWith(Stork.STORK)) { boolean isHttps = "storks".equals(uri.getScheme()); String serviceName = uri.getHost(); diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientImpl.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientImpl.java index 797df44b3b4fd..6169ef6cc5cd5 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientImpl.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/ClientImpl.java @@ -167,7 +167,7 @@ public Vertx get() { }); } - handlerChain = new HandlerChain(followRedirects, loggingScope, clientLogger); + handlerChain = new HandlerChain(followRedirects, loggingScope, clientContext.getMultipartResponsesData(), clientLogger); } public ClientContext getClientContext() { diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/DefaultClientContext.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/DefaultClientContext.java index 5e0d2e461fbdf..add884e033d9f 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/DefaultClientContext.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/DefaultClientContext.java @@ -2,10 +2,12 @@ import io.vertx.core.Vertx; import java.util.Collections; +import java.util.Map; import java.util.function.Supplier; import javax.ws.rs.RuntimeType; import org.jboss.resteasy.reactive.client.spi.ClientContext; import org.jboss.resteasy.reactive.client.spi.ClientContextResolver; +import org.jboss.resteasy.reactive.client.spi.MultipartResponseData; import org.jboss.resteasy.reactive.common.core.GenericTypeMapping; public class DefaultClientContext implements ClientContext { @@ -48,4 +50,9 @@ public Supplier getVertx() { public ClientProxies getClientProxies() { return clientProxies; } + + @Override + public Map, MultipartResponseData> getMultipartResponsesData() { + return Collections.emptyMap(); // supported in quarkus only at the moment + } } diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/HandlerChain.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/HandlerChain.java index 1e3592b1dd9a3..d6770997cdc7d 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/HandlerChain.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/HandlerChain.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import javax.ws.rs.client.ClientRequestFilter; import javax.ws.rs.client.ClientResponseFilter; import org.jboss.resteasy.reactive.client.api.ClientLogger; @@ -14,6 +15,7 @@ import org.jboss.resteasy.reactive.client.handlers.ClientSetResponseEntityRestHandler; import org.jboss.resteasy.reactive.client.handlers.PreResponseFilterHandler; import org.jboss.resteasy.reactive.client.spi.ClientRestHandler; +import org.jboss.resteasy.reactive.client.spi.MultipartResponseData; import org.jboss.resteasy.reactive.common.jaxrs.ConfigurationImpl; class HandlerChain { @@ -27,8 +29,9 @@ class HandlerChain { private ClientRestHandler preClientSendHandler = null; - public HandlerChain(boolean followRedirects, LoggingScope loggingScope, ClientLogger clientLogger) { - this.clientSendHandler = new ClientSendRequestHandler(followRedirects, loggingScope, clientLogger); + public HandlerChain(boolean followRedirects, LoggingScope loggingScope, + Map, MultipartResponseData> multipartData, ClientLogger clientLogger) { + this.clientSendHandler = new ClientSendRequestHandler(followRedirects, loggingScope, clientLogger, multipartData); this.clientSetResponseEntityRestHandler = new ClientSetResponseEntityRestHandler(); this.clientResponseCompleteRestHandler = new ClientResponseCompleteRestHandler(); this.clientErrorHandler = new ClientErrorHandler(loggingScope); diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/RestClientRequestContext.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/RestClientRequestContext.java index 5bf532c888b86..71dd748deff86 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/RestClientRequestContext.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/RestClientRequestContext.java @@ -1,5 +1,6 @@ package org.jboss.resteasy.reactive.client.impl; +import io.netty.handler.codec.http.multipart.InterfaceHttpData; import io.smallrye.stork.ServiceInstance; import io.vertx.core.Context; import io.vertx.core.MultiMap; @@ -35,6 +36,7 @@ import org.jboss.resteasy.reactive.RestResponse; import org.jboss.resteasy.reactive.client.impl.multipart.QuarkusMultipartForm; import org.jboss.resteasy.reactive.client.spi.ClientRestHandler; +import org.jboss.resteasy.reactive.client.spi.MultipartResponseData; import org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext; import org.jboss.resteasy.reactive.common.core.Serialisers; import org.jboss.resteasy.reactive.common.jaxrs.ConfigurationImpl; @@ -86,8 +88,10 @@ public class RestClientRequestContext extends AbstractResteasyReactiveContext responseMultiParts; private Response abortedWith; private ServiceInstance callStatsCollector; + private Map, MultipartResponseData> multipartResponsesData; public RestClientRequestContext(ClientImpl restClient, HttpClient httpClient, String httpMethod, URI uri, @@ -423,6 +427,15 @@ public RestClientRequestContext setResponseEntityStream(InputStream responseEnti return this; } + public RestClientRequestContext setResponseMultipartParts(List responseMultiParts) { + this.responseMultiParts = responseMultiParts; + return this; + } + + public List getResponseMultipartParts() { + return responseMultiParts; + } + public boolean isAborted() { return getAbortedWith() != null; } @@ -455,4 +468,12 @@ public void setCallStatsCollector(ServiceInstance serviceInstance) { public ServiceInstance getCallStatsCollector() { return callStatsCollector; } + + public Map, MultipartResponseData> getMultipartResponsesData() { + return multipartResponsesData; + } + + public void setMultipartResponsesData(Map, MultipartResponseData> multipartResponsesData) { + this.multipartResponsesData = multipartResponsesData; + } } diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/CaseIgnoringComparator.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/CaseIgnoringComparator.java new file mode 100644 index 0000000000000..a1bf5a0f42f0a --- /dev/null +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/CaseIgnoringComparator.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.jboss.resteasy.reactive.client.impl.multipart; + +import java.io.Serializable; +import java.util.Comparator; + +/** + * copied from Netty + */ +final class CaseIgnoringComparator implements Comparator, Serializable { + + private static final long serialVersionUID = 4582133183775373862L; + + static final CaseIgnoringComparator INSTANCE = new CaseIgnoringComparator(); + + private CaseIgnoringComparator() { + } + + @Override + public int compare(CharSequence o1, CharSequence o2) { + int o1Length = o1.length(); + int o2Length = o2.length(); + int min = Math.min(o1Length, o2Length); + for (int i = 0; i < min; i++) { + char c1 = o1.charAt(i); + char c2 = o2.charAt(i); + if (c1 != c2) { + c1 = Character.toUpperCase(c1); + c2 = Character.toUpperCase(c2); + if (c1 != c2) { + c1 = Character.toLowerCase(c1); + c2 = Character.toLowerCase(c2); + if (c1 != c2) { + return c1 - c2; + } + } + } + } + return o1Length - o2Length; + } + + private Object readResolve() { + return INSTANCE; + } +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/FileDownloadImpl.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/FileDownloadImpl.java new file mode 100644 index 0000000000000..d036a5171eb41 --- /dev/null +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/FileDownloadImpl.java @@ -0,0 +1,50 @@ +package org.jboss.resteasy.reactive.client.impl.multipart; + +import io.netty.handler.codec.http.multipart.FileUpload; +import java.io.IOException; +import java.nio.file.Path; +import org.jboss.resteasy.reactive.multipart.FileDownload; + +public class FileDownloadImpl implements FileDownload { + + // we're using netty's file upload to represent download too + private final FileUpload file; + + public FileDownloadImpl(FileUpload httpData) { + this.file = httpData; + } + + @Override + public String name() { + return file.getName(); + } + + @Override + public Path filePath() { + try { + return file == null ? null : file.getFile().toPath(); + } catch (IOException e) { + throw new IllegalArgumentException("Unable to provide file for download", e); + } + } + + @Override + public String fileName() { + return file.getFilename(); + } + + @Override + public long size() { + throw new UnsupportedOperationException("returning size of a downloaded file is not supported"); + } + + @Override + public String contentType() { + return file.getContentType(); + } + + @Override + public String charSet() { + return file.getCharset().name(); + } +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusHttpPostBodyUtil.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusHttpPostBodyUtil.java index 59b84ac780030..3d04956701f9a 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusHttpPostBodyUtil.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusHttpPostBodyUtil.java @@ -17,6 +17,9 @@ import io.netty.buffer.ByteBuf; import io.netty.handler.codec.http.HttpConstants; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder; +import io.netty.util.internal.StringUtil; /** * A copy of Netty's HttpPostBodyUtil which is not public @@ -270,4 +273,79 @@ static int findDelimiter(ByteBuf buffer, int index, byte[] delimiter, boolean pr } return -1; } + + /** + * copied from {@link HttpPostRequestDecoder} + * + * @param contentType + * @return + */ + public static String[] getMultipartDataBoundary(String contentType) { + // Check if Post using "multipart/form-data; boundary=--89421926422648 [; charset=xxx]" + String[] headerContentType = splitHeaderContentType(contentType); + final String multiPartHeader = HttpHeaderValues.MULTIPART_FORM_DATA.toString(); + if (headerContentType[0].regionMatches(true, 0, multiPartHeader, 0, multiPartHeader.length())) { + int mrank; + int crank; + final String boundaryHeader = HttpHeaderValues.BOUNDARY.toString(); + if (headerContentType[1].regionMatches(true, 0, boundaryHeader, 0, boundaryHeader.length())) { + mrank = 1; + crank = 2; + } else if (headerContentType[2].regionMatches(true, 0, boundaryHeader, 0, boundaryHeader.length())) { + mrank = 2; + crank = 1; + } else { + return null; + } + String boundary = StringUtil.substringAfter(headerContentType[mrank], '='); + if (boundary == null) { + throw new HttpPostRequestDecoder.ErrorDataDecoderException("Needs a boundary value"); + } + if (boundary.charAt(0) == '"') { + String bound = boundary.trim(); + int index = bound.length() - 1; + if (bound.charAt(index) == '"') { + boundary = bound.substring(1, index); + } + } + final String charsetHeader = HttpHeaderValues.CHARSET.toString(); + if (headerContentType[crank].regionMatches(true, 0, charsetHeader, 0, charsetHeader.length())) { + String charset = StringUtil.substringAfter(headerContentType[crank], '='); + if (charset != null) { + return new String[] { "--" + boundary, charset }; + } + } + return new String[] { "--" + boundary }; + } + return null; + } + + private static String[] splitHeaderContentType(String sb) { + int aStart; + int aEnd; + int bStart; + int bEnd; + int cStart; + int cEnd; + aStart = findNonWhitespace(sb, 0); + aEnd = sb.indexOf(';'); + if (aEnd == -1) { + return new String[] { sb, "", "" }; + } + bStart = findNonWhitespace(sb, aEnd + 1); + if (sb.charAt(aEnd - 1) == ' ') { + aEnd--; + } + bEnd = sb.indexOf(';', bStart); + if (bEnd == -1) { + bEnd = findEndOfString(sb); + return new String[] { sb.substring(aStart, aEnd), sb.substring(bStart, bEnd), "" }; + } + cStart = findNonWhitespace(sb, bEnd + 1); + if (sb.charAt(bEnd - 1) == ' ') { + bEnd--; + } + cEnd = findEndOfString(sb); + return new String[] { sb.substring(aStart, aEnd), sb.substring(bStart, bEnd), sb.substring(cStart, cEnd) }; + } } diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusMultipartResponseDataFactory.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusMultipartResponseDataFactory.java new file mode 100644 index 0000000000000..0770f91f9e9f0 --- /dev/null +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusMultipartResponseDataFactory.java @@ -0,0 +1,334 @@ +/* + * Copyright 2012 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.jboss.resteasy.reactive.client.impl.multipart; + +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.HttpConstants; +import io.netty.handler.codec.http.multipart.Attribute; +import io.netty.handler.codec.http.multipart.DefaultHttpDataFactory; +import io.netty.handler.codec.http.multipart.DiskAttribute; +import io.netty.handler.codec.http.multipart.DiskFileUpload; +import io.netty.handler.codec.http.multipart.FileUpload; +import io.netty.handler.codec.http.multipart.HttpData; +import io.netty.handler.codec.http.multipart.InterfaceHttpData; +import io.netty.handler.codec.http.multipart.MemoryAttribute; +import io.netty.handler.codec.http.multipart.MemoryFileUpload; +import io.netty.handler.codec.http.multipart.MixedAttribute; +import io.netty.handler.codec.http.multipart.MixedFileUpload; +import io.vertx.core.http.HttpClientResponse; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +/** + * based on {@link DefaultHttpDataFactory} but for responses, the original one is for requests only :( + */ +public class QuarkusMultipartResponseDataFactory { + /** + * Proposed default MINSIZE as 16 KB. + */ + public static final long MINSIZE = 0x4000; + /** + * Proposed default MAXSIZE = -1 as UNLIMITED + */ + public static final long MAXSIZE = -1; + + private final boolean useDisk; + + private final boolean checkSize; + + private long minSize; + + private long maxSize = MAXSIZE; + + private Charset charset = HttpConstants.DEFAULT_CHARSET; + + private String baseDir; + + private boolean deleteOnExit; // false is a good default cause true leaks + + /** + * Keep all {@link HttpData}s until cleaning methods are called. + * We need to use {@link IdentityHashMap} because different requests may be equal. + * See {@link DefaultHttpRequest#hashCode} and {@link DefaultHttpRequest#equals}. + * Similarly, when removing data items, we need to check their identities because + * different data items may be equal. + */ + private final Map> responseFileDeleteMap = Collections + .synchronizedMap(new IdentityHashMap<>()); + + /** + * HttpData will be in memory if less than default size (16KB). + * The type will be Mixed. + */ + public QuarkusMultipartResponseDataFactory() { + useDisk = false; + checkSize = true; + minSize = MINSIZE; + } + + public QuarkusMultipartResponseDataFactory(Charset charset) { + this(); + this.charset = charset; + } + + /** + * HttpData will be always on Disk if useDisk is True, else always in Memory if False + */ + public QuarkusMultipartResponseDataFactory(boolean useDisk) { + this.useDisk = useDisk; + checkSize = false; + } + + public QuarkusMultipartResponseDataFactory(boolean useDisk, Charset charset) { + this(useDisk); + this.charset = charset; + } + + /** + * HttpData will be on Disk if the size of the file is greater than minSize, else it + * will be in memory. The type will be Mixed. + */ + public QuarkusMultipartResponseDataFactory(long minSize) { + useDisk = false; + checkSize = true; + this.minSize = minSize; + } + + public QuarkusMultipartResponseDataFactory(long minSize, Charset charset) { + this(minSize); + this.charset = charset; + } + + /** + * Override global {@link DiskAttribute#baseDirectory} and {@link DiskFileUpload#baseDirectory} values. + * + * @param baseDir directory path where to store disk attributes and file uploads. + */ + public void setBaseDir(String baseDir) { + this.baseDir = baseDir; + } + + /** + * Override global {@link DiskAttribute#deleteOnExitTemporaryFile} and + * {@link DiskFileUpload#deleteOnExitTemporaryFile} values. + * + * @param deleteOnExit true if temporary files should be deleted with the JVM, false otherwise. + */ + public void setDeleteOnExit(boolean deleteOnExit) { + this.deleteOnExit = deleteOnExit; + } + + public void setMaxLimit(long maxSize) { + this.maxSize = maxSize; + } + + /** + * @return the associated list of {@link HttpData} for the request + */ + private List getList(HttpClientResponse response) { + List list = responseFileDeleteMap.get(response); + if (list == null) { + list = new ArrayList<>(); + responseFileDeleteMap.put(response, list); + } + return list; + } + + public Attribute createAttribute(HttpClientResponse response, String name) { + if (useDisk) { + Attribute attribute = new DiskAttribute(name, charset, baseDir, deleteOnExit); + attribute.setMaxSize(maxSize); + List list = getList(response); + list.add(attribute); + return attribute; + } + if (checkSize) { + Attribute attribute = new MixedAttribute(name, minSize, charset, baseDir, deleteOnExit); + attribute.setMaxSize(maxSize); + List list = getList(response); + list.add(attribute); + return attribute; + } + MemoryAttribute attribute = new MemoryAttribute(name); + attribute.setMaxSize(maxSize); + return attribute; + } + + public Attribute createAttribute(HttpClientResponse response, String name, long definedSize) { + if (useDisk) { + Attribute attribute = new DiskAttribute(name, definedSize, charset, baseDir, deleteOnExit); + attribute.setMaxSize(maxSize); + List list = getList(response); + list.add(attribute); + return attribute; + } + if (checkSize) { + Attribute attribute = new MixedAttribute(name, definedSize, minSize, charset, baseDir, deleteOnExit); + attribute.setMaxSize(maxSize); + List list = getList(response); + list.add(attribute); + return attribute; + } + MemoryAttribute attribute = new MemoryAttribute(name, definedSize); + attribute.setMaxSize(maxSize); + return attribute; + } + + /** + * Utility method + */ + private static void checkHttpDataSize(HttpData data) { + try { + data.checkSize(data.length()); + } catch (IOException ignored) { + throw new IllegalArgumentException("Attribute bigger than maxSize allowed"); + } + } + + public Attribute createAttribute(HttpClientResponse response, String name, String value) { + if (useDisk) { + Attribute attribute; + try { + attribute = new DiskAttribute(name, value, charset, baseDir, deleteOnExit); + attribute.setMaxSize(maxSize); + } catch (IOException e) { + // revert to Mixed mode + attribute = new MixedAttribute(name, value, minSize, charset, baseDir, deleteOnExit); + attribute.setMaxSize(maxSize); + } + checkHttpDataSize(attribute); + List list = getList(response); + list.add(attribute); + return attribute; + } + if (checkSize) { + Attribute attribute = new MixedAttribute(name, value, minSize, charset, baseDir, deleteOnExit); + attribute.setMaxSize(maxSize); + checkHttpDataSize(attribute); + List list = getList(response); + list.add(attribute); + return attribute; + } + try { + MemoryAttribute attribute = new MemoryAttribute(name, value, charset); + attribute.setMaxSize(maxSize); + checkHttpDataSize(attribute); + return attribute; + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + } + + // to reuse netty stuff as much as possible, we use FileUpload class to represent the downloaded file + public FileUpload createFileUpload(HttpClientResponse response, String name, String filename, + String contentType, String contentTransferEncoding, Charset charset, + long size) { + if (useDisk) { + FileUpload fileUpload = new DiskFileUpload(name, filename, contentType, + contentTransferEncoding, charset, size, baseDir, deleteOnExit); + fileUpload.setMaxSize(maxSize); + checkHttpDataSize(fileUpload); + List list = getList(response); + list.add(fileUpload); + return fileUpload; + } + if (checkSize) { + FileUpload fileUpload = new MixedFileUpload(name, filename, contentType, + contentTransferEncoding, charset, size, minSize, baseDir, deleteOnExit); + fileUpload.setMaxSize(maxSize); + checkHttpDataSize(fileUpload); + List list = getList(response); + list.add(fileUpload); + return fileUpload; + } + MemoryFileUpload fileUpload = new MemoryFileUpload(name, filename, contentType, + contentTransferEncoding, charset, size); + fileUpload.setMaxSize(maxSize); + checkHttpDataSize(fileUpload); + return fileUpload; + } + + public void removeHttpDataFromClean(HttpClientResponse response, InterfaceHttpData data) { + if (!(data instanceof HttpData)) { + return; + } + + // Do not use getList because it adds empty list to requestFileDeleteMap + // if response is not found + List list = responseFileDeleteMap.get(response); + if (list == null) { + return; + } + + // Can't simply call list.remove(data), because different data items may be equal. + // Need to check identity. + Iterator i = list.iterator(); + while (i.hasNext()) { + HttpData n = i.next(); + if (n == data) { + i.remove(); + + // Remove empty list to avoid memory leak + if (list.isEmpty()) { + responseFileDeleteMap.remove(response); + } + + return; + } + } + } + + public void cleanResponseHttpData(HttpClientResponse response) { + List list = responseFileDeleteMap.remove(response); + if (list != null) { + for (HttpData data : list) { + data.release(); + } + } + } + + public void cleanAllHttpData() { + Iterator>> i = responseFileDeleteMap.entrySet().iterator(); + while (i.hasNext()) { + Entry> e = i.next(); + + // Calling i.remove() here will cause "java.lang.IllegalStateException: Entry was removed" + // at e.getValue() below + + List list = e.getValue(); + for (HttpData data : list) { + data.release(); + } + + i.remove(); + } + } + + public void cleanResponseHttpDatas(HttpClientResponse response) { + cleanResponseHttpData(response); + } + + public void cleanAllHttpDatas() { + cleanAllHttpData(); + } +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusMultipartResponseDecoder.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusMultipartResponseDecoder.java new file mode 100644 index 0000000000000..7620026f8c0cb --- /dev/null +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/multipart/QuarkusMultipartResponseDecoder.java @@ -0,0 +1,1379 @@ +/* + * Copyright 2012 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.jboss.resteasy.reactive.client.impl.multipart; + +import static io.netty.util.internal.ObjectUtil.checkNotNull; +import static io.netty.util.internal.ObjectUtil.checkPositiveOrZero; +import static org.jboss.resteasy.reactive.client.impl.multipart.QuarkusHttpPostBodyUtil.getMultipartDataBoundary; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.http.DefaultHttpContent; +import io.netty.handler.codec.http.HttpConstants; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.LastHttpContent; +import io.netty.handler.codec.http.QueryStringDecoder; +import io.netty.handler.codec.http.multipart.Attribute; +import io.netty.handler.codec.http.multipart.FileUpload; +import io.netty.handler.codec.http.multipart.HttpData; +import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.EndOfDataDecoderException; +import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.ErrorDataDecoderException; +import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.NotEnoughDataDecoderException; +import io.netty.handler.codec.http.multipart.InterfaceHttpData; +import io.netty.util.CharsetUtil; +import io.netty.util.internal.InternalThreadLocalMap; +import io.netty.util.internal.PlatformDependent; +import io.netty.util.internal.StringUtil; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpClientResponse; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.UnsupportedCharsetException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.jboss.resteasy.reactive.client.impl.multipart.QuarkusHttpPostBodyUtil.TransferEncodingMechanism; + +/** + * This decoder will decode response body. + * + * You MUST call {@link #destroy()} after completion to release all resources. + * + * + * Decoder for Multipart responses based on Netty's + * {@link io.netty.handler.codec.http.multipart.HttpPostMultipartRequestDecoder} + * + */ +public class QuarkusMultipartResponseDecoder { + + protected enum MultiPartStatus { + NOTSTARTED, + PREAMBLE, + HEADERDELIMITER, + DISPOSITION, + FIELD, + FILEUPLOAD, + MIXEDPREAMBLE, + MIXEDDELIMITER, + MIXEDDISPOSITION, + MIXEDFILEUPLOAD, + MIXEDCLOSEDELIMITER, + CLOSEDELIMITER, + PREEPILOGUE, + EPILOGUE + } + + static final int DEFAULT_DISCARD_THRESHOLD = 10 * 1024 * 1024; + + /** + * Factory used to create InterfaceHttpData + */ + private final QuarkusMultipartResponseDataFactory factory; + + /** + * Request to decode + */ + private final HttpClientResponse response; + + /** + * Default charset to use + */ + private Charset charset; + + /** + * Does the last chunk already received + */ + private boolean isLastChunk; + + /** + * HttpDatas from Body + */ + private final List bodyListHttpData = new ArrayList<>(); + + /** + * HttpDatas as Map from Body + */ + private final Map> bodyMapHttpData = new TreeMap<>( + CaseIgnoringComparator.INSTANCE); + + /** + * The current channelBuffer + */ + private ByteBuf undecodedChunk; + + /** + * Body HttpDatas current position + */ + private int bodyListHttpDataRank; + + /** + * If multipart, this is the boundary for the global multipart + */ + private final String multipartDataBoundary; + + /** + * If multipart, there could be internal multiparts (mixed) to the global + * multipart. Only one level is allowed. + */ + private String multipartMixedBoundary; + + /** + * Current getStatus + */ + private MultiPartStatus currentStatus = MultiPartStatus.NOTSTARTED; + + /** + * Used in Multipart + */ + private Map currentFieldAttributes; + + /** + * The current FileUpload that is currently in decode process + */ + private FileUpload currentFileUpload; + + /** + * The current Attribute that is currently in decode process + */ + private Attribute currentAttribute; + + private boolean destroyed; + + private int discardThreshold = DEFAULT_DISCARD_THRESHOLD; + + /** + * + * @param response + * the request to decode + * @throws NullPointerException + * for request + * @throws ErrorDataDecoderException + * if the default charset was wrong when decoding or other + * errors + */ + public QuarkusMultipartResponseDecoder(HttpClientResponse response) { + this(new QuarkusMultipartResponseDataFactory(QuarkusMultipartResponseDataFactory.MINSIZE), response, + HttpConstants.DEFAULT_CHARSET); + } + + /** + * + * @param factory + * the factory used to create InterfaceHttpData + * @param response + * the request to decode + * @throws NullPointerException + * for request or factory + * @throws ErrorDataDecoderException + * if the default charset was wrong when decoding or other + * errors + */ + public QuarkusMultipartResponseDecoder(QuarkusMultipartResponseDataFactory factory, HttpClientResponse response) { + this(factory, response, HttpConstants.DEFAULT_CHARSET); + } + + /** + * + * @param factory + * the factory used to create InterfaceHttpData + * @param response + * the request to decode + * @param charset + * the charset to use as default + * @throws NullPointerException + * for request or charset or factory + * @throws ErrorDataDecoderException + * if the default charset was wrong when decoding or other + * errors + */ + public QuarkusMultipartResponseDecoder(QuarkusMultipartResponseDataFactory factory, HttpClientResponse response, + Charset charset) { + this.response = checkNotNull(response, "request"); + this.charset = checkNotNull(charset, "charset"); + this.factory = checkNotNull(factory, "factory"); + // Fill default values + + String contentTypeValue = this.response.headers().get(HttpHeaderNames.CONTENT_TYPE); + if (contentTypeValue == null) { + throw new ErrorDataDecoderException("No '" + HttpHeaderNames.CONTENT_TYPE + "' header present."); + } + + String[] dataBoundary = getMultipartDataBoundary(contentTypeValue); + if (dataBoundary != null) { + multipartDataBoundary = dataBoundary[0]; + if (dataBoundary.length > 1 && dataBoundary[1] != null) { + try { + this.charset = Charset.forName(dataBoundary[1]); + } catch (IllegalCharsetNameException e) { + throw new ErrorDataDecoderException(e); + } + } + } else { + multipartDataBoundary = null; + } + currentStatus = MultiPartStatus.HEADERDELIMITER; + + try { + if (this.response instanceof HttpContent) { + // Offer automatically if the given request is als type of HttpContent + // See #1089 + offer((HttpContent) this.response); + } else { + parseBody(); + } + } catch (Throwable e) { + destroy(); + PlatformDependent.throwException(e); + } + } + + private void checkDestroyed() { + if (destroyed) { + throw new IllegalStateException(QuarkusMultipartResponseDecoder.class.getSimpleName() + + " was destroyed already"); + } + } + + /** + * True if this request is a Multipart request + * + * @return True if this request is a Multipart request + */ + public boolean isMultipart() { + checkDestroyed(); + return true; + } + + /** + * Set the amount of bytes after which read bytes in the buffer should be discarded. + * Setting this lower gives lower memory usage but with the overhead of more memory copies. + * Use {@code 0} to disable it. + */ + public void setDiscardThreshold(int discardThreshold) { + this.discardThreshold = checkPositiveOrZero(discardThreshold, "discardThreshold"); + } + + /** + * Return the threshold in bytes after which read data in the buffer should be discarded. + */ + public int getDiscardThreshold() { + return discardThreshold; + } + + /** + * This getMethod returns a List of all HttpDatas from body.
+ * + * If chunked, all chunks must have been offered using offer() getMethod. If + * not, NotEnoughDataDecoderException will be raised. + * + * @return the list of HttpDatas from Body part for POST getMethod + * @throws NotEnoughDataDecoderException + * Need more chunks + */ + public List getBodyHttpDatas() { + checkDestroyed(); + + if (!isLastChunk) { + throw new NotEnoughDataDecoderException(); + } + return bodyListHttpData; + } + + /** + * This getMethod returns a List of all HttpDatas with the given name from + * body.
+ * + * If chunked, all chunks must have been offered using offer() getMethod. If + * not, NotEnoughDataDecoderException will be raised. + * + * @return All Body HttpDatas with the given name (ignore case) + * @throws NotEnoughDataDecoderException + * need more chunks + */ + public List getBodyHttpDatas(String name) { + checkDestroyed(); + + if (!isLastChunk) { + throw new NotEnoughDataDecoderException(); + } + return bodyMapHttpData.get(name); + } + + /** + * This getMethod returns the first InterfaceHttpData with the given name from + * body.
+ * + * If chunked, all chunks must have been offered using offer() getMethod. If + * not, NotEnoughDataDecoderException will be raised. + * + * @return The first Body InterfaceHttpData with the given name (ignore + * case) + * @throws NotEnoughDataDecoderException + * need more chunks + */ + public InterfaceHttpData getBodyHttpData(String name) { + checkDestroyed(); + + if (!isLastChunk) { + throw new NotEnoughDataDecoderException(); + } + List list = bodyMapHttpData.get(name); + if (list != null) { + return list.get(0); + } + return null; + } + + public QuarkusMultipartResponseDecoder offer(Buffer content) { + return offer(new DefaultHttpContent(content.getByteBuf())); + } + + /** + * Initialized the internals from a new chunk + * + * @param content + * the new received chunk + * @throws ErrorDataDecoderException + * if there is a problem with the charset decoding or other + * errors + */ + public QuarkusMultipartResponseDecoder offer(HttpContent content) { + checkDestroyed(); + + if (content instanceof LastHttpContent) { + isLastChunk = true; + } + + ByteBuf buf = content.content(); + if (undecodedChunk == null) { + undecodedChunk = + // Since the Handler will release the incoming later on, we need to copy it + // + // We are explicit allocate a buffer and NOT calling copy() as otherwise it may set a maxCapacity + // which is not really usable for us as we may exceed it once we add more bytes. + buf.alloc().buffer(buf.readableBytes()).writeBytes(buf); + } else { + undecodedChunk.writeBytes(buf); + } + parseBody(); + if (undecodedChunk != null && undecodedChunk.writerIndex() > discardThreshold) { + if (undecodedChunk.refCnt() == 1) { + // It's safe to call discardBytes() as we are the only owner of the buffer. + undecodedChunk.discardReadBytes(); + } else { + // There seems to be multiple references of the buffer. Let's copy the data and release the buffer to + // ensure we can give back memory to the system. + ByteBuf buffer = undecodedChunk.alloc().buffer(undecodedChunk.readableBytes()); + buffer.writeBytes(undecodedChunk); + undecodedChunk.release(); + undecodedChunk = buffer; + } + } + return this; + } + + /** + * True if at current getStatus, there is an available decoded + * InterfaceHttpData from the Body. + * + * This getMethod works for chunked and not chunked request. + * + * @return True if at current getStatus, there is a decoded InterfaceHttpData + * @throws EndOfDataDecoderException + * No more data will be available + */ + public boolean hasNext() { + checkDestroyed(); + + if (currentStatus == MultiPartStatus.EPILOGUE) { + // OK except if end of list + if (bodyListHttpDataRank >= bodyListHttpData.size()) { + throw new EndOfDataDecoderException(); + } + } + return !bodyListHttpData.isEmpty() && bodyListHttpDataRank < bodyListHttpData.size(); + } + + /** + * Returns the next available InterfaceHttpData or null if, at the time it + * is called, there is no more available InterfaceHttpData. A subsequent + * call to offer(httpChunk) could enable more data. + * + * Be sure to call {@link InterfaceHttpData#release()} after you are done + * with processing to make sure to not leak any resources + * + * @return the next available InterfaceHttpData or null if none + * @throws EndOfDataDecoderException + * No more data will be available + */ + public InterfaceHttpData next() { + checkDestroyed(); + + if (hasNext()) { + return bodyListHttpData.get(bodyListHttpDataRank++); + } + return null; + } + + public InterfaceHttpData currentPartialHttpData() { + if (currentFileUpload != null) { + return currentFileUpload; + } else { + return currentAttribute; + } + } + + /** + * This getMethod will parse as much as possible data and fill the list and map + * + * @throws ErrorDataDecoderException + * if there is a problem with the charset decoding or other + * errors + */ + private void parseBody() { + if (currentStatus == MultiPartStatus.PREEPILOGUE || currentStatus == MultiPartStatus.EPILOGUE) { + if (isLastChunk) { + currentStatus = MultiPartStatus.EPILOGUE; + } + return; + } + parseBodyMultipart(); + } + + /** + * Utility function to add a new decoded data + */ + protected void addHttpData(InterfaceHttpData data) { + if (data == null) { + return; + } + List datas = bodyMapHttpData.get(data.getName()); + if (datas == null) { + datas = new ArrayList<>(1); + bodyMapHttpData.put(data.getName(), datas); + } + datas.add(data); + bodyListHttpData.add(data); + } + + /** + * Parse the Body for multipart + * + * @throws ErrorDataDecoderException + * if there is a problem with the charset decoding or other + * errors + */ + private void parseBodyMultipart() { + if (undecodedChunk == null || undecodedChunk.readableBytes() == 0) { + // nothing to decode + return; + } + InterfaceHttpData data = decodeMultipart(currentStatus); + while (data != null) { + addHttpData(data); + if (currentStatus == MultiPartStatus.PREEPILOGUE || currentStatus == MultiPartStatus.EPILOGUE) { + break; + } + data = decodeMultipart(currentStatus); + } + } + + /** + * Decode a multipart request by pieces
+ *
+ * NOTSTARTED PREAMBLE (
+ * (HEADERDELIMITER DISPOSITION (FIELD | FILEUPLOAD))*
+ * (HEADERDELIMITER DISPOSITION MIXEDPREAMBLE
+ * (MIXEDDELIMITER MIXEDDISPOSITION MIXEDFILEUPLOAD)+
+ * MIXEDCLOSEDELIMITER)*
+ * CLOSEDELIMITER)+ EPILOGUE
+ * + * Inspired from HttpMessageDecoder + * + * @return the next decoded InterfaceHttpData or null if none until now. + * @throws ErrorDataDecoderException + * if an error occurs + */ + private InterfaceHttpData decodeMultipart(MultiPartStatus state) { + switch (state) { + case NOTSTARTED: + throw new ErrorDataDecoderException("Should not be called with the current getStatus"); + case PREAMBLE: + // Content-type: multipart/form-data, boundary=AaB03x + throw new ErrorDataDecoderException("Should not be called with the current getStatus"); + case HEADERDELIMITER: { + // --AaB03x or --AaB03x-- + return findMultipartDelimiter(multipartDataBoundary, MultiPartStatus.DISPOSITION, + MultiPartStatus.PREEPILOGUE); + } + case DISPOSITION: { + // content-disposition: form-data; name="field1" + // content-disposition: form-data; name="pics"; filename="file1.txt" + // and other immediate values like + // Content-type: image/gif + // Content-Type: text/plain + // Content-Type: text/plain; charset=ISO-8859-1 + // Content-Transfer-Encoding: binary + // The following line implies a change of mode (mixed mode) + // Content-type: multipart/mixed, boundary=BbC04y + return findMultipartDisposition(); + } + case FIELD: { + // Now get value according to Content-Type and Charset + Charset localCharset = null; + Attribute charsetAttribute = currentFieldAttributes.get(HttpHeaderValues.CHARSET); + if (charsetAttribute != null) { + try { + localCharset = Charset.forName(charsetAttribute.getValue()); + } catch (IOException | UnsupportedCharsetException e) { + throw new ErrorDataDecoderException(e); + } + } + Attribute nameAttribute = currentFieldAttributes.get(HttpHeaderValues.NAME); + if (currentAttribute == null) { + Attribute lengthAttribute = currentFieldAttributes + .get(HttpHeaderNames.CONTENT_LENGTH); + long size; + try { + size = lengthAttribute != null ? Long.parseLong(lengthAttribute + .getValue()) : 0L; + } catch (IOException e) { + throw new ErrorDataDecoderException(e); + } catch (NumberFormatException ignored) { + size = 0; + } + try { + if (size > 0) { + currentAttribute = factory.createAttribute(response, + cleanString(nameAttribute.getValue()), size); + } else { + currentAttribute = factory.createAttribute(response, + cleanString(nameAttribute.getValue())); + } + } catch (NullPointerException | IllegalArgumentException | IOException e) { + throw new ErrorDataDecoderException(e); + } + if (localCharset != null) { + currentAttribute.setCharset(localCharset); + } + } + // load data + if (!loadDataMultipartOptimized(undecodedChunk, multipartDataBoundary, currentAttribute)) { + // Delimiter is not found. Need more chunks. + return null; + } + Attribute finalAttribute = currentAttribute; + currentAttribute = null; + currentFieldAttributes = null; + // ready to load the next one + currentStatus = MultiPartStatus.HEADERDELIMITER; + return finalAttribute; + } + case FILEUPLOAD: { + // eventually restart from existing FileUpload + return getFileUpload(multipartDataBoundary); + } + case MIXEDDELIMITER: { + // --AaB03x or --AaB03x-- + // Note that currentFieldAttributes exists + return findMultipartDelimiter(multipartMixedBoundary, MultiPartStatus.MIXEDDISPOSITION, + MultiPartStatus.HEADERDELIMITER); + } + case MIXEDDISPOSITION: { + return findMultipartDisposition(); + } + case MIXEDFILEUPLOAD: { + // eventually restart from existing FileUpload + return getFileUpload(multipartMixedBoundary); + } + case PREEPILOGUE: + return null; + case EPILOGUE: + return null; + default: + throw new ErrorDataDecoderException("Shouldn't reach here."); + } + } + + /** + * Skip control Characters + * + * @throws NotEnoughDataDecoderException + */ + private static void skipControlCharacters(ByteBuf undecodedChunk) { + if (!undecodedChunk.hasArray()) { + try { + skipControlCharactersStandard(undecodedChunk); + } catch (IndexOutOfBoundsException e1) { + throw new NotEnoughDataDecoderException(e1); + } + return; + } + QuarkusHttpPostBodyUtil.SeekAheadOptimize sao = new QuarkusHttpPostBodyUtil.SeekAheadOptimize(undecodedChunk); + while (sao.pos < sao.limit) { + char c = (char) (sao.bytes[sao.pos++] & 0xFF); + if (!Character.isISOControl(c) && !Character.isWhitespace(c)) { + sao.setReadPosition(1); + return; + } + } + throw new NotEnoughDataDecoderException("Access out of bounds"); + } + + private static void skipControlCharactersStandard(ByteBuf undecodedChunk) { + for (;;) { + char c = (char) undecodedChunk.readUnsignedByte(); + if (!Character.isISOControl(c) && !Character.isWhitespace(c)) { + undecodedChunk.readerIndex(undecodedChunk.readerIndex() - 1); + break; + } + } + } + + /** + * Find the next Multipart Delimiter + * + * @param delimiter + * delimiter to find + * @param dispositionStatus + * the next getStatus if the delimiter is a start + * @param closeDelimiterStatus + * the next getStatus if the delimiter is a close delimiter + * @return the next InterfaceHttpData if any + * @throws ErrorDataDecoderException + */ + private InterfaceHttpData findMultipartDelimiter(String delimiter, MultiPartStatus dispositionStatus, + MultiPartStatus closeDelimiterStatus) { + // --AaB03x or --AaB03x-- + int readerIndex = undecodedChunk.readerIndex(); + try { + skipControlCharacters(undecodedChunk); + } catch (NotEnoughDataDecoderException ignored) { + undecodedChunk.readerIndex(readerIndex); + return null; + } + skipOneLine(); + String newline; + try { + newline = readDelimiterOptimized(undecodedChunk, delimiter, charset); + } catch (NotEnoughDataDecoderException ignored) { + undecodedChunk.readerIndex(readerIndex); + return null; + } + if (newline.equals(delimiter)) { + currentStatus = dispositionStatus; + return decodeMultipart(dispositionStatus); + } + if (newline.equals(delimiter + "--")) { + // CLOSEDELIMITER or MIXED CLOSEDELIMITER found + currentStatus = closeDelimiterStatus; + if (currentStatus == MultiPartStatus.HEADERDELIMITER) { + // MIXEDCLOSEDELIMITER + // end of the Mixed part + currentFieldAttributes = null; + return decodeMultipart(MultiPartStatus.HEADERDELIMITER); + } + return null; + } + undecodedChunk.readerIndex(readerIndex); + throw new ErrorDataDecoderException("No Multipart delimiter found"); + } + + /** + * Find the next Disposition + * + * @return the next InterfaceHttpData if any + * @throws ErrorDataDecoderException + */ + private InterfaceHttpData findMultipartDisposition() { + int readerIndex = undecodedChunk.readerIndex(); + if (currentStatus == MultiPartStatus.DISPOSITION) { + currentFieldAttributes = new TreeMap<>(CaseIgnoringComparator.INSTANCE); + } + // read many lines until empty line with newline found! Store all data + while (!skipOneLine()) { + String newline; + try { + skipControlCharacters(undecodedChunk); + newline = readLineOptimized(undecodedChunk, charset); + } catch (NotEnoughDataDecoderException ignored) { + undecodedChunk.readerIndex(readerIndex); + return null; + } + String[] contents = splitMultipartHeader(newline); + if (HttpHeaderNames.CONTENT_DISPOSITION.contentEqualsIgnoreCase(contents[0])) { + boolean checkSecondArg; + if (currentStatus == MultiPartStatus.DISPOSITION) { + checkSecondArg = HttpHeaderValues.FORM_DATA.contentEqualsIgnoreCase(contents[1]); + } else { + checkSecondArg = HttpHeaderValues.ATTACHMENT.contentEqualsIgnoreCase(contents[1]) + || HttpHeaderValues.FILE.contentEqualsIgnoreCase(contents[1]); + } + if (checkSecondArg) { + // read next values and store them in the map as Attribute + for (int i = 2; i < contents.length; i++) { + String[] values = contents[i].split("=", 2); + Attribute attribute; + try { + attribute = getContentDispositionAttribute(values); + } catch (NullPointerException | IllegalArgumentException e) { + throw new ErrorDataDecoderException(e); + } + currentFieldAttributes.put(attribute.getName(), attribute); + } + } + } else if (HttpHeaderNames.CONTENT_TRANSFER_ENCODING.contentEqualsIgnoreCase(contents[0])) { + Attribute attribute; + try { + attribute = factory.createAttribute(response, HttpHeaderNames.CONTENT_TRANSFER_ENCODING.toString(), + cleanString(contents[1])); + } catch (NullPointerException | IllegalArgumentException e) { + throw new ErrorDataDecoderException(e); + } + + currentFieldAttributes.put(HttpHeaderNames.CONTENT_TRANSFER_ENCODING, attribute); + } else if (HttpHeaderNames.CONTENT_LENGTH.contentEqualsIgnoreCase(contents[0])) { + Attribute attribute; + try { + attribute = factory.createAttribute(response, HttpHeaderNames.CONTENT_LENGTH.toString(), + cleanString(contents[1])); + } catch (NullPointerException | IllegalArgumentException e) { + throw new ErrorDataDecoderException(e); + } + + currentFieldAttributes.put(HttpHeaderNames.CONTENT_LENGTH, attribute); + } else if (HttpHeaderNames.CONTENT_TYPE.contentEqualsIgnoreCase(contents[0])) { + // Take care of possible "multipart/mixed" + if (HttpHeaderValues.MULTIPART_MIXED.contentEqualsIgnoreCase(contents[1])) { + if (currentStatus == MultiPartStatus.DISPOSITION) { + String values = StringUtil.substringAfter(contents[2], '='); + multipartMixedBoundary = "--" + values; + currentStatus = MultiPartStatus.MIXEDDELIMITER; + return decodeMultipart(MultiPartStatus.MIXEDDELIMITER); + } else { + throw new ErrorDataDecoderException("Mixed Multipart found in a previous Mixed Multipart"); + } + } else { + for (int i = 1; i < contents.length; i++) { + final String charsetHeader = HttpHeaderValues.CHARSET.toString(); + if (contents[i].regionMatches(true, 0, charsetHeader, 0, charsetHeader.length())) { + String values = StringUtil.substringAfter(contents[i], '='); + Attribute attribute; + try { + attribute = factory.createAttribute(response, charsetHeader, cleanString(values)); + } catch (NullPointerException | IllegalArgumentException e) { + throw new ErrorDataDecoderException(e); + } + currentFieldAttributes.put(HttpHeaderValues.CHARSET, attribute); + } else { + Attribute attribute; + try { + attribute = factory.createAttribute(response, + cleanString(contents[0]), contents[i]); + } catch (NullPointerException | IllegalArgumentException e) { + throw new ErrorDataDecoderException(e); + } + currentFieldAttributes.put(attribute.getName(), attribute); + } + } + } + } + } + // Is it a FileUpload + Attribute filenameAttribute = currentFieldAttributes.get(HttpHeaderValues.FILENAME); + if (currentStatus == MultiPartStatus.DISPOSITION) { + if (filenameAttribute != null) { + // FileUpload + currentStatus = MultiPartStatus.FILEUPLOAD; + // do not change the buffer position + return decodeMultipart(MultiPartStatus.FILEUPLOAD); + } else { + // Field + currentStatus = MultiPartStatus.FIELD; + // do not change the buffer position + return decodeMultipart(MultiPartStatus.FIELD); + } + } else { + if (filenameAttribute != null) { + // FileUpload + currentStatus = MultiPartStatus.MIXEDFILEUPLOAD; + // do not change the buffer position + return decodeMultipart(MultiPartStatus.MIXEDFILEUPLOAD); + } else { + // Field is not supported in MIXED mode + throw new ErrorDataDecoderException("Filename not found"); + } + } + } + + private static final String FILENAME_ENCODED = HttpHeaderValues.FILENAME.toString() + '*'; + + private Attribute getContentDispositionAttribute(String... values) { + String name = cleanString(values[0]); + String value = values[1]; + + // Filename can be token, quoted or encoded. See https://tools.ietf.org/html/rfc5987 + if (HttpHeaderValues.FILENAME.contentEquals(name)) { + // Value is quoted or token. Strip if quoted: + int last = value.length() - 1; + if (last > 0 && + value.charAt(0) == HttpConstants.DOUBLE_QUOTE && + value.charAt(last) == HttpConstants.DOUBLE_QUOTE) { + value = value.substring(1, last); + } + } else if (FILENAME_ENCODED.equals(name)) { + try { + name = HttpHeaderValues.FILENAME.toString(); + String[] split = cleanString(value).split("'", 3); + value = QueryStringDecoder.decodeComponent(split[2], Charset.forName(split[0])); + } catch (ArrayIndexOutOfBoundsException | UnsupportedCharsetException e) { + throw new ErrorDataDecoderException(e); + } + } else { + // otherwise we need to clean the value + value = cleanString(value); + } + return factory.createAttribute(response, name, value); + } + + /** + * Get the FileUpload (new one or current one) + * + * @param delimiter + * the delimiter to use + * @return the InterfaceHttpData if any + * @throws ErrorDataDecoderException on decoder error + */ + protected InterfaceHttpData getFileUpload(String delimiter) { + // eventually restart from existing FileUpload + // Now get value according to Content-Type and Charset + Attribute encoding = currentFieldAttributes.get(HttpHeaderNames.CONTENT_TRANSFER_ENCODING); + Charset localCharset = charset; + // Default + TransferEncodingMechanism mechanism = TransferEncodingMechanism.BIT7; + if (encoding != null) { + String code; + try { + code = encoding.getValue().toLowerCase(); + } catch (IOException e) { + throw new ErrorDataDecoderException(e); + } + if (code.equals(TransferEncodingMechanism.BIT7.value())) { + localCharset = CharsetUtil.US_ASCII; + } else if (code.equals(TransferEncodingMechanism.BIT8.value())) { + localCharset = CharsetUtil.ISO_8859_1; + mechanism = TransferEncodingMechanism.BIT8; + } else if (code.equals(TransferEncodingMechanism.BINARY.value())) { + // no real charset, so let the default + mechanism = TransferEncodingMechanism.BINARY; + } else { + throw new ErrorDataDecoderException("TransferEncoding Unknown: " + code); + } + } + Attribute charsetAttribute = currentFieldAttributes.get(HttpHeaderValues.CHARSET); + if (charsetAttribute != null) { + try { + localCharset = Charset.forName(charsetAttribute.getValue()); + } catch (IOException | UnsupportedCharsetException e) { + throw new ErrorDataDecoderException(e); + } + } + if (currentFileUpload == null) { + Attribute filenameAttribute = currentFieldAttributes.get(HttpHeaderValues.FILENAME); + Attribute nameAttribute = currentFieldAttributes.get(HttpHeaderValues.NAME); + Attribute contentTypeAttribute = currentFieldAttributes.get(HttpHeaderNames.CONTENT_TYPE); + Attribute lengthAttribute = currentFieldAttributes.get(HttpHeaderNames.CONTENT_LENGTH); + long size; + try { + size = lengthAttribute != null ? Long.parseLong(lengthAttribute.getValue()) : 0L; + } catch (IOException e) { + throw new ErrorDataDecoderException(e); + } catch (NumberFormatException ignored) { + size = 0; + } + try { + String contentType; + if (contentTypeAttribute != null) { + contentType = contentTypeAttribute.getValue(); + } else { + contentType = QuarkusHttpPostBodyUtil.DEFAULT_BINARY_CONTENT_TYPE; + } + currentFileUpload = factory.createFileUpload(response, + cleanString(nameAttribute.getValue()), cleanString(filenameAttribute.getValue()), + contentType, mechanism.value(), localCharset, + size); + } catch (NullPointerException | IOException | IllegalArgumentException e) { + throw new ErrorDataDecoderException(e); + } + } + // load data as much as possible + if (!loadDataMultipartOptimized(undecodedChunk, delimiter, currentFileUpload)) { + // Delimiter is not found. Need more chunks. + return null; + } + if (currentFileUpload.isCompleted()) { + // ready to load the next one + if (currentStatus == MultiPartStatus.FILEUPLOAD) { + currentStatus = MultiPartStatus.HEADERDELIMITER; + currentFieldAttributes = null; + } else { + currentStatus = MultiPartStatus.MIXEDDELIMITER; + cleanMixedAttributes(); + } + FileUpload fileUpload = currentFileUpload; + currentFileUpload = null; + return fileUpload; + } + // do not change the buffer position + // since some can be already saved into FileUpload + // So do not change the currentStatus + return null; + } + + /** + * Destroy the {@link QuarkusMultipartResponseDecoder} and release all it resources. After this method + * was called it is not possible to operate on it anymore. + */ + public void destroy() { + // Release all data items, including those not yet pulled, only file based items + cleanFiles(); + // Clean Memory based data + for (InterfaceHttpData httpData : bodyListHttpData) { + // Might have been already released by the user + if (httpData.refCnt() > 0) { + httpData.release(); + } + } + + destroyed = true; + + if (undecodedChunk != null && undecodedChunk.refCnt() > 0) { + undecodedChunk.release(); + undecodedChunk = null; + } + } + + /** + * Clean all HttpDatas (on Disk) for the current request. + */ + public void cleanFiles() { + checkDestroyed(); + + factory.cleanResponseHttpData(response); + } + + /** + * Remove the given FileUpload from the list of FileUploads to clean + */ + public void removeHttpDataFromClean(InterfaceHttpData data) { + checkDestroyed(); + + factory.removeHttpDataFromClean(response, data); + } + + /** + * Remove all Attributes that should be cleaned between two FileUpload in + * Mixed mode + */ + private void cleanMixedAttributes() { + currentFieldAttributes.remove(HttpHeaderValues.CHARSET); + currentFieldAttributes.remove(HttpHeaderNames.CONTENT_LENGTH); + currentFieldAttributes.remove(HttpHeaderNames.CONTENT_TRANSFER_ENCODING); + currentFieldAttributes.remove(HttpHeaderNames.CONTENT_TYPE); + currentFieldAttributes.remove(HttpHeaderValues.FILENAME); + } + + /** + * Read one line up to the CRLF or LF + * + * @return the String from one line + * @throws NotEnoughDataDecoderException + * Need more chunks and reset the {@code readerIndex} to the previous + * value + */ + private static String readLineOptimized(ByteBuf undecodedChunk, Charset charset) { + int readerIndex = undecodedChunk.readerIndex(); + ByteBuf line = null; + try { + if (undecodedChunk.isReadable()) { + int posLfOrCrLf = QuarkusHttpPostBodyUtil.findLineBreak(undecodedChunk, undecodedChunk.readerIndex()); + if (posLfOrCrLf <= 0) { + throw new NotEnoughDataDecoderException(); + } + try { + line = undecodedChunk.alloc().heapBuffer(posLfOrCrLf); + line.writeBytes(undecodedChunk, posLfOrCrLf); + + byte nextByte = undecodedChunk.readByte(); + if (nextByte == HttpConstants.CR) { + // force read next byte since LF is the following one + undecodedChunk.readByte(); + } + return line.toString(charset); + } finally { + line.release(); + } + } + } catch (IndexOutOfBoundsException e) { + undecodedChunk.readerIndex(readerIndex); + throw new NotEnoughDataDecoderException(e); + } + undecodedChunk.readerIndex(readerIndex); + throw new NotEnoughDataDecoderException(); + } + + /** + * Read one line up to --delimiter or --delimiter-- and if existing the CRLF + * or LF Read one line up to --delimiter or --delimiter-- and if existing + * the CRLF or LF. Note that CRLF or LF are mandatory for opening delimiter + * (--delimiter) but not for closing delimiter (--delimiter--) since some + * clients does not include CRLF in this case. + * + * @param delimiter + * of the form --string, such that '--' is already included + * @return the String from one line as the delimiter searched (opening or + * closing) + * @throws NotEnoughDataDecoderException + * Need more chunks and reset the {@code readerIndex} to the previous + * value + */ + private static String readDelimiterOptimized(ByteBuf undecodedChunk, String delimiter, Charset charset) { + final int readerIndex = undecodedChunk.readerIndex(); + final byte[] bdelimiter = delimiter.getBytes(charset); + final int delimiterLength = bdelimiter.length; + try { + int delimiterPos = QuarkusHttpPostBodyUtil.findDelimiter(undecodedChunk, readerIndex, bdelimiter, false); + if (delimiterPos < 0) { + // delimiter not found so break here ! + undecodedChunk.readerIndex(readerIndex); + throw new NotEnoughDataDecoderException(); + } + StringBuilder sb = new StringBuilder(delimiter); + undecodedChunk.readerIndex(readerIndex + delimiterPos + delimiterLength); + // Now check if either opening delimiter or closing delimiter + if (undecodedChunk.isReadable()) { + byte nextByte = undecodedChunk.readByte(); + // first check for opening delimiter + if (nextByte == HttpConstants.CR) { + nextByte = undecodedChunk.readByte(); + if (nextByte == HttpConstants.LF) { + return sb.toString(); + } else { + // error since CR must be followed by LF + // delimiter not found so break here ! + undecodedChunk.readerIndex(readerIndex); + throw new NotEnoughDataDecoderException(); + } + } else if (nextByte == HttpConstants.LF) { + return sb.toString(); + } else if (nextByte == '-') { + sb.append('-'); + // second check for closing delimiter + nextByte = undecodedChunk.readByte(); + if (nextByte == '-') { + sb.append('-'); + // now try to find if CRLF or LF there + if (undecodedChunk.isReadable()) { + nextByte = undecodedChunk.readByte(); + if (nextByte == HttpConstants.CR) { + nextByte = undecodedChunk.readByte(); + if (nextByte == HttpConstants.LF) { + return sb.toString(); + } else { + // error CR without LF + // delimiter not found so break here ! + undecodedChunk.readerIndex(readerIndex); + throw new NotEnoughDataDecoderException(); + } + } else if (nextByte == HttpConstants.LF) { + return sb.toString(); + } else { + // No CRLF but ok however (Adobe Flash uploader) + // minus 1 since we read one char ahead but + // should not + undecodedChunk.readerIndex(undecodedChunk.readerIndex() - 1); + return sb.toString(); + } + } + // FIXME what do we do here? + // either considering it is fine, either waiting for + // more data to come? + // lets try considering it is fine... + return sb.toString(); + } + // only one '-' => not enough + // whatever now => error since incomplete + } + } + } catch (IndexOutOfBoundsException e) { + undecodedChunk.readerIndex(readerIndex); + throw new NotEnoughDataDecoderException(e); + } + undecodedChunk.readerIndex(readerIndex); + throw new NotEnoughDataDecoderException(); + } + + /** + * Rewrite buffer in order to skip lengthToSkip bytes from current readerIndex, + * such that any readable bytes available after readerIndex + lengthToSkip (so before writerIndex) + * are moved at readerIndex position, + * therefore decreasing writerIndex of lengthToSkip at the end of the process. + * + * @param buffer the buffer to rewrite from current readerIndex + * @param lengthToSkip the size to skip from readerIndex + */ + private static void rewriteCurrentBuffer(ByteBuf buffer, int lengthToSkip) { + if (lengthToSkip == 0) { + return; + } + final int readerIndex = buffer.readerIndex(); + final int readableBytes = buffer.readableBytes(); + if (readableBytes == lengthToSkip) { + buffer.readerIndex(readerIndex); + buffer.writerIndex(readerIndex); + return; + } + buffer.setBytes(readerIndex, buffer, readerIndex + lengthToSkip, readableBytes - lengthToSkip); + buffer.readerIndex(readerIndex); + buffer.writerIndex(readerIndex + readableBytes - lengthToSkip); + } + + /** + * Load the field value or file data from a Multipart request + * + * @return {@code true} if the last chunk is loaded (boundary delimiter found), {@code false} if need more chunks + * @throws ErrorDataDecoderException on decoder error + */ + private static boolean loadDataMultipartOptimized(ByteBuf undecodedChunk, String delimiter, HttpData httpData) { + if (!undecodedChunk.isReadable()) { + return false; + } + final int startReaderIndex = undecodedChunk.readerIndex(); + final byte[] bdelimiter = delimiter.getBytes(httpData.getCharset()); + int posDelimiter = QuarkusHttpPostBodyUtil.findDelimiter(undecodedChunk, startReaderIndex, bdelimiter, true); + if (posDelimiter < 0) { + // Not found but however perhaps because incomplete so search LF or CRLF from the end. + // Possible last bytes contain partially delimiter + // (delimiter is possibly partially there, at least 1 missing byte), + // therefore searching last delimiter.length +1 (+1 for CRLF instead of LF) + int readableBytes = undecodedChunk.readableBytes(); + int lastPosition = readableBytes - bdelimiter.length - 1; + if (lastPosition < 0) { + // Not enough bytes, but at most delimiter.length bytes available so can still try to find CRLF there + lastPosition = 0; + } + posDelimiter = QuarkusHttpPostBodyUtil.findLastLineBreak(undecodedChunk, startReaderIndex + lastPosition); + // No LineBreak, however CR can be at the end of the buffer, LF not yet there (issue #11668) + // Check if last CR (if any) shall not be in the content (definedLength vs actual length + buffer - 1) + if (posDelimiter < 0 && + httpData.definedLength() == httpData.length() + readableBytes - 1 && + undecodedChunk.getByte(readableBytes + startReaderIndex - 1) == HttpConstants.CR) { + // Last CR shall preceed a future LF + lastPosition = 0; + posDelimiter = readableBytes - 1; + } + if (posDelimiter < 0) { + // not found so this chunk can be fully added + ByteBuf content = undecodedChunk.copy(); + try { + httpData.addContent(content, false); + } catch (IOException e) { + throw new ErrorDataDecoderException(e); + } + undecodedChunk.readerIndex(startReaderIndex); + undecodedChunk.writerIndex(startReaderIndex); + return false; + } + // posDelimiter is not from startReaderIndex but from startReaderIndex + lastPosition + posDelimiter += lastPosition; + if (posDelimiter == 0) { + // Nothing to add + return false; + } + // Not fully but still some bytes to provide: httpData is not yet finished since delimiter not found + ByteBuf content = undecodedChunk.copy(startReaderIndex, posDelimiter); + try { + httpData.addContent(content, false); + } catch (IOException e) { + throw new ErrorDataDecoderException(e); + } + rewriteCurrentBuffer(undecodedChunk, posDelimiter); + return false; + } + // Delimiter found at posDelimiter, including LF or CRLF, so httpData has its last chunk + ByteBuf content = undecodedChunk.copy(startReaderIndex, posDelimiter); + try { + httpData.addContent(content, true); + } catch (IOException e) { + throw new ErrorDataDecoderException(e); + } + rewriteCurrentBuffer(undecodedChunk, posDelimiter); + return true; + } + + /** + * Clean the String from any unallowed character + * + * @return the cleaned String + */ + private static String cleanString(String field) { + int size = field.length(); + StringBuilder sb = new StringBuilder(size); + for (int i = 0; i < size; i++) { + char nextChar = field.charAt(i); + switch (nextChar) { + case HttpConstants.COLON: + case HttpConstants.COMMA: + case HttpConstants.EQUALS: + case HttpConstants.SEMICOLON: + case HttpConstants.HT: + sb.append(HttpConstants.SP_CHAR); + break; + case HttpConstants.DOUBLE_QUOTE: + // nothing added, just removes it + break; + default: + sb.append(nextChar); + break; + } + } + return sb.toString().trim(); + } + + /** + * Skip one empty line + * + * @return True if one empty line was skipped + */ + private boolean skipOneLine() { + if (!undecodedChunk.isReadable()) { + return false; + } + byte nextByte = undecodedChunk.readByte(); + if (nextByte == HttpConstants.CR) { + if (!undecodedChunk.isReadable()) { + undecodedChunk.readerIndex(undecodedChunk.readerIndex() - 1); + return false; + } + nextByte = undecodedChunk.readByte(); + if (nextByte == HttpConstants.LF) { + return true; + } + undecodedChunk.readerIndex(undecodedChunk.readerIndex() - 2); + return false; + } + if (nextByte == HttpConstants.LF) { + return true; + } + undecodedChunk.readerIndex(undecodedChunk.readerIndex() - 1); + return false; + } + + /** + * Split one header in Multipart + * + * @return an array of String where rank 0 is the name of the header, + * follows by several values that were separated by ';' or ',' + */ + private static String[] splitMultipartHeader(String sb) { + ArrayList headers = new ArrayList<>(1); + int nameStart; + int nameEnd; + int colonEnd; + int valueStart; + int valueEnd; + nameStart = QuarkusHttpPostBodyUtil.findNonWhitespace(sb, 0); + for (nameEnd = nameStart; nameEnd < sb.length(); nameEnd++) { + char ch = sb.charAt(nameEnd); + if (ch == ':' || Character.isWhitespace(ch)) { + break; + } + } + for (colonEnd = nameEnd; colonEnd < sb.length(); colonEnd++) { + if (sb.charAt(colonEnd) == ':') { + colonEnd++; + break; + } + } + valueStart = QuarkusHttpPostBodyUtil.findNonWhitespace(sb, colonEnd); + valueEnd = QuarkusHttpPostBodyUtil.findEndOfString(sb); + headers.add(sb.substring(nameStart, nameEnd)); + String svalue = (valueStart >= valueEnd) ? StringUtil.EMPTY_STRING : sb.substring(valueStart, valueEnd); + String[] values; + if (svalue.indexOf(';') >= 0) { + values = splitMultipartHeaderValues(svalue); + } else { + values = svalue.split(","); + } + for (String value : values) { + headers.add(value.trim()); + } + String[] array = new String[headers.size()]; + for (int i = 0; i < headers.size(); i++) { + array[i] = headers.get(i); + } + return array; + } + + /** + * Split one header value in Multipart + * + * @return an array of String where values that were separated by ';' or ',' + */ + private static String[] splitMultipartHeaderValues(String svalue) { + List values = InternalThreadLocalMap.get().arrayList(1); + boolean inQuote = false; + boolean escapeNext = false; + int start = 0; + for (int i = 0; i < svalue.length(); i++) { + char c = svalue.charAt(i); + if (inQuote) { + if (escapeNext) { + escapeNext = false; + } else { + if (c == '\\') { + escapeNext = true; + } else if (c == '"') { + inQuote = false; + } + } + } else { + if (c == '"') { + inQuote = true; + } else if (c == ';') { + values.add(svalue.substring(start, i)); + start = i + 1; + } + } + } + values.add(svalue.substring(start)); + return values.toArray(new String[0]); + } + + /** + * This method is package private intentionally in order to allow during tests + * to access to the amount of memory allocated (capacity) within the private + * ByteBuf undecodedChunk + * + * @return the number of bytes the internal buffer can contain + */ + int getCurrentAllocatedCapacity() { + return undecodedChunk.capacity(); + } +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/ClientContext.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/ClientContext.java index 72e0cca965c5e..51bd3b7e7fa7c 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/ClientContext.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/ClientContext.java @@ -1,6 +1,7 @@ package org.jboss.resteasy.reactive.client.spi; import io.vertx.core.Vertx; +import java.util.Map; import java.util.function.Supplier; import org.jboss.resteasy.reactive.client.impl.ClientProxies; import org.jboss.resteasy.reactive.common.core.GenericTypeMapping; @@ -14,4 +15,6 @@ public interface ClientContext { Supplier getVertx(); ClientProxies getClientProxies(); + + Map, MultipartResponseData> getMultipartResponsesData(); } diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/FieldFiller.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/FieldFiller.java new file mode 100644 index 0000000000000..a1dadcc683e53 --- /dev/null +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/FieldFiller.java @@ -0,0 +1,43 @@ +package org.jboss.resteasy.reactive.client.spi; + +import java.io.File; +import java.nio.file.Path; +import javax.ws.rs.core.GenericType; +import org.jboss.resteasy.reactive.multipart.FileDownload; + +public abstract class FieldFiller { + + private final GenericType fieldType; + private final String partName; + private final String mediaType; + + protected FieldFiller(GenericType fieldType, String partName, String mediaType) { + this.fieldType = fieldType; + this.partName = partName; + this.mediaType = mediaType; + } + + public abstract void set(Object responseObject, Object fieldValue); + + public GenericType getFieldType() { + return fieldType; + } + + public String getPartName() { + return partName; + } + + public String getMediaType() { + return mediaType; + } + + @SuppressWarnings("unused") // used in generated classes + public static File fileDownloadToFile(FileDownload fileDownload) { + return fileDownload.filePath().toFile(); + } + + @SuppressWarnings("unused") // used in generated classes + public static Path fileDownloadToPath(FileDownload fileDownload) { + return fileDownload.filePath(); + } +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/MultipartResponseData.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/MultipartResponseData.java new file mode 100644 index 0000000000000..096067b514ff5 --- /dev/null +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/spi/MultipartResponseData.java @@ -0,0 +1,9 @@ +package org.jboss.resteasy.reactive.client.spi; + +import java.util.List; + +public interface MultipartResponseData { + T newInstance(); + + List getFieldFillers(); +} diff --git a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/MultipartForm.java b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/MultipartForm.java index b977dd8ef1c86..7b629679cc6bf 100644 --- a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/MultipartForm.java +++ b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/MultipartForm.java @@ -17,7 +17,7 @@ * annotated with {@link io.smallrye.common.annotation.Blocking}. */ @Retention(RetentionPolicy.RUNTIME) -@Target({ ElementType.PARAMETER }) +@Target({ ElementType.PARAMETER, ElementType.TYPE }) public @interface MultipartForm { String value() default ""; } diff --git a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FileDownload.java b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FileDownload.java new file mode 100644 index 0000000000000..8a797e2766aee --- /dev/null +++ b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FileDownload.java @@ -0,0 +1,4 @@ +package org.jboss.resteasy.reactive.multipart; + +public interface FileDownload extends FilePart { +} diff --git a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FilePart.java b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FilePart.java new file mode 100644 index 0000000000000..cfd7eef77a131 --- /dev/null +++ b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FilePart.java @@ -0,0 +1,39 @@ +package org.jboss.resteasy.reactive.multipart; + +import java.nio.file.Path; + +/** + * Represents a file-part (upload or download) from an HTTP multipart form submission. + */ +public interface FilePart { + + /** + * @return the name of the upload as provided in the form submission. + */ + String name(); + + /** + * @return the actual temporary file name on the server where the file was uploaded to. + */ + Path filePath(); + + /** + * @return the file name of the upload as provided in the form submission. + */ + String fileName(); + + /** + * @return the size of the upload, in bytes + */ + long size(); + + /** + * @return the content type (MIME type) of the upload. + */ + String contentType(); + + /** + * @return the charset of the upload. + */ + String charSet(); +} diff --git a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FileUpload.java b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FileUpload.java index 93a471f3051ca..419555c144dba 100644 --- a/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FileUpload.java +++ b/independent-projects/resteasy-reactive/common/runtime/src/main/java/org/jboss/resteasy/reactive/multipart/FileUpload.java @@ -2,38 +2,9 @@ import java.nio.file.Path; -/** - * Represents a file-upload from an HTTP multipart form submission. - */ -public interface FileUpload { +public interface FileUpload extends FilePart { - /** - * @return the name of the upload as provided in the form submission. - */ - String name(); - - /** - * @return the actual temporary file name on the server where the file was uploaded to. - */ - Path uploadedFile(); - - /** - * @return the file name of the upload as provided in the form submission. - */ - String fileName(); - - /** - * @return the size of the upload, in bytes - */ - long size(); - - /** - * @return the content type (MIME type) of the upload. - */ - String contentType(); - - /** - * @return the charset of the upload. - */ - String charSet(); + default Path uploadedFile() { + return filePath(); + } } diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/DefaultFileUpload.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/DefaultFileUpload.java index 35c132c4cb08c..ca89b80771250 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/DefaultFileUpload.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/multipart/DefaultFileUpload.java @@ -22,7 +22,7 @@ public String name() { } @Override - public Path uploadedFile() { + public Path filePath() { return fileUpload.getFileItem().getFile(); }