diff --git a/commons/deployment/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/EndpointControllerVisitor.java b/commons/deployment/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/EndpointControllerVisitor.java new file mode 100644 index 00000000..f8878b1d --- /dev/null +++ b/commons/deployment/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/EndpointControllerVisitor.java @@ -0,0 +1,81 @@ +/* + * Copyright 2025 Marco Collovati, Dario Götze + * + * Licensed 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 + * + * http://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 com.github.mcollovati.quarkus.hilla.deployment.asm; + +import io.quarkus.gizmo.Gizmo; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TypeInsnNode; + +/** + * Modifies EndpointController class to use the extension MultipartRequest + * wrapper instead of Spring MultipartHttpServletRequest. + */ +public class EndpointControllerVisitor extends ClassVisitor { + + public static final String SPRING_MULTIPART_HTTP_SERVLET_REQUEST = + "org/springframework/web/multipart/MultipartHttpServletRequest"; + public static final String QH_MULTIPART_HTTP_SERVLET_REQUEST = + "com/github/mcollovati/quarkus/hilla/multipart/MultipartRequest"; + + public EndpointControllerVisitor(ClassVisitor classVisitor) { + super(Gizmo.ASM_API_VERSION, classVisitor); + } + + @Override + public MethodVisitor visitMethod( + int access, String name, String descriptor, String signature, String[] exceptions) { + MethodVisitor superVisitor = super.visitMethod(access, name, descriptor, signature, exceptions); + if ("doServeEndpoint".equals(name)) { + return new MethodNode(Gizmo.ASM_API_VERSION, access, name, descriptor, signature, exceptions) { + @Override + public void visitEnd() { + var iterator = instructions.iterator(); + TypeInsnNode checkCastNode = AsmUtils.findNextInsnNode( + iterator, + node -> node.getOpcode() == Opcodes.CHECKCAST + && node.desc.equals(SPRING_MULTIPART_HTTP_SERVLET_REQUEST), + TypeInsnNode.class); + checkCastNode.desc = QH_MULTIPART_HTTP_SERVLET_REQUEST; + MethodInsnNode getParameterNode = AsmUtils.findNextInsnNode( + iterator, + node -> node.getOpcode() == Opcodes.INVOKEINTERFACE + && node.owner.equals(SPRING_MULTIPART_HTTP_SERVLET_REQUEST) + && node.name.equals("getParameter"), + MethodInsnNode.class); + getParameterNode.setOpcode(Opcodes.INVOKEVIRTUAL); + getParameterNode.owner = QH_MULTIPART_HTTP_SERVLET_REQUEST; + getParameterNode.itf = false; + + MethodInsnNode getFileMapNode = AsmUtils.findNextInsnNode( + iterator, + node -> node.getOpcode() == Opcodes.INVOKEINTERFACE + && node.owner.equals(SPRING_MULTIPART_HTTP_SERVLET_REQUEST) + && node.name.equals("getFileMap"), + MethodInsnNode.class); + getFileMapNode.setOpcode(Opcodes.INVOKEVIRTUAL); + getFileMapNode.owner = QH_MULTIPART_HTTP_SERVLET_REQUEST; + getFileMapNode.itf = false; + accept(superVisitor); + } + }; + } + return superVisitor; + } +} diff --git a/commons/deployment/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/OffendingMethodCallsReplacer.java b/commons/deployment/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/OffendingMethodCallsReplacer.java index 14c98486..b0633ae1 100644 --- a/commons/deployment/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/OffendingMethodCallsReplacer.java +++ b/commons/deployment/src/main/java/com/github/mcollovati/quarkus/hilla/deployment/asm/OffendingMethodCallsReplacer.java @@ -120,6 +120,9 @@ public static void addClassVisitors(BuildProducer })); producer.produce(applicationContextProvider_runOnContext_patch()); producer.produce(endpointCodeGenerator_findBrowserCallables_replacement()); + producer.produce(new BytecodeTransformerBuildItem( + "com.vaadin.hilla.EndpointController", + (className, classVisitor) -> new EndpointControllerVisitor(classVisitor))); } @SafeVarargs diff --git a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/AbstractEndpointControllerTest.java b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/AbstractEndpointControllerTest.java index e4daa8bc..ef2023a7 100644 --- a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/AbstractEndpointControllerTest.java +++ b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/AbstractEndpointControllerTest.java @@ -15,10 +15,20 @@ */ package com.github.mcollovati.quarkus.hilla.deployment; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.vaadin.hilla.EndpointController; import com.vaadin.hilla.exception.EndpointValidationException; import io.restassured.RestAssured; import io.restassured.http.ContentType; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.assertj.core.api.Assertions; import org.hamcrest.CoreMatchers; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import com.github.mcollovati.quarkus.hilla.deployment.endpoints.Pojo; @@ -71,6 +81,7 @@ void invokeEndpoint_multipleParameters() { } @Test + @Disabled("Order does not matter anymore") void invokeEndpoint_wrongParametersOrder_badRequest() { givenEndpointRequest( getEndpointPrefix(), @@ -158,6 +169,33 @@ void invokeEndpoint_missingMethodName_notFound() { .statusCode(404); } + @Test + void invokeEndpoint_multipart_fileTransfer() throws IOException { + Path tempFile = Files.createTempFile("upload", "txt"); + Files.writeString(tempFile, "hello world"); + ExtractableResponse res = RestAssured.given() + .contentType(ContentType.MULTIPART) + .multiPart("/file", tempFile.toFile()) + .multiPart( + EndpointController.BODY_PART_NAME, + """ + { "info": { "id": "UPLOAD-1", "date": "2025-02-02" } } + """) + .cookie("csrfToken", "CSRF_TOKEN") + .header("X-CSRF-Token", "CSRF_TOKEN") + .basePath(getEndpointPrefix()) + .when() + .post("UploadEndpoint/upload") + .then() + .assertThat() + .statusCode(200) + .body("info.id", equalTo("UPLOAD-1")) + .body("info.date", equalTo("2025-02-02")) + .extract(); + Path uploadedFile = Path.of(URI.create(res.body().path("uri"))); + Assertions.assertThat(uploadedFile).hasSameTextualContentAs(tempFile); + } + protected String getEndpointPrefix() { return TestUtils.DEFAULT_PREFIX; } diff --git a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/BrowserCallableControllerTest.java b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/BrowserCallableControllerTest.java index 0830652e..6085b51d 100644 --- a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/BrowserCallableControllerTest.java +++ b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/BrowserCallableControllerTest.java @@ -22,6 +22,7 @@ import com.github.mcollovati.quarkus.hilla.deployment.endpoints.Pojo; import com.github.mcollovati.quarkus.hilla.deployment.endpoints.TestBrowserCallable; +import com.github.mcollovati.quarkus.hilla.deployment.endpoints.UploadEndpoint; class BrowserCallableControllerTest extends AbstractEndpointControllerTest { @@ -31,7 +32,7 @@ class BrowserCallableControllerTest extends AbstractEndpointControllerTest { static final QuarkusUnitTest config = new QuarkusUnitTest() .withConfigurationResource(testResource("test-application.properties")) .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) - .addClasses(TestUtils.class, Pojo.class, TestBrowserCallable.class)); + .addClasses(TestUtils.class, Pojo.class, TestBrowserCallable.class, UploadEndpoint.class)); @Override protected String getEndpointName() { diff --git a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/CustomPrefixEndpointControllerTest.java b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/CustomPrefixEndpointControllerTest.java index 475684c9..f460168a 100644 --- a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/CustomPrefixEndpointControllerTest.java +++ b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/CustomPrefixEndpointControllerTest.java @@ -23,6 +23,7 @@ import com.github.mcollovati.quarkus.hilla.QuarkusEndpointConfiguration; import com.github.mcollovati.quarkus.hilla.deployment.endpoints.Pojo; import com.github.mcollovati.quarkus.hilla.deployment.endpoints.TestEndpoint; +import com.github.mcollovati.quarkus.hilla.deployment.endpoints.UploadEndpoint; class CustomPrefixEndpointControllerTest extends AbstractEndpointControllerTest { @@ -34,8 +35,8 @@ class CustomPrefixEndpointControllerTest extends AbstractEndpointControllerTest static final QuarkusUnitTest config = new QuarkusUnitTest() .withConfigurationResource(testResource("test-application.properties")) .overrideConfigKey(QuarkusEndpointConfiguration.VAADIN_ENDPOINT_PREFIX, CUSTOM_PREFIX) - .setArchiveProducer(() -> - ShrinkWrap.create(JavaArchive.class).addClasses(TestUtils.class, Pojo.class, TestEndpoint.class)); + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(TestUtils.class, Pojo.class, TestEndpoint.class, UploadEndpoint.class)); @Override protected String getEndpointPrefix() { diff --git a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/EndpointControllerTest.java b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/EndpointControllerTest.java index 9efc9fcc..8b3609df 100644 --- a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/EndpointControllerTest.java +++ b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/EndpointControllerTest.java @@ -22,6 +22,7 @@ import com.github.mcollovati.quarkus.hilla.deployment.endpoints.Pojo; import com.github.mcollovati.quarkus.hilla.deployment.endpoints.TestEndpoint; +import com.github.mcollovati.quarkus.hilla.deployment.endpoints.UploadEndpoint; class EndpointControllerTest extends AbstractEndpointControllerTest { @@ -30,8 +31,8 @@ class EndpointControllerTest extends AbstractEndpointControllerTest { @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() .withConfigurationResource(testResource("test-application.properties")) - .setArchiveProducer(() -> - ShrinkWrap.create(JavaArchive.class).addClasses(TestUtils.class, Pojo.class, TestEndpoint.class)); + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(TestUtils.class, Pojo.class, TestEndpoint.class, UploadEndpoint.class)); @Override protected String getEndpointName() { diff --git a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/SpringDataExtensionsSupportTest.java b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/SpringDataExtensionsSupportTest.java index e6070afa..1b772d71 100644 --- a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/SpringDataExtensionsSupportTest.java +++ b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/SpringDataExtensionsSupportTest.java @@ -26,6 +26,7 @@ import com.github.mcollovati.quarkus.hilla.deployment.endpoints.Pojo; import com.github.mcollovati.quarkus.hilla.deployment.endpoints.TestBrowserCallable; +import com.github.mcollovati.quarkus.hilla.deployment.endpoints.UploadEndpoint; class SpringDataExtensionsSupportTest extends AbstractEndpointControllerTest { @@ -37,7 +38,7 @@ class SpringDataExtensionsSupportTest extends AbstractEndpointControllerTest { .setForcedDependencies( List.of(Dependency.of("io.quarkus", "quarkus-spring-data-jpa", Version.getVersion()))) .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) - .addClasses(TestUtils.class, Pojo.class, TestBrowserCallable.class)); + .addClasses(TestUtils.class, Pojo.class, TestBrowserCallable.class, UploadEndpoint.class)); protected String getEndpointName() { return ENDPOINT_NAME; diff --git a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/SpringDiExtensionsSupportTest.java b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/SpringDiExtensionsSupportTest.java index 31fb9126..6a76c7a6 100644 --- a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/SpringDiExtensionsSupportTest.java +++ b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/SpringDiExtensionsSupportTest.java @@ -26,6 +26,7 @@ import com.github.mcollovati.quarkus.hilla.deployment.endpoints.Pojo; import com.github.mcollovati.quarkus.hilla.deployment.endpoints.TestBrowserCallable; +import com.github.mcollovati.quarkus.hilla.deployment.endpoints.UploadEndpoint; class SpringDiExtensionsSupportTest extends AbstractEndpointControllerTest { @@ -36,7 +37,7 @@ class SpringDiExtensionsSupportTest extends AbstractEndpointControllerTest { .withConfigurationResource(testResource("test-spring-di-application.properties")) .setForcedDependencies(List.of(Dependency.of("io.quarkus", "quarkus-spring-di", Version.getVersion()))) .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) - .addClasses(TestUtils.class, Pojo.class, TestBrowserCallable.class)); + .addClasses(TestUtils.class, Pojo.class, TestBrowserCallable.class, UploadEndpoint.class)); protected String getEndpointName() { return ENDPOINT_NAME; diff --git a/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/endpoints/UploadEndpoint.java b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/endpoints/UploadEndpoint.java new file mode 100644 index 00000000..da7feb6a --- /dev/null +++ b/commons/deployment/src/test/java/com/github/mcollovati/quarkus/hilla/deployment/endpoints/UploadEndpoint.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Marco Collovati, Dario Götze + * + * Licensed 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 + * + * http://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 com.github.mcollovati.quarkus.hilla.deployment.endpoints; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.vaadin.flow.server.auth.AnonymousAllowed; +import com.vaadin.hilla.BrowserCallable; +import org.springframework.web.multipart.MultipartFile; + +@BrowserCallable +@AnonymousAllowed +public class UploadEndpoint { + + public Object upload(Info info, MultipartFile file) { + try { + Path tempFile = Files.createTempFile("upload", "test"); + file.transferTo(tempFile); + return new Result(info, tempFile.toUri().toString()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public record Info(String id, @JsonFormat(pattern = "yyyy-MM-dd") LocalDate date) {} + + public record Result(Info info, String uri) {} +} diff --git a/commons/hilla-shaded-deps/pom.xml b/commons/hilla-shaded-deps/pom.xml index 6255ee0e..65ec419d 100644 --- a/commons/hilla-shaded-deps/pom.xml +++ b/commons/hilla-shaded-deps/pom.xml @@ -100,6 +100,7 @@ org.springframework:spring-web org/springframework/http/*.class + org/springframework/web/multipart/MultipartFile.class diff --git a/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointController.java b/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointController.java index b99e018a..fff297b7 100644 --- a/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointController.java +++ b/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointController.java @@ -18,6 +18,7 @@ import jakarta.inject.Inject; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.Consumes; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -25,16 +26,15 @@ import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import java.io.IOException; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.vaadin.hilla.Endpoint; import com.vaadin.hilla.EndpointController; -import com.vaadin.hilla.EndpointInvoker; -import com.vaadin.hilla.EndpointRegistry; -import com.vaadin.hilla.auth.CsrfChecker; -import org.springframework.context.ApplicationContext; +import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput; import org.springframework.http.ResponseEntity; +import com.github.mcollovati.quarkus.hilla.multipart.MultipartRequest; + @Path("") public class QuarkusEndpointController { @@ -42,29 +42,33 @@ public class QuarkusEndpointController { private final EndpointController delegate; - /** - * A constructor used to initialize the controller. - * - * @param context Spring context to extract beans annotated with - * {@link Endpoint} from - * @param endpointRegistry the registry used to store endpoint information - * @param endpointInvoker then end point invoker - * @param csrfChecker the csrf checker to use - */ - public QuarkusEndpointController( - ApplicationContext context, - EndpointRegistry endpointRegistry, - EndpointInvoker endpointInvoker, - CsrfChecker csrfChecker) { - delegate = new EndpointController(context, endpointRegistry, endpointInvoker, csrfChecker); - } - @Inject public QuarkusEndpointController(EndpointController delegate) { this.delegate = delegate; QuarkusHillaExtension.markUsed(); } + /** + * Captures and processes the Vaadin endpoint requests. + *

+ * Matches the endpoint name and a method name with the corresponding Java + * class and a public method in the class. Extracts parameters from a + * request body if the Java method requires any and applies in the same + * order. After the method call, serializes the Java method execution result + * and sends it back. + *

+ * If an issue occurs during the request processing, an error response is + * returned instead of the serialized Java method return value. + * + * @param endpointName the name of an endpoint to address the calls to, not case + * sensitive + * @param methodName the method name to execute on an endpoint, not case sensitive + * @param body optional request body, that should be specified if the method + * called has parameters + * @param request the current request which triggers the endpoint call + * @param response the current response + * @return execution result as a JSON string or an error message string + */ @POST @Path(ENDPOINT_METHODS) @Produces(MediaType.APPLICATION_JSON) @@ -77,6 +81,41 @@ public Response serveEndpoint( ResponseEntity endpointResponse = delegate.serveEndpoint(endpointName, methodName, body, request, response); + return buildResponse(endpointResponse); + } + + /** + * Captures and processes the Vaadin multipart endpoint requests. They are + * used when there are uploaded files. + *

+ * This method works as + * {@link #serveEndpoint(String, String, HttpServletRequest, HttpServletResponse, ObjectNode)}, + * but it also captures the files uploaded in the request. + * + * @param endpointName the name of an endpoint to address the calls to, not case + * sensitive + * @param methodName the method name to execute on an endpoint, not case sensitive + * @param request the current multipart request which triggers the endpoint call + * @param response the current response + * @return execution result as a JSON string or an error message string + */ + @POST + @Path(ENDPOINT_METHODS) + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) + public Response serveMultipartEndpoint( + @PathParam("endpoint") String endpointName, + @PathParam("method") String methodName, + @Context HttpServletRequest request, + @Context HttpServletResponse response, + MultipartFormDataInput formData) + throws IOException { + ResponseEntity endpointResponse = delegate.serveMultipartEndpoint( + endpointName, methodName, new MultipartRequest(request, formData), response); + return buildResponse(endpointResponse); + } + + private static Response buildResponse(ResponseEntity endpointResponse) { Response.ResponseBuilder builder = Response.status(endpointResponse.getStatusCode().value()); endpointResponse.getHeaders().forEach((name, values) -> values.forEach(value -> builder.header(name, value))); diff --git a/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointControllerConfiguration.java b/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointControllerConfiguration.java index 6bb195e1..064dd025 100644 --- a/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointControllerConfiguration.java +++ b/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/QuarkusEndpointControllerConfiguration.java @@ -204,8 +204,9 @@ EndpointController endpointController( ApplicationContext context, EndpointRegistry endpointRegistry, EndpointInvoker endpointInvoker, - CsrfChecker csrfChecker) { - return new EndpointController(context, endpointRegistry, endpointInvoker, csrfChecker); + CsrfChecker csrfChecker, + @Named("endpointObjectMapper") ObjectMapper objectMapper) { + return new EndpointController(context, endpointRegistry, endpointInvoker, csrfChecker, objectMapper); } @Produces diff --git a/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/multipart/MultipartRequest.java b/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/multipart/MultipartRequest.java new file mode 100644 index 00000000..3e71db25 --- /dev/null +++ b/commons/runtime/src/main/java/com/github/mcollovati/quarkus/hilla/multipart/MultipartRequest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2025 Marco Collovati, Dario Götze + * + * Licensed 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 + * + * http://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 com.github.mcollovati.quarkus.hilla.multipart; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.Serializable; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jboss.resteasy.reactive.server.multipart.FormValue; +import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput; +import org.springframework.http.HttpHeaders; +import org.springframework.web.multipart.MultipartFile; + +/** + * A {@link HttpServletRequest} wrapper that supports a better integration with + * Hilla multipart form data handling. + */ +public final class MultipartRequest extends HttpServletRequestWrapper { + + private final Map> formData; + + /** + * Constructs a request object wrapping the given request. + * + * @param request the {@link HttpServletRequest} to be wrapped. + * @throws IllegalArgumentException if the request is null + */ + public MultipartRequest(HttpServletRequest request, MultipartFormDataInput formData) { + super(request); + this.formData = formData.getValues(); + } + + @Override + public String getParameter(String name) { + Collection values = formData.get(name); + return Optional.ofNullable(values).stream() + .flatMap(Collection::stream) + .filter(fv -> !fv.isFileItem()) + .findFirst() + .map(FormValue::getValue) + .orElseGet(() -> super.getParameter(name)); + } + + /** + * Return a {@link java.util.Map} of the multipart files contained in this request. + * + * @return a map containing the parameter names as keys, and the + * {@link MultipartFile} objects as values + */ + public Map getFileMap() { + return formData.entrySet().stream() + .map(e -> Map.entry(e.getKey(), e.getValue().iterator().next())) + .filter(e -> e.getValue().isFileItem()) + .map(e -> new MultipartFileImpl(e.getKey(), e.getValue())) + .collect(Collectors.toMap(MultipartFileImpl::getName, Function.identity())); + } + + private static class MultipartFileImpl implements MultipartFile, Serializable { + + private final String name; + private final FormValue formValue; + + public MultipartFileImpl(String name, FormValue formValue) { + this.name = name; + this.formValue = formValue; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return formValue.getFileName(); + } + + @Override + public String getContentType() { + return formValue.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE); + } + + @Override + public boolean isEmpty() { + return getSize() <= 0L; + } + + @Override + public long getSize() { + try { + return formValue.getFileItem().getFileSize(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public byte[] getBytes() throws IOException { + return formValue.getFileItem().getInputStream().readAllBytes(); + } + + @Override + public InputStream getInputStream() throws IOException { + return formValue.getFileItem().getInputStream(); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + transferTo(dest.toPath()); + } + + @Override + public void transferTo(Path dest) throws IOException, IllegalStateException { + Files.deleteIfExists(dest); + formValue.getFileItem().write(dest); + } + } +} diff --git a/integration-tests/react-smoke-tests/src/main/frontend/views/UploadView.tsx b/integration-tests/react-smoke-tests/src/main/frontend/views/UploadView.tsx new file mode 100644 index 00000000..c78aa43c --- /dev/null +++ b/integration-tests/react-smoke-tests/src/main/frontend/views/UploadView.tsx @@ -0,0 +1,81 @@ +import {useState} from 'react'; + +import {ViewConfig} from "@vaadin/hilla-file-router/types.js"; +//import {UploadEndpoint} from "Frontend/generated/endpoints"; + +export const config: ViewConfig = { + title: "Upload", + route: "upload" +}; + +export default function UploadView() { + const [file, setFile] = useState(null); + const [uploadState, setUploadState] = useState(""); + + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files && event.target.files[0]) { + setFile(event.target.files[0]); + } + }; + + // Handle form submission + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); // Prevent the default form submission behavior + if (file) { + uploadFile(file); + } + }; + const uploadFile = (file: File) => { + + // const file = new FormData(event.target as HTMLFormElement).get("file"); + // const savedFile = await UploadEndpoint.upload("my-test", file); + + // Temporary upload logic until Hilla completes multipart form + // support on the client side services + const formData = new FormData(); + formData.append("/file", file); + formData.append("hilla_body_part", `{ "name": "my-test" }`); + + const csrfCookie = "csrfToken"; + const crsfToken = document.cookie.split(";") + .map((c) => c.trim()) + .filter(c => c.startsWith(csrfCookie + "=")) + .map(c => c.substring(csrfCookie.length +1))[0]; + const xhr = new XMLHttpRequest(); + xhr.withCredentials = true; + xhr.open('POST', '/connect/UploadEndpoint/upload', true); + xhr.setRequestHeader("X-CSRF-Token", crsfToken); + // Set up event listeners for progress, completion, etc. + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percentComplete = (event.loaded / event.total) * 100; + console.log(`Upload progress: ${percentComplete}%`); + } + }; + xhr.onload = () => { + if (xhr.status === 200) { + console.log('File uploaded successfully!'); + console.log('Response:', xhr.responseText); + setUploadState("File saved to " + xhr.responseText); + } else { + console.error('File upload failed.'); + } + }; + xhr.onerror = () => { + console.error('Error during file upload.'); + }; + // Send the request + xhr.send(formData); + + }; + + return (

+
+ +
+
+ +
+ {uploadState} +
); +} \ No newline at end of file diff --git a/integration-tests/react-smoke-tests/src/main/java/com/example/application/upload/UploadEndpoint.java b/integration-tests/react-smoke-tests/src/main/java/com/example/application/upload/UploadEndpoint.java new file mode 100644 index 00000000..4b0dde0f --- /dev/null +++ b/integration-tests/react-smoke-tests/src/main/java/com/example/application/upload/UploadEndpoint.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Marco Collovati, Dario Götze + * + * Licensed 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 + * + * http://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 com.example.application.upload; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.vaadin.flow.server.auth.AnonymousAllowed; +import com.vaadin.hilla.BrowserCallable; +import org.springframework.web.multipart.MultipartFile; + +@BrowserCallable +@AnonymousAllowed +public class UploadEndpoint { + + public String upload(String name, MultipartFile file) { + try { + Path tempFile = Files.createTempFile(name, ".deleteme"); + file.transferTo(tempFile); + return tempFile.toAbsolutePath().toUri().toString(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/integration-tests/react-smoke-tests/src/test/java/com/example/application/UploadNativeIT.java b/integration-tests/react-smoke-tests/src/test/java/com/example/application/UploadNativeIT.java new file mode 100644 index 00000000..57cead28 --- /dev/null +++ b/integration-tests/react-smoke-tests/src/test/java/com/example/application/UploadNativeIT.java @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Marco Collovati, Dario Götze + * + * Licensed 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 + * + * http://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 com.example.application; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class UploadNativeIT extends UploadTest {} diff --git a/integration-tests/react-smoke-tests/src/test/java/com/example/application/UploadTest.java b/integration-tests/react-smoke-tests/src/test/java/com/example/application/UploadTest.java new file mode 100644 index 00000000..41825778 --- /dev/null +++ b/integration-tests/react-smoke-tests/src/test/java/com/example/application/UploadTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Marco Collovati, Dario Götze + * + * Licensed 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 + * + * http://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 com.example.application; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.codeborne.selenide.SelenideElement; +import io.quarkus.test.junit.QuarkusTest; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.github.mcollovati.quarkus.testing.AbstractTest; + +import static com.codeborne.selenide.Condition.partialText; +import static com.codeborne.selenide.Condition.visible; +import static com.codeborne.selenide.Selenide.$; + +@QuarkusTest +public class UploadTest extends AbstractTest { + + @Override + protected String getTestUrl() { + return super.getBaseURL() + "/upload"; + } + + @Test + void rootPath_viewDisplayed() throws IOException { + Path tempFile = Files.createTempFile("upload", "test"); + Files.writeString(tempFile, "hello world"); + openAndWait(() -> $("form#upload")); + + $("input[type=file]").shouldBe(visible).uploadFile(tempFile.toFile()); + $("button[type=submit]").shouldBe(visible).click(); + + SelenideElement out = $("output#out").shouldBe(visible); + out.shouldHave(partialText("File saved to ")).shouldHave(partialText("my-test")); + String fileURI = out.getText().replaceFirst("^.* saved to \"(.*)\".*$", "$1"); + Path path = Path.of(URI.create(fileURI)); + Assertions.assertThat(path).hasSameTextualContentAs(tempFile); + } +}