From 22e7f24731ffbb06ae48cc3bd4ce8facc997cdb1 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 5 May 2025 19:05:39 +0100 Subject: [PATCH] Add defaultApiVersion to RestClient and WebClient Closes gh-34857 --- .../web/client/DefaultRestClient.java | 21 +++- .../web/client/DefaultRestClientBuilder.java | 12 +- .../web/client/RestClient.java | 9 ++ .../web/client/RestClientVersionTests.java | 11 +- .../function/client/DefaultWebClient.java | 19 ++- .../client/DefaultWebClientBuilder.java | 11 +- .../reactive/function/client/WebClient.java | 9 ++ .../client/WebClientVersionTests.java | 115 ++++++++++++++++++ 8 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientVersionTests.java diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java index f054ba055dd..d39e43bd5f2 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java @@ -108,6 +108,8 @@ final class DefaultRestClient implements RestClient { private final @Nullable MultiValueMap defaultCookies; + private final @Nullable Object defaultApiVersion; + private final @Nullable ApiVersionInserter apiVersionInserter; private final @Nullable Consumer> defaultRequest; @@ -130,7 +132,7 @@ final class DefaultRestClient implements RestClient { UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders defaultHeaders, @Nullable MultiValueMap defaultCookies, - @Nullable ApiVersionInserter apiVersionInserter, + @Nullable Object defaultApiVersion, @Nullable ApiVersionInserter apiVersionInserter, @Nullable Consumer> defaultRequest, @Nullable List statusHandlers, List> messageConverters, @@ -145,6 +147,7 @@ final class DefaultRestClient implements RestClient { this.uriBuilderFactory = uriBuilderFactory; this.defaultHeaders = defaultHeaders; this.defaultCookies = defaultCookies; + this.defaultApiVersion = defaultApiVersion; this.apiVersionInserter = apiVersionInserter; this.defaultRequest = defaultRequest; this.defaultStatusHandlers = (statusHandlers != null ? new ArrayList<>(statusHandlers) : new ArrayList<>()); @@ -609,13 +612,18 @@ final class DefaultRestClient implements RestClient { private URI initUri() { URI uriToUse = this.uri != null ? this.uri : DefaultRestClient.this.uriBuilderFactory.expand(""); - if (this.apiVersion != null) { + Object version = getApiVersionOrDefault(); + if (version != null) { Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured"); - uriToUse = apiVersionInserter.insertVersion(this.apiVersion, uriToUse); + uriToUse = apiVersionInserter.insertVersion(version, uriToUse); } return uriToUse; } + private @Nullable Object getApiVersionOrDefault() { + return (this.apiVersion != null ? this.apiVersion : DefaultRestClient.this.defaultApiVersion); + } + private @Nullable String serializeCookies() { MultiValueMap map; MultiValueMap defaultCookies = DefaultRestClient.this.defaultCookies; @@ -652,7 +660,8 @@ final class DefaultRestClient implements RestClient { private @Nullable HttpHeaders initHeaders() { HttpHeaders defaultHeaders = DefaultRestClient.this.defaultHeaders; - if (this.apiVersion == null) { + Object version = getApiVersionOrDefault(); + if (version == null) { if (this.headers == null || this.headers.isEmpty()) { return defaultHeaders; } @@ -669,9 +678,9 @@ final class DefaultRestClient implements RestClient { result.putAll(this.headers); } - if (this.apiVersion != null) { + if (version != null) { Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured"); - apiVersionInserter.insertVersion(this.apiVersion, result); + apiVersionInserter.insertVersion(version, result); } return result; diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java index 933ab940ccd..f59a4aa1537 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java @@ -116,7 +116,6 @@ final class DefaultRestClientBuilder implements RestClient.Builder { private static final boolean kotlinSerializationProtobufPresent; - static { ClassLoader loader = DefaultRestClientBuilder.class.getClassLoader(); @@ -150,6 +149,8 @@ final class DefaultRestClientBuilder implements RestClient.Builder { private @Nullable MultiValueMap defaultCookies; + private @Nullable Object defaultApiVersion; + private @Nullable ApiVersionInserter apiVersionInserter; private @Nullable Consumer> defaultRequest; @@ -188,6 +189,7 @@ final class DefaultRestClientBuilder implements RestClient.Builder { this.defaultHeaders = null; } this.defaultCookies = (other.defaultCookies != null ? new LinkedMultiValueMap<>(other.defaultCookies) : null); + this.defaultApiVersion = other.defaultApiVersion; this.apiVersionInserter = other.apiVersionInserter; this.defaultRequest = other.defaultRequest; this.statusHandlers = (other.statusHandlers != null ? new ArrayList<>(other.statusHandlers) : null); @@ -324,6 +326,12 @@ final class DefaultRestClientBuilder implements RestClient.Builder { return this.defaultCookies; } + @Override + public RestClient.Builder defaultApiVersion(@Nullable Object version) { + this.defaultApiVersion = version; + return this; + } + @Override public RestClient.Builder apiVersionInserter(ApiVersionInserter apiVersionInserter) { this.apiVersionInserter = apiVersionInserter; @@ -521,7 +529,7 @@ final class DefaultRestClientBuilder implements RestClient.Builder { return new DefaultRestClient( requestFactory, this.interceptors, this.bufferingPredicate, this.initializers, - uriBuilderFactory, defaultHeaders, defaultCookies, + uriBuilderFactory, defaultHeaders, defaultCookies, this.defaultApiVersion, this.apiVersionInserter, this.defaultRequest, this.statusHandlers, converters, this.observationRegistry, this.observationConvention, diff --git a/spring-web/src/main/java/org/springframework/web/client/RestClient.java b/spring-web/src/main/java/org/springframework/web/client/RestClient.java index 7973be15a31..9804bf55bab 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestClient.java @@ -332,6 +332,15 @@ public interface RestClient { */ Builder defaultCookies(Consumer> cookiesConsumer); + /** + * Global option to specify an API version to be added to every request, + * if not explicitly set. + * @param version the version to use + * @return this builder + * @since 7.0 + */ + Builder defaultApiVersion(Object version); + /** * Configure an {@link ApiVersionInserter} to abstract how an API version * specified via {@link RequestHeadersSpec#apiVersion(Object)} diff --git a/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java b/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java index 98e8eb7c1c8..d607f182b3a 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java @@ -86,7 +86,16 @@ public class RestClientVersionTests { assertThatIllegalStateException() .isThrownBy(() -> performRequest(DefaultApiVersionInserter.fromPathSegment(2))) .withMessage("Cannot insert version into '/path' at path segment index 2"); - } + } + + @Test + void defaultVersion() { + ApiVersionInserter inserter = DefaultApiVersionInserter.fromHeader("X-API-Version").build(); + RestClient restClient = restClientBuilder.defaultApiVersion(1.2).apiVersionInserter(inserter).build(); + restClient.get().uri("/path").retrieve().body(String.class); + + expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2")); + } private void performRequest(DefaultApiVersionInserter.Builder builder) { ApiVersionInserter versionInserter = builder.build(); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java index 09a4ae1b52a..f16d0c1a463 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java @@ -94,6 +94,8 @@ final class DefaultWebClient implements WebClient { private final @Nullable MultiValueMap defaultCookies; + private final @Nullable Object defaultApiVersion; + private final @Nullable ApiVersionInserter apiVersionInserter; private final @Nullable Consumer> defaultRequest; @@ -110,7 +112,7 @@ final class DefaultWebClient implements WebClient { DefaultWebClient(ExchangeFunction exchangeFunction, @Nullable ExchangeFilterFunction filterFunctions, UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders defaultHeaders, @Nullable MultiValueMap defaultCookies, - @Nullable ApiVersionInserter apiVersionInserter, + @Nullable Object defaultApiVersion, @Nullable ApiVersionInserter apiVersionInserter, @Nullable Consumer> defaultRequest, @Nullable Map, Function>> statusHandlerMap, ObservationRegistry observationRegistry, @Nullable ClientRequestObservationConvention observationConvention, @@ -121,6 +123,7 @@ final class DefaultWebClient implements WebClient { this.uriBuilderFactory = uriBuilderFactory; this.defaultHeaders = defaultHeaders; this.defaultCookies = defaultCookies; + this.defaultApiVersion = defaultApiVersion; this.apiVersionInserter = apiVersionInserter; this.defaultRequest = defaultRequest; this.defaultStatusHandlers = initStatusHandlers(statusHandlerMap); @@ -491,13 +494,18 @@ final class DefaultWebClient implements WebClient { private URI initUri() { URI uriToUse = (this.uri != null ? this.uri : uriBuilderFactory.expand("")); - if (this.apiVersion != null) { + Object version = getApiVersionOrDefault(); + if (version != null) { Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured"); - uriToUse = apiVersionInserter.insertVersion(this.apiVersion, uriToUse); + uriToUse = apiVersionInserter.insertVersion(version, uriToUse); } return uriToUse; } + private @Nullable Object getApiVersionOrDefault() { + return (this.apiVersion != null ? this.apiVersion : DefaultWebClient.this.defaultApiVersion); + } + private void initHeaders(HttpHeaders out) { if (defaultHeaders != null && !defaultHeaders.isEmpty()) { out.putAll(defaultHeaders); @@ -505,9 +513,10 @@ final class DefaultWebClient implements WebClient { if (this.headers != null && !this.headers.isEmpty()) { out.putAll(this.headers); } - if (this.apiVersion != null) { + Object version = getApiVersionOrDefault(); + if (version != null) { Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured"); - apiVersionInserter.insertVersion(this.apiVersion, out); + apiVersionInserter.insertVersion(version, out); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java index 530fb42ef78..69c71252a95 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java @@ -81,6 +81,8 @@ final class DefaultWebClientBuilder implements WebClient.Builder { private @Nullable MultiValueMap defaultCookies; + private @Nullable Object defaultApiVersion; + private @Nullable ApiVersionInserter apiVersionInserter; private @Nullable Consumer> defaultRequest; @@ -122,6 +124,8 @@ final class DefaultWebClientBuilder implements WebClient.Builder { } this.defaultCookies = (other.defaultCookies != null ? new LinkedMultiValueMap<>(other.defaultCookies) : null); + + this.defaultApiVersion = other.defaultApiVersion; this.apiVersionInserter = other.apiVersionInserter; this.defaultRequest = other.defaultRequest; @@ -194,6 +198,11 @@ final class DefaultWebClientBuilder implements WebClient.Builder { return this.defaultCookies; } + @Override + public WebClient.Builder defaultApiVersion(Object version) { + this.defaultApiVersion = version; + return this; + } @Override public WebClient.Builder apiVersionInserter(ApiVersionInserter apiVersionInserter) { @@ -308,7 +317,7 @@ final class DefaultWebClientBuilder implements WebClient.Builder { return new DefaultWebClient( exchange, filterFunctions, initUriBuilderFactory(), defaultHeaders, defaultCookies, - this.apiVersionInserter, + this.defaultApiVersion, this.apiVersionInserter, this.defaultRequest, this.statusHandlers, this.observationRegistry, this.observationConvention, diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java index 9586783d765..976eff8f5d2 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java @@ -252,6 +252,15 @@ public interface WebClient { */ Builder defaultCookies(Consumer> cookiesConsumer); + /** + * Global option to specify an API version to add to every request, + * if not already set. + * @param version the version to use + * @return this builder + * @since 7.0 + */ + Builder defaultApiVersion(Object version); + /** * Configure an {@link ApiVersionInserter} to abstract how an API version * specified via {@link RequestHeadersSpec#apiVersion(Object)} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientVersionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientVersionTests.java new file mode 100644 index 00000000000..035cce64a00 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientVersionTests.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2025 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.web.reactive.function.client; + +import java.io.IOException; +import java.util.function.Consumer; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.web.client.ApiVersionInserter; +import org.springframework.web.client.DefaultApiVersionInserter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * {@link WebClient} tests for sending API versions. + * @author Rossen Stoyanchev + */ +public class WebClientVersionTests { + + private final MockWebServer server = new MockWebServer(); + + private final WebClient.Builder webClientBuilder = + WebClient.builder().baseUrl(this.server.url("/").toString()); + + + @BeforeEach + void setUp() { + MockResponse response = new MockResponse(); + response.setHeader("Content-Type", "text/plain").setBody("body"); + this.server.enqueue(response); + } + + @AfterEach + void shutdown() throws IOException { + this.server.shutdown(); + } + + + @Test + void header() { + performRequest(DefaultApiVersionInserter.fromHeader("X-API-Version")); + expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2")); + } + + @Test + void queryParam() { + performRequest(DefaultApiVersionInserter.fromQueryParam("api-version")); + expectRequest(request -> assertThat(request.getPath()).isEqualTo("/path?api-version=1.2")); + } + + @Test + void pathSegmentIndexLessThanSize() { + performRequest(DefaultApiVersionInserter.fromPathSegment(0).withVersionFormatter(v -> "v" + v)); + expectRequest(request -> assertThat(request.getPath()).isEqualTo("/v1.2/path")); + } + + @Test + void pathSegmentIndexEqualToSize() { + performRequest(DefaultApiVersionInserter.fromPathSegment(1).withVersionFormatter(v -> "v" + v)); + expectRequest(request -> assertThat(request.getPath()).isEqualTo("/path/v1.2")); + } + + @Test + void pathSegmentIndexGreaterThanSize() { + assertThatIllegalStateException() + .isThrownBy(() -> performRequest(DefaultApiVersionInserter.fromPathSegment(2))) + .withMessage("Cannot insert version into '/path' at path segment index 2"); + } + + @Test + void defaultVersion() { + ApiVersionInserter inserter = DefaultApiVersionInserter.fromHeader("X-API-Version").build(); + WebClient webClient = webClientBuilder.defaultApiVersion(1.2).apiVersionInserter(inserter).build(); + webClient.get().uri("/path").retrieve().bodyToMono(String.class).block(); + + expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2")); + } + + private void performRequest(DefaultApiVersionInserter.Builder builder) { + ApiVersionInserter versionInserter = builder.build(); + WebClient webClient = webClientBuilder.apiVersionInserter(versionInserter).build(); + webClient.get().uri("/path").apiVersion(1.2).retrieve().bodyToMono(String.class).block(); + } + + private void expectRequest(Consumer consumer) { + try { + consumer.accept(this.server.takeRequest()); + } + catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + } + +}