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 0000000000..dd3bd1e874 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfiguration.java @@ -0,0 +1,83 @@ +/* + * 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.io.IOException; +import java.nio.file.Path; +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.multipart.DefaultPartHttpMessageReader; +import org.springframework.util.unit.DataSize; +import org.springframework.web.reactive.config.WebFluxConfigurer; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for multipart support in Spring + * WebFlux. + * + * @author Chris Bono + * @author Brian Clozel + * @since 2.6.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ DefaultPartHttpMessageReader.class, WebFluxConfigurer.class }) +@ConditionalOnWebApplication(type = Type.REACTIVE) +@EnableConfigurationProperties(ReactiveMultipartProperties.class) +public class ReactiveMultipartAutoConfiguration { + + @Bean + @Order(0) + CodecCustomizer defaultPartHttpMessageReaderCustomizer(ReactiveMultipartProperties multipartProperties) { + return (configurer) -> configurer.defaultCodecs().configureDefaultCodec((codec) -> { + if (codec instanceof DefaultPartHttpMessageReader) { + DefaultPartHttpMessageReader defaultPartHttpMessageReader = (DefaultPartHttpMessageReader) codec; + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(multipartProperties::getMaxInMemorySize).asInt(DataSize::toBytes) + .to(defaultPartHttpMessageReader::setMaxInMemorySize); + map.from(multipartProperties::getMaxHeadersSize).asInt(DataSize::toBytes) + .to(defaultPartHttpMessageReader::setMaxHeadersSize); + map.from(multipartProperties::getMaxDiskUsagePerPart).asInt(DataSize::toBytes) + .to(defaultPartHttpMessageReader::setMaxDiskUsagePerPart); + map.from(multipartProperties::getMaxParts).to(defaultPartHttpMessageReader::setMaxParts); + map.from(multipartProperties::getStreaming).to(defaultPartHttpMessageReader::setStreaming); + map.from(multipartProperties::getFileStorageDirectory).as(Paths::get) + .to((dir) -> configureFileStorageDirectory(defaultPartHttpMessageReader, dir)); + map.from(multipartProperties::getHeadersCharset).to(defaultPartHttpMessageReader::setHeadersCharset); + } + }); + } + + private void configureFileStorageDirectory(DefaultPartHttpMessageReader defaultPartHttpMessageReader, + Path fileStorageDirectory) { + try { + defaultPartHttpMessageReader.setFileStorageDirectory(fileStorageDirectory); + } + catch (IOException ex) { + throw new IllegalStateException("Failed to configure multipart file storage directory", ex); + } + } + +} 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 0000000000..bf06bc926c --- /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 before it's written to disk. 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. + */ + 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 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 file parts larger than 'maxInMemorySize'. Default is a + * directory named 'spring-multipart' created under the system temporary directory. + * Ignored when streaming is enabled. + */ + private String fileStorageDirectory; + + /** + * Character set used to decode headers. + */ + private Charset headersCharset = StandardCharsets.UTF_8; + + public DataSize getMaxInMemorySize() { + return this.maxInMemorySize; + } + + public void setMaxInMemorySize(DataSize maxInMemorySize) { + this.maxInMemorySize = maxInMemorySize; + } + + public DataSize getMaxHeadersSize() { + return this.maxHeadersSize; + } + + public void setMaxHeadersSize(DataSize maxHeadersSize) { + this.maxHeadersSize = maxHeadersSize; + } + + public DataSize getMaxDiskUsagePerPart() { + return this.maxDiskUsagePerPart; + } + + public void setMaxDiskUsagePerPart(DataSize maxDiskUsagePerPart) { + this.maxDiskUsagePerPart = maxDiskUsagePerPart; + } + + public Integer getMaxParts() { + return this.maxParts; + } + + public void setMaxParts(Integer maxParts) { + this.maxParts = maxParts; + } + + public Boolean getStreaming() { + return this.streaming; + } + + public void setStreaming(Boolean streaming) { + this.streaming = streaming; + } + + public String getFileStorageDirectory() { + return this.fileStorageDirectory; + } + + public void setFileStorageDirectory(String fileStorageDirectory) { + this.fileStorageDirectory = fileStorageDirectory; + } + + public Charset getHeadersCharset() { + return this.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 c6f369543f..d9ce69ead6 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 33b91643fd..ab1d2ac664 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 @@ -138,6 +138,7 @@ org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration,\ 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.ReactiveMultipartAutoConfiguration,\ org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration,\ org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration,\ org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration,\ 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 0000000000..27c68bcf80 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartAutoConfigurationTests.java @@ -0,0 +1,91 @@ +/* + * 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.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; + +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.http.codec.multipart.DefaultPartHttpMessageReader; +import org.springframework.http.codec.support.DefaultServerCodecConfigurer; +import org.springframework.util.unit.DataSize; +import org.springframework.web.reactive.config.WebFluxConfigurer; + +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 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 shouldNotProvideCustomizerWhenWebFluxNotAvailable() { + this.contextRunner.withClassLoader(new FilteredClassLoader(WebFluxConfigurer.class)) + .run((context) -> assertThat(context).doesNotHaveBean(CodecCustomizer.class)); + } + + @Test + void shouldConfigureMultipartProperties() { + this.contextRunner.withPropertyValues("spring.webflux.multipart.streaming:true", + "spring.webflux.multipart.max-in-memory-size=1GB", "spring.webflux.multipart.max-headers-size=16KB", + "spring.webflux.multipart.max-disk-usage-per-part=100MB", "spring.webflux.multipart.max-parts=7", + "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); + assertThat(partReader).hasFieldOrPropertyWithValue("streaming", true); + assertThat(partReader).hasFieldOrPropertyWithValue("maxParts", 7); + assertThat(partReader).hasFieldOrPropertyWithValue("maxHeadersSize", + Math.toIntExact(DataSize.ofKilobytes(16).toBytes())); + assertThat(partReader).hasFieldOrPropertyWithValue("headersCharset", StandardCharsets.UTF_16); + assertThat(partReader).hasFieldOrPropertyWithValue("maxInMemorySize", + Math.toIntExact(DataSize.ofGigabytes(1).toBytes())); + assertThat(partReader).hasFieldOrPropertyWithValue("maxDiskUsagePerPart", + DataSize.ofMegabytes(100).toBytes()); + }); + } + + private DefaultPartHttpMessageReader getPartReader(DefaultServerCodecConfigurer codecConfigurer) { + return codecConfigurer.getReaders().stream().filter(DefaultPartHttpMessageReader.class::isInstance) + .map(DefaultPartHttpMessageReader.class::cast).findFirst() + .orElseThrow(() -> new IllegalStateException("Could not find DefaultPartHttpMessageReader")); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartPropertiesTests.java new file mode 100644 index 0000000000..f9815d619f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/ReactiveMultipartPropertiesTests.java @@ -0,0 +1,46 @@ +/* + * 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 org.junit.jupiter.api.Test; +import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ReactiveMultipartProperties} + * + * @author Chris Bono + */ +class ReactiveMultipartPropertiesTests { + + @Test + void defaultValuesAreConsistent() { + ReactiveMultipartProperties multipartProperties = new ReactiveMultipartProperties(); + DefaultPartHttpMessageReader defaultPartHttpMessageReader = new DefaultPartHttpMessageReader(); + assertThat(defaultPartHttpMessageReader).hasFieldOrPropertyWithValue("streaming", + multipartProperties.getStreaming()); + assertThat(defaultPartHttpMessageReader).hasFieldOrPropertyWithValue("maxInMemorySize", + (int) multipartProperties.getMaxInMemorySize().toBytes()); + assertThat(defaultPartHttpMessageReader).hasFieldOrPropertyWithValue("maxHeadersSize", + (int) multipartProperties.getMaxHeadersSize().toBytes()); + assertThat(defaultPartHttpMessageReader).hasFieldOrPropertyWithValue("maxDiskUsagePerPart", + multipartProperties.getMaxDiskUsagePerPart().toBytes()); + assertThat(defaultPartHttpMessageReader).hasFieldOrPropertyWithValue("maxParts", + multipartProperties.getMaxParts()); + } + +}