Browse Source

Add API versioning auto-configuration and properties support

Update `RestClient`, `WebClient`, Spring MVC and Spring WebFlux
auto-configuration to support API versioning.

Closes gh-46519
pull/46602/head
Phillip Webb 8 months ago
parent
commit
707388beff
  1. 29
      documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/io/rest-client.adoc
  2. 32
      documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/reactive.adoc
  3. 32
      documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc
  4. 98
      module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/ApiversionProperties.java
  5. 127
      module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/PropertiesApiVersionInserter.java
  6. 91
      module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/PropertiesApiVersionInserterTests.java
  7. 20
      module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/AbstractRestClientProperties.java
  8. 76
      module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/PropertiesRestClientCustomizer.java
  9. 13
      module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.java
  10. 13
      module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurer.java
  11. 31
      module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientProperties.java
  12. 5
      module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpClientServiceProperties.java
  13. 8
      module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpServiceClientAutoConfiguration.java
  14. 33
      module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/RestClientPropertiesHttpServiceGroupConfigurer.java
  15. 76
      module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/PropertiesRestClientCustomizerTests.java
  16. 66
      module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfigurationTests.java
  17. 3
      module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurerTests.java
  18. 20
      module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/AbstractWebClientProperties.java
  19. 76
      module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/PropertiesWebClientCustomizer.java
  20. 11
      module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfiguration.java
  21. 31
      module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientProperties.java
  22. 5
      module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpClientServiceProperties.java
  23. 9
      module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpServiceClientAutoConfiguration.java
  24. 33
      module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/WebClientPropertiesHttpServiceGroupConfigurer.java
  25. 76
      module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/PropertiesWebClientCustomizerTests.java
  26. 67
      module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfigurationTests.java
  27. 51
      module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfiguration.java
  28. 134
      module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxProperties.java
  29. 127
      module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfigurationTests.java
  30. 51
      module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration.java
  31. 135
      module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcProperties.java
  32. 117
      module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfigurationTests.java

29
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 @@ -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.

32
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: @@ -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

32
documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc

@ -465,6 +465,38 @@ include-code::MyCorsConfiguration[] @@ -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

98
module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/ApiversionProperties.java

@ -0,0 +1,98 @@ @@ -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;
}
}
}

127
module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/PropertiesApiVersionInserter.java

@ -0,0 +1,127 @@ @@ -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<ApiVersionInserter> inserters;
private PropertiesApiVersionInserter(List<ApiVersionInserter> 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<ApiversionProperties> propertiesStream) {
List<ApiVersionInserter> 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> T counted(T value) {
this.empty = false;
return value;
}
boolean isEmpty() {
return this.empty;
}
}
}

91
module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/PropertiesApiVersionInserterTests.java

@ -0,0 +1,91 @@ @@ -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");
}
}

20
module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/AbstractHttpClientServiceProperties.java → module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/AbstractRestClientProperties.java

@ -14,23 +14,27 @@ @@ -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 @@ -44,6 +48,12 @@ public abstract class AbstractHttpClientServiceProperties extends AbstractHttpRe
*/
private Map<String, List<String>> 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 @@ -60,4 +70,8 @@ public abstract class AbstractHttpClientServiceProperties extends AbstractHttpRe
this.defaultHeader = defaultHeaders;
}
public ApiversionProperties getApiversion() {
return this.apiversion;
}
}

76
module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/PropertiesRestClientCustomizer.java

@ -0,0 +1,76 @@ @@ -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<HttpHeaders> putAllHeaders(Map<String, List<String>> defaultHeaders) {
return (httpHeaders) -> httpHeaders.putAll(defaultHeaders);
}
}

13
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; @@ -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; @@ -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; @@ -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 { @@ -73,11 +78,15 @@ public final class RestClientAutoConfiguration {
RestClientBuilderConfigurer restClientBuilderConfigurer(
ObjectProvider<ClientHttpRequestFactoryBuilder<?>> clientHttpRequestFactoryBuilder,
ObjectProvider<ClientHttpRequestFactorySettings> clientHttpRequestFactorySettings,
ObjectProvider<RestClientCustomizer> customizerProvider) {
ObjectProvider<RestClientCustomizer> customizerProvider,
ObjectProvider<ApiVersionInserter> apiVersionInserter,
ObjectProvider<ApiVersionFormatter> 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

13
module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurer.java

@ -43,15 +43,19 @@ public class RestClientBuilderConfigurer { @@ -43,15 +43,19 @@ public class RestClientBuilderConfigurer {
private final List<RestClientCustomizer> 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<RestClientCustomizer> customizers) {
ClientHttpRequestFactorySettings requestFactorySettings,
PropertiesRestClientCustomizer propertiesCustomizer, List<RestClientCustomizer> customizers) {
this.requestFactoryBuilder = requestFactoryBuilder;
this.requestFactorySettings = requestFactorySettings;
this.propertiesCustomizer = propertiesCustomizer;
this.customizers = customizers;
}
@ -67,7 +71,10 @@ public class RestClientBuilderConfigurer { @@ -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);
}

31
module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientProperties.java

@ -0,0 +1,31 @@ @@ -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 {
}

5
module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/HttpClientServiceProperties.java

@ -20,6 +20,7 @@ import java.util.LinkedHashMap; @@ -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; @@ -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 @@ -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 {
}

8
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 @@ -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 @@ -68,10 +70,12 @@ public final class HttpServiceClientAutoConfiguration implements BeanClassLoader
ObjectProvider<SslBundles> sslBundles, ObjectProvider<HttpClientProperties> httpClientProperties,
HttpClientServiceProperties serviceProperties,
ObjectProvider<ClientHttpRequestFactoryBuilder<?>> clientFactoryBuilder,
ObjectProvider<ClientHttpRequestFactorySettings> clientHttpRequestFactorySettings) {
ObjectProvider<ClientHttpRequestFactorySettings> clientHttpRequestFactorySettings,
ObjectProvider<ApiVersionInserter> apiVersionInserter,
ObjectProvider<ApiVersionFormatter> apiVersionFormatter) {
return new RestClientPropertiesHttpServiceGroupConfigurer(this.beanClassLoader, sslBundles,
httpClientProperties.getIfAvailable(), serviceProperties, clientFactoryBuilder,
clientHttpRequestFactorySettings);
clientHttpRequestFactorySettings, apiVersionInserter, apiVersionFormatter);
}
@Bean

33
module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/service/RestClientPropertiesHttpServiceGroupConfigurer.java

@ -16,20 +16,17 @@ @@ -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 @@ -55,16 +52,24 @@ class RestClientPropertiesHttpServiceGroupConfigurer implements RestClientHttpSe
private final ObjectProvider<ClientHttpRequestFactorySettings> requestFactorySettings;
private final ApiVersionInserter apiVersionInserter;
private final ApiVersionFormatter apiVersionFormatter;
RestClientPropertiesHttpServiceGroupConfigurer(ClassLoader classLoader, ObjectProvider<SslBundles> sslBundles,
HttpClientProperties clientProperties, HttpClientServiceProperties serviceProperties,
ObjectProvider<ClientHttpRequestFactoryBuilder<?>> requestFactoryBuilder,
ObjectProvider<ClientHttpRequestFactorySettings> requestFactorySettings) {
ObjectProvider<ClientHttpRequestFactorySettings> requestFactorySettings,
ObjectProvider<ApiVersionInserter> apiVersionInserter,
ObjectProvider<ApiVersionFormatter> 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 @@ -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<HttpHeaders> putAllHeaders(Map<String, List<String>> 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) {

76
module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/PropertiesRestClientCustomizerTests.java

@ -0,0 +1,76 @@ @@ -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 {
}
}

66
module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfigurationTests.java

@ -16,8 +16,10 @@ @@ -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; @@ -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 { @@ -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 { @@ -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);
}
}
}

3
module/spring-boot-restclient/src/test/java/org/springframework/boot/restclient/autoconfigure/RestClientBuilderConfigurerTests.java

@ -55,9 +55,8 @@ class RestClientBuilderConfigurerTests { @@ -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);

20
module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/AbstractHttpReactiveClientServiceProperties.java → module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/AbstractWebClientProperties.java

@ -14,23 +14,27 @@ @@ -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 @@ -44,6 +48,12 @@ public abstract class AbstractHttpReactiveClientServiceProperties extends Abstra
*/
private Map<String, List<String>> 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 @@ -60,4 +70,8 @@ public abstract class AbstractHttpReactiveClientServiceProperties extends Abstra
this.defaultHeader = defaultHeaders;
}
public ApiversionProperties getApiversion() {
return this.apiversion;
}
}

76
module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/PropertiesWebClientCustomizer.java

@ -0,0 +1,76 @@ @@ -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<HttpHeaders> putAllHeaders(Map<String, List<String>> defaultHeaders) {
return (httpHeaders) -> httpHeaders.putAll(defaultHeaders);
}
}

11
module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfiguration.java

@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -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; @@ -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; @@ -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<WebClientCustomizer> customizerProvider) {
WebClient.Builder webClientBuilder(ObjectProvider<WebClientCustomizer> customizerProvider,
ObjectProvider<ApiVersionInserter> apiVersionInserter,
ObjectProvider<ApiVersionFormatter> 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;
}

31
module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/WebClientProperties.java

@ -0,0 +1,31 @@ @@ -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 {
}

5
module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/ReactiveHttpClientServiceProperties.java

@ -20,6 +20,7 @@ import java.util.LinkedHashMap; @@ -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; @@ -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 @@ -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 {
}

9
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; @@ -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 @@ -66,9 +68,12 @@ public final class ReactiveHttpServiceClientAutoConfiguration implements BeanCla
ObjectProvider<SslBundles> sslBundles, HttpReactiveClientProperties httpReactiveClientProperties,
ReactiveHttpClientServiceProperties serviceProperties,
ObjectProvider<ClientHttpConnectorBuilder<?>> clientConnectorBuilder,
ObjectProvider<ClientHttpConnectorSettings> clientConnectorSettings) {
ObjectProvider<ClientHttpConnectorSettings> clientConnectorSettings,
ObjectProvider<ApiVersionInserter> apiVersionInserter,
ObjectProvider<ApiVersionFormatter> apiVersionFormatter) {
return new WebClientPropertiesHttpServiceGroupConfigurer(this.beanClassLoader, sslBundles,
httpReactiveClientProperties, serviceProperties, clientConnectorBuilder, clientConnectorSettings);
httpReactiveClientProperties, serviceProperties, clientConnectorBuilder, clientConnectorSettings,
apiVersionInserter, apiVersionFormatter);
}
@Bean

33
module/spring-boot-webclient/src/main/java/org/springframework/boot/webclient/autoconfigure/service/WebClientPropertiesHttpServiceGroupConfigurer.java

@ -16,20 +16,17 @@ @@ -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 @@ -57,16 +54,24 @@ class WebClientPropertiesHttpServiceGroupConfigurer implements WebClientHttpServ
private final ObjectProvider<ClientHttpConnectorSettings> clientConnectorSettings;
private final ApiVersionInserter apiVersionInserter;
private final ApiVersionFormatter apiVersionFormatter;
WebClientPropertiesHttpServiceGroupConfigurer(ClassLoader classLoader, ObjectProvider<SslBundles> sslBundles,
HttpReactiveClientProperties clientProperties, ReactiveHttpClientServiceProperties serviceProperties,
ObjectProvider<ClientHttpConnectorBuilder<?>> clientConnectorBuilder,
ObjectProvider<ClientHttpConnectorSettings> clientConnectorSettings) {
ObjectProvider<ClientHttpConnectorSettings> clientConnectorSettings,
ObjectProvider<ApiVersionInserter> apiVersionInserter,
ObjectProvider<ApiVersionFormatter> 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 @@ -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<HttpHeaders> putAllHeaders(Map<String, List<String>> 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) {

76
module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/PropertiesWebClientCustomizerTests.java

@ -0,0 +1,76 @@ @@ -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 {
}
}

67
module/spring-boot-webclient/src/test/java/org/springframework/boot/webclient/autoconfigure/WebClientAutoConfigurationTests.java

@ -16,6 +16,9 @@ @@ -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; @@ -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; @@ -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 { @@ -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 { @@ -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);
}
}
}

51
module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfiguration.java

@ -21,6 +21,7 @@ import java.util.List; @@ -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; @@ -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; @@ -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 { @@ -169,11 +178,19 @@ public final class WebFluxAutoConfiguration {
private final ObjectProvider<ViewResolver> viewResolvers;
private final ObjectProvider<ApiVersionResolver> apiVersionResolvers;
private final ObjectProvider<ApiVersionParser<?>> apiVersionParser;
private final ObjectProvider<ApiVersionDeprecationHandler> apiVersionDeprecationHandler;
WebFluxConfig(Environment environment, WebProperties webProperties, WebFluxProperties webFluxProperties,
ListableBeanFactory beanFactory, ObjectProvider<HandlerMethodArgumentResolver> resolvers,
ObjectProvider<CodecCustomizer> codecCustomizers,
ObjectProvider<ResourceHandlerRegistrationCustomizer> resourceHandlerRegistrationCustomizers,
ObjectProvider<ViewResolver> viewResolvers) {
ObjectProvider<ViewResolver> viewResolvers, ObjectProvider<ApiVersionResolver> apiVersionResolvers,
ObjectProvider<ApiVersionParser<?>> apiVersionParser,
ObjectProvider<ApiVersionDeprecationHandler> apiVersionDeprecationHandler) {
this.environment = environment;
this.resourceProperties = webProperties.getResources();
this.webFluxProperties = webFluxProperties;
@ -182,6 +199,9 @@ public final class WebFluxAutoConfiguration { @@ -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 { @@ -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 { @@ -347,6 +390,12 @@ public final class WebFluxAutoConfiguration {
return webSessionManager;
}
@Override
@ConditionalOnMissingBean(name = "mvcApiVersionStrategy")
public @Nullable ApiVersionStrategy mvcApiVersionStrategy() {
return super.mvcApiVersionStrategy();
}
}
@Configuration(proxyBeanMethods = false)

134
module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxProperties.java

@ -16,7 +16,13 @@ @@ -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 { @@ -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 { @@ -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 { @@ -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<String> 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<String> getSupported() {
return this.supported;
}
public void setSupported(List<String> 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<MediaType, String> 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<MediaType, String> getMediaTypeParameter() {
return this.mediaTypeParameter;
}
public void setMediaTypeParameter(Map<MediaType, String> mediaTypeParameter) {
this.mediaTypeParameter = mediaTypeParameter;
}
}
}
}

127
module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfigurationTests.java

@ -85,11 +85,18 @@ import org.springframework.test.util.ReflectionTestUtils; @@ -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; @@ -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 { @@ -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<ReactiveWebApplicationContext> assertExchangeWithSession(
Consumer<MockServerWebExchange> exchange) {
return (context) -> {
@ -1187,4 +1294,24 @@ class WebFluxAutoConfigurationTests { @@ -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<String> apiVersionParser() {
return (version) -> String.valueOf(version);
}
}
}

51
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; @@ -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; @@ -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; @@ -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; @@ -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 { @@ -197,11 +205,20 @@ public final class WebMvcAutoConfiguration {
private ServletContext servletContext;
private final ObjectProvider<ApiVersionResolver> apiVersionResolvers;
private final ObjectProvider<ApiVersionParser<?>> apiVersionParser;
private final ObjectProvider<ApiVersionDeprecationHandler> apiVersionDeprecationHandler;
WebMvcAutoConfigurationAdapter(WebProperties webProperties, WebMvcProperties mvcProperties,
ListableBeanFactory beanFactory, ObjectProvider<HttpMessageConverters> messageConvertersProvider,
ObjectProvider<ResourceHandlerRegistrationCustomizer> resourceHandlerRegistrationCustomizerProvider,
ObjectProvider<DispatcherServletPath> dispatcherServletPath,
ObjectProvider<ServletRegistrationBean<?>> servletRegistrations) {
ObjectProvider<ServletRegistrationBean<?>> servletRegistrations,
ObjectProvider<ApiVersionResolver> apiVersionResolvers,
ObjectProvider<ApiVersionParser<?>> apiVersionParser,
ObjectProvider<ApiVersionDeprecationHandler> apiVersionDeprecationHandler) {
this.resourceProperties = webProperties.getResources();
this.mvcProperties = mvcProperties;
this.beanFactory = beanFactory;
@ -209,6 +226,9 @@ public final class WebMvcAutoConfiguration { @@ -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 { @@ -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 { @@ -575,6 +618,12 @@ public final class WebMvcAutoConfiguration {
this.resourceLoader = resourceLoader;
}
@Override
@ConditionalOnMissingBean(name = "mvcApiVersionStrategy")
public ApiVersionStrategy mvcApiVersionStrategy() {
return super.mvcApiVersionStrategy();
}
}
@Configuration(proxyBeanMethods = false)

135
module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcProperties.java

@ -23,6 +23,7 @@ import java.util.List; @@ -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 { @@ -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 { @@ -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 { @@ -442,6 +449,9 @@ public class WebMvcProperties {
}
/**
* Problem Details.
*/
public static class Problemdetails {
/**
@ -459,4 +469,129 @@ public class WebMvcProperties { @@ -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<String> 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<String> getSupported() {
return this.supported;
}
public void setSupported(List<String> 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<MediaType, String> 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<MediaType, String> getMediaTypeParameter() {
return this.mediaTypeParameter;
}
public void setMediaTypeParameter(Map<MediaType, String> mediaTypeParameter) {
this.mediaTypeParameter = mediaTypeParameter;
}
}
}
}

117
module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfigurationTests.java

@ -83,14 +83,23 @@ import org.springframework.http.CacheControl; @@ -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; @@ -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 { @@ -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<ResourceHttpRequestHandler> handlerConsumer) {
Map<String, Object> handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class));
@ -1568,4 +1665,24 @@ class WebMvcAutoConfigurationTests { @@ -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<String> apiVersionParser() {
return (version) -> String.valueOf(version);
}
}
}

Loading…
Cancel
Save