Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Executor> executors) {
List<Executor> executors,
SpringBootPlatformHttpServerProperties serverHttpProperties) {
if (executors == null || executors.isEmpty()) {
throw new IllegalStateException("No Executor configured");
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<Method> METHODS_WITH_BODY_ALLOWED = List.of(Method.POST,
Method.PUT, Method.PATCH, Method.DELETE);
Expand Down Expand Up @@ -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<Path> uploadedTmpFiles = new ArrayList<>();
multipartHttpServletRequest.getFileMap().forEach((name, multipartFile) -> {
try {
if (name != null) {
Expand All @@ -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();
Expand All @@ -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.
* <p/>
* 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<Path> 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);
}
}
}
});
}

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Loading
Loading