From 7bf628c8277bc6d8609bf88f75e5c5ed8f7eb63e Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 1 Apr 2025 17:02:27 +0100 Subject: [PATCH] Client support for API versioning Closes gh-34567 --- .../web/client/ApiVersionFormatter.java | 36 ++++ .../web/client/ApiVersionInserter.java | 50 +++++ .../web/client/DefaultApiVersionInserter.java | 193 ++++++++++++++++++ .../web/client/DefaultRestClient.java | 46 ++++- .../web/client/DefaultRestClientBuilder.java | 14 +- .../web/client/RestClient.java | 20 ++ .../web/client/support/RestClientAdapter.java | 6 +- .../service/annotation/DeleteExchange.java | 9 +- .../web/service/annotation/GetExchange.java | 9 +- .../web/service/annotation/HttpExchange.java | 8 +- .../web/service/annotation/PatchExchange.java | 9 +- .../web/service/annotation/PostExchange.java | 9 +- .../web/service/annotation/PutExchange.java | 9 +- .../service/invoker/HttpRequestValues.java | 35 +++- .../service/invoker/HttpServiceMethod.java | 29 ++- .../invoker/ReactiveHttpRequestValues.java | 16 +- .../web/client/RestClientVersionTests.java | 111 ++++++++++ .../support/RestClientAdapterTests.java | 21 ++ .../function/client/DefaultWebClient.java | 27 ++- .../client/DefaultWebClientBuilder.java | 16 +- .../reactive/function/client/WebClient.java | 22 ++ .../client/support/WebClientAdapter.java | 7 +- 22 files changed, 661 insertions(+), 41 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/client/ApiVersionFormatter.java create mode 100644 spring-web/src/main/java/org/springframework/web/client/ApiVersionInserter.java create mode 100644 spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserter.java create mode 100644 spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java diff --git a/spring-web/src/main/java/org/springframework/web/client/ApiVersionFormatter.java b/spring-web/src/main/java/org/springframework/web/client/ApiVersionFormatter.java new file mode 100644 index 00000000000..b4351022d63 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/ApiVersionFormatter.java @@ -0,0 +1,36 @@ +/* + * 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.client; + +/** + * Contract to format the API version for a request. + * + * @author Rossen Stoyanchev + * @since 7.0 + * @see DefaultApiVersionInserter.Builder#withVersionFormatter(ApiVersionFormatter) + */ +@FunctionalInterface +public interface ApiVersionFormatter { + + /** + * Format the given version Object into a String value. + * @param version the version to format + * @return the final String version to use + */ + String formatVersion(Object version); + +} diff --git a/spring-web/src/main/java/org/springframework/web/client/ApiVersionInserter.java b/spring-web/src/main/java/org/springframework/web/client/ApiVersionInserter.java new file mode 100644 index 00000000000..f106f636a4b --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/ApiVersionInserter.java @@ -0,0 +1,50 @@ +/* + * 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.client; + +import java.net.URI; + +import org.springframework.http.HttpHeaders; + +/** + * Contract to determine how to insert an API version into the URI or headers + * of a request. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public interface ApiVersionInserter { + + /** + * Allows inserting the version into the URI. + * @param version the version to insert + * @param uri the URI for the request + * @return the updated or the same URI + */ + default URI insertVersion(Object version, URI uri) { + return uri; + } + + /** + * Allows inserting the version into request headers. + * @param version the version to insert + * @param headers the request headers + */ + default void insertVersion(Object version, HttpHeaders headers) { + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserter.java b/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserter.java new file mode 100644 index 00000000000..e6119f3a5ba --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserter.java @@ -0,0 +1,193 @@ +/* + * 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.client; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpHeaders; +import org.springframework.util.Assert; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Default implementation of {@link ApiVersionInserter} to insert the version + * into a request header, query parameter, or the URL path. + * + *

Use {@link #builder()} to create an instance. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public final class DefaultApiVersionInserter implements ApiVersionInserter { + + private final @Nullable String header; + + private final @Nullable String queryParam; + + private final @Nullable Integer pathSegmentIndex; + + private final ApiVersionFormatter versionFormatter; + + + private DefaultApiVersionInserter( + @Nullable String header, @Nullable String queryParam, @Nullable Integer pathSegmentIndex, + @Nullable ApiVersionFormatter formatter) { + + Assert.isTrue(header != null || queryParam != null || pathSegmentIndex != null, + "Expected 'header', 'queryParam', or 'pathSegmentIndex' to be configured"); + + this.header = header; + this.queryParam = queryParam; + this.pathSegmentIndex = pathSegmentIndex; + this.versionFormatter = (formatter != null ? formatter : Object::toString); + } + + + @Override + public URI insertVersion(Object version, URI uri) { + if (this.queryParam == null && this.pathSegmentIndex == null) { + return uri; + } + String formattedVersion = this.versionFormatter.formatVersion(version); + UriComponentsBuilder builder = UriComponentsBuilder.fromUri(uri); + if (this.queryParam != null) { + builder.queryParam(this.queryParam, formattedVersion); + } + if (this.pathSegmentIndex != null) { + List pathSegments = new ArrayList<>(builder.build().getPathSegments()); + assertPathSegmentIndex(this.pathSegmentIndex, pathSegments.size(), uri); + pathSegments.add(this.pathSegmentIndex, formattedVersion); + builder.replacePath(null); + pathSegments.forEach(builder::pathSegment); + } + return builder.build().toUri(); + } + + private void assertPathSegmentIndex(Integer index, int pathSegmentsSize, URI uri) { + Assert.state(index <= pathSegmentsSize, + "Cannot insert version into '" + uri.getPath() + "' at path segment index " + index); + } + + @Override + public void insertVersion(Object version, HttpHeaders headers) { + if (this.header != null) { + headers.set(this.header, this.versionFormatter.formatVersion(version)); + } + } + + + /** + * Create a builder for an inserter that sets a header. + * @param header the name of a header to hold the version + */ + public static Builder fromHeader(@Nullable String header) { + return new Builder(header, null, null); + } + + /** + * Create a builder for an inserter that sets a query parameter. + * @param queryParam the name of a query parameter to hold the version + */ + public static Builder fromQueryParam(@Nullable String queryParam) { + return new Builder(null, queryParam, null); + } + + /** + * Create a builder for an inserter that inserts a path segment. + * @param pathSegmentIndex the index of the path segment to hold the version + */ + public static Builder fromPathSegment(@Nullable Integer pathSegmentIndex) { + return new Builder(null, null, pathSegmentIndex); + } + + /** + * Create a builder. + */ + public static Builder builder() { + return new Builder(null, null, null); + } + + + /** + * A builder for {@link DefaultApiVersionInserter}. + */ + public static final class Builder { + + private @Nullable String header; + + private @Nullable String queryParam; + + private @Nullable Integer pathSegmentIndex; + + private @Nullable ApiVersionFormatter versionFormatter; + + private Builder(@Nullable String header, @Nullable String queryParam, @Nullable Integer pathSegmentIndex) { + this.header = header; + this.queryParam = queryParam; + this.pathSegmentIndex = pathSegmentIndex; + } + + /** + * Configure the inserter to set a header. + * @param header the name of the header to hold the version + */ + public Builder fromHeader(@Nullable String header) { + this.header = header; + return this; + } + + /** + * Configure the inserter to set a query parameter. + * @param queryParam the name of the query parameter to hold the version + */ + public Builder fromQueryParam(@Nullable String queryParam) { + this.queryParam = queryParam; + return this; + } + + /** + * Configure the inserter to insert a path segment. + * @param pathSegmentIndex the index of the path segment to hold the version + */ + public Builder fromPathSegment(@Nullable Integer pathSegmentIndex) { + this.pathSegmentIndex = pathSegmentIndex; + return this; + } + + /** + * Format the version Object into a String using the given {@link ApiVersionFormatter}. + *

By default, the version is formatted with {@link Object#toString()}. + * @param versionFormatter the formatter to use + */ + public Builder withVersionFormatter(ApiVersionFormatter versionFormatter) { + this.versionFormatter = versionFormatter; + return this; + } + + /** + * Build the inserter. + */ + public ApiVersionInserter build() { + return new DefaultApiVersionInserter( + this.header, this.queryParam, this.pathSegmentIndex, this.versionFormatter); + } + } + +} 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 fc29f2a09bc..4300a354a07 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 ApiVersionInserter apiVersionInserter; + private final @Nullable Consumer> defaultRequest; private final List defaultStatusHandlers; @@ -128,6 +130,7 @@ final class DefaultRestClient implements RestClient { UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders defaultHeaders, @Nullable MultiValueMap defaultCookies, + @Nullable ApiVersionInserter apiVersionInserter, @Nullable Consumer> defaultRequest, @Nullable List statusHandlers, List> messageConverters, @@ -142,6 +145,7 @@ final class DefaultRestClient implements RestClient { this.uriBuilderFactory = uriBuilderFactory; this.defaultHeaders = defaultHeaders; this.defaultCookies = defaultCookies; + this.apiVersionInserter = apiVersionInserter; this.defaultRequest = defaultRequest; this.defaultStatusHandlers = (statusHandlers != null ? new ArrayList<>(statusHandlers) : new ArrayList<>()); this.messageConverters = messageConverters; @@ -293,6 +297,8 @@ final class DefaultRestClient implements RestClient { private @Nullable MultiValueMap cookies; + private @Nullable Object apiVersion; + private @Nullable InternalBody body; private @Nullable Map attributes; @@ -417,6 +423,12 @@ final class DefaultRestClient implements RestClient { return this; } + @Override + public RequestBodySpec apiVersion(Object version) { + this.apiVersion = version; + return this; + } + @Override public RequestBodySpec attribute(String name, Object value) { getAttributes().put(name, value); @@ -589,7 +601,12 @@ final class DefaultRestClient implements RestClient { } private URI initUri() { - return (this.uri != null ? this.uri : DefaultRestClient.this.uriBuilderFactory.expand("")); + URI uriToUse = this.uri != null ? this.uri : DefaultRestClient.this.uriBuilderFactory.expand(""); + if (this.apiVersion != null) { + Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured"); + uriToUse = apiVersionInserter.insertVersion(this.apiVersion, uriToUse); + } + return uriToUse; } private @Nullable String serializeCookies() { @@ -628,18 +645,29 @@ final class DefaultRestClient implements RestClient { private @Nullable HttpHeaders initHeaders() { HttpHeaders defaultHeaders = DefaultRestClient.this.defaultHeaders; - if (this.headers == null || this.headers.isEmpty()) { - return defaultHeaders; - } - else if (defaultHeaders == null || defaultHeaders.isEmpty()) { - return this.headers; + if (this.apiVersion == null) { + if (this.headers == null || this.headers.isEmpty()) { + return defaultHeaders; + } + else if (defaultHeaders == null || defaultHeaders.isEmpty()) { + return this.headers; + } } - else { - HttpHeaders result = new HttpHeaders(); + + HttpHeaders result = new HttpHeaders(); + if (defaultHeaders != null) { result.putAll(defaultHeaders); + } + if (this.headers != null) { result.putAll(this.headers); - return result; } + + if (this.apiVersion != null) { + Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured"); + apiVersionInserter.insertVersion(this.apiVersion, result); + } + + return result; } private ClientHttpRequest createRequest(URI uri) throws IOException { 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 3d5aaafdf38..933ab940ccd 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 @@ -150,6 +150,8 @@ final class DefaultRestClientBuilder implements RestClient.Builder { private @Nullable MultiValueMap defaultCookies; + private @Nullable ApiVersionInserter apiVersionInserter; + private @Nullable Consumer> defaultRequest; private @Nullable List statusHandlers; @@ -186,6 +188,7 @@ final class DefaultRestClientBuilder implements RestClient.Builder { this.defaultHeaders = null; } this.defaultCookies = (other.defaultCookies != null ? new LinkedMultiValueMap<>(other.defaultCookies) : null); + this.apiVersionInserter = other.apiVersionInserter; this.defaultRequest = other.defaultRequest; this.statusHandlers = (other.statusHandlers != null ? new ArrayList<>(other.statusHandlers) : null); this.interceptors = (other.interceptors != null) ? new ArrayList<>(other.interceptors) : null; @@ -321,6 +324,12 @@ final class DefaultRestClientBuilder implements RestClient.Builder { return this.defaultCookies; } + @Override + public RestClient.Builder apiVersionInserter(ApiVersionInserter apiVersionInserter) { + this.apiVersionInserter = apiVersionInserter; + return this; + } + @Override public RestClient.Builder defaultRequest(Consumer> defaultRequest) { this.defaultRequest = this.defaultRequest != null ? @@ -513,9 +522,8 @@ final class DefaultRestClientBuilder implements RestClient.Builder { return new DefaultRestClient( requestFactory, this.interceptors, this.bufferingPredicate, this.initializers, uriBuilderFactory, defaultHeaders, defaultCookies, - this.defaultRequest, - this.statusHandlers, - converters, + this.apiVersionInserter, this.defaultRequest, + this.statusHandlers, converters, this.observationRegistry, this.observationConvention, new DefaultRestClientBuilder(this)); } 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 28142dcb4df..79d4ef58398 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); + /** + * 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); + /** * Provide a consumer to customize every request being built. * @param defaultRequest the consumer to use for modifying requests @@ -596,6 +605,17 @@ public interface RestClient { */ 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-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java index 2b2552e3d4f..eed353283a5 100644 --- a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * 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. @@ -121,6 +121,10 @@ public final class RestClientAdapter implements HttpExchangeAdapter { bodySpec.header(HttpHeaders.COOKIE, String.join("; ", cookies)); } + if (values.getApiVersion() != null) { + bodySpec.apiVersion(values.getApiVersion()); + } + bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); if (values.getBodyValue() != null) { diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java index 7b3c478fcb7..bb3a758db9a 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * 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. @@ -67,4 +67,11 @@ public @interface DeleteExchange { @AliasFor(annotation = HttpExchange.class) String[] headers() default {}; + /** + * Alias for {@link HttpExchange#version()}. + * @since 7.0 + */ + @AliasFor(annotation = HttpExchange.class) + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/GetExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/GetExchange.java index 8a5816c4b0e..4507fa3722c 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/GetExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/GetExchange.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * 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. @@ -54,4 +54,11 @@ public @interface GetExchange { @AliasFor(annotation = HttpExchange.class) String[] accept() default {}; + /** + * Alias for {@link HttpExchange#version()}. + * @since 7.0 + */ + @AliasFor(annotation = HttpExchange.class) + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java index 2b34bb53ee1..745e59533d5 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * 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. @@ -185,4 +185,10 @@ public @interface HttpExchange { */ String[] headers() default {}; + /** + * The API version associated with the request. + * @since 7.0 + */ + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java index e36d89d6e68..4a9937ceeba 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * 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. @@ -67,4 +67,11 @@ public @interface PatchExchange { @AliasFor(annotation = HttpExchange.class) String[] headers() default {}; + /** + * Alias for {@link HttpExchange#version()}. + * @since 7.0 + */ + @AliasFor(annotation = HttpExchange.class) + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java index 7e2c2a46154..735360b5739 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * 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. @@ -67,4 +67,11 @@ public @interface PostExchange { @AliasFor(annotation = HttpExchange.class) String[] headers() default {}; + /** + * Alias for {@link HttpExchange#version()}. + * @since 7.0 + */ + @AliasFor(annotation = HttpExchange.class) + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java index e7d17a8017b..aa1f8f98037 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * 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. @@ -67,4 +67,11 @@ public @interface PutExchange { @AliasFor(annotation = HttpExchange.class) String[] headers() default {}; + /** + * Alias for {@link HttpExchange#version()}. + * @since 7.0 + */ + @AliasFor(annotation = HttpExchange.class) + String version() default ""; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java index 3a9615fdec8..6e8bcdbf747 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java @@ -67,6 +67,8 @@ public class HttpRequestValues { private final MultiValueMap cookies; + private @Nullable Object version; + private final Map attributes; private final @Nullable Object bodyValue; @@ -79,8 +81,8 @@ public class HttpRequestValues { protected HttpRequestValues(@Nullable HttpMethod httpMethod, @Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory, @Nullable String uriTemplate, Map uriVariables, - HttpHeaders headers, MultiValueMap cookies, Map attributes, - @Nullable Object bodyValue) { + HttpHeaders headers, MultiValueMap cookies, @Nullable Object version, + Map attributes, @Nullable Object bodyValue) { Assert.isTrue(uri != null || uriTemplate != null, "Neither URI nor URI template"); @@ -91,6 +93,7 @@ public class HttpRequestValues { this.uriVariables = uriVariables; this.headers = headers; this.cookies = cookies; + this.version = version; this.attributes = attributes; this.bodyValue = bodyValue; } @@ -154,6 +157,10 @@ public class HttpRequestValues { return this.cookies; } + public @Nullable Object getApiVersion() { + return this.version; + } + /** * Return the attributes associated with the request, or an empty map. */ @@ -225,6 +232,8 @@ public class HttpRequestValues { private @Nullable MultiValueMap parts; + private @Nullable Object version; + private @Nullable Map attributes; private @Nullable Object bodyValue; @@ -347,6 +356,20 @@ public class HttpRequestValues { return this; } + /** + * Set an API version for the request. The version is passed on to the + * underlying {@code RestClient} or {@code WebClient} that in turn are + * configured with an {@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 org.springframework.web.client.ApiVersionFormatter}. + * @since 7.0 + */ + public Builder setApiVersion(Object version) { + this.version = version; + return this; + } + /** * Configure an attribute to associate with the request. * @param name the attribute name @@ -439,7 +462,7 @@ public class HttpRequestValues { return createRequestValues( this.httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars, - headers, cookies, attributes, bodyValue); + headers, cookies, this.version, attributes, bodyValue); } protected boolean hasParts() { @@ -484,12 +507,12 @@ public class HttpRequestValues { @Nullable HttpMethod httpMethod, @Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory, @Nullable String uriTemplate, Map uriVars, - HttpHeaders headers, MultiValueMap cookies, Map attributes, - @Nullable Object bodyValue) { + HttpHeaders headers, MultiValueMap cookies, @Nullable Object version, + Map attributes, @Nullable Object bodyValue) { return new HttpRequestValues( this.httpMethod, uri, uriBuilderFactory, uriTemplate, - uriVars, headers, cookies, attributes, bodyValue); + uriVars, headers, cookies, version, attributes, bodyValue); } } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java index 59fc65e73a1..b66610c4566 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java @@ -158,7 +158,7 @@ final class HttpServiceMethod { private record HttpRequestValuesInitializer( @Nullable HttpMethod httpMethod, @Nullable String url, @Nullable MediaType contentType, @Nullable List acceptMediaTypes, - MultiValueMap headers, + MultiValueMap headers, @Nullable String version, Supplier requestValuesSupplier) { public HttpRequestValues.Builder initializeRequestValuesBuilder() { @@ -177,6 +177,9 @@ final class HttpServiceMethod { } this.headers.forEach((name, values) -> values.forEach(value -> requestValues.addHeader(name, value))); + if (this.version != null) { + requestValues.setApiVersion(this.version); + } return requestValues; } @@ -208,9 +211,11 @@ final class HttpServiceMethod { MediaType contentType = initContentType(typeAnnotation, methodAnnotation); List acceptableMediaTypes = initAccept(typeAnnotation, methodAnnotation); MultiValueMap headers = initHeaders(typeAnnotation, methodAnnotation, embeddedValueResolver); + String version = initVersion(typeAnnotation, methodAnnotation); return new HttpRequestValuesInitializer( - httpMethod, url, contentType, acceptableMediaTypes, headers, requestValuesSupplier); + httpMethod, url, contentType, acceptableMediaTypes, headers, version, + requestValuesSupplier); } private static @Nullable HttpMethod initHttpMethod(@Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation) { @@ -254,7 +259,9 @@ final class HttpServiceMethod { return (hasMethodLevelUrl ? methodLevelUrl : typeLevelUrl); } - private static @Nullable MediaType initContentType(@Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation) { + private static @Nullable MediaType initContentType( + @Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation) { + String methodLevelContentType = methodAnnotation.contentType(); if (StringUtils.hasText(methodLevelContentType)) { return MediaType.parseMediaType(methodLevelContentType); @@ -268,7 +275,9 @@ final class HttpServiceMethod { return null; } - private static @Nullable List initAccept(@Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation) { + private static @Nullable List initAccept( + @Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation) { + String[] methodLevelAccept = methodAnnotation.accept(); if (!ObjectUtils.isEmpty(methodLevelAccept)) { return MediaType.parseMediaTypes(List.of(methodLevelAccept)); @@ -294,6 +303,18 @@ final class HttpServiceMethod { return headers; } + private static @Nullable String initVersion( + @Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation) { + + if (StringUtils.hasText(methodAnnotation.version())) { + return methodAnnotation.version(); + } + if (typeAnnotation != null && StringUtils.hasText(typeAnnotation.version())) { + return typeAnnotation.version(); + } + return null; + } + private static void addHeaders( String[] rawValues, @Nullable StringValueResolver embeddedValueResolver, MultiValueMap outputHeaders) { diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java index b8cfdb897cf..e2bf12b22d8 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * 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. @@ -50,10 +50,12 @@ public final class ReactiveHttpRequestValues extends HttpRequestValues { @Nullable HttpMethod httpMethod, @Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory, @Nullable String uriTemplate, Map uriVars, - HttpHeaders headers, MultiValueMap cookies, Map attributes, - @Nullable Object bodyValue, @Nullable Publisher body, @Nullable ParameterizedTypeReference elementType) { + HttpHeaders headers, MultiValueMap cookies, @Nullable Object version, + Map attributes, + @Nullable Object bodyValue, @Nullable Publisher body, + @Nullable ParameterizedTypeReference elementType) { - super(httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars, headers, cookies, attributes, bodyValue); + super(httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars, headers, cookies, version, attributes, bodyValue); this.body = body; this.bodyElementType = elementType; } @@ -232,12 +234,12 @@ public final class ReactiveHttpRequestValues extends HttpRequestValues { @Nullable HttpMethod httpMethod, @Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory, @Nullable String uriTemplate, Map uriVars, - HttpHeaders headers, MultiValueMap cookies, Map attributes, - @Nullable Object bodyValue) { + HttpHeaders headers, MultiValueMap cookies, @Nullable Object version, + Map attributes, @Nullable Object bodyValue) { return new ReactiveHttpRequestValues( httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars, - headers, cookies, attributes, bodyValue, this.body, this.bodyElementType); + headers, cookies, version, attributes, bodyValue, this.body, this.bodyElementType); } } 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 new file mode 100644 index 00000000000..98e8eb7c1c8 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java @@ -0,0 +1,111 @@ +/* + * 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.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.http.client.JdkClientHttpRequestFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * {@link RestClient} tests for sending API versions. + * @author Rossen Stoyanchev + */ +public class RestClientVersionTests { + + private final MockWebServer server = new MockWebServer(); + + private final RestClient.Builder restClientBuilder = RestClient.builder() + .requestFactory(new JdkClientHttpRequestFactory()) + .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"); + } + + private void performRequest(DefaultApiVersionInserter.Builder builder) { + ApiVersionInserter versionInserter = builder.build(); + RestClient restClient = restClientBuilder.apiVersionInserter(versionInserter).build(); + + restClient.get() + .uri("/path") + .apiVersion(1.2) + .retrieve() + .body(String.class); + } + + private void expectRequest(Consumer consumer) { + try { + consumer.accept(this.server.takeRequest()); + } + catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java index 809bbbb227b..5a7e51af3f5 100644 --- a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java @@ -32,6 +32,7 @@ import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -46,6 +47,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.client.DefaultApiVersionInserter; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; @@ -266,6 +268,22 @@ class RestClientAdapterTests { assertThat(this.anotherServer.getRequestCount()).isEqualTo(0); } + @Test + void apiVersion() throws Exception { + RestClient restClient = RestClient.builder() + .baseUrl(anotherServer.url("/").toString()) + .apiVersionInserter(DefaultApiVersionInserter.fromHeader("X-API-Version").build()) + .build(); + + RestClientAdapter adapter = RestClientAdapter.create(restClient); + Service service = HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class); + + service.getGreetingWithVersion(); + + RecordedRequest request = anotherServer.takeRequest(); + assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2"); + } + private static MockWebServer anotherServer() { MockWebServer server = new MockWebServer(); @@ -287,6 +305,9 @@ class RestClientAdapterTests { @GetExchange("/greeting/{id}") Optional getGreetingWithDynamicUri(@Nullable URI uri, @PathVariable String id); + @GetExchange(url = "/greeting", version = "1.2") + String getGreetingWithVersion(); + @PostExchange("/greeting") void postWithHeader(@RequestHeader("testHeaderName") String testHeader, @RequestBody String requestBody); 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 127b1cd5453..5acb5e223c3 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 @@ -52,6 +52,7 @@ import org.springframework.util.Assert; 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.BodyExtractor; import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.BodyInserters; @@ -93,6 +94,8 @@ final class DefaultWebClient implements WebClient { private final @Nullable MultiValueMap defaultCookies; + private final @Nullable ApiVersionInserter apiVersionInserter; + private final @Nullable Consumer> defaultRequest; private final List defaultStatusHandlers; @@ -106,7 +109,9 @@ final class DefaultWebClient implements WebClient { DefaultWebClient(ExchangeFunction exchangeFunction, @Nullable ExchangeFilterFunction filterFunctions, UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders defaultHeaders, - @Nullable MultiValueMap defaultCookies, @Nullable Consumer> defaultRequest, + @Nullable MultiValueMap defaultCookies, + @Nullable ApiVersionInserter apiVersionInserter, + @Nullable Consumer> defaultRequest, @Nullable Map, Function>> statusHandlerMap, ObservationRegistry observationRegistry, @Nullable ClientRequestObservationConvention observationConvention, DefaultWebClientBuilder builder) { @@ -116,6 +121,7 @@ final class DefaultWebClient implements WebClient { this.uriBuilderFactory = uriBuilderFactory; this.defaultHeaders = defaultHeaders; this.defaultCookies = defaultCookies; + this.apiVersionInserter = apiVersionInserter; this.defaultRequest = defaultRequest; this.defaultStatusHandlers = initStatusHandlers(statusHandlerMap); this.observationRegistry = observationRegistry; @@ -205,6 +211,8 @@ final class DefaultWebClient implements WebClient { private @Nullable MultiValueMap cookies; + private @Nullable Object apiVersion; + private @Nullable BodyInserter inserter; private final Map attributes = new LinkedHashMap<>(4); @@ -323,6 +331,12 @@ final class DefaultWebClient implements WebClient { return this; } + @Override + public DefaultRequestBodyUriSpec apiVersion(Object version) { + this.apiVersion = version; + return this; + } + @Override public RequestBodySpec attribute(String name, Object value) { this.attributes.put(name, value); @@ -474,7 +488,12 @@ final class DefaultWebClient implements WebClient { } private URI initUri() { - return (this.uri != null ? this.uri : uriBuilderFactory.expand("")); + URI uriToUse = (this.uri != null ? this.uri : uriBuilderFactory.expand("")); + if (this.apiVersion != null) { + Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured"); + uriToUse = apiVersionInserter.insertVersion(this.apiVersion, uriToUse); + } + return uriToUse; } private void initHeaders(HttpHeaders out) { @@ -484,6 +503,10 @@ final class DefaultWebClient implements WebClient { if (this.headers != null && !this.headers.isEmpty()) { out.putAll(this.headers); } + if (this.apiVersion != null) { + Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured"); + apiVersionInserter.insertVersion(this.apiVersion, out); + } } private void initCookies(MultiValueMap 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 002e9446815..530fb42ef78 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 @@ -42,6 +42,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.util.DefaultUriBuilderFactory; import org.springframework.web.util.UriBuilderFactory; @@ -80,6 +81,8 @@ final class DefaultWebClientBuilder implements WebClient.Builder { private @Nullable MultiValueMap defaultCookies; + private @Nullable ApiVersionInserter apiVersionInserter; + private @Nullable Consumer> defaultRequest; private @Nullable Map, Function>> statusHandlers; @@ -118,8 +121,9 @@ final class DefaultWebClientBuilder implements WebClient.Builder { this.defaultHeaders = null; } - this.defaultCookies = (other.defaultCookies != null ? - new LinkedMultiValueMap<>(other.defaultCookies) : null); + this.defaultCookies = (other.defaultCookies != null ? new LinkedMultiValueMap<>(other.defaultCookies) : null); + this.apiVersionInserter = other.apiVersionInserter; + this.defaultRequest = other.defaultRequest; this.statusHandlers = (other.statusHandlers != null ? new LinkedHashMap<>(other.statusHandlers) : null); this.filters = (other.filters != null ? new ArrayList<>(other.filters) : null); @@ -190,6 +194,13 @@ final class DefaultWebClientBuilder implements WebClient.Builder { return this.defaultCookies; } + + @Override + public WebClient.Builder apiVersionInserter(ApiVersionInserter apiVersionInserter) { + this.apiVersionInserter = apiVersionInserter; + return this; + } + @Override public WebClient.Builder defaultRequest(Consumer> defaultRequest) { this.defaultRequest = this.defaultRequest != null ? @@ -297,6 +308,7 @@ final class DefaultWebClientBuilder implements WebClient.Builder { return new DefaultWebClient( exchange, filterFunctions, initUriBuilderFactory(), defaultHeaders, defaultCookies, + 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 3921a9e131f..9586783d765 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 @@ -45,6 +45,8 @@ import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.http.client.reactive.ClientHttpResponse; import org.springframework.http.codec.ClientCodecConfigurer; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.BodyInserters; @@ -250,6 +252,15 @@ public interface WebClient { */ 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); + /** * Provide a consumer to customize every request being built. * @param defaultRequest the consumer to use for modifying requests @@ -475,6 +486,17 @@ public interface WebClient { */ 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-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java index 03e85490baf..7e648dbd08c 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * 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. @@ -127,6 +127,11 @@ public final class WebClientAdapter extends AbstractReactorHttpExchangeAdapter { bodySpec.headers(headers -> headers.putAll(values.getHeaders())); bodySpec.cookies(cookies -> cookies.putAll(values.getCookies())); + + if (values.getApiVersion() != null) { + bodySpec.apiVersion(values.getApiVersion()); + } + bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); if (values.getBodyValue() != null) {