From 158c47f3f8898b8844c8db175cb0654932fa9530 Mon Sep 17 00:00:00 2001 From: croway Date: Wed, 2 Sep 2026 15:03:00 +0200 Subject: [PATCH] CAMEL-24593: platform-http-starter - delete multipart uploads when the exchange is done SpringBootPlatformHttpBinding.populateAttachments() copies every accepted multipart upload into the servlet temp directory and uses that copy as the attachment DataSource and, for a single upload, as the Path message body and the CamelFilePath header. MultipartFile.transferTo() moves the container's part file, so the container's own end-of-request cleanup no longer finds it, and nothing in the starter deleted the copy either: every upload the application accepted stayed on disk for the life of the process. The copy was introduced in CAMEL-21461 so the body can be a Path and the attachment stays readable after the servlet request completed, which is a good reason to own the file - but owning it means removing it. The other HTTP bindings do not leak: camel-http-common reads the part through the container managed file, which the container deletes, and camel-platform-http-vertx has deleteUploadedFilesOnEnd defaulting to true. The binding now collects the temp files it created for a request and registers a Synchronization through ExchangeExtension.addOnCompletion that deletes them when the exchange is done being routed. The DataSource and the Path body point at the files until then, so they cannot be deleted any earlier; the consumer writes the HTTP response before doneUoW, so the response is already out. The opt-out mirrors the Vert.x option. Endpoint options are defined in upstream camel-platform-http and cannot be extended from here, so the option is a new starter owned configuration class, SpringBootPlatformHttpServerProperties, next to the existing camel.component.platform-http.server.undertow.accesslog properties: camel.component.platform-http.server.delete-uploaded-files-on-end=false It is wired from the auto configuration through the engine and the consumer onto the binding, and defaults to true. Existing public constructors are unchanged. Co-Authored-By: Claude Opus 5 --- .../src/main/docs/platform-http.adoc | 21 +++ .../src/main/docs/platform-http.json | 12 ++ ...ringBootPlatformHttpAutoConfiguration.java | 8 +- .../SpringBootPlatformHttpBinding.java | 52 +++++++ .../SpringBootPlatformHttpConsumer.java | 9 ++ .../SpringBootPlatformHttpEngine.java | 14 +- ...pringBootPlatformHttpServerProperties.java | 43 ++++++ ...PlatformHttpUploadCleanupDisabledTest.java | 111 ++++++++++++++ ...ringBootPlatformHttpUploadCleanupTest.java | 141 ++++++++++++++++++ .../http/springboot/UploadCleanupRoute.java | 74 +++++++++ .../ROOT/pages/starters/platform-http.adoc | 3 +- 11 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpServerProperties.java create mode 100644 components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpUploadCleanupDisabledTest.java create mode 100644 components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpUploadCleanupTest.java create mode 100644 components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/UploadCleanupRoute.java diff --git a/components-starter/camel-platform-http-starter/src/main/docs/platform-http.adoc b/components-starter/camel-platform-http-starter/src/main/docs/platform-http.adoc index 15053a6a48cb..333361232593 100644 --- a/components-starter/camel-platform-http-starter/src/main/docs/platform-http.adoc +++ b/components-starter/camel-platform-http-starter/src/main/docs/platform-http.adoc @@ -17,6 +17,27 @@ carry no matrix parameters. A request to `/greeting/John%20Doe;v=1` sets the `na The `CamelHttpPath` header is not affected: it reports the raw request path, with the servlet context-path removed. +== File uploads + +Every accepted multipart file upload is copied out of the servlet container into the servlet temporary directory +(`jakarta.servlet.ServletContext#TEMPDIR`), so that it can still be read after the HTTP request has completed. The +copy is what the route sees: it is the `jakarta.activation.DataSource` of the attachment and, when the request carries +a single file, also the `java.nio.file.Path` message body and the value of the `CamelFilePath` header. + +Because Camel owns that copy, it is deleted again once the exchange is done being routed, that is after the response +has been written. A route that consumes the upload during routing - saving it with the file producer, streaming it to +a remote system, unmarshalling it - is unaffected, since the content has already been read or copied by then. + +Set the following property if the application hands the temporary file over to something that reads it *after* the +exchange has completed, in which case the application becomes responsible for deleting the file: + +[source,properties] +---- +camel.component.platform-http.server.delete-uploaded-files-on-end=false +---- + +This mirrors the `deleteUploadedFilesOnEnd` option of the Vert.x platform-http implementation and defaults to `true`. + == Undertow Access Log You can enable Undertow access log to be managed by whatever logging library you have in your camel application, you have to set the following parameters: diff --git a/components-starter/camel-platform-http-starter/src/main/docs/platform-http.json b/components-starter/camel-platform-http-starter/src/main/docs/platform-http.json index 641e67f14d74..6acdca2e9d57 100644 --- a/components-starter/camel-platform-http-starter/src/main/docs/platform-http.json +++ b/components-starter/camel-platform-http-starter/src/main/docs/platform-http.json @@ -11,6 +11,11 @@ "sourceType": "org.apache.camel.component.platform.http.springboot.PlatformHttpComponentConfiguration", "sourceMethod": "getCustomizer()" }, + { + "name": "camel.component.platform-http.server", + "type": "org.apache.camel.component.platform.http.springboot.SpringBootPlatformHttpServerProperties", + "sourceType": "org.apache.camel.component.platform.http.springboot.SpringBootPlatformHttpServerProperties" + }, { "name": "camel.component.platform-http.server.undertow.accesslog", "type": "org.apache.camel.component.platform.http.springboot.customizer.UndertowAccessLogProperties", @@ -75,6 +80,13 @@ "sourceType": "org.apache.camel.component.platform.http.springboot.PlatformHttpComponentConfiguration", "defaultValue": true }, + { + "name": "camel.component.platform-http.server.delete-uploaded-files-on-end", + "type": "java.lang.Boolean", + "description": "Whether the temporary files, that multipart file uploads are written to, are deleted when the exchange is done being routed. The uploaded file is copied out of the servlet container into the servlet temp directory so that it stays readable after the HTTP request has completed, which makes Camel the owner of that copy. Turn this off only if the route hands the file over to something that reads it after the exchange has completed - the route is then responsible for deleting the file.", + "sourceType": "org.apache.camel.component.platform.http.springboot.SpringBootPlatformHttpServerProperties", + "defaultValue": true + }, { "name": "camel.component.platform-http.server.undertow.accesslog.use-camel-logging", "type": "java.lang.Boolean", diff --git a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpAutoConfiguration.java b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpAutoConfiguration.java index 22f3c1b9b114..d65511a6ad54 100644 --- a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpAutoConfiguration.java +++ b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpAutoConfiguration.java @@ -37,14 +37,16 @@ import java.util.concurrent.Executor; @AutoConfiguration(after = { PlatformHttpComponentAutoConfiguration.class, PlatformHttpComponentConverter.class }) -@EnableConfigurationProperties({ ComponentConfigurationProperties.class, PlatformHttpComponentConfiguration.class }) +@EnableConfigurationProperties({ ComponentConfigurationProperties.class, PlatformHttpComponentConfiguration.class, + SpringBootPlatformHttpServerProperties.class }) public class SpringBootPlatformHttpAutoConfiguration { private static final Logger LOG = LoggerFactory.getLogger(SpringBootPlatformHttpAutoConfiguration.class); @Bean(name = "platform-http-engine") @ConditionalOnMissingBean(PlatformHttpEngine.class) public PlatformHttpEngine springBootPlatformHttpEngine(Environment env, ServerProperties serverProperties, - List executors) { + List executors, + SpringBootPlatformHttpServerProperties serverHttpProperties) { if (executors == null || executors.isEmpty()) { throw new IllegalStateException("No Executor configured"); } @@ -87,7 +89,7 @@ public PlatformHttpEngine springBootPlatformHttpEngine(Environment env, ServerPr LOG.debug("Using executor: {}", executor.getClass().getName()); } int port = serverProperties.getPort() != null ? serverProperties.getPort() : 8080; - return new SpringBootPlatformHttpEngine(port, executor); + return new SpringBootPlatformHttpEngine(port, executor, serverHttpProperties.isDeleteUploadedFilesOnEnd()); } // Must not be @Lazy: eager beans are created before Camel starts, so the mapping is listening before the first diff --git a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBinding.java b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBinding.java index 6c432da6769d..ac2dffd198a8 100644 --- a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBinding.java +++ b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpBinding.java @@ -38,6 +38,7 @@ import org.apache.camel.http.common.DefaultHttpBinding; import org.apache.camel.http.common.HttpConstants; import org.apache.camel.support.ExchangeHelper; +import org.apache.camel.support.SynchronizationAdapter; import org.apache.camel.util.FileUtil; import org.apache.camel.util.IOHelper; import org.apache.camel.util.URISupport; @@ -57,8 +58,10 @@ import java.io.Serializable; import java.net.URISyntaxException; import java.nio.ByteBuffer; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -68,6 +71,7 @@ public class SpringBootPlatformHttpBinding extends DefaultHttpBinding { private static final Logger LOG = LoggerFactory.getLogger(SpringBootPlatformHttpBinding.class); private boolean streaming; + private boolean deleteUploadedFilesOnEnd = true; private static final String CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded"; private static final List METHODS_WITH_BODY_ALLOWED = List.of(Method.POST, Method.PUT, Method.PATCH, Method.DELETE); @@ -145,6 +149,9 @@ protected void populateAttachments(HttpServletRequest request, Message message) boolean isSingleAttachment = multipartHttpServletRequest.getFileMap() != null && multipartHttpServletRequest.getFileMap().keySet().size() == 1; message.setHeader(Exchange.ATTACHMENTS_SIZE, multipartHttpServletRequest.getFileMap().keySet().size()); + // the uploads are copied out of the servlet container and are therefore owned by Camel, they are + // deleted again when the exchange is done being routed (unless deleteUploadedFilesOnEnd is turned off) + final List uploadedTmpFiles = new ArrayList<>(); multipartHttpServletRequest.getFileMap().forEach((name, multipartFile) -> { try { if (name != null) { @@ -163,6 +170,7 @@ protected void populateAttachments(HttpServletRequest request, Message message) Path uploadedTmpFile = Paths.get(tmpFolder.getPath(), UUID.randomUUID().toString()); multipartFile.transferTo(uploadedTmpFile); + uploadedTmpFiles.add(uploadedTmpFile); AttachmentMessage am = new DefaultAttachmentMessage(message); File uploadedFile = uploadedTmpFile.toFile(); @@ -185,7 +193,40 @@ protected void populateAttachments(HttpServletRequest request, Message message) throw new RuntimeException(e); } }); + + if (deleteUploadedFilesOnEnd && !uploadedTmpFiles.isEmpty()) { + registerUploadedFilesCleanup(message, uploadedTmpFiles); + } + } + } + + /** + * Deletes the temporary copies of the uploaded files when the exchange is done being routed. + *

+ * The attachment {@code DataSource} and, for a single upload, the message body point at those + * files for as long as the exchange is being routed, so the files can only be deleted on completion. + */ + private void registerUploadedFilesCleanup(Message message, List uploadedTmpFiles) { + Exchange exchange = message.getExchange(); + if (exchange == null) { + LOG.debug("Cannot delete uploaded temporary files as the message is not associated with an exchange"); + return; } + exchange.getExchangeExtension().addOnCompletion(new SynchronizationAdapter() { + @Override + public void onDone(Exchange doneExchange) { + for (Path uploadedTmpFile : uploadedTmpFiles) { + try { + if (Files.deleteIfExists(uploadedTmpFile)) { + LOG.trace("Deleted uploaded temporary file: {}", uploadedTmpFile); + } + } catch (IOException e) { + LOG.debug("Cannot delete uploaded temporary file: {} due to: {}. This exception is ignored.", + uploadedTmpFile, e.getMessage(), e); + } + } + } + }); } /** @@ -224,6 +265,17 @@ public void setStreaming(boolean streaming) { this.streaming = streaming; } + public boolean isDeleteUploadedFilesOnEnd() { + return deleteUploadedFilesOnEnd; + } + + /** + * Whether the temporary copies of multipart file uploads are deleted when the exchange is done being routed. + */ + public void setDeleteUploadedFilesOnEnd(boolean deleteUploadedFilesOnEnd) { + this.deleteUploadedFilesOnEnd = deleteUploadedFilesOnEnd; + } + public Object parseBody(HttpServletRequest request, Message message) throws IOException { if (request instanceof StandardMultipartHttpServletRequest || // In case of Spring FormContentFilter diff --git a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpConsumer.java b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpConsumer.java index 9a6a3b1a904e..26fc27569989 100644 --- a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpConsumer.java +++ b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpConsumer.java @@ -92,6 +92,15 @@ void setBinding(HttpBinding binding) { this.binding = binding; } + /** + * Whether the temporary copies of multipart file uploads are deleted when the exchange is done being routed. + */ + public void setDeleteUploadedFilesOnEnd(boolean deleteUploadedFilesOnEnd) { + if (binding instanceof SpringBootPlatformHttpBinding springBootBinding) { + springBootBinding.setDeleteUploadedFilesOnEnd(deleteUploadedFilesOnEnd); + } + } + @Override public PlatformHttpEndpoint getEndpoint() { return (PlatformHttpEndpoint) super.getEndpoint(); diff --git a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpEngine.java b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpEngine.java index 7a32fa7ebc57..b3b7589da911 100644 --- a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpEngine.java +++ b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpEngine.java @@ -27,23 +27,33 @@ public class SpringBootPlatformHttpEngine implements PlatformHttpEngine { private final int port; private final Executor executor; + private final boolean deleteUploadedFilesOnEnd; public SpringBootPlatformHttpEngine(int port) { this(port, null); } public SpringBootPlatformHttpEngine(int port, Executor executor) { + this(port, executor, true); + } + + public SpringBootPlatformHttpEngine(int port, Executor executor, boolean deleteUploadedFilesOnEnd) { this.port = port; this.executor = executor; + this.deleteUploadedFilesOnEnd = deleteUploadedFilesOnEnd; } @Override public PlatformHttpConsumer createConsumer(PlatformHttpEndpoint endpoint, Processor processor) { + SpringBootPlatformHttpConsumer consumer; if (executor == null) { // engine created without an executor: let the consumer manage its own - return new SpringBootPlatformHttpConsumer(endpoint, processor); + consumer = new SpringBootPlatformHttpConsumer(endpoint, processor); + } else { + consumer = new SpringBootPlatformHttpConsumer(endpoint, processor, executor); } - return new SpringBootPlatformHttpConsumer(endpoint, processor, executor); + consumer.setDeleteUploadedFilesOnEnd(deleteUploadedFilesOnEnd); + return consumer; } @Override diff --git a/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpServerProperties.java b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpServerProperties.java new file mode 100644 index 000000000000..f4d0e0d2c34e --- /dev/null +++ b/components-starter/camel-platform-http-starter/src/main/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpServerProperties.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * 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 org.apache.camel.component.platform.http.springboot; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for the Spring Boot HTTP server serving the platform-http endpoints. + */ +@ConfigurationProperties(prefix = "camel.component.platform-http.server") +public class SpringBootPlatformHttpServerProperties { + + /** + * Whether the temporary files, that multipart file uploads are written to, are deleted when the exchange is done + * being routed. The uploaded file is copied out of the servlet container into the servlet temp directory so that + * it stays readable after the HTTP request has completed, which makes Camel the owner of that copy. Turn this off + * only if the route hands the file over to something that reads it after the exchange has completed - the route is + * then responsible for deleting the file. + */ + private boolean deleteUploadedFilesOnEnd = true; + + public boolean isDeleteUploadedFilesOnEnd() { + return deleteUploadedFilesOnEnd; + } + + public void setDeleteUploadedFilesOnEnd(boolean deleteUploadedFilesOnEnd) { + this.deleteUploadedFilesOnEnd = deleteUploadedFilesOnEnd; + } +} diff --git a/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpUploadCleanupDisabledTest.java b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpUploadCleanupDisabledTest.java new file mode 100644 index 000000000000..c01d5c7476d7 --- /dev/null +++ b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpUploadCleanupDisabledTest.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * 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 org.apache.camel.component.platform.http.springboot; + +import io.restassured.RestAssured; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.spring.boot.CamelAutoConfiguration; +import org.apache.camel.test.spring.junit6.CamelSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; + +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the opt-out: with + * {@code camel.component.platform-http.server.delete-uploaded-files-on-end=false} the temporary copy of the upload + * survives the exchange and the route is responsible for it. + */ +@EnableAutoConfiguration +@CamelSpringBootTest +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "camel.component.platform-http.server.delete-uploaded-files-on-end=false", + classes = { CamelAutoConfiguration.class, + SpringBootPlatformHttpUploadCleanupDisabledTest.class, + SpringBootPlatformHttpUploadCleanupDisabledTest.TestConfiguration.class, + PlatformHttpComponentAutoConfiguration.class, SpringBootPlatformHttpAutoConfiguration.class }) +public class SpringBootPlatformHttpUploadCleanupDisabledTest { + + private static final byte[] CONTENT = "upload content".getBytes(StandardCharsets.UTF_8); + + @Autowired + private Environment env; + + @BeforeEach + void setUp() { + RestAssured.port = env.getRequiredProperty("local.server.port", Integer.class); + } + + @Test + void uploadIsKeptWhenCleanupIsTurnedOff() throws IOException { + String uploadPath = given().multiPart("file", "invoice.txt", CONTENT) + .post("/upload") + .then() + .statusCode(200) + .header(UploadCleanupRoute.ATTACHMENT_COUNT, is("1")) + .header(UploadCleanupRoute.EXISTED_DURING_ROUTING, is("true")) + .extract() + .header(UploadCleanupRoute.UPLOAD_PATHS); + + Path uploadedFile = Paths.get(uploadPath); + try { + // give any (unwanted) completion driven deletion time to happen before asserting the file is still there + await().pollDelay(Duration.ofMillis(500)) + .atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertTrue(Files.exists(uploadedFile), + "Uploaded temporary file should have been kept: " + uploadedFile)); + } finally { + // the application owns the file when the cleanup is turned off, so do not leave it behind + Files.deleteIfExists(uploadedFile); + } + } + + @Configuration + public static class TestConfiguration { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .csrf(AbstractHttpConfigurer::disable); + return http.build(); + } + + @Bean + public RouteBuilder uploadCleanupRoute() { + return new UploadCleanupRoute(); + } + } +} diff --git a/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpUploadCleanupTest.java b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpUploadCleanupTest.java new file mode 100644 index 000000000000..c882e737681e --- /dev/null +++ b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/SpringBootPlatformHttpUploadCleanupTest.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * 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 org.apache.camel.component.platform.http.springboot; + +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.spring.boot.CamelAutoConfiguration; +import org.apache.camel.test.spring.junit6.CamelSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.TimeUnit; + +import static io.restassured.RestAssured.given; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * Multipart uploads are copied out of the servlet container into the servlet temp directory, which makes Camel the + * owner of the copy. Verifies the copy is readable while the exchange is routed and is deleted once the exchange is + * done, which is the default behaviour. + */ +@EnableAutoConfiguration +@CamelSpringBootTest +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = { CamelAutoConfiguration.class, + SpringBootPlatformHttpUploadCleanupTest.class, + SpringBootPlatformHttpUploadCleanupTest.TestConfiguration.class, + PlatformHttpComponentAutoConfiguration.class, SpringBootPlatformHttpAutoConfiguration.class }) +public class SpringBootPlatformHttpUploadCleanupTest { + + private static final byte[] CONTENT = "upload content".getBytes(StandardCharsets.UTF_8); + + @Autowired + private Environment env; + + @BeforeEach + void setUp() { + RestAssured.port = env.getRequiredProperty("local.server.port", Integer.class); + } + + @Test + void singleUploadIsDeletedWhenTheExchangeIsDone() { + ExtractableResponse response = given().multiPart("file", "invoice.txt", CONTENT) + .post("/upload") + .then() + .statusCode(200) + .header(UploadCleanupRoute.ATTACHMENT_COUNT, is("1")) + .header(UploadCleanupRoute.EXISTED_DURING_ROUTING, is("true")) + .extract(); + + String uploadPath = response.header(UploadCleanupRoute.UPLOAD_PATHS); + // the single upload is also handed to the route as the message body and as the CamelFilePath header + assertEquals(uploadPath, response.header(UploadCleanupRoute.FILE_PATH_HEADER)); + assertEquals(uploadPath, response.header(UploadCleanupRoute.BODY_PATH)); + + Path uploadedFile = Paths.get(uploadPath); + await().atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> assertFalse(Files.exists(uploadedFile), + "Uploaded temporary file should have been deleted: " + uploadedFile)); + } + + @Test + void allUploadsAreDeletedWhenTheExchangeIsDone() { + String uploadPaths = given().multiPart("first", "first.txt", CONTENT) + .multiPart("second", "second.txt", CONTENT) + .post("/upload") + .then() + .statusCode(200) + .header(UploadCleanupRoute.ATTACHMENT_COUNT, is("2")) + .header(UploadCleanupRoute.EXISTED_DURING_ROUTING, is("true")) + .extract() + .header(UploadCleanupRoute.UPLOAD_PATHS); + + String[] paths = uploadPaths.split(","); + assertEquals(2, paths.length, "Expected both uploads to be reported: " + uploadPaths); + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + for (String path : paths) { + assertFalse(Files.exists(Paths.get(path)), "Uploaded temporary file should have been deleted: " + path); + } + }); + } + + /** + * A multipart request that carries no file part registers no cleanup and keeps working. + */ + @Test + void requestWithoutFilePartIsNotAffected() { + given().multiPart("field", "value") + .post("/upload") + .then() + .statusCode(200) + .header(UploadCleanupRoute.ATTACHMENT_COUNT, is("0")); + } + + @Configuration + public static class TestConfiguration { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .csrf(AbstractHttpConfigurer::disable); + return http.build(); + } + + @Bean + public RouteBuilder uploadCleanupRoute() { + return new UploadCleanupRoute(); + } + } +} diff --git a/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/UploadCleanupRoute.java b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/UploadCleanupRoute.java new file mode 100644 index 000000000000..c435f9b3839a --- /dev/null +++ b/components-starter/camel-platform-http-starter/src/test/java/org/apache/camel/component/platform/http/springboot/UploadCleanupRoute.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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 + * + * 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 org.apache.camel.component.platform.http.springboot; + +import jakarta.activation.DataHandler; +import jakarta.activation.FileDataSource; +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.attachment.AttachmentMessage; +import org.apache.camel.builder.RouteBuilder; + +import java.io.File; +import java.nio.file.Path; + +/** + * Reports back, in the response headers, where the accepted multipart uploads were written to and whether they were + * still readable while the exchange was being routed. Used to assert the temporary file handling of + * {@link SpringBootPlatformHttpBinding}. + */ +public class UploadCleanupRoute extends RouteBuilder { + + static final String UPLOAD_PATHS = "uploadPaths"; + static final String ATTACHMENT_COUNT = "attachmentCount"; + static final String EXISTED_DURING_ROUTING = "existedDuringRouting"; + static final String FILE_PATH_HEADER = "filePathHeader"; + static final String BODY_PATH = "bodyPath"; + + @Override + public void configure() { + from("platform-http:/upload") + .routeId("upload") + .process(exchange -> { + AttachmentMessage am = exchange.getMessage(AttachmentMessage.class); + StringBuilder paths = new StringBuilder(); + boolean existed = true; + int count = 0; + if (am.getAttachments() != null) { + for (DataHandler dataHandler : am.getAttachments().values()) { + File file = ((FileDataSource) dataHandler.getDataSource()).getFile(); + if (count > 0) { + paths.append(','); + } + paths.append(file.getAbsolutePath()); + existed = existed && file.isFile(); + count++; + } + } + + Message message = exchange.getMessage(); + Object body = message.getBody(); + Object filePath = message.getHeader(Exchange.FILE_PATH); + message.setHeader(ATTACHMENT_COUNT, String.valueOf(count)); + message.setHeader(UPLOAD_PATHS, paths.toString()); + message.setHeader(EXISTED_DURING_ROUTING, String.valueOf(existed)); + message.setHeader(FILE_PATH_HEADER, filePath == null ? "" : filePath.toString()); + message.setHeader(BODY_PATH, body instanceof Path path ? path.toAbsolutePath().toString() : ""); + message.setBody("ok"); + }); + } +} diff --git a/docs/spring-boot/modules/ROOT/pages/starters/platform-http.adoc b/docs/spring-boot/modules/ROOT/pages/starters/platform-http.adoc index b3449d246377..4e0cdc8faa69 100644 --- a/docs/spring-boot/modules/ROOT/pages/starters/platform-http.adoc +++ b/docs/spring-boot/modules/ROOT/pages/starters/platform-http.adoc @@ -23,7 +23,7 @@ Please refer to the above links for usage and configuration details. == Spring Boot Auto-Configuration -The starter supports 9 options, which are listed below. +The starter supports 10 options, which are listed below. [width="100%",cols="2,5,^1,2",options="header"] |=== @@ -36,5 +36,6 @@ The starter supports 9 options, which are listed below. | camel.component.platform-http.header-filter-strategy | To use a custom org.apache.camel.spi.HeaderFilterStrategy to filter header to and from Camel message. The option is a org.apache.camel.spi.HeaderFilterStrategy type. | | HeaderFilterStrategy | camel.component.platform-http.request-timeout | The period in milliseconds after which the request should be timed out. | | Long | camel.component.platform-http.server-request-validation | Whether HTTP server should do preliminary validation of incoming requests, validating if Content-Type/Accept header, matches what is allowed according to consumes/produces configuration (if set). If validation fails HTTP Status 415/406 is returned. The HTTP server performs this validation before Camel is involved, and as such if validation fails then Camel is never activated. Setting this option to false, allows Camel to process any incoming requests such as to do custom validation or all requests must be handled by Camel. | true | Boolean +| camel.component.platform-http.server.delete-uploaded-files-on-end | Whether the temporary files, that multipart file uploads are written to, are deleted when the exchange is done being routed. The uploaded file is copied out of the servlet container into the servlet temp directory so that it stays readable after the HTTP request has completed, which makes Camel the owner of that copy. Turn this off only if the route hands the file over to something that reads it after the exchange has completed - the route is then responsible for deleting the file. | true | Boolean | camel.component.platform-http.server.undertow.accesslog.use-camel-logging | Use camel logging for undertow http access log. | false | Boolean |===