diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/io/rest-client.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/io/rest-client.adoc index e26736c9a2c..0863dc99734 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/io/rest-client.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/io/rest-client.adoc @@ -277,3 +277,32 @@ For example, the following will use a JDK client configured with a specific java include-code::MyClientHttpConfiguration[] + + +[[io.rest-client.apiversioning]] +== API Versioning + +Both `WebClient` and `RestClient` support making versioned remote HTTP calls so that APIs can be evolved over time. +Commonly this involves sending an HTTP header, a query parameter or URL path segment that indicates the version of the API that should be used. + +You can configure API versioning using methods on `WebClient.Builder` or `RestClient.Builder`. +You can also the `spring.http.reactiveclient.webclient.apiversion` or `spring.http.client.restclient.apiversion` properties if you want to apply the same configuration to all builders. + +For example, the following adds an `X-Version` HTTP header to all calls from the `RestClient` and uses the version `1.0.0` unless overridden for specific requests: + +[configprops,yaml] +---- +spring: + http: + client: + restclient: + apiversion: + default: 1.0.0 + insert: + header: X-Version +---- + +You can also defined javadoc:org.springframework.web.client.ApiVersionInserter[] and javadoc:org.springframework.web.client.ApiVersionFormatter[] beans if you need more control of the way that version information should be inserted and formatted. + +TIP: API versioning is also supported on the server-side. +See the xref:web/servlet.adoc#web.servlet.spring-mvc.api-versioning[Spring MVC] and xref:web/reactive.adoc#web.reactive.webflux.api-versioning[Spring WebFlux] sections for details. diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/reactive.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/reactive.adoc index 79101081656..442acee3ade 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/reactive.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/reactive.adoc @@ -275,6 +275,38 @@ When it does so, the orders shown in the following table will be used: +[[web.reactive.webflux.api-versioning]] +=== API Versioning + +Spring WebFlux supports API versioning which can be used to evolve an HTTP API over time. +The same `@Controller` path can be mapped multiple times to support different versions of the API. + +For more details see {url-spring-framework-docs}/web/webflux/controller/ann-requestmapping.html#webflux-ann-requestmapping-version[Spring Framework's reference documentation]. + +One mappings have been added, you additionally need to configure Spring WebFlux so that it is able to use any version information sent with a request. +Typically, versions are sent as HTTP headers, query parameters or as part of the path. + +To configure Spring WebFlux, you can either use a javadoc:org.springframework.web.reactive.config.WebFluxConfigurer[] bean and override the `configureApiVersioning(...)` method, or you can use properties. + +For example, the following will use an `X-Version` HTTP header to obtain version information and default to `1.0.0` when no header is sent. + +[configprops,yaml] +---- +spring: + webflux: + apiversion: + default: 1.0.0 + use: + header: X-Version +---- + +For more complete control, you can also define javadoc:org.springframework.web.reactive.accept.ApiVersionResolver[], javadoc:org.springframework.web.reactive.accept.ApiVersionParser[] and javadoc:org.springframework.web.reactive.accept.ApiVersionDeprecationHandler[] beans which will be injected into the auto-configured Spring MVC configuration. + +TIP: API versioning is also supported on the client-side with both `WebClient` and `RestClient`. +See xref:io/rest-client.adoc#io.rest-client.apiversioning[] for details. + + + [[web.reactive.reactive-server]] == Embedded Reactive Server Support diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc index e411e5c2bb1..3dffba49ef2 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc @@ -465,6 +465,38 @@ include-code::MyCorsConfiguration[] +[[web.servlet.spring-mvc.api-versioning]] +=== API Versioning + +Spring MVC supports API versioning which can be used to evolve an HTTP API over time. +The same `@Controller` path can be mapped multiple times to support different versions of the API. + +For more details see {url-spring-framework-docs}/web/webmvc/mvc-controller/ann-requestmapping.html#mvc-ann-requestmapping-version[Spring Framework's reference documentation]. + +One mappings have been added, you additionally need to configure Spring MVC so that it is able to use any version information sent with a request. +Typically, versions are sent as HTTP headers, query parameters or as part of the path. + +To configure Spring MVC, you can either use a javadoc:org.springframework.web.servlet.config.annotation.WebMvcConfigurer[] bean and override the `configureApiVersioning(...)` method, or you can use properties. + +For example, the following will use an `X-Version` HTTP header to obtain version information and default to `1.0.0` when no header is sent. + +[configprops,yaml] +---- +spring: + mvc: + apiversion: + default: 1.0.0 + use: + header: X-Version +---- + +For more complete control, you can also define javadoc:org.springframework.web.accept.ApiVersionResolver[], javadoc:org.springframework.web.accept.ApiVersionParser[] and javadoc:org.springframework.web.accept.ApiVersionDeprecationHandler[] beans which will be injected into the auto-configured Spring MVC configuration. + +TIP: API versioning is also supported with both `WebClient` and `RestClient`. +See xref:io/rest-client.adoc#io.rest-client.apiversioning[] for details. + + + [[web.servlet.jersey]] == JAX-RS and Jersey diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/ApiversionProperties.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/ApiversionProperties.java new file mode 100644 index 00000000000..53002e03aa3 --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/ApiversionProperties.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-present 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.http.client.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationPropertiesSource; +import org.springframework.boot.context.properties.bind.Name; + +/** + * API Version properties for reactive and blocking HTTP Clients. + * + * @author Phillip Webb + * @since 4.0.0 + */ +@ConfigurationPropertiesSource +public class ApiversionProperties { + + /** + * Default version that should be used for each request. + */ + @Name("default") + private String defaultVersion; + + /** + * How version details should be inserted into requests. + */ + private final Insert insert = new Insert(); + + public String getDefaultVersion() { + return this.defaultVersion; + } + + public void setDefaultVersion(String defaultVersion) { + this.defaultVersion = defaultVersion; + } + + public Insert getInsert() { + return this.insert; + } + + @ConfigurationPropertiesSource + public static class Insert { + + /** + * Insert the version into a header with the given name. + */ + private String header; + + /** + * Insert the version into a query parameter with the given name. + */ + private String queryParameter; + + /** + * Insert the version into a path segment at the given index. + */ + private Integer pathSegment; + + public String getHeader() { + return this.header; + } + + public void setHeader(String header) { + this.header = header; + } + + public String getQueryParameter() { + return this.queryParameter; + } + + public void setQueryParameter(String queryParameter) { + this.queryParameter = queryParameter; + } + + public Integer getPathSegment() { + return this.pathSegment; + } + + public void setPathSegment(Integer pathSegment) { + this.pathSegment = pathSegment; + } + + } + +} diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/PropertiesApiVersionInserter.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/PropertiesApiVersionInserter.java new file mode 100644 index 00000000000..a80daa961d7 --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/PropertiesApiVersionInserter.java @@ -0,0 +1,127 @@ +/* + * Copyright 2012-present 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.http.client.autoconfigure; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.http.client.autoconfigure.ApiversionProperties.Insert; +import org.springframework.http.HttpHeaders; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; + +/** + * {@link ApiVersionInserter} to apply {@link ApiversionProperties}. + * + * @author Phillip Webb + * @since 4.0.0 + */ +public final class PropertiesApiVersionInserter implements ApiVersionInserter { + + private final List inserters; + + private PropertiesApiVersionInserter(List inserters) { + this.inserters = inserters; + } + + @Override + public URI insertVersion(Object version, URI uri) { + for (ApiVersionInserter delegate : this.inserters) { + uri = delegate.insertVersion(version, uri); + } + return uri; + } + + @Override + public void insertVersion(Object version, HttpHeaders headers) { + for (ApiVersionInserter delegate : this.inserters) { + delegate.insertVersion(version, headers); + } + } + + /** + * Factory method that returns an {@link ApiVersionInserter} to apply the given + * properties and delegate. + * @param apiVersionInserter a delegate {@link ApiVersionInserter} that should also + * apply (may be {@code null}) + * @param apiVersionFormatter the version formatter to use or {@code null} + * @param properties the properties that should be applied + * @return an {@link ApiVersionInserter} or {@code null} if no API version should be + * inserted + */ + public static ApiVersionInserter get(ApiVersionInserter apiVersionInserter, ApiVersionFormatter apiVersionFormatter, + ApiversionProperties... properties) { + return get(apiVersionInserter, apiVersionFormatter, Arrays.stream(properties)); + } + + /** + * Factory method that returns an {@link ApiVersionInserter} to apply the given + * properties and delegate. + * @param apiVersionInserter a delegate {@link ApiVersionInserter} that should also + * apply (may be {@code null}) + * @param apiVersionFormatter the version formatter to use or {@code null} + * @param propertiesStream the properties that should be applied + * @return an {@link ApiVersionInserter} or {@code null} if no API version should be + * inserted + */ + public static ApiVersionInserter get(ApiVersionInserter apiVersionInserter, ApiVersionFormatter apiVersionFormatter, + Stream propertiesStream) { + List inserters = new ArrayList<>(); + if (apiVersionInserter != null) { + inserters.add(apiVersionInserter); + } + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + propertiesStream.forEach((properties) -> { + if (properties != null && properties.getInsert() != null) { + Insert insert = properties.getInsert(); + Counter counter = new Counter(); + ApiVersionInserter.Builder builder = ApiVersionInserter.builder(); + map.from(apiVersionFormatter).to(builder::withVersionFormatter); + map.from(insert::getHeader).whenHasText().as(counter::counted).to(builder::useHeader); + map.from(insert::getQueryParameter).whenHasText().as(counter::counted).to(builder::useQueryParam); + map.from(insert::getPathSegment).as(counter::counted).to(builder::usePathSegment); + if (!counter.isEmpty()) { + inserters.add(builder.build()); + } + } + }); + return (!inserters.isEmpty()) ? new PropertiesApiVersionInserter(inserters) : null; + } + + /** + * Internal counter used to track if properties were applied. + */ + private static final class Counter { + + private boolean empty = true; + + T counted(T value) { + this.empty = false; + return value; + } + + boolean isEmpty() { + return this.empty; + } + + } + +} diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/PropertiesApiVersionInserterTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/PropertiesApiVersionInserterTests.java new file mode 100644 index 00000000000..ea95221361d --- /dev/null +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/PropertiesApiVersionInserterTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-present 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.http.client.autoconfigure; + +import java.net.URI; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PropertiesApiVersionInserter}. + * + * @author Phillip Webb + */ +class PropertiesApiVersionInserterTests { + + @Test + void getWhenEmptyPropertiesArrayAndNoDeleteReturnsNull() { + assertThat(PropertiesApiVersionInserter.get(null, null)).isNull(); + } + + @Test + void getWhenNoPropertiesAndNoDelegateReturnsNull() { + assertThat(PropertiesApiVersionInserter.get(null, null, new ApiversionProperties(), new ApiversionProperties())) + .isNull(); + } + + @Test + void getWhenNoPropertiesAndDelegateUsesDelegate() throws Exception { + ApiVersionInserter inserter = PropertiesApiVersionInserter.get(ApiVersionInserter.useQueryParam("v"), null); + URI uri = new URI("https://example.com"); + assertThat(inserter.insertVersion("123", uri)).hasToString("https://example.com?v=123"); + } + + @Test + void getReturnsInserterThatAppliesProperties() throws Exception { + ApiversionProperties properties1 = new ApiversionProperties(); + properties1.getInsert().setHeader("x-test"); + properties1.getInsert().setQueryParameter("v1"); + ApiversionProperties properties2 = new ApiversionProperties(); + properties2.getInsert().setQueryParameter("v2"); + properties2.getInsert().setPathSegment(1); + ApiVersionInserter inserter = PropertiesApiVersionInserter.get(null, null, properties1, properties2); + URI uri = new URI("https://example.com/foo/bar"); + assertThat(inserter.insertVersion("123", uri)).hasToString("https://example.com/foo/123/bar?v1=123&v2=123"); + HttpHeaders headers = new HttpHeaders(); + inserter.insertVersion("123", headers); + assertThat(headers.get("x-test")).containsExactly("123"); + } + + @Test + void getWhenHasDelegateReturnsInserterThatAppliesPropertiesAndDelegate() throws Exception { + ApiVersionInserter delegate = ApiVersionInserter.useQueryParam("d"); + ApiversionProperties properties = new ApiversionProperties(); + properties.getInsert().setQueryParameter("v"); + ApiVersionInserter inserter = PropertiesApiVersionInserter.get(delegate, null, properties); + assertThat(inserter.insertVersion("123", new URI("https://example.com"))) + .hasToString("https://example.com?d=123&v=123"); + } + + @Test + void getWhenHasFormatterAppliesToProperties() throws Exception { + ApiversionProperties properties1 = new ApiversionProperties(); + properties1.getInsert().setQueryParameter("v"); + ApiVersionFormatter formatter = (version) -> String.valueOf(version).toUpperCase(Locale.ROOT); + ApiVersionInserter inserter = PropertiesApiVersionInserter.get(null, formatter, properties1); + URI uri = new URI("https://example.com"); + assertThat(inserter.insertVersion("latest", uri)).hasToString("https://example.com?v=LATEST"); + } + +} diff --git a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/AbstractHttpClientServiceProperties.java b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/AbstractRestClientProperties.java similarity index 68% rename from module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/AbstractHttpClientServiceProperties.java rename to module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/AbstractRestClientProperties.java index 3f22869a65c..60fb70651e3 100644 --- a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/AbstractHttpClientServiceProperties.java +++ b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/AbstractRestClientProperties.java @@ -14,23 +14,27 @@ * limitations under the License. */ -package org.springframework.boot.restclient.autoconfigure.service; +package org.springframework.boot.restclient.autoconfigure; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.springframework.boot.context.properties.NestedConfigurationProperty; import org.springframework.boot.http.client.autoconfigure.AbstractHttpRequestFactoryProperties; +import org.springframework.boot.http.client.autoconfigure.ApiversionProperties; +import org.springframework.web.client.RestClient; /** - * {@link AbstractHttpRequestFactoryProperties} for HTTP Service clients. + * {@link AbstractHttpRequestFactoryProperties} for properties to configure technologies + * built on {@link RestClient}. * * @author Olga Maciaszek-Sharma * @author Rossen Stoyanchev * @author Phillip Webb * @since 4.0.0 */ -public abstract class AbstractHttpClientServiceProperties extends AbstractHttpRequestFactoryProperties { +public abstract class AbstractRestClientProperties extends AbstractHttpRequestFactoryProperties { /** * Base url to set in the underlying HTTP client group. By default, set to @@ -44,6 +48,12 @@ public abstract class AbstractHttpClientServiceProperties extends AbstractHttpRe */ private Map> defaultHeader = new LinkedHashMap<>(); + /** + * API version properties. + */ + @NestedConfigurationProperty + private final ApiversionProperties apiversion = new ApiversionProperties(); + public String getBaseUrl() { return this.baseUrl; } @@ -60,4 +70,8 @@ public abstract class AbstractHttpClientServiceProperties extends AbstractHttpRe this.defaultHeader = defaultHeaders; } + public ApiversionProperties getApiversion() { + return this.apiversion; + } + } diff --git a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/PropertiesRestClientCustomizer.java b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/PropertiesRestClientCustomizer.java new file mode 100644 index 00000000000..8aed9fc48c6 --- /dev/null +++ b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/PropertiesRestClientCustomizer.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-present 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.restclient.autoconfigure; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.http.client.autoconfigure.ApiversionProperties; +import org.springframework.boot.http.client.autoconfigure.PropertiesApiVersionInserter; +import org.springframework.boot.restclient.RestClientCustomizer; +import org.springframework.http.HttpHeaders; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; +import org.springframework.web.client.RestClient; + +/** + * {@link RestClientCustomizer} to apply {@link AbstractRestClientProperties}. + * + * @author Phillip Webb + * @since 4.0.0 + */ +public class PropertiesRestClientCustomizer implements RestClientCustomizer { + + private final AbstractRestClientProperties[] orderedProperties; + + private ApiVersionInserter apiVersionInserter; + + public PropertiesRestClientCustomizer(ApiVersionInserter apiVersionInserter, + ApiVersionFormatter apiVersionFormatter, AbstractRestClientProperties... orderedProperties) { + this.orderedProperties = orderedProperties; + this.apiVersionInserter = PropertiesApiVersionInserter.get(apiVersionInserter, apiVersionFormatter, + Arrays.stream(orderedProperties).map(this::getApiVersion)); + } + + private ApiversionProperties getApiVersion(AbstractRestClientProperties properties) { + return (properties != null) ? properties.getApiversion() : null; + } + + @Override + public void customize(RestClient.Builder builder) { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(this.apiVersionInserter).to(builder::apiVersionInserter); + for (int i = this.orderedProperties.length - 1; i >= 0; i--) { + AbstractRestClientProperties properties = this.orderedProperties[i]; + if (properties != null) { + map.from(properties::getBaseUrl).whenHasText().to(builder::baseUrl); + map.from(properties::getDefaultHeader).as(this::putAllHeaders).to(builder::defaultHeaders); + map.from(properties.getApiversion()) + .as(ApiversionProperties::getDefaultVersion) + .to(builder::defaultApiVersion); + } + } + } + + private Consumer putAllHeaders(Map> defaultHeaders) { + return (httpHeaders) -> httpHeaders.putAll(defaultHeaders); + } + +} diff --git a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.java b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.java index 190211ecc2f..a05efaedd37 100644 --- a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.java +++ b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.java @@ -25,6 +25,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; import org.springframework.boot.http.client.autoconfigure.HttpClientAutoConfiguration; @@ -36,6 +37,8 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Scope; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient.Builder; @@ -48,12 +51,14 @@ import org.springframework.web.client.RestClient.Builder; * * @author Arjen Poutsma * @author Moritz Halbritter + * @author Phillip Webb * @since 4.0.0 */ @AutoConfiguration( after = { HttpClientAutoConfiguration.class, TaskExecutionAutoConfiguration.class, SslAutoConfiguration.class }) @ConditionalOnClass(RestClient.class) @Conditional(NotReactiveWebApplicationOrVirtualThreadsExecutorEnabledCondition.class) +@EnableConfigurationProperties(RestClientProperties.class) public final class RestClientAutoConfiguration { @Bean @@ -73,11 +78,15 @@ public final class RestClientAutoConfiguration { RestClientBuilderConfigurer restClientBuilderConfigurer( ObjectProvider> clientHttpRequestFactoryBuilder, ObjectProvider clientHttpRequestFactorySettings, - ObjectProvider customizerProvider) { + ObjectProvider customizerProvider, + ObjectProvider apiVersionInserter, + ObjectProvider apiVersionFormatter, RestClientProperties restClientProperties) { + PropertiesRestClientCustomizer propertiesCustomizer = new PropertiesRestClientCustomizer( + apiVersionInserter.getIfAvailable(), apiVersionFormatter.getIfAvailable(), restClientProperties); return new RestClientBuilderConfigurer( clientHttpRequestFactoryBuilder.getIfAvailable(ClientHttpRequestFactoryBuilder::detect), clientHttpRequestFactorySettings.getIfAvailable(ClientHttpRequestFactorySettings::defaults), - customizerProvider.orderedStream().toList()); + propertiesCustomizer, customizerProvider.orderedStream().toList()); } @Bean diff --git a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurer.java b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurer.java index 231abe2cfde..1b609d5ba08 100644 --- a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurer.java +++ b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurer.java @@ -43,15 +43,19 @@ public class RestClientBuilderConfigurer { private final List customizers; + private final PropertiesRestClientCustomizer propertiesCustomizer; + public RestClientBuilderConfigurer() { - this(ClientHttpRequestFactoryBuilder.detect(), ClientHttpRequestFactorySettings.defaults(), + this(ClientHttpRequestFactoryBuilder.detect(), ClientHttpRequestFactorySettings.defaults(), null, Collections.emptyList()); } RestClientBuilderConfigurer(ClientHttpRequestFactoryBuilder requestFactoryBuilder, - ClientHttpRequestFactorySettings requestFactorySettings, List customizers) { + ClientHttpRequestFactorySettings requestFactorySettings, + PropertiesRestClientCustomizer propertiesCustomizer, List customizers) { this.requestFactoryBuilder = requestFactoryBuilder; this.requestFactorySettings = requestFactorySettings; + this.propertiesCustomizer = propertiesCustomizer; this.customizers = customizers; } @@ -67,7 +71,10 @@ public class RestClientBuilderConfigurer { return builder; } - private void applyCustomizers(Builder builder) { + private void applyCustomizers(RestClient.Builder builder) { + if (this.propertiesCustomizer != null) { + this.propertiesCustomizer.customize(builder); + } for (RestClientCustomizer customizer : this.customizers) { customizer.customize(builder); } diff --git a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientProperties.java b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientProperties.java new file mode 100644 index 00000000000..72fa4b1ce19 --- /dev/null +++ b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientProperties.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-present 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.restclient.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.web.client.RestClient; + +/** + * Properties for {@link RestClient}. + * + * @author Phillip Webb + * @since 4.0.0 + */ +@ConfigurationProperties("spring.http.client.restclient") +public class RestClientProperties extends AbstractRestClientProperties { + +} diff --git a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpClientServiceProperties.java b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpClientServiceProperties.java index ef1873da050..f3e83e92612 100644 --- a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpClientServiceProperties.java +++ b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpClientServiceProperties.java @@ -20,6 +20,7 @@ import java.util.LinkedHashMap; import java.util.Map; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.restclient.autoconfigure.AbstractRestClientProperties; /** * Properties for HTTP Service clients. @@ -30,7 +31,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * @since 4.0.0 */ @ConfigurationProperties("spring.http.client.service") -public class HttpClientServiceProperties extends AbstractHttpClientServiceProperties { +public class HttpClientServiceProperties extends AbstractRestClientProperties { /** * Group settings. @@ -48,7 +49,7 @@ public class HttpClientServiceProperties extends AbstractHttpClientServiceProper /** * Properties for a single HTTP Service client group. */ - public static class Group extends AbstractHttpClientServiceProperties { + public static class Group extends AbstractRestClientProperties { } diff --git a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpServiceClientAutoConfiguration.java b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpServiceClientAutoConfiguration.java index 3a3396e76ce..d6a59819fb1 100644 --- a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpServiceClientAutoConfiguration.java +++ b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpServiceClientAutoConfiguration.java @@ -31,6 +31,8 @@ import org.springframework.boot.restclient.autoconfigure.RestClientAutoConfigura import org.springframework.boot.ssl.SslBundles; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.client.support.RestClientAdapter; import org.springframework.web.service.registry.HttpServiceProxyRegistry; import org.springframework.web.service.registry.ImportHttpServices; @@ -68,10 +70,12 @@ public final class HttpServiceClientAutoConfiguration implements BeanClassLoader ObjectProvider sslBundles, ObjectProvider httpClientProperties, HttpClientServiceProperties serviceProperties, ObjectProvider> clientFactoryBuilder, - ObjectProvider clientHttpRequestFactorySettings) { + ObjectProvider clientHttpRequestFactorySettings, + ObjectProvider apiVersionInserter, + ObjectProvider apiVersionFormatter) { return new RestClientPropertiesHttpServiceGroupConfigurer(this.beanClassLoader, sslBundles, httpClientProperties.getIfAvailable(), serviceProperties, clientFactoryBuilder, - clientHttpRequestFactorySettings); + clientHttpRequestFactorySettings, apiVersionInserter, apiVersionFormatter); } @Bean diff --git a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/RestClientPropertiesHttpServiceGroupConfigurer.java b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/RestClientPropertiesHttpServiceGroupConfigurer.java index ec7790ddd1a..1d33de58adb 100644 --- a/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/RestClientPropertiesHttpServiceGroupConfigurer.java +++ b/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/RestClientPropertiesHttpServiceGroupConfigurer.java @@ -16,20 +16,17 @@ package org.springframework.boot.restclient.autoconfigure.service; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; import org.springframework.boot.http.client.autoconfigure.ClientHttpRequestFactories; import org.springframework.boot.http.client.autoconfigure.HttpClientProperties; +import org.springframework.boot.restclient.autoconfigure.PropertiesRestClientCustomizer; import org.springframework.boot.ssl.SslBundles; import org.springframework.core.Ordered; -import org.springframework.http.HttpHeaders; import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.client.RestClient; import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; import org.springframework.web.service.registry.HttpServiceGroup; @@ -55,16 +52,24 @@ class RestClientPropertiesHttpServiceGroupConfigurer implements RestClientHttpSe private final ObjectProvider requestFactorySettings; + private final ApiVersionInserter apiVersionInserter; + + private final ApiVersionFormatter apiVersionFormatter; + RestClientPropertiesHttpServiceGroupConfigurer(ClassLoader classLoader, ObjectProvider sslBundles, HttpClientProperties clientProperties, HttpClientServiceProperties serviceProperties, ObjectProvider> requestFactoryBuilder, - ObjectProvider requestFactorySettings) { + ObjectProvider requestFactorySettings, + ObjectProvider apiVersionInserter, + ObjectProvider apiVersionFormatter) { this.classLoader = classLoader; this.sslBundles = sslBundles; this.clientProperties = clientProperties; this.serviceProperties = serviceProperties; this.requestFactoryBuilder = requestFactoryBuilder; this.requestFactorySettings = requestFactorySettings; + this.apiVersionInserter = apiVersionInserter.getIfAvailable(); + this.apiVersionFormatter = apiVersionFormatter.getIfAvailable(); } @Override @@ -80,17 +85,13 @@ class RestClientPropertiesHttpServiceGroupConfigurer implements RestClientHttpSe private void configureClient(HttpServiceGroup group, RestClient.Builder builder) { HttpClientServiceProperties.Group groupProperties = this.serviceProperties.getGroup().get(group.name()); builder.requestFactory(getRequestFactory(groupProperties)); - PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); - map.from(this.serviceProperties::getBaseUrl).whenHasText().to(builder::baseUrl); - map.from(this.serviceProperties::getDefaultHeader).as(this::putAllHeaders).to(builder::defaultHeaders); - if (groupProperties != null) { - map.from(groupProperties::getBaseUrl).whenHasText().to(builder::baseUrl); - map.from(groupProperties::getDefaultHeader).as(this::putAllHeaders).to(builder::defaultHeaders); - } + getPropertiesRestClientCustomizer(groupProperties).customize(builder); } - private Consumer putAllHeaders(Map> defaultHeaders) { - return (httpHeaders) -> httpHeaders.putAll(defaultHeaders); + private PropertiesRestClientCustomizer getPropertiesRestClientCustomizer( + HttpClientServiceProperties.Group groupProperties) { + return new PropertiesRestClientCustomizer(this.apiVersionInserter, this.apiVersionFormatter, groupProperties, + this.serviceProperties); } private ClientHttpRequestFactory getRequestFactory(HttpClientServiceProperties.Group groupProperties) { diff --git a/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/PropertiesRestClientCustomizerTests.java b/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/PropertiesRestClientCustomizerTests.java new file mode 100644 index 00000000000..5f9ae98d12d --- /dev/null +++ b/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/PropertiesRestClientCustomizerTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-present 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.restclient.autoconfigure; + +import java.net.URI; +import java.util.List; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilderFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PropertiesRestClientCustomizer}. + * + * @author Phillip Webb + */ +class PropertiesRestClientCustomizerTests { + + @Test + void customizeAppliesPropertiesInOrder() throws Exception { + ApiVersionInserter delegateApiVersionInserter = ApiVersionInserter.useQueryParam("v"); + ApiVersionFormatter apiVersionFormatter = (version) -> String.valueOf(version).toUpperCase(Locale.ROOT); + TestRestClientProperties properties1 = new TestRestClientProperties(); + properties1.setBaseUrl("https://example.com/b1"); + properties1.getDefaultHeader().put("x-h1", List.of("v1")); + properties1.getApiversion().setDefaultVersion("dv1"); + properties1.getApiversion().getInsert().setQueryParameter("p1"); + TestRestClientProperties properties2 = new TestRestClientProperties(); + properties2.setBaseUrl("https://example.com/b2"); + properties1.getDefaultHeader().put("x-h2", List.of("v2")); + properties2.getApiversion().setDefaultVersion("dv2"); + PropertiesRestClientCustomizer customizer = new PropertiesRestClientCustomizer(delegateApiVersionInserter, + apiVersionFormatter, properties1, properties2); + RestClient.Builder builder = RestClient.builder(); + customizer.customize(builder); + RestClient client = builder.build(); + assertThat(client).extracting("defaultApiVersion").isEqualTo("dv1"); + UriBuilderFactory uriBuilderFactory = (UriBuilderFactory) ReflectionTestUtils.getField(client, + "uriBuilderFactory"); + assertThat(uriBuilderFactory.builder().build()).hasToString("https://example.com/b1"); + HttpHeaders defaultHeaders = (HttpHeaders) ReflectionTestUtils.getField(client, "defaultHeaders"); + assertThat(defaultHeaders.get("x-h1")).containsExactly("v1"); + assertThat(defaultHeaders.get("x-h2")).containsExactly("v2"); + ApiVersionInserter apiVersionInserter = (ApiVersionInserter) ReflectionTestUtils.getField(client, + "apiVersionInserter"); + assertThat(apiVersionInserter.insertVersion("v123", new URI("https://example.com"))) + .hasToString("https://example.com?v=v123&p1=V123"); + } + + static class TestRestClientProperties extends AbstractRestClientProperties { + + } + +} diff --git a/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfigurationTests.java b/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfigurationTests.java index e72064cce44..9181dd53de9 100644 --- a/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfigurationTests.java +++ b/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfigurationTests.java @@ -16,8 +16,10 @@ package org.springframework.boot.restclient.autoconfigure; +import java.net.URI; import java.time.Duration; import java.util.List; +import java.util.Locale; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledForJreRange; @@ -43,6 +45,8 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient.Builder; @@ -316,6 +320,48 @@ class RestClientAutoConfigurationTests { .run((context) -> assertThat(context).doesNotHaveBean(RestClient.Builder.class)); } + @Test + void whenHasApiVersionProperties() { + this.contextRunner.withConfiguration(AutoConfigurations.of(HttpMessageConvertersAutoConfiguration.class)) + .withPropertyValues("spring.http.client.restclient.apiversion.default=123", + "spring.http.client.restclient.apiversion.insert.query-parameter=version") + .run((context) -> { + RestClient restClient = context.getBean(RestClient.Builder.class).build(); + assertThat(restClient).extracting("defaultApiVersion").isEqualTo("123"); + ApiVersionInserter apiVersionInserter = (ApiVersionInserter) ReflectionTestUtils.getField(restClient, + "apiVersionInserter"); + assertThat(apiVersionInserter.insertVersion("123", new URI("https://example.com"))) + .hasToString("https://example.com?version=123"); + }); + } + + @Test + void whenHasCustomApiVersionInserter() { + this.contextRunner.withConfiguration(AutoConfigurations.of(HttpMessageConvertersAutoConfiguration.class)) + .withUserConfiguration(ApiVersionInserterConfig.class) + .run((context) -> { + RestClient restClient = context.getBean(RestClient.Builder.class).build(); + ApiVersionInserter apiVersionInserter = (ApiVersionInserter) ReflectionTestUtils.getField(restClient, + "apiVersionInserter"); + assertThat(apiVersionInserter.insertVersion("123", new URI("https://example.com"))) + .hasToString("https://example.com?version=123"); + }); + } + + @Test + void whenHasCustomApiVersionFormatter() { + this.contextRunner.withConfiguration(AutoConfigurations.of(HttpMessageConvertersAutoConfiguration.class)) + .withPropertyValues("spring.http.client.restclient.apiversion.insert.query-parameter=version") + .withUserConfiguration(ApiVersionFormatterConfig.class) + .run((context) -> { + RestClient restClient = context.getBean(RestClient.Builder.class).build(); + ApiVersionInserter apiVersionInserter = (ApiVersionInserter) ReflectionTestUtils.getField(restClient, + "apiVersionInserter"); + assertThat(apiVersionInserter.insertVersion("best", new URI("https://example.com"))) + .hasToString("https://example.com?version=BEST"); + }); + } + @Configuration(proxyBeanMethods = false) static class RestClientCustomizerConfig { @@ -354,4 +400,24 @@ class RestClientAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class ApiVersionInserterConfig { + + @Bean + ApiVersionInserter apiVersionInserter() { + return ApiVersionInserter.useQueryParam("version"); + } + + } + + @Configuration(proxyBeanMethods = false) + static class ApiVersionFormatterConfig { + + @Bean + ApiVersionFormatter apiVersionFormatter() { + return (version) -> String.valueOf(version).toUpperCase(Locale.ROOT); + } + + } + } diff --git a/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurerTests.java b/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurerTests.java index e76ae038510..b1e6f3c5224 100644 --- a/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurerTests.java +++ b/module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurerTests.java @@ -55,9 +55,8 @@ class RestClientBuilderConfigurerTests { RestClientCustomizer customizer = mock(RestClientCustomizer.class); RestClientCustomizer customizer1 = mock(RestClientCustomizer.class); RestClientBuilderConfigurer configurer = new RestClientBuilderConfigurer(this.clientHttpRequestFactoryBuilder, - settings, List.of(customizer, customizer1)); + settings, null, List.of(customizer, customizer1)); given(this.clientHttpRequestFactoryBuilder.build(settings)).willReturn(this.clientHttpRequestFactory); - RestClient.Builder builder = RestClient.builder(); configurer.configure(builder); assertThat(builder.build()).hasFieldOrPropertyWithValue("clientRequestFactory", this.clientHttpRequestFactory); diff --git a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/AbstractHttpReactiveClientServiceProperties.java b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/AbstractWebClientProperties.java similarity index 68% rename from module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/AbstractHttpReactiveClientServiceProperties.java rename to module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/AbstractWebClientProperties.java index 3bc42855259..0f48a87586c 100644 --- a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/AbstractHttpReactiveClientServiceProperties.java +++ b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/AbstractWebClientProperties.java @@ -14,23 +14,27 @@ * limitations under the License. */ -package org.springframework.boot.webclient.autoconfigure.service; +package org.springframework.boot.webclient.autoconfigure; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.boot.http.client.autoconfigure.ApiversionProperties; import org.springframework.boot.http.client.autoconfigure.reactive.AbstractClientHttpConnectorProperties; +import org.springframework.web.reactive.function.client.WebClient; /** - * {@link AbstractClientHttpConnectorProperties} for reactive HTTP Service clients. + * {@link AbstractClientHttpConnectorProperties} for properties to configure technologies + * built on {@link WebClient}. * * @author Olga Maciaszek-Sharma * @author Rossen Stoyanchev * @author Phillip Webb * @since 4.0.0 */ -public abstract class AbstractHttpReactiveClientServiceProperties extends AbstractClientHttpConnectorProperties { +public abstract class AbstractWebClientProperties extends AbstractClientHttpConnectorProperties { /** * Base url to set in the underlying HTTP client group. By default, set to @@ -44,6 +48,12 @@ public abstract class AbstractHttpReactiveClientServiceProperties extends Abstra */ private Map> defaultHeader = new LinkedHashMap<>(); + /** + * API version properties. + */ + @NestedConfigurationProperty + private final ApiversionProperties apiversion = new ApiversionProperties(); + public String getBaseUrl() { return this.baseUrl; } @@ -60,4 +70,8 @@ public abstract class AbstractHttpReactiveClientServiceProperties extends Abstra this.defaultHeader = defaultHeaders; } + public ApiversionProperties getApiversion() { + return this.apiversion; + } + } diff --git a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/PropertiesWebClientCustomizer.java b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/PropertiesWebClientCustomizer.java new file mode 100644 index 00000000000..b0b3efe799b --- /dev/null +++ b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/PropertiesWebClientCustomizer.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-present 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.webclient.autoconfigure; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.http.client.autoconfigure.ApiversionProperties; +import org.springframework.boot.http.client.autoconfigure.PropertiesApiVersionInserter; +import org.springframework.boot.webclient.WebClientCustomizer; +import org.springframework.http.HttpHeaders; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * {@link WebClientCustomizer} to apply {@link AbstractWebClientProperties}. + * + * @author Phillip Webb + * @since 4.0.0 + */ +public class PropertiesWebClientCustomizer implements WebClientCustomizer { + + private final AbstractWebClientProperties[] orderedProperties; + + private ApiVersionInserter apiVersionInserter; + + public PropertiesWebClientCustomizer(ApiVersionInserter apiVersionInserter, ApiVersionFormatter apiVersionFormatter, + AbstractWebClientProperties... orderedProperties) { + this.orderedProperties = orderedProperties; + this.apiVersionInserter = PropertiesApiVersionInserter.get(apiVersionInserter, apiVersionFormatter, + Arrays.stream(orderedProperties).map(this::getApiVersion)); + } + + private ApiversionProperties getApiVersion(AbstractWebClientProperties properties) { + return (properties != null) ? properties.getApiversion() : null; + } + + @Override + public void customize(WebClient.Builder builder) { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(this.apiVersionInserter).to(builder::apiVersionInserter); + for (int i = this.orderedProperties.length - 1; i >= 0; i--) { + AbstractWebClientProperties properties = this.orderedProperties[i]; + if (properties != null) { + map.from(properties::getBaseUrl).whenHasText().to(builder::baseUrl); + map.from(properties::getDefaultHeader).as(this::putAllHeaders).to(builder::defaultHeaders); + map.from(properties.getApiversion()) + .as(ApiversionProperties::getDefaultVersion) + .to(builder::defaultApiVersion); + } + } + } + + private Consumer putAllHeaders(Map> defaultHeaders) { + return (httpHeaders) -> httpHeaders.putAll(defaultHeaders); + } + +} diff --git a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfiguration.java b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfiguration.java index 6566727c28f..b1ceef3b601 100644 --- a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfiguration.java +++ b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfiguration.java @@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.http.client.autoconfigure.reactive.ClientHttpConnectorAutoConfiguration; import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings; @@ -36,6 +37,8 @@ import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Scope; import org.springframework.core.annotation.Order; import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.reactive.function.client.WebClient; /** @@ -52,13 +55,19 @@ import org.springframework.web.reactive.function.client.WebClient; */ @AutoConfiguration(after = { ClientHttpConnectorAutoConfiguration.class, CodecsAutoConfiguration.class }) @ConditionalOnClass(WebClient.class) +@EnableConfigurationProperties(WebClientProperties.class) public final class WebClientAutoConfiguration { @Bean @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) @ConditionalOnMissingBean - WebClient.Builder webClientBuilder(ObjectProvider customizerProvider) { + WebClient.Builder webClientBuilder(ObjectProvider customizerProvider, + ObjectProvider apiVersionInserter, + ObjectProvider apiVersionFormatter, WebClientProperties webClientProperties) { WebClient.Builder builder = WebClient.builder(); + PropertiesWebClientCustomizer propertiesCustomizer = new PropertiesWebClientCustomizer( + apiVersionInserter.getIfAvailable(), apiVersionFormatter.getIfAvailable(), webClientProperties); + propertiesCustomizer.customize(builder); customizerProvider.orderedStream().forEach((customizer) -> customizer.customize(builder)); return builder; } diff --git a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientProperties.java b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientProperties.java new file mode 100644 index 00000000000..7f335db4a2e --- /dev/null +++ b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientProperties.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-present 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.webclient.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Properties for {@link WebClient}. + * + * @author Phillip Webb + * @since 4.0.0 + */ +@ConfigurationProperties("spring.http.reactiveclient.webclient") +public class WebClientProperties extends AbstractWebClientProperties { + +} diff --git a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpClientServiceProperties.java b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpClientServiceProperties.java index 2e5791d5dd8..62ab89bafad 100644 --- a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpClientServiceProperties.java +++ b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpClientServiceProperties.java @@ -20,6 +20,7 @@ import java.util.LinkedHashMap; import java.util.Map; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.webclient.autoconfigure.AbstractWebClientProperties; /** * Properties for Reactive HTTP Service clients. @@ -30,7 +31,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * @since 4.0.0 */ @ConfigurationProperties("spring.http.reactiveclient.service") -public class ReactiveHttpClientServiceProperties extends AbstractHttpReactiveClientServiceProperties { +public class ReactiveHttpClientServiceProperties extends AbstractWebClientProperties { /** * Group settings. @@ -48,7 +49,7 @@ public class ReactiveHttpClientServiceProperties extends AbstractHttpReactiveCli /** * Properties for a single HTTP Service client group. */ - public static class Group extends AbstractHttpReactiveClientServiceProperties { + public static class Group extends AbstractWebClientProperties { } diff --git a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpServiceClientAutoConfiguration.java b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpServiceClientAutoConfiguration.java index 54fc17309d9..3584b5893e5 100644 --- a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpServiceClientAutoConfiguration.java +++ b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpServiceClientAutoConfiguration.java @@ -30,6 +30,8 @@ import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.webclient.WebClientCustomizer; import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.reactive.function.client.support.WebClientAdapter; import org.springframework.web.service.registry.HttpServiceProxyRegistry; import org.springframework.web.service.registry.ImportHttpServices; @@ -66,9 +68,12 @@ public final class ReactiveHttpServiceClientAutoConfiguration implements BeanCla ObjectProvider sslBundles, HttpReactiveClientProperties httpReactiveClientProperties, ReactiveHttpClientServiceProperties serviceProperties, ObjectProvider> clientConnectorBuilder, - ObjectProvider clientConnectorSettings) { + ObjectProvider clientConnectorSettings, + ObjectProvider apiVersionInserter, + ObjectProvider apiVersionFormatter) { return new WebClientPropertiesHttpServiceGroupConfigurer(this.beanClassLoader, sslBundles, - httpReactiveClientProperties, serviceProperties, clientConnectorBuilder, clientConnectorSettings); + httpReactiveClientProperties, serviceProperties, clientConnectorBuilder, clientConnectorSettings, + apiVersionInserter, apiVersionFormatter); } @Bean diff --git a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/WebClientPropertiesHttpServiceGroupConfigurer.java b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/WebClientPropertiesHttpServiceGroupConfigurer.java index 1081991cff9..fb3e80ffca4 100644 --- a/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/WebClientPropertiesHttpServiceGroupConfigurer.java +++ b/module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/WebClientPropertiesHttpServiceGroupConfigurer.java @@ -16,20 +16,17 @@ package org.springframework.boot.webclient.autoconfigure.service; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.http.client.autoconfigure.reactive.ClientHttpConnectors; import org.springframework.boot.http.client.autoconfigure.reactive.HttpReactiveClientProperties; import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings; import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.webclient.autoconfigure.PropertiesWebClientCustomizer; import org.springframework.core.Ordered; -import org.springframework.http.HttpHeaders; import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.client.RestClient; import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; import org.springframework.web.reactive.function.client.WebClient; @@ -57,16 +54,24 @@ class WebClientPropertiesHttpServiceGroupConfigurer implements WebClientHttpServ private final ObjectProvider clientConnectorSettings; + private final ApiVersionInserter apiVersionInserter; + + private final ApiVersionFormatter apiVersionFormatter; + WebClientPropertiesHttpServiceGroupConfigurer(ClassLoader classLoader, ObjectProvider sslBundles, HttpReactiveClientProperties clientProperties, ReactiveHttpClientServiceProperties serviceProperties, ObjectProvider> clientConnectorBuilder, - ObjectProvider clientConnectorSettings) { + ObjectProvider clientConnectorSettings, + ObjectProvider apiVersionInserter, + ObjectProvider apiVersionFormatter) { this.classLoader = classLoader; this.sslBundles = sslBundles; this.clientProperties = clientProperties; this.serviceProperties = serviceProperties; this.clientConnectorBuilder = clientConnectorBuilder; this.clientConnectorSettings = clientConnectorSettings; + this.apiVersionInserter = apiVersionInserter.getIfAvailable(); + this.apiVersionFormatter = apiVersionFormatter.getIfAvailable(); } @Override @@ -82,17 +87,13 @@ class WebClientPropertiesHttpServiceGroupConfigurer implements WebClientHttpServ private void configureClient(HttpServiceGroup group, WebClient.Builder builder) { ReactiveHttpClientServiceProperties.Group groupProperties = this.serviceProperties.getGroup().get(group.name()); builder.clientConnector(getClientConnector(groupProperties)); - PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); - map.from(this.serviceProperties::getBaseUrl).whenHasText().to(builder::baseUrl); - map.from(this.serviceProperties::getDefaultHeader).as(this::putAllHeaders).to(builder::defaultHeaders); - if (groupProperties != null) { - map.from(groupProperties::getBaseUrl).whenHasText().to(builder::baseUrl); - map.from(groupProperties::getDefaultHeader).as(this::putAllHeaders).to(builder::defaultHeaders); - } + getPropertiesWebClientCustomizer(groupProperties).customize(builder); } - private Consumer putAllHeaders(Map> defaultHeaders) { - return (httpHeaders) -> httpHeaders.putAll(defaultHeaders); + private PropertiesWebClientCustomizer getPropertiesWebClientCustomizer( + ReactiveHttpClientServiceProperties.Group groupProperties) { + return new PropertiesWebClientCustomizer(this.apiVersionInserter, this.apiVersionFormatter, groupProperties, + this.serviceProperties); } private ClientHttpConnector getClientConnector(ReactiveHttpClientServiceProperties.Group groupProperties) { diff --git a/module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/PropertiesWebClientCustomizerTests.java b/module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/PropertiesWebClientCustomizerTests.java new file mode 100644 index 00000000000..652c26d66a2 --- /dev/null +++ b/module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/PropertiesWebClientCustomizerTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-present 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.webclient.autoconfigure; + +import java.net.URI; +import java.util.List; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriBuilderFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PropertiesWebClientCustomizer}. + * + * @author Phillip Webb + */ +class PropertiesWebClientCustomizerTests { + + @Test + void customizeAppliesPropertiesInOrder() throws Exception { + ApiVersionInserter delegateApiVersionInserter = ApiVersionInserter.useQueryParam("v"); + ApiVersionFormatter apiVersionFormatter = (version) -> String.valueOf(version).toUpperCase(Locale.ROOT); + TestWebClientProperties properties1 = new TestWebClientProperties(); + properties1.setBaseUrl("https://example.com/b1"); + properties1.getDefaultHeader().put("x-h1", List.of("v1")); + properties1.getApiversion().setDefaultVersion("dv1"); + properties1.getApiversion().getInsert().setQueryParameter("p1"); + TestWebClientProperties properties2 = new TestWebClientProperties(); + properties2.setBaseUrl("https://example.com/b2"); + properties1.getDefaultHeader().put("x-h2", List.of("v2")); + properties2.getApiversion().setDefaultVersion("dv2"); + PropertiesWebClientCustomizer customizer = new PropertiesWebClientCustomizer(delegateApiVersionInserter, + apiVersionFormatter, properties1, properties2); + WebClient.Builder builder = WebClient.builder(); + customizer.customize(builder); + WebClient client = builder.build(); + assertThat(client).extracting("defaultApiVersion").isEqualTo("dv1"); + UriBuilderFactory uriBuilderFactory = (UriBuilderFactory) ReflectionTestUtils.getField(client, + "uriBuilderFactory"); + assertThat(uriBuilderFactory.builder().build()).hasToString("https://example.com/b1"); + HttpHeaders defaultHeaders = (HttpHeaders) ReflectionTestUtils.getField(client, "defaultHeaders"); + assertThat(defaultHeaders.get("x-h1")).containsExactly("v1"); + assertThat(defaultHeaders.get("x-h2")).containsExactly("v2"); + ApiVersionInserter apiVersionInserter = (ApiVersionInserter) ReflectionTestUtils.getField(client, + "apiVersionInserter"); + assertThat(apiVersionInserter.insertVersion("v123", new URI("https://example.com"))) + .hasToString("https://example.com?v=v123&p1=V123"); + } + + static class TestWebClientProperties extends AbstractWebClientProperties { + + } + +} diff --git a/module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfigurationTests.java b/module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfigurationTests.java index 0c6c819ba4d..d156012a9cc 100644 --- a/module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfigurationTests.java +++ b/module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfigurationTests.java @@ -16,6 +16,9 @@ package org.springframework.boot.webclient.autoconfigure; +import java.net.URI; +import java.util.Locale; + import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -26,6 +29,9 @@ import org.springframework.boot.webclient.WebClientCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.codec.CodecConfigurer; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.reactive.function.client.WebClient; import static org.assertj.core.api.Assertions.assertThat; @@ -37,6 +43,7 @@ import static org.mockito.Mockito.mock; * Tests for {@link WebClientAutoConfiguration} * * @author Brian Clozel + * @author Phillip Webb */ class WebClientAutoConfigurationTests { @@ -102,6 +109,46 @@ class WebClientAutoConfigurationTests { }); } + @Test + void whenHasApiVersionProperties() { + this.contextRunner + .withPropertyValues("spring.http.reactiveclient.webclient.apiversion.default=123", + "spring.http.reactiveclient.webclient.apiversion.insert.query-parameter=version") + .run((context) -> { + WebClient webClient = context.getBean(WebClient.Builder.class).build(); + assertThat(webClient).extracting("defaultApiVersion").isEqualTo("123"); + ApiVersionInserter apiVersionInserter = (ApiVersionInserter) ReflectionTestUtils.getField(webClient, + "apiVersionInserter"); + assertThat(apiVersionInserter.insertVersion("123", new URI("https://example.com"))) + .hasToString("https://example.com?version=123"); + }); + } + + @Test + void whenHasCustomApiVersionInserter() { + this.contextRunner.withUserConfiguration(ApiVersionInserterConfig.class).run((context) -> { + WebClient webClient = context.getBean(WebClient.Builder.class).build(); + ApiVersionInserter apiVersionInserter = (ApiVersionInserter) ReflectionTestUtils.getField(webClient, + "apiVersionInserter"); + assertThat(apiVersionInserter.insertVersion("123", new URI("https://example.com"))) + .hasToString("https://example.com?version=123"); + }); + } + + @Test + void whenHasCustomApiVersionFormatter() { + this.contextRunner + .withPropertyValues("spring.http.reactiveclient.webclient.apiversion.insert.query-parameter=version") + .withUserConfiguration(ApiVersionFormatterConfig.class) + .run((context) -> { + WebClient webClient = context.getBean(WebClient.Builder.class).build(); + ApiVersionInserter apiVersionInserter = (ApiVersionInserter) ReflectionTestUtils.getField(webClient, + "apiVersionInserter"); + assertThat(apiVersionInserter.insertVersion("best", new URI("https://example.com"))) + .hasToString("https://example.com?version=BEST"); + }); + } + @Configuration(proxyBeanMethods = false) static class CodecConfiguration { @@ -136,4 +183,24 @@ class WebClientAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class ApiVersionInserterConfig { + + @Bean + ApiVersionInserter apiVersionInserter() { + return ApiVersionInserter.useQueryParam("version"); + } + + } + + @Configuration(proxyBeanMethods = false) + static class ApiVersionFormatterConfig { + + @Bean + ApiVersionFormatter apiVersionFormatter() { + return (version) -> String.valueOf(version).toUpperCase(Locale.ROOT); + } + + } + } diff --git a/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfiguration.java b/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfiguration.java index a51a9c3d788..4b74f52cf26 100644 --- a/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfiguration.java +++ b/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfiguration.java @@ -21,6 +21,7 @@ import java.util.List; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; import reactor.core.publisher.Mono; import org.springframework.beans.factory.ListableBeanFactory; @@ -42,12 +43,15 @@ import org.springframework.boot.autoconfigure.web.WebResourcesRuntimeHints; import org.springframework.boot.autoconfigure.web.format.DateTimeFormatters; import org.springframework.boot.autoconfigure.web.format.WebConversionService; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.boot.http.codec.CodecCustomizer; import org.springframework.boot.http.codec.autoconfigure.CodecsAutoConfiguration; import org.springframework.boot.thread.Threading; import org.springframework.boot.validation.autoconfigure.ValidatorAdapter; import org.springframework.boot.web.server.autoconfigure.ServerProperties; +import org.springframework.boot.webflux.autoconfigure.WebFluxProperties.Apiversion; +import org.springframework.boot.webflux.autoconfigure.WebFluxProperties.Apiversion.Use; import org.springframework.boot.webflux.autoconfigure.WebFluxProperties.Format; import org.springframework.boot.webflux.filter.OrderedHiddenHttpMethodFilter; import org.springframework.context.ApplicationContext; @@ -64,7 +68,12 @@ import org.springframework.format.support.FormattingConversionService; import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.util.ClassUtils; import org.springframework.validation.Validator; +import org.springframework.web.accept.ApiVersionParser; import org.springframework.web.filter.reactive.HiddenHttpMethodFilter; +import org.springframework.web.reactive.accept.ApiVersionDeprecationHandler; +import org.springframework.web.reactive.accept.ApiVersionResolver; +import org.springframework.web.reactive.accept.ApiVersionStrategy; +import org.springframework.web.reactive.config.ApiVersionConfigurer; import org.springframework.web.reactive.config.BlockingExecutionConfigurer; import org.springframework.web.reactive.config.DelegatingWebFluxConfiguration; import org.springframework.web.reactive.config.EnableWebFlux; @@ -169,11 +178,19 @@ public final class WebFluxAutoConfiguration { private final ObjectProvider viewResolvers; + private final ObjectProvider apiVersionResolvers; + + private final ObjectProvider> apiVersionParser; + + private final ObjectProvider apiVersionDeprecationHandler; + WebFluxConfig(Environment environment, WebProperties webProperties, WebFluxProperties webFluxProperties, ListableBeanFactory beanFactory, ObjectProvider resolvers, ObjectProvider codecCustomizers, ObjectProvider resourceHandlerRegistrationCustomizers, - ObjectProvider viewResolvers) { + ObjectProvider viewResolvers, ObjectProvider apiVersionResolvers, + ObjectProvider> apiVersionParser, + ObjectProvider apiVersionDeprecationHandler) { this.environment = environment; this.resourceProperties = webProperties.getResources(); this.webFluxProperties = webFluxProperties; @@ -182,6 +199,9 @@ public final class WebFluxAutoConfiguration { this.codecCustomizers = codecCustomizers; this.resourceHandlerRegistrationCustomizers = resourceHandlerRegistrationCustomizers; this.viewResolvers = viewResolvers; + this.apiVersionResolvers = apiVersionResolvers; + this.apiVersionParser = apiVersionParser; + this.apiVersionDeprecationHandler = apiVersionDeprecationHandler; } @Override @@ -252,6 +272,29 @@ public final class WebFluxAutoConfiguration { ApplicationConversionService.addBeans(registry, this.beanFactory); } + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + Apiversion properties = this.webFluxProperties.getApiversion(); + map.from(properties::isRequired).to(configurer::setVersionRequired); + map.from(properties::getDefaultVersion).to(configurer::setDefaultVersion); + map.from(properties::getSupported).to((supported) -> supported.forEach(configurer::addSupportedVersions)); + map.from(properties::isDetectSupported).to(configurer::detectSupportedVersions); + configureApiVersioningUse(configurer, properties.getUse()); + this.apiVersionResolvers.orderedStream().forEach(configurer::useVersionResolver); + this.apiVersionParser.ifAvailable(configurer::setVersionParser); + this.apiVersionDeprecationHandler.ifAvailable(configurer::setDeprecationHandler); + } + + private void configureApiVersioningUse(ApiVersionConfigurer configurer, Use use) { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(use::getHeader).whenHasText().to(configurer::useRequestHeader); + map.from(use::getRequestParameter).whenHasText().to(configurer::useRequestParam); + map.from(use::getPathSegment).to(configurer::usePathSegment); + use.getMediaTypeParameter() + .forEach((mediaType, parameterName) -> configurer.useMediaTypeParameter(mediaType, parameterName)); + } + } /** @@ -347,6 +390,12 @@ public final class WebFluxAutoConfiguration { return webSessionManager; } + @Override + @ConditionalOnMissingBean(name = "mvcApiVersionStrategy") + public @Nullable ApiVersionStrategy mvcApiVersionStrategy() { + return super.mvcApiVersionStrategy(); + } + } @Configuration(proxyBeanMethods = false) diff --git a/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxProperties.java b/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxProperties.java index 640339f9db2..5ce15f3adfa 100644 --- a/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxProperties.java +++ b/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxProperties.java @@ -16,7 +16,13 @@ package org.springframework.boot.webflux.autoconfigure; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.Name; +import org.springframework.http.MediaType; import org.springframework.util.StringUtils; /** @@ -38,6 +44,8 @@ public class WebFluxProperties { private final Problemdetails problemdetails = new Problemdetails(); + private final Apiversion apiversion = new Apiversion(); + /** * Path pattern used for static resources. */ @@ -80,6 +88,10 @@ public class WebFluxProperties { return this.problemdetails; } + public Apiversion getApiversion() { + return this.apiversion; + } + public String getStaticPathPattern() { return this.staticPathPattern; } @@ -159,4 +171,126 @@ public class WebFluxProperties { } + public static class Apiversion { + + /** + * Whether the API version is required with each request. + */ + private boolean required = false; + + /** + * Default version that should be used for each request. + */ + @Name("default") + private String defaultVersion; + + /** + * Supported versions. + */ + private List supported; + + /** + * Whether supported versions should be detected from controllers. + */ + private boolean detectSupported = true; + + /** + * How version details should be inserted into requests. + */ + private final Use use = new Use(); + + public boolean isRequired() { + return this.required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public String getDefaultVersion() { + return this.defaultVersion; + } + + public void setDefaultVersion(String defaultVersion) { + this.defaultVersion = defaultVersion; + } + + public List getSupported() { + return this.supported; + } + + public void setSupported(List supported) { + this.supported = supported; + } + + public Use getUse() { + return this.use; + } + + public boolean isDetectSupported() { + return this.detectSupported; + } + + public void setDetectSupported(boolean detectSupported) { + this.detectSupported = detectSupported; + } + + public static class Use { + + /** + * Use the HTTP header with the given name to obtain the version. + */ + private String header; + + /** + * Use the query parameter with the given name to obtain the version. + */ + private String requestParameter; + + /** + * Use the path segment at the given index to obtain the version. + */ + private Integer pathSegment; + + /** + * Use the media type parameter with the given name to obtain the version. + */ + private Map mediaTypeParameter = new LinkedHashMap<>(); + + public String getHeader() { + return this.header; + } + + public void setHeader(String header) { + this.header = header; + } + + public String getRequestParameter() { + return this.requestParameter; + } + + public void setRequestParameter(String queryParameter) { + this.requestParameter = queryParameter; + } + + public Integer getPathSegment() { + return this.pathSegment; + } + + public void setPathSegment(Integer pathSegment) { + this.pathSegment = pathSegment; + } + + public Map getMediaTypeParameter() { + return this.mediaTypeParameter; + } + + public void setMediaTypeParameter(Map mediaTypeParameter) { + this.mediaTypeParameter = mediaTypeParameter; + } + + } + + } + } diff --git a/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfigurationTests.java b/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfigurationTests.java index 356049579b8..288702a4d34 100644 --- a/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfigurationTests.java +++ b/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfigurationTests.java @@ -85,11 +85,18 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.StringUtils; import org.springframework.validation.Validator; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.accept.ApiVersionParser; +import org.springframework.web.accept.InvalidApiVersionException; +import org.springframework.web.accept.MissingApiVersionException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.filter.reactive.HiddenHttpMethodFilter; import org.springframework.web.method.ControllerAdviceBean; import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.accept.ApiVersionDeprecationHandler; +import org.springframework.web.reactive.accept.ApiVersionResolver; +import org.springframework.web.reactive.accept.DefaultApiVersionStrategy; import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.accept.StandardApiVersionDeprecationHandler; import org.springframework.web.reactive.config.BlockingExecutionConfigurer; import org.springframework.web.reactive.config.DelegatingWebFluxConfiguration; import org.springframework.web.reactive.config.ResourceHandlerRegistration; @@ -122,6 +129,7 @@ import org.springframework.web.server.session.WebSessionStore; import org.springframework.web.util.pattern.PathPattern; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -792,6 +800,105 @@ class WebFluxAutoConfigurationTests { }); } + @Test + void apiVersionPropertiesAreApplied() { + this.contextRunner + .withPropertyValues("spring.webflux.apiversion.use.header=version", + "spring.webflux.apiversion.required=true", "spring.webflux.apiversion.supported=123,456", + "spring.webflux.apiversion.detect-supported=false") + .run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", + DefaultApiVersionStrategy.class); + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("https://example.com")); + assertThatExceptionOfType(MissingApiVersionException.class) + .isThrownBy(() -> versionStrategy.validateVersion(null, request)); + assertThatExceptionOfType(InvalidApiVersionException.class) + .isThrownBy(() -> versionStrategy.validateVersion(versionStrategy.parseVersion("789"), + MockServerWebExchange.from(MockServerHttpRequest.get("https://example.com")))); + assertThat(versionStrategy.detectSupportedVersions()).isFalse(); + }); + } + + @Test + void apiVersionDefaultVersionPropertyIsApplied() { + this.contextRunner + .withPropertyValues("spring.webflux.apiversion.use.header=version", + "spring.webflux.apiversion.default=1.0.0") + .run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", + DefaultApiVersionStrategy.class); + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("https://example.com")); + versionStrategy.addSupportedVersion("1.0.0"); + Comparable version = versionStrategy.parseVersion("1.0.0"); + assertThat(versionStrategy.getDefaultVersion()).isEqualTo(version); + versionStrategy.validateVersion(version, request); + versionStrategy.validateVersion(null, request); + }); + } + + @Test + void apiVersionUseHeaderPropertyIsApplied() { + this.contextRunner.withPropertyValues("spring.webflux.apiversion.use.header=hv").run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", + DefaultApiVersionStrategy.class); + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("https://example.com").header("hv", "123")); + assertThat(versionStrategy.resolveVersion(request)).isEqualTo("123"); + }); + } + + @Test + void apiVersionUseRequestParameterPropertyIsApplied() { + this.contextRunner.withPropertyValues("spring.webflux.apiversion.use.request-parameter=rpv").run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", + DefaultApiVersionStrategy.class); + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("https://example.com?rpv=123")); + assertThat(versionStrategy.resolveVersion(request)).isEqualTo("123"); + }); + } + + @Test + void apiVersionUsePathSegmentPropertyIsApplied() { + this.contextRunner.withPropertyValues("spring.webflux.apiversion.use.path-segment=1").run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", + DefaultApiVersionStrategy.class); + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("https://example.com/test/123")); + assertThat(versionStrategy.resolveVersion(request)).isEqualTo("123"); + }); + } + + @Test + void apiVersionUseMediaTypeParameterPropertyIsApplied() { + this.contextRunner + .withPropertyValues("spring.webflux.apiversion.use.media-type-parameter[application/json]=mtpv") + .run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", + DefaultApiVersionStrategy.class); + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("https://example.com") + .header("content-type", "application/json;mtpv=123")); + assertThat(versionStrategy.resolveVersion(request)).isEqualTo("123"); + }); + } + + @Test + void apiVersionBeansAreInjected() { + this.contextRunner.withUserConfiguration(ApiVersionConfiguration.class).run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", + DefaultApiVersionStrategy.class); + assertThat(versionStrategy).extracting("versionResolvers") + .asInstanceOf(InstanceOfAssertFactories.LIST) + .containsExactly(context.getBean(ApiVersionResolver.class)); + assertThat(versionStrategy).extracting("deprecationHandler") + .isEqualTo(context.getBean(ApiVersionDeprecationHandler.class)); + assertThat(versionStrategy).extracting("versionParser").isEqualTo(context.getBean(ApiVersionParser.class)); + }); + } + private ContextConsumer assertExchangeWithSession( Consumer exchange) { return (context) -> { @@ -1187,4 +1294,24 @@ class WebFluxAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class ApiVersionConfiguration { + + @Bean + ApiVersionResolver apiVersionResolver() { + return (request) -> "latest"; + } + + @Bean + ApiVersionDeprecationHandler apiVersionDeprecationHandler(ApiVersionParser apiVersionParser) { + return new StandardApiVersionDeprecationHandler(apiVersionParser); + } + + @Bean + ApiVersionParser apiVersionParser() { + return (version) -> String.valueOf(version); + } + + } + } diff --git a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration.java b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration.java index 25192968e4f..57b3d9588e9 100644 --- a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration.java +++ b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration.java @@ -50,6 +50,7 @@ import org.springframework.boot.autoconfigure.web.WebResourcesRuntimeHints; import org.springframework.boot.autoconfigure.web.format.DateTimeFormatters; import org.springframework.boot.autoconfigure.web.format.WebConversionService; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.boot.http.converter.autoconfigure.HttpMessageConverters; import org.springframework.boot.servlet.filter.OrderedFormContentFilter; @@ -57,6 +58,8 @@ import org.springframework.boot.servlet.filter.OrderedHiddenHttpMethodFilter; import org.springframework.boot.servlet.filter.OrderedRequestContextFilter; import org.springframework.boot.validation.autoconfigure.ValidatorAdapter; import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties.Apiversion; +import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties.Apiversion.Use; import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties.Format; import org.springframework.context.ApplicationContext; import org.springframework.context.ResourceLoaderAware; @@ -79,6 +82,10 @@ import org.springframework.util.CollectionUtils; import org.springframework.validation.DefaultMessageCodesResolver; import org.springframework.validation.MessageCodesResolver; import org.springframework.validation.Validator; +import org.springframework.web.accept.ApiVersionDeprecationHandler; +import org.springframework.web.accept.ApiVersionParser; +import org.springframework.web.accept.ApiVersionResolver; +import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.ContentNegotiationManager; import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; import org.springframework.web.context.ServletContextAware; @@ -94,6 +101,7 @@ import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.RequestToViewNameTranslator; import org.springframework.web.servlet.View; import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer; import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer; import org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration; @@ -197,11 +205,20 @@ public final class WebMvcAutoConfiguration { private ServletContext servletContext; + private final ObjectProvider apiVersionResolvers; + + private final ObjectProvider> apiVersionParser; + + private final ObjectProvider apiVersionDeprecationHandler; + WebMvcAutoConfigurationAdapter(WebProperties webProperties, WebMvcProperties mvcProperties, ListableBeanFactory beanFactory, ObjectProvider messageConvertersProvider, ObjectProvider resourceHandlerRegistrationCustomizerProvider, ObjectProvider dispatcherServletPath, - ObjectProvider> servletRegistrations) { + ObjectProvider> servletRegistrations, + ObjectProvider apiVersionResolvers, + ObjectProvider> apiVersionParser, + ObjectProvider apiVersionDeprecationHandler) { this.resourceProperties = webProperties.getResources(); this.mvcProperties = mvcProperties; this.beanFactory = beanFactory; @@ -209,6 +226,9 @@ public final class WebMvcAutoConfiguration { this.resourceHandlerRegistrationCustomizer = resourceHandlerRegistrationCustomizerProvider.getIfAvailable(); this.dispatcherServletPath = dispatcherServletPath; this.servletRegistrations = servletRegistrations; + this.apiVersionResolvers = apiVersionResolvers; + this.apiVersionParser = apiVersionParser; + this.apiVersionDeprecationHandler = apiVersionDeprecationHandler; } @Override @@ -366,6 +386,29 @@ public final class WebMvcAutoConfiguration { } } + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + Apiversion properties = this.mvcProperties.getApiversion(); + map.from(properties::isRequired).to(configurer::setVersionRequired); + map.from(properties::getDefaultVersion).to(configurer::setDefaultVersion); + map.from(properties::getSupported).to((supported) -> supported.forEach(configurer::addSupportedVersions)); + map.from(properties::isDetectSupported).to(configurer::detectSupportedVersions); + configureApiVersioningUse(configurer, properties.getUse()); + this.apiVersionResolvers.orderedStream().forEach(configurer::useVersionResolver); + this.apiVersionParser.ifAvailable(configurer::setVersionParser); + this.apiVersionDeprecationHandler.ifAvailable(configurer::setDeprecationHandler); + } + + private void configureApiVersioningUse(ApiVersionConfigurer configurer, Use use) { + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(use::getHeader).whenHasText().to(configurer::useRequestHeader); + map.from(use::getRequestParameter).whenHasText().to(configurer::useRequestParam); + map.from(use::getPathSegment).to(configurer::usePathSegment); + use.getMediaTypeParameter() + .forEach((mediaType, parameterName) -> configurer.useMediaTypeParameter(mediaType, parameterName)); + } + @Bean @ConditionalOnMissingBean({ RequestContextListener.class, RequestContextFilter.class }) @ConditionalOnMissingFilterBean @@ -575,6 +618,12 @@ public final class WebMvcAutoConfiguration { this.resourceLoader = resourceLoader; } + @Override + @ConditionalOnMissingBean(name = "mvcApiVersionStrategy") + public ApiVersionStrategy mvcApiVersionStrategy() { + return super.mvcApiVersionStrategy(); + } + } @Configuration(proxyBeanMethods = false) diff --git a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcProperties.java b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcProperties.java index 675db799829..e0bc81e65d0 100644 --- a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcProperties.java +++ b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcProperties.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.Name; import org.springframework.http.MediaType; import org.springframework.util.Assert; import org.springframework.validation.DefaultMessageCodesResolver; @@ -97,6 +98,8 @@ public class WebMvcProperties { private final Problemdetails problemdetails = new Problemdetails(); + private final Apiversion apiversion = new Apiversion(); + public DefaultMessageCodesResolver.Format getMessageCodesResolverFormat() { return this.messageCodesResolverFormat; } @@ -189,6 +192,10 @@ public class WebMvcProperties { return this.problemdetails; } + public Apiversion getApiversion() { + return this.apiversion; + } + public static class Async { /** @@ -442,6 +449,9 @@ public class WebMvcProperties { } + /** + * Problem Details. + */ public static class Problemdetails { /** @@ -459,4 +469,129 @@ public class WebMvcProperties { } + /** + * API Version. + */ + public static class Apiversion { + + /** + * Whether the API version is required with each request. + */ + private boolean required = false; + + /** + * Default version that should be used for each request. + */ + @Name("default") + private String defaultVersion; + + /** + * Supported versions. + */ + private List supported; + + /** + * Whether supported versions should be detected from controllers. + */ + private boolean detectSupported = true; + + /** + * How version details should be inserted into requests. + */ + private final Use use = new Use(); + + public boolean isRequired() { + return this.required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public String getDefaultVersion() { + return this.defaultVersion; + } + + public void setDefaultVersion(String defaultVersion) { + this.defaultVersion = defaultVersion; + } + + public List getSupported() { + return this.supported; + } + + public void setSupported(List supported) { + this.supported = supported; + } + + public Use getUse() { + return this.use; + } + + public boolean isDetectSupported() { + return this.detectSupported; + } + + public void setDetectSupported(boolean detectSupported) { + this.detectSupported = detectSupported; + } + + public static class Use { + + /** + * Use the HTTP header with the given name to obtain the version. + */ + private String header; + + /** + * Use the query parameter with the given name to obtain the version. + */ + private String requestParameter; + + /** + * Use the path segment at the given index to obtain the version. + */ + private Integer pathSegment; + + /** + * Use the media type parameter with the given name to obtain the version. + */ + private Map mediaTypeParameter = new LinkedHashMap<>(); + + public String getHeader() { + return this.header; + } + + public void setHeader(String header) { + this.header = header; + } + + public String getRequestParameter() { + return this.requestParameter; + } + + public void setRequestParameter(String queryParameter) { + this.requestParameter = queryParameter; + } + + public Integer getPathSegment() { + return this.pathSegment; + } + + public void setPathSegment(Integer pathSegment) { + this.pathSegment = pathSegment; + } + + public Map getMediaTypeParameter() { + return this.mediaTypeParameter; + } + + public void setMediaTypeParameter(Map mediaTypeParameter) { + this.mediaTypeParameter = mediaTypeParameter; + } + + } + + } + } diff --git a/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfigurationTests.java b/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfigurationTests.java index 619d01b10c2..a157bf2d1b4 100644 --- a/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfigurationTests.java +++ b/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfigurationTests.java @@ -83,14 +83,23 @@ import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.RequestPath; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.StringUtils; import org.springframework.validation.Validator; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.accept.ApiVersionDeprecationHandler; +import org.springframework.web.accept.ApiVersionParser; +import org.springframework.web.accept.ApiVersionResolver; +import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.accept.DefaultApiVersionStrategy; import org.springframework.web.accept.FixedContentNegotiationStrategy; +import org.springframework.web.accept.InvalidApiVersionException; +import org.springframework.web.accept.MissingApiVersionException; import org.springframework.web.accept.ParameterContentNegotiationStrategy; +import org.springframework.web.accept.StandardApiVersionDeprecationHandler; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; @@ -141,9 +150,11 @@ import org.springframework.web.servlet.support.SessionFlashMapManager; import org.springframework.web.servlet.view.AbstractView; import org.springframework.web.servlet.view.ContentNegotiatingViewResolver; import org.springframework.web.servlet.view.DefaultRequestToViewNameTranslator; +import org.springframework.web.util.ServletRequestPathUtils; import org.springframework.web.util.UrlPathHelper; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.Mockito.mock; /** @@ -1007,6 +1018,92 @@ class WebMvcAutoConfigurationTests { OrderedControllerAdviceBeansConfiguration.LowestOrderedControllerAdvice.class)); } + @Test + void apiVersionPropertiesAreApplied() { + this.contextRunner + .withPropertyValues("spring.mvc.apiversion.use.header=version", "spring.mvc.apiversion.required=true", + "spring.mvc.apiversion.supported=123,456", "spring.mvc.apiversion.detect-supported=false") + .run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", + DefaultApiVersionStrategy.class); + assertThatExceptionOfType(MissingApiVersionException.class) + .isThrownBy(() -> versionStrategy.validateVersion(null, new MockHttpServletRequest())); + assertThatExceptionOfType(InvalidApiVersionException.class).isThrownBy(() -> versionStrategy + .validateVersion(versionStrategy.parseVersion("789"), new MockHttpServletRequest())); + assertThat(versionStrategy.detectSupportedVersions()).isFalse(); + }); + } + + @Test + void apiVersionDefaultVersionPropertyIsApplied() { + this.contextRunner + .withPropertyValues("spring.mvc.apiversion.use.header=version", "spring.mvc.apiversion.default=1.0.0") + .run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", + DefaultApiVersionStrategy.class); + versionStrategy.addSupportedVersion("1.0.0"); + Comparable version = versionStrategy.parseVersion("1.0.0"); + assertThat(versionStrategy.getDefaultVersion()).isEqualTo(version); + versionStrategy.validateVersion(version, new MockHttpServletRequest()); + versionStrategy.validateVersion(null, new MockHttpServletRequest()); + }); + } + + @Test + void apiVersionUseHeaderPropertyIsApplied() { + this.contextRunner.withPropertyValues("spring.mvc.apiversion.use.header=hv").run((context) -> { + ApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", ApiVersionStrategy.class); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("hv", "123"); + assertThat(versionStrategy.resolveVersion(request)).isEqualTo("123"); + }); + } + + @Test + void apiVersionUseRequestParameterPropertyIsApplied() { + this.contextRunner.withPropertyValues("spring.mvc.apiversion.use.request-parameter=rpv").run((context) -> { + ApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", ApiVersionStrategy.class); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addParameter("rpv", "123"); + assertThat(versionStrategy.resolveVersion(request)).isEqualTo("123"); + }); + } + + @Test + void apiVersionUsePathSegmentPropertyIsApplied() { + this.contextRunner.withPropertyValues("spring.mvc.apiversion.use.path-segment=1").run((context) -> { + ApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", ApiVersionStrategy.class); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "https://example.com/test/123"); + ServletRequestPathUtils.setParsedRequestPath(RequestPath.parse("/test/123", "/"), request); + assertThat(versionStrategy.resolveVersion(request)).isEqualTo("123"); + }); + } + + @Test + void apiVersionUseMediaTypeParameterPropertyIsApplied() { + this.contextRunner.withPropertyValues("spring.mvc.apiversion.use.media-type-parameter[application/json]=mtpv") + .run((context) -> { + ApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", ApiVersionStrategy.class); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeaders.CONTENT_TYPE, "application/json;mtpv=123"); + assertThat(versionStrategy.resolveVersion(request)).isEqualTo("123"); + }); + } + + @Test + void apiVersionBeansAreInjected() { + this.contextRunner.withUserConfiguration(ApiVersionConfiguration.class).run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", + DefaultApiVersionStrategy.class); + assertThat(versionStrategy).extracting("versionResolvers") + .asInstanceOf(InstanceOfAssertFactories.LIST) + .containsExactly(context.getBean(ApiVersionResolver.class)); + assertThat(versionStrategy).extracting("deprecationHandler") + .isEqualTo(context.getBean(ApiVersionDeprecationHandler.class)); + assertThat(versionStrategy).extracting("versionParser").isEqualTo(context.getBean(ApiVersionParser.class)); + }); + } + private void assertResourceHttpRequestHandler(AssertableWebApplicationContext context, Consumer handlerConsumer) { Map handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class)); @@ -1568,4 +1665,24 @@ class WebMvcAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class ApiVersionConfiguration { + + @Bean + ApiVersionResolver apiVersionResolver() { + return (request) -> "latest"; + } + + @Bean + ApiVersionDeprecationHandler apiVersionDeprecationHandler(ApiVersionParser apiVersionParser) { + return new StandardApiVersionDeprecationHandler(apiVersionParser); + } + + @Bean + ApiVersionParser apiVersionParser() { + return (version) -> String.valueOf(version); + } + + } + }