From fb94109c09d6a4facf2197577e01ff2ba31ea194 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 3 Apr 2025 16:36:39 +0100 Subject: [PATCH] WebTestClient support for API versioning Closes gh-34568 --- .../reactive/server/DefaultWebTestClient.java | 28 +++++- .../server/DefaultWebTestClientBuilder.java | 20 +++- .../web/reactive/server/WebTestClient.java | 22 +++++ .../server/samples/ApiVersionTests.java | 93 +++++++++++++++++++ 4 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java index 4a7390c9d06..821eefc318e 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java @@ -56,6 +56,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MimeType; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.ClientRequest; @@ -88,6 +89,8 @@ class DefaultWebTestClient implements WebTestClient { private final @Nullable MultiValueMap defaultCookies; + private final @Nullable ApiVersionInserter apiVersionInserter; + private final Consumer> entityResultConsumer; private final Duration responseTimeout; @@ -97,10 +100,11 @@ class DefaultWebTestClient implements WebTestClient { private final AtomicLong requestIndex = new AtomicLong(); - DefaultWebTestClient(ClientHttpConnector connector, ExchangeStrategies exchangeStrategies, + DefaultWebTestClient( + ClientHttpConnector connector, ExchangeStrategies exchangeStrategies, Function exchangeFactory, UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders headers, @Nullable MultiValueMap cookies, - Consumer> entityResultConsumer, + @Nullable ApiVersionInserter apiVersionInserter, Consumer> entityResultConsumer, @Nullable Duration responseTimeout, DefaultWebTestClientBuilder clientBuilder) { this.wiretapConnector = new WiretapConnector(connector); @@ -110,6 +114,7 @@ class DefaultWebTestClient implements WebTestClient { this.uriBuilderFactory = uriBuilderFactory; this.defaultHeaders = headers; this.defaultCookies = cookies; + this.apiVersionInserter = apiVersionInserter; this.entityResultConsumer = entityResultConsumer; this.responseTimeout = (responseTimeout != null ? responseTimeout : Duration.ofSeconds(5)); this.builder = clientBuilder; @@ -186,6 +191,8 @@ class DefaultWebTestClient implements WebTestClient { private @Nullable MultiValueMap cookies; + private @Nullable Object apiVersion; + private @Nullable BodyInserter inserter; private final Map attributes = new LinkedHashMap<>(4); @@ -310,6 +317,12 @@ class DefaultWebTestClient implements WebTestClient { return this; } + @Override + public RequestBodySpec apiVersion(Object version) { + this.apiVersion = version; + return this; + } + @Override public RequestHeadersSpec bodyValue(Object body) { this.inserter = BodyInserters.fromValue(body); @@ -373,6 +386,10 @@ class DefaultWebTestClient implements WebTestClient { if (!this.headers.isEmpty()) { headersToUse.putAll(this.headers); } + if (this.apiVersion != null) { + Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured"); + apiVersionInserter.insertVersion(this.apiVersion, headersToUse); + } }) .cookies(cookiesToUse -> { if (!CollectionUtils.isEmpty(DefaultWebTestClient.this.defaultCookies)) { @@ -386,7 +403,12 @@ class DefaultWebTestClient implements WebTestClient { } private URI initUri() { - return (this.uri != null ? this.uri : DefaultWebTestClient.this.uriBuilderFactory.expand("")); + URI uriToUse = this.uri != null ? this.uri : DefaultWebTestClient.this.uriBuilderFactory.expand(""); + if (this.apiVersion != null) { + Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured"); + uriToUse = apiVersionInserter.insertVersion(this.apiVersion, uriToUse); + } + return uriToUse; } } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java index daab08e7538..573e1261ebb 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java @@ -37,6 +37,7 @@ import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.reactive.function.client.ExchangeFilterFunction; import org.springframework.web.reactive.function.client.ExchangeFunction; import org.springframework.web.reactive.function.client.ExchangeFunctions; @@ -85,6 +86,8 @@ class DefaultWebTestClientBuilder implements WebTestClient.Builder { private @Nullable MultiValueMap defaultCookies; + private @Nullable ApiVersionInserter apiVersionInserter; + private @Nullable List filters; private Consumer> entityResultConsumer = result -> {}; @@ -142,6 +145,7 @@ class DefaultWebTestClientBuilder implements WebTestClient.Builder { } this.defaultCookies = (other.defaultCookies != null ? new LinkedMultiValueMap<>(other.defaultCookies) : null); + this.apiVersionInserter = other.apiVersionInserter; this.filters = (other.filters != null ? new ArrayList<>(other.filters) : null); this.entityResultConsumer = other.entityResultConsumer; this.strategies = other.strategies; @@ -200,6 +204,12 @@ class DefaultWebTestClientBuilder implements WebTestClient.Builder { return this.defaultCookies; } + @Override + public WebTestClient.Builder apiVersionInserter(ApiVersionInserter apiVersionInserter) { + this.apiVersionInserter = apiVersionInserter; + return this; + } + @Override public WebTestClient.Builder filter(ExchangeFilterFunction filter) { Assert.notNull(filter, "ExchangeFilterFunction is required"); @@ -283,10 +293,12 @@ class DefaultWebTestClientBuilder implements WebTestClient.Builder { .orElse(exchange); }; - return new DefaultWebTestClient(connectorToUse, exchangeStrategies, exchangeFactory, initUriBuilderFactory(), - this.defaultHeaders != null ? HttpHeaders.readOnlyHttpHeaders(this.defaultHeaders) : null, - this.defaultCookies != null ? CollectionUtils.unmodifiableMultiValueMap(this.defaultCookies) : null, - this.entityResultConsumer, this.responseTimeout, new DefaultWebTestClientBuilder(this)); + return new DefaultWebTestClient( + connectorToUse, exchangeStrategies, exchangeFactory, initUriBuilderFactory(), + (this.defaultHeaders != null ? HttpHeaders.readOnlyHttpHeaders(this.defaultHeaders) : null), + (this.defaultCookies != null ? CollectionUtils.unmodifiableMultiValueMap(this.defaultCookies) : null), + this.apiVersionInserter, this.entityResultConsumer, + this.responseTimeout, new DefaultWebTestClientBuilder(this)); } private static ClientHttpConnector initConnector() { diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index d9849d8c27b..77aa24f7c61 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -45,6 +45,8 @@ import org.springframework.test.json.JsonCompareMode; import org.springframework.test.json.JsonComparison; import org.springframework.util.MultiValueMap; import org.springframework.validation.Validator; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; import org.springframework.web.reactive.config.BlockingExecutionConfigurer; import org.springframework.web.reactive.config.CorsRegistry; @@ -428,6 +430,15 @@ public interface WebTestClient { */ Builder defaultCookies(Consumer> cookiesConsumer); + /** + * Configure an {@link ApiVersionInserter} to abstract how an API version + * specified via {@link RequestHeadersSpec#apiVersion(Object)} + * is inserted into the request. + * @param apiVersionInserter the inserter to use + * @since 7.0 + */ + Builder apiVersionInserter(ApiVersionInserter apiVersionInserter); + /** * Add the given filter to the filter chain. * @param filter the filter to be added to the chain @@ -643,6 +654,17 @@ public interface WebTestClient { */ S headers(Consumer headersConsumer); + /** + * Set an API version for the request. The version is inserted into the + * request by the {@link Builder#apiVersionInserter(ApiVersionInserter) + * configured} {@code ApiVersionInserter}. + * @param version the API version of the request; this can be a String or + * some Object that can be formatted the inserter, e.g. through an + * {@link ApiVersionFormatter}. + * @since 7.0 + */ + S apiVersion(Object version); + /** * Set the attribute with the given name to the given value. * @param name the name of the attribute to add diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java new file mode 100644 index 00000000000..fa9337d9efb --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java @@ -0,0 +1,93 @@ +/* + * 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.test.web.reactive.server.samples; + +import java.net.URI; +import java.util.Map; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.DefaultApiVersionInserter; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link WebTestClient} tests for sending API versions. + * + * @author Rossen Stoyanchev + */ +public class ApiVersionTests { + + private static final String HEADER_NAME = "X-API-Version"; + + + @Test + void header() { + Map result = performRequest(builder -> builder.fromHeader("X-API-Version")); + assertThat(result.get(HEADER_NAME)).isEqualTo("1.2"); + } + + @Test + void queryParam() { + Map result = performRequest(builder -> builder.fromQueryParam("api-version")); + assertThat(result.get("query")).isEqualTo("api-version=1.2"); + } + + @Test + void pathSegment() { + Map result = performRequest(builder -> builder.fromPathSegment(0)); + assertThat(result.get("path")).isEqualTo("/1.2/path"); + } + + @SuppressWarnings("unchecked") + private Map performRequest(Consumer consumer) { + DefaultApiVersionInserter.Builder builder = DefaultApiVersionInserter.builder(); + consumer.accept(builder); + return (Map) WebTestClient.bindToController(new TestController()) + .configureClient() + .baseUrl("/path") + .apiVersionInserter(builder.build()) + .build() + .get() + .apiVersion(1.2) + .exchange() + .returnResult(Map.class) + .getResponseBody() + .blockFirst(); + } + + + @RestController + static class TestController { + + @GetMapping("/**") + Map handle(ServerHttpRequest request) { + URI uri = request.getURI(); + String query = uri.getQuery(); + String header = request.getHeaders().getFirst(HEADER_NAME); + return Map.of("path", uri.getRawPath(), + "query", (query != null ? query : ""), + HEADER_NAME, (header != null ? header : "")); + } + } + +}