diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java new file mode 100644 index 00000000000..454a7408c95 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java @@ -0,0 +1,80 @@ +package org.springframework.boot.autoconfigure.web.reactive; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Paths; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.web.codec.CodecCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.codec.CodecConfigurer; +import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader; +import org.springframework.util.unit.DataSize; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for multipart support in Spring + * Webflux. + *
+ * Configures the {@link DefaultPartHttpMessageReader} via a {@link CodecCustomizer}.
+ *
+ * @author Chris Bono
+ * @since 2.6.0
+ */
+@Configuration(proxyBeanMethods = false)
+@ConditionalOnClass({ CodecConfigurer.class, DefaultPartHttpMessageReader.class })
+@ConditionalOnWebApplication(type = Type.REACTIVE)
+@EnableConfigurationProperties(ReactiveMultipartProperties.class)
+class ReactiveMultipartAutoConfiguration {
+
+ @Bean
+ @Order(1)
+ CodecCustomizer defaultPartHttpMessageReaderCustomizer(ReactiveMultipartProperties multipartProperties) {
+ return (configurer) -> configurer.defaultCodecs().configureDefaultCodec((codec) -> {
+ if (!DefaultPartHttpMessageReader.class.isInstance(codec)) {
+ return;
+ }
+ DefaultPartHttpMessageReader defaultPartHttpMessageReader = (DefaultPartHttpMessageReader) codec;
+ boolean streaming = multipartProperties.getStreaming() != null && multipartProperties.getStreaming();
+ boolean unlimitedMaxMemorySize = multipartProperties.getMaxInMemorySize() != null
+ && multipartProperties.getMaxInMemorySize().toBytes() == -1;
+ PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
+ map.from(multipartProperties::getStreaming).to(defaultPartHttpMessageReader::setStreaming);
+ if (!streaming) {
+ map.from(multipartProperties::getMaxInMemorySize).asInt(this::convertToBytes)
+ .to(defaultPartHttpMessageReader::setMaxInMemorySize);
+ }
+ if (!streaming && !unlimitedMaxMemorySize) {
+ map.from(multipartProperties::getMaxDiskUsagePerPart).as(this::convertToBytes)
+ .to(defaultPartHttpMessageReader::setMaxDiskUsagePerPart);
+ map.from(multipartProperties::getFileStorageDirectory)
+ .to(dir -> setFileStorageDirectory(dir, defaultPartHttpMessageReader));
+ }
+ map.from(multipartProperties::getMaxParts).to(defaultPartHttpMessageReader::setMaxParts);
+ map.from(multipartProperties::getMaxHeadersSize).asInt(this::convertToBytes)
+ .to(defaultPartHttpMessageReader::setMaxHeadersSize);
+ map.from(multipartProperties::getHeadersCharset).to(defaultPartHttpMessageReader::setHeadersCharset);
+ });
+ }
+
+ private void setFileStorageDirectory(String fileStorageDirectory,
+ DefaultPartHttpMessageReader defaultPartHttpMessageReader) {
+ try {
+ defaultPartHttpMessageReader.setFileStorageDirectory(Paths.get(fileStorageDirectory));
+ }
+ catch (IOException ex) {
+ throw new UncheckedIOException(ex);
+ }
+ }
+
+ private Long convertToBytes(DataSize size) {
+ return (size != null) ? size.toBytes() : null;
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java
new file mode 100644
index 00000000000..36392e9b725
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartProperties.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2012-2021 the original author or authors.
+ *
+ * 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
+ *
+ * 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.springframework.boot.autoconfigure.web.reactive;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader;
+import org.springframework.util.unit.DataSize;
+
+/**
+ * {@link ConfigurationProperties Configuration properties} for configuring multipart
+ * support in Spring Webflux. Used to configure the {@link DefaultPartHttpMessageReader}.
+ *
+ * @author Chris Bono
+ * @since 2.6.0
+ */
+@ConfigurationProperties(prefix = "spring.webflux.multipart")
+public class ReactiveMultipartProperties {
+
+ /**
+ * Maximum amount of memory allowed per part. Set to -1 to store all contents in
+ * memory. Ignored when streaming is enabled.
+ */
+ private DataSize maxInMemorySize = DataSize.ofKilobytes(256);
+
+ /**
+ * Maximum amount of memory allowed per headers section of each part. Set to -1 to
+ * enforce no limits.
+ */
+ private DataSize maxHeadersSize = DataSize.ofKilobytes(8);
+
+ /**
+ * Maximum amount of disk space allowed per part. Default is -1 which enforces no
+ * limits. Ignored when streaming is enabled or 'maxInMemorySize' is set to -1.
+ */
+ private DataSize maxDiskUsagePerPart = DataSize.ofBytes(-1);
+
+ /**
+ * Maximum number of parts allowed in a given multipart request. Default is -1 which
+ * enforces no limits.
+ */
+ private Integer maxParts = -1;
+
+ /**
+ * Whether or not to stream directly from the parsed input buffer stream without
+ * storing in memory nor file. Default is non-streaming.
+ */
+ private Boolean streaming = Boolean.FALSE;
+
+ /**
+ * Directory used to store parts larger than 'maxInMemorySize'. Default is a directory
+ * named 'spring-multipart' created under the system temporary directory. Ignored when
+ * streaming is enabled or 'maxInMemorySize' is set to -1.
+ */
+ private String fileStorageDirectory;
+
+ /**
+ * Character set used to decode headers.
+ */
+ private Charset headersCharset = StandardCharsets.UTF_8;
+
+ public DataSize getMaxInMemorySize() {
+ return maxInMemorySize;
+ }
+
+ public void setMaxInMemorySize(DataSize maxInMemorySize) {
+ this.maxInMemorySize = maxInMemorySize;
+ }
+
+ public DataSize getMaxHeadersSize() {
+ return maxHeadersSize;
+ }
+
+ public void setMaxHeadersSize(DataSize maxHeadersSize) {
+ this.maxHeadersSize = maxHeadersSize;
+ }
+
+ public DataSize getMaxDiskUsagePerPart() {
+ return maxDiskUsagePerPart;
+ }
+
+ public void setMaxDiskUsagePerPart(DataSize maxDiskUsagePerPart) {
+ this.maxDiskUsagePerPart = maxDiskUsagePerPart;
+ }
+
+ public Integer getMaxParts() {
+ return maxParts;
+ }
+
+ public void setMaxParts(Integer maxParts) {
+ this.maxParts = maxParts;
+ }
+
+ public Boolean getStreaming() {
+ return streaming;
+ }
+
+ public void setStreaming(Boolean streaming) {
+ this.streaming = streaming;
+ }
+
+ public String getFileStorageDirectory() {
+ return fileStorageDirectory;
+ }
+
+ public void setFileStorageDirectory(String fileStorageDirectory) {
+ this.fileStorageDirectory = fileStorageDirectory;
+ }
+
+ public Charset getHeadersCharset() {
+ return headersCharset;
+ }
+
+ public void setHeadersCharset(Charset headersCharset) {
+ this.headersCharset = headersCharset;
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java
index c6f369543f8..d9ce69ead68 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java
@@ -91,6 +91,7 @@ import org.springframework.web.server.session.WebSessionManager;
* @author Phillip Webb
* @author EddĂș MelĂ©ndez
* @author Artsiom Yudovin
+ * @author Chris Bono
* @since 2.0.0
*/
@Configuration(proxyBeanMethods = false)
@@ -98,7 +99,7 @@ import org.springframework.web.server.session.WebSessionManager;
@ConditionalOnClass(WebFluxConfigurer.class)
@ConditionalOnMissingBean({ WebFluxConfigurationSupport.class })
@AutoConfigureAfter({ ReactiveWebServerFactoryAutoConfiguration.class, CodecsAutoConfiguration.class,
- ValidationAutoConfiguration.class })
+ ReactiveMultipartAutoConfiguration.class, ValidationAutoConfiguration.class })
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
public class WebFluxAutoConfiguration {
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories
index 33b91643fd5..305690f0bab 100644
--- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories
+++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories
@@ -139,6 +139,7 @@ org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration,
org.springframework.boot.autoconfigure.web.embedded.EmbeddedWebServerFactoryCustomizerAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration,\
+org.springframework.boot.autoconfigure.web.reactive.ReactiveMultipartAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.function.client.ClientHttpConnectorAutoConfiguration,\
diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java
new file mode 100644
index 00000000000..5055cfe7d9a
--- /dev/null
+++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright 2012-2021 the original author or authors.
+ *
+ * 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
+ *
+ * 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.springframework.boot.autoconfigure.web.reactive;
+
+import java.lang.reflect.Method;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.assertj.core.api.InstanceOfAssertFactories;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.FilteredClassLoader;
+import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
+import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
+import org.springframework.boot.web.codec.CodecCustomizer;
+import org.springframework.core.annotation.AnnotationAwareOrderComparator;
+import org.springframework.http.codec.CodecConfigurer;
+import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader;
+import org.springframework.http.codec.support.DefaultServerCodecConfigurer;
+import org.springframework.util.ReflectionUtils;
+import org.springframework.util.unit.DataSize;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ReactiveMultipartAutoConfiguration}.
+ *
+ * @author Chris Bono
+ */
+class ReactiveMultipartAutoConfigurationTests {
+
+ private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(ReactiveMultipartAutoConfiguration.class));
+
+ private static final boolean DEFAULT_STREAMING = false;
+
+ private static final int DEFAULT_MAX_IN_MEMORY_SIZE = 256 * 1024;
+
+ private static final int DEFAULT_MAX_PARTS = -1;
+
+ private static final int DEFAULT_MAX_HEADERS_SIZE = 8 * 1024;
+
+ private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
+
+ private static final long DEFAULT_MAX_DISK_USAGE_PER_PART = -1;
+
+ private static final Path DEFAULT_FILE_STORAGE_DIRECTORY = Paths.get(System.getProperty("java.io.tmpdir"),
+ "spring-multipart");
+
+ @Test
+ void shouldNotProvideCustomizerForNonReactiveApp() {
+ new WebApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(ReactiveMultipartAutoConfiguration.class))
+ .run((context) -> assertThat(context).doesNotHaveBean(CodecCustomizer.class));
+ }
+
+ @Test
+ void shouldNotProvideCustomizerWhenDefaultPartHttpMessageReaderNotAvailable() {
+ this.contextRunner.withClassLoader(new FilteredClassLoader(DefaultPartHttpMessageReader.class))
+ .run((context) -> assertThat(context).doesNotHaveBean(CodecCustomizer.class));
+ }
+
+ @Test
+ void shouldNotProvideCustomizerWhenCodecConfigurerNotAvailable() {
+ this.contextRunner.withClassLoader(new FilteredClassLoader(CodecConfigurer.class))
+ .run((context) -> assertThat(context).doesNotHaveBean(CodecCustomizer.class));
+ }
+
+ @Test
+ void customizerSetsAppropriatePropsWhenStreamingEnabled() {
+ this.contextRunner.withPropertyValues("spring.webflux.multipart.streaming:true",
+ "spring.webflux.multipart.max-in-memory-size=1GB", "spring.webflux.multipart.max-headers-size=512MB",
+ "spring.webflux.multipart.max-disk-usage-per-part=100MB", "spring.webflux.multipart.max-parts=7",
+ "spring.webflux.multipart.file-storage-directory:.", "spring.webflux.multipart.headers-charset:UTF_16")
+ .run((context) -> {
+ CodecCustomizer customizer = context.getBean(CodecCustomizer.class);
+ DefaultServerCodecConfigurer configurer = new DefaultServerCodecConfigurer();
+ customizer.customize(configurer);
+ DefaultPartHttpMessageReader partReader = getPartReader(configurer);
+
+ // always set
+ assertThat(partReader).hasFieldOrPropertyWithValue("streaming", true);
+ assertThat(partReader).hasFieldOrPropertyWithValue("maxParts", 7);
+ assertThat(partReader).hasFieldOrPropertyWithValue("maxHeadersSize",
+ toIntBytes(DataSize.ofMegabytes(512)));
+ assertThat(partReader).hasFieldOrPropertyWithValue("headersCharset", StandardCharsets.UTF_16);
+
+ // never set when streaming
+ assertThat(partReader).hasFieldOrPropertyWithValue("maxInMemorySize", DEFAULT_MAX_IN_MEMORY_SIZE);
+ assertThat(partReader).hasFieldOrPropertyWithValue("maxDiskUsagePerPart",
+ DEFAULT_MAX_DISK_USAGE_PER_PART);
+ assertThat(partReader).extracting("fileStorageDirectory")
+ .asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
+ .returns(DEFAULT_FILE_STORAGE_DIRECTORY, Mono