Browse Source

Client support for API versioning

Closes gh-34567
pull/34701/head
rstoyanchev 1 year ago
parent
commit
7bf628c827
  1. 36
      spring-web/src/main/java/org/springframework/web/client/ApiVersionFormatter.java
  2. 50
      spring-web/src/main/java/org/springframework/web/client/ApiVersionInserter.java
  3. 193
      spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserter.java
  4. 46
      spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java
  5. 14
      spring-web/src/main/java/org/springframework/web/client/DefaultRestClientBuilder.java
  6. 20
      spring-web/src/main/java/org/springframework/web/client/RestClient.java
  7. 6
      spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java
  8. 9
      spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java
  9. 9
      spring-web/src/main/java/org/springframework/web/service/annotation/GetExchange.java
  10. 8
      spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java
  11. 9
      spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java
  12. 9
      spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java
  13. 9
      spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java
  14. 35
      spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java
  15. 29
      spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java
  16. 16
      spring-web/src/main/java/org/springframework/web/service/invoker/ReactiveHttpRequestValues.java
  17. 111
      spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java
  18. 21
      spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java
  19. 27
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java
  20. 16
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClientBuilder.java
  21. 22
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClient.java
  22. 7
      spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientAdapter.java

36
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);
}

50
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) {
}
}

193
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.
*
* <p>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<String> 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}.
* <p>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);
}
}
}

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

@ -108,6 +108,8 @@ final class DefaultRestClient implements RestClient {
private final @Nullable MultiValueMap<String, String> defaultCookies; private final @Nullable MultiValueMap<String, String> defaultCookies;
private final @Nullable ApiVersionInserter apiVersionInserter;
private final @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest; private final @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest;
private final List<StatusHandler> defaultStatusHandlers; private final List<StatusHandler> defaultStatusHandlers;
@ -128,6 +130,7 @@ final class DefaultRestClient implements RestClient {
UriBuilderFactory uriBuilderFactory, UriBuilderFactory uriBuilderFactory,
@Nullable HttpHeaders defaultHeaders, @Nullable HttpHeaders defaultHeaders,
@Nullable MultiValueMap<String, String> defaultCookies, @Nullable MultiValueMap<String, String> defaultCookies,
@Nullable ApiVersionInserter apiVersionInserter,
@Nullable Consumer<RequestHeadersSpec<?>> defaultRequest, @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest,
@Nullable List<StatusHandler> statusHandlers, @Nullable List<StatusHandler> statusHandlers,
List<HttpMessageConverter<?>> messageConverters, List<HttpMessageConverter<?>> messageConverters,
@ -142,6 +145,7 @@ final class DefaultRestClient implements RestClient {
this.uriBuilderFactory = uriBuilderFactory; this.uriBuilderFactory = uriBuilderFactory;
this.defaultHeaders = defaultHeaders; this.defaultHeaders = defaultHeaders;
this.defaultCookies = defaultCookies; this.defaultCookies = defaultCookies;
this.apiVersionInserter = apiVersionInserter;
this.defaultRequest = defaultRequest; this.defaultRequest = defaultRequest;
this.defaultStatusHandlers = (statusHandlers != null ? new ArrayList<>(statusHandlers) : new ArrayList<>()); this.defaultStatusHandlers = (statusHandlers != null ? new ArrayList<>(statusHandlers) : new ArrayList<>());
this.messageConverters = messageConverters; this.messageConverters = messageConverters;
@ -293,6 +297,8 @@ final class DefaultRestClient implements RestClient {
private @Nullable MultiValueMap<String, String> cookies; private @Nullable MultiValueMap<String, String> cookies;
private @Nullable Object apiVersion;
private @Nullable InternalBody body; private @Nullable InternalBody body;
private @Nullable Map<String, Object> attributes; private @Nullable Map<String, Object> attributes;
@ -417,6 +423,12 @@ final class DefaultRestClient implements RestClient {
return this; return this;
} }
@Override
public RequestBodySpec apiVersion(Object version) {
this.apiVersion = version;
return this;
}
@Override @Override
public RequestBodySpec attribute(String name, Object value) { public RequestBodySpec attribute(String name, Object value) {
getAttributes().put(name, value); getAttributes().put(name, value);
@ -589,7 +601,12 @@ final class DefaultRestClient implements RestClient {
} }
private URI initUri() { 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() { private @Nullable String serializeCookies() {
@ -628,18 +645,29 @@ final class DefaultRestClient implements RestClient {
private @Nullable HttpHeaders initHeaders() { private @Nullable HttpHeaders initHeaders() {
HttpHeaders defaultHeaders = DefaultRestClient.this.defaultHeaders; HttpHeaders defaultHeaders = DefaultRestClient.this.defaultHeaders;
if (this.headers == null || this.headers.isEmpty()) { if (this.apiVersion == null) {
return defaultHeaders; if (this.headers == null || this.headers.isEmpty()) {
} return defaultHeaders;
else if (defaultHeaders == null || defaultHeaders.isEmpty()) { }
return this.headers; else if (defaultHeaders == null || defaultHeaders.isEmpty()) {
return this.headers;
}
} }
else {
HttpHeaders result = new HttpHeaders(); HttpHeaders result = new HttpHeaders();
if (defaultHeaders != null) {
result.putAll(defaultHeaders); result.putAll(defaultHeaders);
}
if (this.headers != null) {
result.putAll(this.headers); 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 { private ClientHttpRequest createRequest(URI uri) throws IOException {

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

@ -150,6 +150,8 @@ final class DefaultRestClientBuilder implements RestClient.Builder {
private @Nullable MultiValueMap<String, String> defaultCookies; private @Nullable MultiValueMap<String, String> defaultCookies;
private @Nullable ApiVersionInserter apiVersionInserter;
private @Nullable Consumer<RestClient.RequestHeadersSpec<?>> defaultRequest; private @Nullable Consumer<RestClient.RequestHeadersSpec<?>> defaultRequest;
private @Nullable List<StatusHandler> statusHandlers; private @Nullable List<StatusHandler> statusHandlers;
@ -186,6 +188,7 @@ final class DefaultRestClientBuilder implements RestClient.Builder {
this.defaultHeaders = null; 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.defaultRequest = other.defaultRequest;
this.statusHandlers = (other.statusHandlers != null ? new ArrayList<>(other.statusHandlers) : null); this.statusHandlers = (other.statusHandlers != null ? new ArrayList<>(other.statusHandlers) : null);
this.interceptors = (other.interceptors != null) ? new ArrayList<>(other.interceptors) : null; this.interceptors = (other.interceptors != null) ? new ArrayList<>(other.interceptors) : null;
@ -321,6 +324,12 @@ final class DefaultRestClientBuilder implements RestClient.Builder {
return this.defaultCookies; return this.defaultCookies;
} }
@Override
public RestClient.Builder apiVersionInserter(ApiVersionInserter apiVersionInserter) {
this.apiVersionInserter = apiVersionInserter;
return this;
}
@Override @Override
public RestClient.Builder defaultRequest(Consumer<RestClient.RequestHeadersSpec<?>> defaultRequest) { public RestClient.Builder defaultRequest(Consumer<RestClient.RequestHeadersSpec<?>> defaultRequest) {
this.defaultRequest = this.defaultRequest != null ? this.defaultRequest = this.defaultRequest != null ?
@ -513,9 +522,8 @@ final class DefaultRestClientBuilder implements RestClient.Builder {
return new DefaultRestClient( return new DefaultRestClient(
requestFactory, this.interceptors, this.bufferingPredicate, this.initializers, requestFactory, this.interceptors, this.bufferingPredicate, this.initializers,
uriBuilderFactory, defaultHeaders, defaultCookies, uriBuilderFactory, defaultHeaders, defaultCookies,
this.defaultRequest, this.apiVersionInserter, this.defaultRequest,
this.statusHandlers, this.statusHandlers, converters,
converters,
this.observationRegistry, this.observationConvention, this.observationRegistry, this.observationConvention,
new DefaultRestClientBuilder(this)); new DefaultRestClientBuilder(this));
} }

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

@ -332,6 +332,15 @@ public interface RestClient {
*/ */
Builder defaultCookies(Consumer<MultiValueMap<String, String>> cookiesConsumer); Builder defaultCookies(Consumer<MultiValueMap<String, String>> 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. * Provide a consumer to customize every request being built.
* @param defaultRequest the consumer to use for modifying requests * @param defaultRequest the consumer to use for modifying requests
@ -596,6 +605,17 @@ public interface RestClient {
*/ */
S headers(Consumer<HttpHeaders> headersConsumer); S headers(Consumer<HttpHeaders> 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. * Set the attribute with the given name to the given value.
* @param name the name of the attribute to add * @param name the name of the attribute to add

6
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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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)); bodySpec.header(HttpHeaders.COOKIE, String.join("; ", cookies));
} }
if (values.getApiVersion() != null) {
bodySpec.apiVersion(values.getApiVersion());
}
bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes()));
if (values.getBodyValue() != null) { if (values.getBodyValue() != null) {

9
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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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) @AliasFor(annotation = HttpExchange.class)
String[] headers() default {}; String[] headers() default {};
/**
* Alias for {@link HttpExchange#version()}.
* @since 7.0
*/
@AliasFor(annotation = HttpExchange.class)
String version() default "";
} }

9
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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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) @AliasFor(annotation = HttpExchange.class)
String[] accept() default {}; String[] accept() default {};
/**
* Alias for {@link HttpExchange#version()}.
* @since 7.0
*/
@AliasFor(annotation = HttpExchange.class)
String version() default "";
} }

8
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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -185,4 +185,10 @@ public @interface HttpExchange {
*/ */
String[] headers() default {}; String[] headers() default {};
/**
* The API version associated with the request.
* @since 7.0
*/
String version() default "";
} }

9
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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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) @AliasFor(annotation = HttpExchange.class)
String[] headers() default {}; String[] headers() default {};
/**
* Alias for {@link HttpExchange#version()}.
* @since 7.0
*/
@AliasFor(annotation = HttpExchange.class)
String version() default "";
} }

9
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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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) @AliasFor(annotation = HttpExchange.class)
String[] headers() default {}; String[] headers() default {};
/**
* Alias for {@link HttpExchange#version()}.
* @since 7.0
*/
@AliasFor(annotation = HttpExchange.class)
String version() default "";
} }

9
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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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) @AliasFor(annotation = HttpExchange.class)
String[] headers() default {}; String[] headers() default {};
/**
* Alias for {@link HttpExchange#version()}.
* @since 7.0
*/
@AliasFor(annotation = HttpExchange.class)
String version() default "";
} }

35
spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java

@ -67,6 +67,8 @@ public class HttpRequestValues {
private final MultiValueMap<String, String> cookies; private final MultiValueMap<String, String> cookies;
private @Nullable Object version;
private final Map<String, Object> attributes; private final Map<String, Object> attributes;
private final @Nullable Object bodyValue; private final @Nullable Object bodyValue;
@ -79,8 +81,8 @@ public class HttpRequestValues {
protected HttpRequestValues(@Nullable HttpMethod httpMethod, protected HttpRequestValues(@Nullable HttpMethod httpMethod,
@Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory, @Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory,
@Nullable String uriTemplate, Map<String, String> uriVariables, @Nullable String uriTemplate, Map<String, String> uriVariables,
HttpHeaders headers, MultiValueMap<String, String> cookies, Map<String, Object> attributes, HttpHeaders headers, MultiValueMap<String, String> cookies, @Nullable Object version,
@Nullable Object bodyValue) { Map<String, Object> attributes, @Nullable Object bodyValue) {
Assert.isTrue(uri != null || uriTemplate != null, "Neither URI nor URI template"); Assert.isTrue(uri != null || uriTemplate != null, "Neither URI nor URI template");
@ -91,6 +93,7 @@ public class HttpRequestValues {
this.uriVariables = uriVariables; this.uriVariables = uriVariables;
this.headers = headers; this.headers = headers;
this.cookies = cookies; this.cookies = cookies;
this.version = version;
this.attributes = attributes; this.attributes = attributes;
this.bodyValue = bodyValue; this.bodyValue = bodyValue;
} }
@ -154,6 +157,10 @@ public class HttpRequestValues {
return this.cookies; return this.cookies;
} }
public @Nullable Object getApiVersion() {
return this.version;
}
/** /**
* Return the attributes associated with the request, or an empty map. * Return the attributes associated with the request, or an empty map.
*/ */
@ -225,6 +232,8 @@ public class HttpRequestValues {
private @Nullable MultiValueMap<String, Object> parts; private @Nullable MultiValueMap<String, Object> parts;
private @Nullable Object version;
private @Nullable Map<String, Object> attributes; private @Nullable Map<String, Object> attributes;
private @Nullable Object bodyValue; private @Nullable Object bodyValue;
@ -347,6 +356,20 @@ public class HttpRequestValues {
return this; 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. * Configure an attribute to associate with the request.
* @param name the attribute name * @param name the attribute name
@ -439,7 +462,7 @@ public class HttpRequestValues {
return createRequestValues( return createRequestValues(
this.httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars, this.httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars,
headers, cookies, attributes, bodyValue); headers, cookies, this.version, attributes, bodyValue);
} }
protected boolean hasParts() { protected boolean hasParts() {
@ -484,12 +507,12 @@ public class HttpRequestValues {
@Nullable HttpMethod httpMethod, @Nullable HttpMethod httpMethod,
@Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory, @Nullable String uriTemplate, @Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory, @Nullable String uriTemplate,
Map<String, String> uriVars, Map<String, String> uriVars,
HttpHeaders headers, MultiValueMap<String, String> cookies, Map<String, Object> attributes, HttpHeaders headers, MultiValueMap<String, String> cookies, @Nullable Object version,
@Nullable Object bodyValue) { Map<String, Object> attributes, @Nullable Object bodyValue) {
return new HttpRequestValues( return new HttpRequestValues(
this.httpMethod, uri, uriBuilderFactory, uriTemplate, this.httpMethod, uri, uriBuilderFactory, uriTemplate,
uriVars, headers, cookies, attributes, bodyValue); uriVars, headers, cookies, version, attributes, bodyValue);
} }
} }

29
spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java

@ -158,7 +158,7 @@ final class HttpServiceMethod {
private record HttpRequestValuesInitializer( private record HttpRequestValuesInitializer(
@Nullable HttpMethod httpMethod, @Nullable String url, @Nullable HttpMethod httpMethod, @Nullable String url,
@Nullable MediaType contentType, @Nullable List<MediaType> acceptMediaTypes, @Nullable MediaType contentType, @Nullable List<MediaType> acceptMediaTypes,
MultiValueMap<String, String> headers, MultiValueMap<String, String> headers, @Nullable String version,
Supplier<HttpRequestValues.Builder> requestValuesSupplier) { Supplier<HttpRequestValues.Builder> requestValuesSupplier) {
public HttpRequestValues.Builder initializeRequestValuesBuilder() { public HttpRequestValues.Builder initializeRequestValuesBuilder() {
@ -177,6 +177,9 @@ final class HttpServiceMethod {
} }
this.headers.forEach((name, values) -> this.headers.forEach((name, values) ->
values.forEach(value -> requestValues.addHeader(name, value))); values.forEach(value -> requestValues.addHeader(name, value)));
if (this.version != null) {
requestValues.setApiVersion(this.version);
}
return requestValues; return requestValues;
} }
@ -208,9 +211,11 @@ final class HttpServiceMethod {
MediaType contentType = initContentType(typeAnnotation, methodAnnotation); MediaType contentType = initContentType(typeAnnotation, methodAnnotation);
List<MediaType> acceptableMediaTypes = initAccept(typeAnnotation, methodAnnotation); List<MediaType> acceptableMediaTypes = initAccept(typeAnnotation, methodAnnotation);
MultiValueMap<String, String> headers = initHeaders(typeAnnotation, methodAnnotation, embeddedValueResolver); MultiValueMap<String, String> headers = initHeaders(typeAnnotation, methodAnnotation, embeddedValueResolver);
String version = initVersion(typeAnnotation, methodAnnotation);
return new HttpRequestValuesInitializer( 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) { private static @Nullable HttpMethod initHttpMethod(@Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation) {
@ -254,7 +259,9 @@ final class HttpServiceMethod {
return (hasMethodLevelUrl ? methodLevelUrl : typeLevelUrl); 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(); String methodLevelContentType = methodAnnotation.contentType();
if (StringUtils.hasText(methodLevelContentType)) { if (StringUtils.hasText(methodLevelContentType)) {
return MediaType.parseMediaType(methodLevelContentType); return MediaType.parseMediaType(methodLevelContentType);
@ -268,7 +275,9 @@ final class HttpServiceMethod {
return null; return null;
} }
private static @Nullable List<MediaType> initAccept(@Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation) { private static @Nullable List<MediaType> initAccept(
@Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation) {
String[] methodLevelAccept = methodAnnotation.accept(); String[] methodLevelAccept = methodAnnotation.accept();
if (!ObjectUtils.isEmpty(methodLevelAccept)) { if (!ObjectUtils.isEmpty(methodLevelAccept)) {
return MediaType.parseMediaTypes(List.of(methodLevelAccept)); return MediaType.parseMediaTypes(List.of(methodLevelAccept));
@ -294,6 +303,18 @@ final class HttpServiceMethod {
return headers; 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( private static void addHeaders(
String[] rawValues, @Nullable StringValueResolver embeddedValueResolver, String[] rawValues, @Nullable StringValueResolver embeddedValueResolver,
MultiValueMap<String, String> outputHeaders) { MultiValueMap<String, String> outputHeaders) {

16
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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 HttpMethod httpMethod,
@Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory, @Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory,
@Nullable String uriTemplate, Map<String, String> uriVars, @Nullable String uriTemplate, Map<String, String> uriVars,
HttpHeaders headers, MultiValueMap<String, String> cookies, Map<String, Object> attributes, HttpHeaders headers, MultiValueMap<String, String> cookies, @Nullable Object version,
@Nullable Object bodyValue, @Nullable Publisher<?> body, @Nullable ParameterizedTypeReference<?> elementType) { Map<String, Object> 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.body = body;
this.bodyElementType = elementType; this.bodyElementType = elementType;
} }
@ -232,12 +234,12 @@ public final class ReactiveHttpRequestValues extends HttpRequestValues {
@Nullable HttpMethod httpMethod, @Nullable HttpMethod httpMethod,
@Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory, @Nullable URI uri, @Nullable UriBuilderFactory uriBuilderFactory,
@Nullable String uriTemplate, Map<String, String> uriVars, @Nullable String uriTemplate, Map<String, String> uriVars,
HttpHeaders headers, MultiValueMap<String, String> cookies, Map<String, Object> attributes, HttpHeaders headers, MultiValueMap<String, String> cookies, @Nullable Object version,
@Nullable Object bodyValue) { Map<String, Object> attributes, @Nullable Object bodyValue) {
return new ReactiveHttpRequestValues( return new ReactiveHttpRequestValues(
httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars, httpMethod, uri, uriBuilderFactory, uriTemplate, uriVars,
headers, cookies, attributes, bodyValue, this.body, this.bodyElementType); headers, cookies, version, attributes, bodyValue, this.body, this.bodyElementType);
} }
} }

111
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<RecordedRequest> consumer) {
try {
consumer.accept(this.server.takeRequest());
}
catch (InterruptedException ex) {
throw new IllegalStateException(ex);
}
}
}

21
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 okhttp3.mockwebserver.RecordedRequest;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource; 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.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.client.DefaultApiVersionInserter;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@ -266,6 +268,22 @@ class RestClientAdapterTests {
assertThat(this.anotherServer.getRequestCount()).isEqualTo(0); 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() { private static MockWebServer anotherServer() {
MockWebServer server = new MockWebServer(); MockWebServer server = new MockWebServer();
@ -287,6 +305,9 @@ class RestClientAdapterTests {
@GetExchange("/greeting/{id}") @GetExchange("/greeting/{id}")
Optional<String> getGreetingWithDynamicUri(@Nullable URI uri, @PathVariable String id); Optional<String> getGreetingWithDynamicUri(@Nullable URI uri, @PathVariable String id);
@GetExchange(url = "/greeting", version = "1.2")
String getGreetingWithVersion();
@PostExchange("/greeting") @PostExchange("/greeting")
void postWithHeader(@RequestHeader("testHeaderName") String testHeader, @RequestBody String requestBody); void postWithHeader(@RequestHeader("testHeaderName") String testHeader, @RequestBody String requestBody);

27
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.CollectionUtils;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ApiVersionInserter;
import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.reactive.function.BodyExtractor;
import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.BodyInserter;
import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.BodyInserters;
@ -93,6 +94,8 @@ final class DefaultWebClient implements WebClient {
private final @Nullable MultiValueMap<String, String> defaultCookies; private final @Nullable MultiValueMap<String, String> defaultCookies;
private final @Nullable ApiVersionInserter apiVersionInserter;
private final @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest; private final @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest;
private final List<DefaultResponseSpec.StatusHandler> defaultStatusHandlers; private final List<DefaultResponseSpec.StatusHandler> defaultStatusHandlers;
@ -106,7 +109,9 @@ final class DefaultWebClient implements WebClient {
DefaultWebClient(ExchangeFunction exchangeFunction, @Nullable ExchangeFilterFunction filterFunctions, DefaultWebClient(ExchangeFunction exchangeFunction, @Nullable ExchangeFilterFunction filterFunctions,
UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders defaultHeaders, UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders defaultHeaders,
@Nullable MultiValueMap<String, String> defaultCookies, @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest, @Nullable MultiValueMap<String, String> defaultCookies,
@Nullable ApiVersionInserter apiVersionInserter,
@Nullable Consumer<RequestHeadersSpec<?>> defaultRequest,
@Nullable Map<Predicate<HttpStatusCode>, Function<ClientResponse, Mono<? extends Throwable>>> statusHandlerMap, @Nullable Map<Predicate<HttpStatusCode>, Function<ClientResponse, Mono<? extends Throwable>>> statusHandlerMap,
ObservationRegistry observationRegistry, @Nullable ClientRequestObservationConvention observationConvention, ObservationRegistry observationRegistry, @Nullable ClientRequestObservationConvention observationConvention,
DefaultWebClientBuilder builder) { DefaultWebClientBuilder builder) {
@ -116,6 +121,7 @@ final class DefaultWebClient implements WebClient {
this.uriBuilderFactory = uriBuilderFactory; this.uriBuilderFactory = uriBuilderFactory;
this.defaultHeaders = defaultHeaders; this.defaultHeaders = defaultHeaders;
this.defaultCookies = defaultCookies; this.defaultCookies = defaultCookies;
this.apiVersionInserter = apiVersionInserter;
this.defaultRequest = defaultRequest; this.defaultRequest = defaultRequest;
this.defaultStatusHandlers = initStatusHandlers(statusHandlerMap); this.defaultStatusHandlers = initStatusHandlers(statusHandlerMap);
this.observationRegistry = observationRegistry; this.observationRegistry = observationRegistry;
@ -205,6 +211,8 @@ final class DefaultWebClient implements WebClient {
private @Nullable MultiValueMap<String, String> cookies; private @Nullable MultiValueMap<String, String> cookies;
private @Nullable Object apiVersion;
private @Nullable BodyInserter<?, ? super ClientHttpRequest> inserter; private @Nullable BodyInserter<?, ? super ClientHttpRequest> inserter;
private final Map<String, Object> attributes = new LinkedHashMap<>(4); private final Map<String, Object> attributes = new LinkedHashMap<>(4);
@ -323,6 +331,12 @@ final class DefaultWebClient implements WebClient {
return this; return this;
} }
@Override
public DefaultRequestBodyUriSpec apiVersion(Object version) {
this.apiVersion = version;
return this;
}
@Override @Override
public RequestBodySpec attribute(String name, Object value) { public RequestBodySpec attribute(String name, Object value) {
this.attributes.put(name, value); this.attributes.put(name, value);
@ -474,7 +488,12 @@ final class DefaultWebClient implements WebClient {
} }
private URI initUri() { 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) { private void initHeaders(HttpHeaders out) {
@ -484,6 +503,10 @@ final class DefaultWebClient implements WebClient {
if (this.headers != null && !this.headers.isEmpty()) { if (this.headers != null && !this.headers.isEmpty()) {
out.putAll(this.headers); out.putAll(this.headers);
} }
if (this.apiVersion != null) {
Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
apiVersionInserter.insertVersion(this.apiVersion, out);
}
} }
private void initCookies(MultiValueMap<String, String> out) { private void initCookies(MultiValueMap<String, String> out) {

16
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.CollectionUtils;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ApiVersionInserter;
import org.springframework.web.util.DefaultUriBuilderFactory; import org.springframework.web.util.DefaultUriBuilderFactory;
import org.springframework.web.util.UriBuilderFactory; import org.springframework.web.util.UriBuilderFactory;
@ -80,6 +81,8 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
private @Nullable MultiValueMap<String, String> defaultCookies; private @Nullable MultiValueMap<String, String> defaultCookies;
private @Nullable ApiVersionInserter apiVersionInserter;
private @Nullable Consumer<WebClient.RequestHeadersSpec<?>> defaultRequest; private @Nullable Consumer<WebClient.RequestHeadersSpec<?>> defaultRequest;
private @Nullable Map<Predicate<HttpStatusCode>, Function<ClientResponse, Mono<? extends Throwable>>> statusHandlers; private @Nullable Map<Predicate<HttpStatusCode>, Function<ClientResponse, Mono<? extends Throwable>>> statusHandlers;
@ -118,8 +121,9 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
this.defaultHeaders = null; this.defaultHeaders = null;
} }
this.defaultCookies = (other.defaultCookies != null ? this.defaultCookies = (other.defaultCookies != null ? new LinkedMultiValueMap<>(other.defaultCookies) : null);
new LinkedMultiValueMap<>(other.defaultCookies) : null); this.apiVersionInserter = other.apiVersionInserter;
this.defaultRequest = other.defaultRequest; this.defaultRequest = other.defaultRequest;
this.statusHandlers = (other.statusHandlers != null ? new LinkedHashMap<>(other.statusHandlers) : null); this.statusHandlers = (other.statusHandlers != null ? new LinkedHashMap<>(other.statusHandlers) : null);
this.filters = (other.filters != null ? new ArrayList<>(other.filters) : null); this.filters = (other.filters != null ? new ArrayList<>(other.filters) : null);
@ -190,6 +194,13 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
return this.defaultCookies; return this.defaultCookies;
} }
@Override
public WebClient.Builder apiVersionInserter(ApiVersionInserter apiVersionInserter) {
this.apiVersionInserter = apiVersionInserter;
return this;
}
@Override @Override
public WebClient.Builder defaultRequest(Consumer<WebClient.RequestHeadersSpec<?>> defaultRequest) { public WebClient.Builder defaultRequest(Consumer<WebClient.RequestHeadersSpec<?>> defaultRequest) {
this.defaultRequest = this.defaultRequest != null ? this.defaultRequest = this.defaultRequest != null ?
@ -297,6 +308,7 @@ final class DefaultWebClientBuilder implements WebClient.Builder {
return new DefaultWebClient( return new DefaultWebClient(
exchange, filterFunctions, exchange, filterFunctions,
initUriBuilderFactory(), defaultHeaders, defaultCookies, initUriBuilderFactory(), defaultHeaders, defaultCookies,
this.apiVersionInserter,
this.defaultRequest, this.defaultRequest,
this.statusHandlers, this.statusHandlers,
this.observationRegistry, this.observationConvention, this.observationRegistry, this.observationConvention,

22
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.client.reactive.ClientHttpResponse;
import org.springframework.http.codec.ClientCodecConfigurer; import org.springframework.http.codec.ClientCodecConfigurer;
import org.springframework.util.MultiValueMap; 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.BodyExtractor;
import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.BodyInserter;
import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.BodyInserters;
@ -250,6 +252,15 @@ public interface WebClient {
*/ */
Builder defaultCookies(Consumer<MultiValueMap<String, String>> cookiesConsumer); Builder defaultCookies(Consumer<MultiValueMap<String, String>> 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. * Provide a consumer to customize every request being built.
* @param defaultRequest the consumer to use for modifying requests * @param defaultRequest the consumer to use for modifying requests
@ -475,6 +486,17 @@ public interface WebClient {
*/ */
S headers(Consumer<HttpHeaders> headersConsumer); S headers(Consumer<HttpHeaders> 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. * Set the attribute with the given name to the given value.
* @param name the name of the attribute to add * @param name the name of the attribute to add

7
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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.headers(headers -> headers.putAll(values.getHeaders()));
bodySpec.cookies(cookies -> cookies.putAll(values.getCookies())); bodySpec.cookies(cookies -> cookies.putAll(values.getCookies()));
if (values.getApiVersion() != null) {
bodySpec.apiVersion(values.getApiVersion());
}
bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes())); bodySpec.attributes(attributes -> attributes.putAll(values.getAttributes()));
if (values.getBodyValue() != null) { if (values.getBodyValue() != null) {

Loading…
Cancel
Save