Skip to content

Commit 22e7f24

Browse files
committed
Add defaultApiVersion to RestClient and WebClient
Closes gh-34857
1 parent fef9691 commit 22e7f24

File tree

8 files changed

+192
-15
lines changed

8 files changed

+192
-15
lines changed

spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ final class DefaultRestClient implements RestClient {
108108

109109
private final @Nullable MultiValueMap<String, String> defaultCookies;
110110

111+
private final @Nullable Object defaultApiVersion;
112+
111113
private final @Nullable ApiVersionInserter apiVersionInserter;
112114

113115
private final @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest;
@@ -130,7 +132,7 @@ final class DefaultRestClient implements RestClient {
130132
UriBuilderFactory uriBuilderFactory,
131133
@Nullable HttpHeaders defaultHeaders,
132134
@Nullable MultiValueMap<String, String> defaultCookies,
133-
@Nullable ApiVersionInserter apiVersionInserter,
135+
@Nullable Object defaultApiVersion, @Nullable ApiVersionInserter apiVersionInserter,
134136
@Nullable Consumer<RequestHeadersSpec<?>> defaultRequest,
135137
@Nullable List<StatusHandler> statusHandlers,
136138
List<HttpMessageConverter<?>> messageConverters,
@@ -145,6 +147,7 @@ final class DefaultRestClient implements RestClient {
145147
this.uriBuilderFactory = uriBuilderFactory;
146148
this.defaultHeaders = defaultHeaders;
147149
this.defaultCookies = defaultCookies;
150+
this.defaultApiVersion = defaultApiVersion;
148151
this.apiVersionInserter = apiVersionInserter;
149152
this.defaultRequest = defaultRequest;
150153
this.defaultStatusHandlers = (statusHandlers != null ? new ArrayList<>(statusHandlers) : new ArrayList<>());
@@ -609,13 +612,18 @@ public <T> T exchangeForRequiredValue(RequiredValueExchangeFunction<T> exchangeF
609612

610613
private URI initUri() {
611614
URI uriToUse = this.uri != null ? this.uri : DefaultRestClient.this.uriBuilderFactory.expand("");
612-
if (this.apiVersion != null) {
615+
Object version = getApiVersionOrDefault();
616+
if (version != null) {
613617
Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
614-
uriToUse = apiVersionInserter.insertVersion(this.apiVersion, uriToUse);
618+
uriToUse = apiVersionInserter.insertVersion(version, uriToUse);
615619
}
616620
return uriToUse;
617621
}
618622

623+
private @Nullable Object getApiVersionOrDefault() {
624+
return (this.apiVersion != null ? this.apiVersion : DefaultRestClient.this.defaultApiVersion);
625+
}
626+
619627
private @Nullable String serializeCookies() {
620628
MultiValueMap<String, String> map;
621629
MultiValueMap<String, String> defaultCookies = DefaultRestClient.this.defaultCookies;
@@ -652,7 +660,8 @@ private static String serializeCookies(MultiValueMap<String, String> map) {
652660

653661
private @Nullable HttpHeaders initHeaders() {
654662
HttpHeaders defaultHeaders = DefaultRestClient.this.defaultHeaders;
655-
if (this.apiVersion == null) {
663+
Object version = getApiVersionOrDefault();
664+
if (version == null) {
656665
if (this.headers == null || this.headers.isEmpty()) {
657666
return defaultHeaders;
658667
}
@@ -669,9 +678,9 @@ else if (defaultHeaders == null || defaultHeaders.isEmpty()) {
669678
result.putAll(this.headers);
670679
}
671680

672-
if (this.apiVersion != null) {
681+
if (version != null) {
673682
Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
674-
apiVersionInserter.insertVersion(this.apiVersion, result);
683+
apiVersionInserter.insertVersion(version, result);
675684
}
676685

677686
return result;

spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ final class DefaultRestClientBuilder implements RestClient.Builder {
116116

117117
private static final boolean kotlinSerializationProtobufPresent;
118118

119-
120119
static {
121120
ClassLoader loader = DefaultRestClientBuilder.class.getClassLoader();
122121

@@ -150,6 +149,8 @@ final class DefaultRestClientBuilder implements RestClient.Builder {
150149

151150
private @Nullable MultiValueMap<String, String> defaultCookies;
152151

152+
private @Nullable Object defaultApiVersion;
153+
153154
private @Nullable ApiVersionInserter apiVersionInserter;
154155

155156
private @Nullable Consumer<RestClient.RequestHeadersSpec<?>> defaultRequest;
@@ -188,6 +189,7 @@ public DefaultRestClientBuilder(DefaultRestClientBuilder other) {
188189
this.defaultHeaders = null;
189190
}
190191
this.defaultCookies = (other.defaultCookies != null ? new LinkedMultiValueMap<>(other.defaultCookies) : null);
192+
this.defaultApiVersion = other.defaultApiVersion;
191193
this.apiVersionInserter = other.apiVersionInserter;
192194
this.defaultRequest = other.defaultRequest;
193195
this.statusHandlers = (other.statusHandlers != null ? new ArrayList<>(other.statusHandlers) : null);
@@ -324,6 +326,12 @@ private MultiValueMap<String, String> initCookies() {
324326
return this.defaultCookies;
325327
}
326328

329+
@Override
330+
public RestClient.Builder defaultApiVersion(@Nullable Object version) {
331+
this.defaultApiVersion = version;
332+
return this;
333+
}
334+
327335
@Override
328336
public RestClient.Builder apiVersionInserter(ApiVersionInserter apiVersionInserter) {
329337
this.apiVersionInserter = apiVersionInserter;
@@ -521,7 +529,7 @@ public RestClient build() {
521529

522530
return new DefaultRestClient(
523531
requestFactory, this.interceptors, this.bufferingPredicate, this.initializers,
524-
uriBuilderFactory, defaultHeaders, defaultCookies,
532+
uriBuilderFactory, defaultHeaders, defaultCookies, this.defaultApiVersion,
525533
this.apiVersionInserter, this.defaultRequest,
526534
this.statusHandlers, converters,
527535
this.observationRegistry, this.observationConvention,

spring-web/src/main/java/org/springframework/web/client/RestClient.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,15 @@ interface Builder {
332332
*/
333333
Builder defaultCookies(Consumer<MultiValueMap<String, String>> cookiesConsumer);
334334

335+
/**
336+
* Global option to specify an API version to be added to every request,
337+
* if not explicitly set.
338+
* @param version the version to use
339+
* @return this builder
340+
* @since 7.0
341+
*/
342+
Builder defaultApiVersion(Object version);
343+
335344
/**
336345
* Configure an {@link ApiVersionInserter} to abstract how an API version
337346
* specified via {@link RequestHeadersSpec#apiVersion(Object)}

spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,16 @@ void pathSegmentIndexGreaterThanSize() {
8686
assertThatIllegalStateException()
8787
.isThrownBy(() -> performRequest(DefaultApiVersionInserter.fromPathSegment(2)))
8888
.withMessage("Cannot insert version into '/path' at path segment index 2");
89-
}
89+
}
90+
91+
@Test
92+
void defaultVersion() {
93+
ApiVersionInserter inserter = DefaultApiVersionInserter.fromHeader("X-API-Version").build();
94+
RestClient restClient = restClientBuilder.defaultApiVersion(1.2).apiVersionInserter(inserter).build();
95+
restClient.get().uri("/path").retrieve().body(String.class);
96+
97+
expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2"));
98+
}
9099

91100
private void performRequest(DefaultApiVersionInserter.Builder builder) {
92101
ApiVersionInserter versionInserter = builder.build();

spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ final class DefaultWebClient implements WebClient {
9494

9595
private final @Nullable MultiValueMap<String, String> defaultCookies;
9696

97+
private final @Nullable Object defaultApiVersion;
98+
9799
private final @Nullable ApiVersionInserter apiVersionInserter;
98100

99101
private final @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest;
@@ -110,7 +112,7 @@ final class DefaultWebClient implements WebClient {
110112
DefaultWebClient(ExchangeFunction exchangeFunction, @Nullable ExchangeFilterFunction filterFunctions,
111113
UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders defaultHeaders,
112114
@Nullable MultiValueMap<String, String> defaultCookies,
113-
@Nullable ApiVersionInserter apiVersionInserter,
115+
@Nullable Object defaultApiVersion, @Nullable ApiVersionInserter apiVersionInserter,
114116
@Nullable Consumer<RequestHeadersSpec<?>> defaultRequest,
115117
@Nullable Map<Predicate<HttpStatusCode>, Function<ClientResponse, Mono<? extends Throwable>>> statusHandlerMap,
116118
ObservationRegistry observationRegistry, @Nullable ClientRequestObservationConvention observationConvention,
@@ -121,6 +123,7 @@ final class DefaultWebClient implements WebClient {
121123
this.uriBuilderFactory = uriBuilderFactory;
122124
this.defaultHeaders = defaultHeaders;
123125
this.defaultCookies = defaultCookies;
126+
this.defaultApiVersion = defaultApiVersion;
124127
this.apiVersionInserter = apiVersionInserter;
125128
this.defaultRequest = defaultRequest;
126129
this.defaultStatusHandlers = initStatusHandlers(statusHandlerMap);
@@ -491,23 +494,29 @@ private ClientRequest.Builder initRequestBuilder() {
491494

492495
private URI initUri() {
493496
URI uriToUse = (this.uri != null ? this.uri : uriBuilderFactory.expand(""));
494-
if (this.apiVersion != null) {
497+
Object version = getApiVersionOrDefault();
498+
if (version != null) {
495499
Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
496-
uriToUse = apiVersionInserter.insertVersion(this.apiVersion, uriToUse);
500+
uriToUse = apiVersionInserter.insertVersion(version, uriToUse);
497501
}
498502
return uriToUse;
499503
}
500504

505+
private @Nullable Object getApiVersionOrDefault() {
506+
return (this.apiVersion != null ? this.apiVersion : DefaultWebClient.this.defaultApiVersion);
507+
}
508+
501509
private void initHeaders(HttpHeaders out) {
502510
if (defaultHeaders != null && !defaultHeaders.isEmpty()) {
503511
out.putAll(defaultHeaders);
504512
}
505513
if (this.headers != null && !this.headers.isEmpty()) {
506514
out.putAll(this.headers);
507515
}
508-
if (this.apiVersion != null) {
516+
Object version = getApiVersionOrDefault();
517+
if (version != null) {
509518
Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
510-
apiVersionInserter.insertVersion(this.apiVersion, out);
519+
apiVersionInserter.insertVersion(version, out);
511520
}
512521
}
513522

spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
8181

8282
private @Nullable MultiValueMap<String, String> defaultCookies;
8383

84+
private @Nullable Object defaultApiVersion;
85+
8486
private @Nullable ApiVersionInserter apiVersionInserter;
8587

8688
private @Nullable Consumer<WebClient.RequestHeadersSpec<?>> defaultRequest;
@@ -122,6 +124,8 @@ public DefaultWebClientBuilder(DefaultWebClientBuilder other) {
122124
}
123125

124126
this.defaultCookies = (other.defaultCookies != null ? new LinkedMultiValueMap<>(other.defaultCookies) : null);
127+
128+
this.defaultApiVersion = other.defaultApiVersion;
125129
this.apiVersionInserter = other.apiVersionInserter;
126130

127131
this.defaultRequest = other.defaultRequest;
@@ -194,6 +198,11 @@ private MultiValueMap<String, String> initCookies() {
194198
return this.defaultCookies;
195199
}
196200

201+
@Override
202+
public WebClient.Builder defaultApiVersion(Object version) {
203+
this.defaultApiVersion = version;
204+
return this;
205+
}
197206

198207
@Override
199208
public WebClient.Builder apiVersionInserter(ApiVersionInserter apiVersionInserter) {
@@ -308,7 +317,7 @@ public WebClient build() {
308317
return new DefaultWebClient(
309318
exchange, filterFunctions,
310319
initUriBuilderFactory(), defaultHeaders, defaultCookies,
311-
this.apiVersionInserter,
320+
this.defaultApiVersion, this.apiVersionInserter,
312321
this.defaultRequest,
313322
this.statusHandlers,
314323
this.observationRegistry, this.observationConvention,

spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,15 @@ interface Builder {
252252
*/
253253
Builder defaultCookies(Consumer<MultiValueMap<String, String>> cookiesConsumer);
254254

255+
/**
256+
* Global option to specify an API version to add to every request,
257+
* if not already set.
258+
* @param version the version to use
259+
* @return this builder
260+
* @since 7.0
261+
*/
262+
Builder defaultApiVersion(Object version);
263+
255264
/**
256265
* Configure an {@link ApiVersionInserter} to abstract how an API version
257266
* specified via {@link RequestHeadersSpec#apiVersion(Object)}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.reactive.function.client;
18+
19+
import java.io.IOException;
20+
import java.util.function.Consumer;
21+
22+
import okhttp3.mockwebserver.MockResponse;
23+
import okhttp3.mockwebserver.MockWebServer;
24+
import okhttp3.mockwebserver.RecordedRequest;
25+
import org.junit.jupiter.api.AfterEach;
26+
import org.junit.jupiter.api.BeforeEach;
27+
import org.junit.jupiter.api.Test;
28+
29+
import org.springframework.web.client.ApiVersionInserter;
30+
import org.springframework.web.client.DefaultApiVersionInserter;
31+
32+
import static org.assertj.core.api.Assertions.assertThat;
33+
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
34+
35+
/**
36+
* {@link WebClient} tests for sending API versions.
37+
* @author Rossen Stoyanchev
38+
*/
39+
public class WebClientVersionTests {
40+
41+
private final MockWebServer server = new MockWebServer();
42+
43+
private final WebClient.Builder webClientBuilder =
44+
WebClient.builder().baseUrl(this.server.url("/").toString());
45+
46+
47+
@BeforeEach
48+
void setUp() {
49+
MockResponse response = new MockResponse();
50+
response.setHeader("Content-Type", "text/plain").setBody("body");
51+
this.server.enqueue(response);
52+
}
53+
54+
@AfterEach
55+
void shutdown() throws IOException {
56+
this.server.shutdown();
57+
}
58+
59+
60+
@Test
61+
void header() {
62+
performRequest(DefaultApiVersionInserter.fromHeader("X-API-Version"));
63+
expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2"));
64+
}
65+
66+
@Test
67+
void queryParam() {
68+
performRequest(DefaultApiVersionInserter.fromQueryParam("api-version"));
69+
expectRequest(request -> assertThat(request.getPath()).isEqualTo("/path?api-version=1.2"));
70+
}
71+
72+
@Test
73+
void pathSegmentIndexLessThanSize() {
74+
performRequest(DefaultApiVersionInserter.fromPathSegment(0).withVersionFormatter(v -> "v" + v));
75+
expectRequest(request -> assertThat(request.getPath()).isEqualTo("/v1.2/path"));
76+
}
77+
78+
@Test
79+
void pathSegmentIndexEqualToSize() {
80+
performRequest(DefaultApiVersionInserter.fromPathSegment(1).withVersionFormatter(v -> "v" + v));
81+
expectRequest(request -> assertThat(request.getPath()).isEqualTo("/path/v1.2"));
82+
}
83+
84+
@Test
85+
void pathSegmentIndexGreaterThanSize() {
86+
assertThatIllegalStateException()
87+
.isThrownBy(() -> performRequest(DefaultApiVersionInserter.fromPathSegment(2)))
88+
.withMessage("Cannot insert version into '/path' at path segment index 2");
89+
}
90+
91+
@Test
92+
void defaultVersion() {
93+
ApiVersionInserter inserter = DefaultApiVersionInserter.fromHeader("X-API-Version").build();
94+
WebClient webClient = webClientBuilder.defaultApiVersion(1.2).apiVersionInserter(inserter).build();
95+
webClient.get().uri("/path").retrieve().bodyToMono(String.class).block();
96+
97+
expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2"));
98+
}
99+
100+
private void performRequest(DefaultApiVersionInserter.Builder builder) {
101+
ApiVersionInserter versionInserter = builder.build();
102+
WebClient webClient = webClientBuilder.apiVersionInserter(versionInserter).build();
103+
webClient.get().uri("/path").apiVersion(1.2).retrieve().bodyToMono(String.class).block();
104+
}
105+
106+
private void expectRequest(Consumer<RecordedRequest> consumer) {
107+
try {
108+
consumer.accept(this.server.takeRequest());
109+
}
110+
catch (InterruptedException ex) {
111+
throw new IllegalStateException(ex);
112+
}
113+
}
114+
115+
}

0 commit comments

Comments
 (0)