Skip to content

Commit fb94109

Browse files
committed
WebTestClient support for API versioning
Closes gh-34568
1 parent c55beba commit fb94109

File tree

4 files changed

+156
-7
lines changed

4 files changed

+156
-7
lines changed

Diff for: spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java

+25-3
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
import org.springframework.util.LinkedMultiValueMap;
5757
import org.springframework.util.MimeType;
5858
import org.springframework.util.MultiValueMap;
59+
import org.springframework.web.client.ApiVersionInserter;
5960
import org.springframework.web.reactive.function.BodyInserter;
6061
import org.springframework.web.reactive.function.BodyInserters;
6162
import org.springframework.web.reactive.function.client.ClientRequest;
@@ -88,6 +89,8 @@ class DefaultWebTestClient implements WebTestClient {
8889

8990
private final @Nullable MultiValueMap<String, String> defaultCookies;
9091

92+
private final @Nullable ApiVersionInserter apiVersionInserter;
93+
9194
private final Consumer<EntityExchangeResult<?>> entityResultConsumer;
9295

9396
private final Duration responseTimeout;
@@ -97,10 +100,11 @@ class DefaultWebTestClient implements WebTestClient {
97100
private final AtomicLong requestIndex = new AtomicLong();
98101

99102

100-
DefaultWebTestClient(ClientHttpConnector connector, ExchangeStrategies exchangeStrategies,
103+
DefaultWebTestClient(
104+
ClientHttpConnector connector, ExchangeStrategies exchangeStrategies,
101105
Function<ClientHttpConnector, ExchangeFunction> exchangeFactory, UriBuilderFactory uriBuilderFactory,
102106
@Nullable HttpHeaders headers, @Nullable MultiValueMap<String, String> cookies,
103-
Consumer<EntityExchangeResult<?>> entityResultConsumer,
107+
@Nullable ApiVersionInserter apiVersionInserter, Consumer<EntityExchangeResult<?>> entityResultConsumer,
104108
@Nullable Duration responseTimeout, DefaultWebTestClientBuilder clientBuilder) {
105109

106110
this.wiretapConnector = new WiretapConnector(connector);
@@ -110,6 +114,7 @@ class DefaultWebTestClient implements WebTestClient {
110114
this.uriBuilderFactory = uriBuilderFactory;
111115
this.defaultHeaders = headers;
112116
this.defaultCookies = cookies;
117+
this.apiVersionInserter = apiVersionInserter;
113118
this.entityResultConsumer = entityResultConsumer;
114119
this.responseTimeout = (responseTimeout != null ? responseTimeout : Duration.ofSeconds(5));
115120
this.builder = clientBuilder;
@@ -186,6 +191,8 @@ private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec {
186191

187192
private @Nullable MultiValueMap<String, String> cookies;
188193

194+
private @Nullable Object apiVersion;
195+
189196
private @Nullable BodyInserter<?, ? super ClientHttpRequest> inserter;
190197

191198
private final Map<String, Object> attributes = new LinkedHashMap<>(4);
@@ -310,6 +317,12 @@ public RequestBodySpec ifNoneMatch(String... ifNoneMatches) {
310317
return this;
311318
}
312319

320+
@Override
321+
public RequestBodySpec apiVersion(Object version) {
322+
this.apiVersion = version;
323+
return this;
324+
}
325+
313326
@Override
314327
public RequestHeadersSpec<?> bodyValue(Object body) {
315328
this.inserter = BodyInserters.fromValue(body);
@@ -373,6 +386,10 @@ private ClientRequest.Builder initRequestBuilder() {
373386
if (!this.headers.isEmpty()) {
374387
headersToUse.putAll(this.headers);
375388
}
389+
if (this.apiVersion != null) {
390+
Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
391+
apiVersionInserter.insertVersion(this.apiVersion, headersToUse);
392+
}
376393
})
377394
.cookies(cookiesToUse -> {
378395
if (!CollectionUtils.isEmpty(DefaultWebTestClient.this.defaultCookies)) {
@@ -386,7 +403,12 @@ private ClientRequest.Builder initRequestBuilder() {
386403
}
387404

388405
private URI initUri() {
389-
return (this.uri != null ? this.uri : DefaultWebTestClient.this.uriBuilderFactory.expand(""));
406+
URI uriToUse = this.uri != null ? this.uri : DefaultWebTestClient.this.uriBuilderFactory.expand("");
407+
if (this.apiVersion != null) {
408+
Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
409+
uriToUse = apiVersionInserter.insertVersion(this.apiVersion, uriToUse);
410+
}
411+
return uriToUse;
390412
}
391413

392414
}

Diff for: spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java

+16-4
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.springframework.util.CollectionUtils;
3838
import org.springframework.util.LinkedMultiValueMap;
3939
import org.springframework.util.MultiValueMap;
40+
import org.springframework.web.client.ApiVersionInserter;
4041
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
4142
import org.springframework.web.reactive.function.client.ExchangeFunction;
4243
import org.springframework.web.reactive.function.client.ExchangeFunctions;
@@ -85,6 +86,8 @@ class DefaultWebTestClientBuilder implements WebTestClient.Builder {
8586

8687
private @Nullable MultiValueMap<String, String> defaultCookies;
8788

89+
private @Nullable ApiVersionInserter apiVersionInserter;
90+
8891
private @Nullable List<ExchangeFilterFunction> filters;
8992

9093
private Consumer<EntityExchangeResult<?>> entityResultConsumer = result -> {};
@@ -142,6 +145,7 @@ class DefaultWebTestClientBuilder implements WebTestClient.Builder {
142145
}
143146
this.defaultCookies = (other.defaultCookies != null ?
144147
new LinkedMultiValueMap<>(other.defaultCookies) : null);
148+
this.apiVersionInserter = other.apiVersionInserter;
145149
this.filters = (other.filters != null ? new ArrayList<>(other.filters) : null);
146150
this.entityResultConsumer = other.entityResultConsumer;
147151
this.strategies = other.strategies;
@@ -200,6 +204,12 @@ private MultiValueMap<String, String> initCookies() {
200204
return this.defaultCookies;
201205
}
202206

207+
@Override
208+
public WebTestClient.Builder apiVersionInserter(ApiVersionInserter apiVersionInserter) {
209+
this.apiVersionInserter = apiVersionInserter;
210+
return this;
211+
}
212+
203213
@Override
204214
public WebTestClient.Builder filter(ExchangeFilterFunction filter) {
205215
Assert.notNull(filter, "ExchangeFilterFunction is required");
@@ -283,10 +293,12 @@ public WebTestClient build() {
283293
.orElse(exchange);
284294

285295
};
286-
return new DefaultWebTestClient(connectorToUse, exchangeStrategies, exchangeFactory, initUriBuilderFactory(),
287-
this.defaultHeaders != null ? HttpHeaders.readOnlyHttpHeaders(this.defaultHeaders) : null,
288-
this.defaultCookies != null ? CollectionUtils.unmodifiableMultiValueMap(this.defaultCookies) : null,
289-
this.entityResultConsumer, this.responseTimeout, new DefaultWebTestClientBuilder(this));
296+
return new DefaultWebTestClient(
297+
connectorToUse, exchangeStrategies, exchangeFactory, initUriBuilderFactory(),
298+
(this.defaultHeaders != null ? HttpHeaders.readOnlyHttpHeaders(this.defaultHeaders) : null),
299+
(this.defaultCookies != null ? CollectionUtils.unmodifiableMultiValueMap(this.defaultCookies) : null),
300+
this.apiVersionInserter, this.entityResultConsumer,
301+
this.responseTimeout, new DefaultWebTestClientBuilder(this));
290302
}
291303

292304
private static ClientHttpConnector initConnector() {

Diff for: spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java

+22
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
import org.springframework.test.json.JsonComparison;
4646
import org.springframework.util.MultiValueMap;
4747
import org.springframework.validation.Validator;
48+
import org.springframework.web.client.ApiVersionFormatter;
49+
import org.springframework.web.client.ApiVersionInserter;
4850
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
4951
import org.springframework.web.reactive.config.BlockingExecutionConfigurer;
5052
import org.springframework.web.reactive.config.CorsRegistry;
@@ -428,6 +430,15 @@ interface Builder {
428430
*/
429431
Builder defaultCookies(Consumer<MultiValueMap<String, String>> cookiesConsumer);
430432

433+
/**
434+
* Configure an {@link ApiVersionInserter} to abstract how an API version
435+
* specified via {@link RequestHeadersSpec#apiVersion(Object)}
436+
* is inserted into the request.
437+
* @param apiVersionInserter the inserter to use
438+
* @since 7.0
439+
*/
440+
Builder apiVersionInserter(ApiVersionInserter apiVersionInserter);
441+
431442
/**
432443
* Add the given filter to the filter chain.
433444
* @param filter the filter to be added to the chain
@@ -643,6 +654,17 @@ interface RequestHeadersSpec<S extends RequestHeadersSpec<S>> {
643654
*/
644655
S headers(Consumer<HttpHeaders> headersConsumer);
645656

657+
/**
658+
* Set an API version for the request. The version is inserted into the
659+
* request by the {@link Builder#apiVersionInserter(ApiVersionInserter)
660+
* configured} {@code ApiVersionInserter}.
661+
* @param version the API version of the request; this can be a String or
662+
* some Object that can be formatted the inserter, e.g. through an
663+
* {@link ApiVersionFormatter}.
664+
* @since 7.0
665+
*/
666+
S apiVersion(Object version);
667+
646668
/**
647669
* Set the attribute with the given name to the given value.
648670
* @param name the name of the attribute to add
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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.test.web.reactive.server.samples;
18+
19+
import java.net.URI;
20+
import java.util.Map;
21+
import java.util.function.Consumer;
22+
23+
import org.junit.jupiter.api.Test;
24+
25+
import org.springframework.http.server.reactive.ServerHttpRequest;
26+
import org.springframework.test.web.reactive.server.WebTestClient;
27+
import org.springframework.web.bind.annotation.GetMapping;
28+
import org.springframework.web.bind.annotation.RestController;
29+
import org.springframework.web.client.DefaultApiVersionInserter;
30+
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
33+
/**
34+
* {@link WebTestClient} tests for sending API versions.
35+
*
36+
* @author Rossen Stoyanchev
37+
*/
38+
public class ApiVersionTests {
39+
40+
private static final String HEADER_NAME = "X-API-Version";
41+
42+
43+
@Test
44+
void header() {
45+
Map<String, String> result = performRequest(builder -> builder.fromHeader("X-API-Version"));
46+
assertThat(result.get(HEADER_NAME)).isEqualTo("1.2");
47+
}
48+
49+
@Test
50+
void queryParam() {
51+
Map<String, String> result = performRequest(builder -> builder.fromQueryParam("api-version"));
52+
assertThat(result.get("query")).isEqualTo("api-version=1.2");
53+
}
54+
55+
@Test
56+
void pathSegment() {
57+
Map<String, String> result = performRequest(builder -> builder.fromPathSegment(0));
58+
assertThat(result.get("path")).isEqualTo("/1.2/path");
59+
}
60+
61+
@SuppressWarnings("unchecked")
62+
private Map<String, String> performRequest(Consumer<DefaultApiVersionInserter.Builder> consumer) {
63+
DefaultApiVersionInserter.Builder builder = DefaultApiVersionInserter.builder();
64+
consumer.accept(builder);
65+
return (Map<String, String>) WebTestClient.bindToController(new TestController())
66+
.configureClient()
67+
.baseUrl("/path")
68+
.apiVersionInserter(builder.build())
69+
.build()
70+
.get()
71+
.apiVersion(1.2)
72+
.exchange()
73+
.returnResult(Map.class)
74+
.getResponseBody()
75+
.blockFirst();
76+
}
77+
78+
79+
@RestController
80+
static class TestController {
81+
82+
@GetMapping("/**")
83+
Map<String, String> handle(ServerHttpRequest request) {
84+
URI uri = request.getURI();
85+
String query = uri.getQuery();
86+
String header = request.getHeaders().getFirst(HEADER_NAME);
87+
return Map.of("path", uri.getRawPath(),
88+
"query", (query != null ? query : ""),
89+
HEADER_NAME, (header != null ? header : ""));
90+
}
91+
}
92+
93+
}

0 commit comments

Comments
 (0)