Skip to content

Commit 7bf628c

Browse files
committed
Client support for API versioning
Closes gh-34567
1 parent 483abd9 commit 7bf628c

22 files changed

+661
-41
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.client;
18+
19+
/**
20+
* Contract to format the API version for a request.
21+
*
22+
* @author Rossen Stoyanchev
23+
* @since 7.0
24+
* @see DefaultApiVersionInserter.Builder#withVersionFormatter(ApiVersionFormatter)
25+
*/
26+
@FunctionalInterface
27+
public interface ApiVersionFormatter {
28+
29+
/**
30+
* Format the given version Object into a String value.
31+
* @param version the version to format
32+
* @return the final String version to use
33+
*/
34+
String formatVersion(Object version);
35+
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.client;
18+
19+
import java.net.URI;
20+
21+
import org.springframework.http.HttpHeaders;
22+
23+
/**
24+
* Contract to determine how to insert an API version into the URI or headers
25+
* of a request.
26+
*
27+
* @author Rossen Stoyanchev
28+
* @since 7.0
29+
*/
30+
public interface ApiVersionInserter {
31+
32+
/**
33+
* Allows inserting the version into the URI.
34+
* @param version the version to insert
35+
* @param uri the URI for the request
36+
* @return the updated or the same URI
37+
*/
38+
default URI insertVersion(Object version, URI uri) {
39+
return uri;
40+
}
41+
42+
/**
43+
* Allows inserting the version into request headers.
44+
* @param version the version to insert
45+
* @param headers the request headers
46+
*/
47+
default void insertVersion(Object version, HttpHeaders headers) {
48+
}
49+
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.client;
18+
19+
import java.net.URI;
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
23+
import org.jspecify.annotations.Nullable;
24+
25+
import org.springframework.http.HttpHeaders;
26+
import org.springframework.util.Assert;
27+
import org.springframework.web.util.UriComponentsBuilder;
28+
29+
/**
30+
* Default implementation of {@link ApiVersionInserter} to insert the version
31+
* into a request header, query parameter, or the URL path.
32+
*
33+
* <p>Use {@link #builder()} to create an instance.
34+
*
35+
* @author Rossen Stoyanchev
36+
* @since 7.0
37+
*/
38+
public final class DefaultApiVersionInserter implements ApiVersionInserter {
39+
40+
private final @Nullable String header;
41+
42+
private final @Nullable String queryParam;
43+
44+
private final @Nullable Integer pathSegmentIndex;
45+
46+
private final ApiVersionFormatter versionFormatter;
47+
48+
49+
private DefaultApiVersionInserter(
50+
@Nullable String header, @Nullable String queryParam, @Nullable Integer pathSegmentIndex,
51+
@Nullable ApiVersionFormatter formatter) {
52+
53+
Assert.isTrue(header != null || queryParam != null || pathSegmentIndex != null,
54+
"Expected 'header', 'queryParam', or 'pathSegmentIndex' to be configured");
55+
56+
this.header = header;
57+
this.queryParam = queryParam;
58+
this.pathSegmentIndex = pathSegmentIndex;
59+
this.versionFormatter = (formatter != null ? formatter : Object::toString);
60+
}
61+
62+
63+
@Override
64+
public URI insertVersion(Object version, URI uri) {
65+
if (this.queryParam == null && this.pathSegmentIndex == null) {
66+
return uri;
67+
}
68+
String formattedVersion = this.versionFormatter.formatVersion(version);
69+
UriComponentsBuilder builder = UriComponentsBuilder.fromUri(uri);
70+
if (this.queryParam != null) {
71+
builder.queryParam(this.queryParam, formattedVersion);
72+
}
73+
if (this.pathSegmentIndex != null) {
74+
List<String> pathSegments = new ArrayList<>(builder.build().getPathSegments());
75+
assertPathSegmentIndex(this.pathSegmentIndex, pathSegments.size(), uri);
76+
pathSegments.add(this.pathSegmentIndex, formattedVersion);
77+
builder.replacePath(null);
78+
pathSegments.forEach(builder::pathSegment);
79+
}
80+
return builder.build().toUri();
81+
}
82+
83+
private void assertPathSegmentIndex(Integer index, int pathSegmentsSize, URI uri) {
84+
Assert.state(index <= pathSegmentsSize,
85+
"Cannot insert version into '" + uri.getPath() + "' at path segment index " + index);
86+
}
87+
88+
@Override
89+
public void insertVersion(Object version, HttpHeaders headers) {
90+
if (this.header != null) {
91+
headers.set(this.header, this.versionFormatter.formatVersion(version));
92+
}
93+
}
94+
95+
96+
/**
97+
* Create a builder for an inserter that sets a header.
98+
* @param header the name of a header to hold the version
99+
*/
100+
public static Builder fromHeader(@Nullable String header) {
101+
return new Builder(header, null, null);
102+
}
103+
104+
/**
105+
* Create a builder for an inserter that sets a query parameter.
106+
* @param queryParam the name of a query parameter to hold the version
107+
*/
108+
public static Builder fromQueryParam(@Nullable String queryParam) {
109+
return new Builder(null, queryParam, null);
110+
}
111+
112+
/**
113+
* Create a builder for an inserter that inserts a path segment.
114+
* @param pathSegmentIndex the index of the path segment to hold the version
115+
*/
116+
public static Builder fromPathSegment(@Nullable Integer pathSegmentIndex) {
117+
return new Builder(null, null, pathSegmentIndex);
118+
}
119+
120+
/**
121+
* Create a builder.
122+
*/
123+
public static Builder builder() {
124+
return new Builder(null, null, null);
125+
}
126+
127+
128+
/**
129+
* A builder for {@link DefaultApiVersionInserter}.
130+
*/
131+
public static final class Builder {
132+
133+
private @Nullable String header;
134+
135+
private @Nullable String queryParam;
136+
137+
private @Nullable Integer pathSegmentIndex;
138+
139+
private @Nullable ApiVersionFormatter versionFormatter;
140+
141+
private Builder(@Nullable String header, @Nullable String queryParam, @Nullable Integer pathSegmentIndex) {
142+
this.header = header;
143+
this.queryParam = queryParam;
144+
this.pathSegmentIndex = pathSegmentIndex;
145+
}
146+
147+
/**
148+
* Configure the inserter to set a header.
149+
* @param header the name of the header to hold the version
150+
*/
151+
public Builder fromHeader(@Nullable String header) {
152+
this.header = header;
153+
return this;
154+
}
155+
156+
/**
157+
* Configure the inserter to set a query parameter.
158+
* @param queryParam the name of the query parameter to hold the version
159+
*/
160+
public Builder fromQueryParam(@Nullable String queryParam) {
161+
this.queryParam = queryParam;
162+
return this;
163+
}
164+
165+
/**
166+
* Configure the inserter to insert a path segment.
167+
* @param pathSegmentIndex the index of the path segment to hold the version
168+
*/
169+
public Builder fromPathSegment(@Nullable Integer pathSegmentIndex) {
170+
this.pathSegmentIndex = pathSegmentIndex;
171+
return this;
172+
}
173+
174+
/**
175+
* Format the version Object into a String using the given {@link ApiVersionFormatter}.
176+
* <p>By default, the version is formatted with {@link Object#toString()}.
177+
* @param versionFormatter the formatter to use
178+
*/
179+
public Builder withVersionFormatter(ApiVersionFormatter versionFormatter) {
180+
this.versionFormatter = versionFormatter;
181+
return this;
182+
}
183+
184+
/**
185+
* Build the inserter.
186+
*/
187+
public ApiVersionInserter build() {
188+
return new DefaultApiVersionInserter(
189+
this.header, this.queryParam, this.pathSegmentIndex, this.versionFormatter);
190+
}
191+
}
192+
193+
}

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

+37-9
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ final class DefaultRestClient implements RestClient {
108108

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

111+
private final @Nullable ApiVersionInserter apiVersionInserter;
112+
111113
private final @Nullable Consumer<RequestHeadersSpec<?>> defaultRequest;
112114

113115
private final List<StatusHandler> defaultStatusHandlers;
@@ -128,6 +130,7 @@ final class DefaultRestClient implements RestClient {
128130
UriBuilderFactory uriBuilderFactory,
129131
@Nullable HttpHeaders defaultHeaders,
130132
@Nullable MultiValueMap<String, String> defaultCookies,
133+
@Nullable ApiVersionInserter apiVersionInserter,
131134
@Nullable Consumer<RequestHeadersSpec<?>> defaultRequest,
132135
@Nullable List<StatusHandler> statusHandlers,
133136
List<HttpMessageConverter<?>> messageConverters,
@@ -142,6 +145,7 @@ final class DefaultRestClient implements RestClient {
142145
this.uriBuilderFactory = uriBuilderFactory;
143146
this.defaultHeaders = defaultHeaders;
144147
this.defaultCookies = defaultCookies;
148+
this.apiVersionInserter = apiVersionInserter;
145149
this.defaultRequest = defaultRequest;
146150
this.defaultStatusHandlers = (statusHandlers != null ? new ArrayList<>(statusHandlers) : new ArrayList<>());
147151
this.messageConverters = messageConverters;
@@ -293,6 +297,8 @@ private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec {
293297

294298
private @Nullable MultiValueMap<String, String> cookies;
295299

300+
private @Nullable Object apiVersion;
301+
296302
private @Nullable InternalBody body;
297303

298304
private @Nullable Map<String, Object> attributes;
@@ -417,6 +423,12 @@ public DefaultRequestBodyUriSpec ifNoneMatch(String... ifNoneMatches) {
417423
return this;
418424
}
419425

426+
@Override
427+
public RequestBodySpec apiVersion(Object version) {
428+
this.apiVersion = version;
429+
return this;
430+
}
431+
420432
@Override
421433
public RequestBodySpec attribute(String name, Object value) {
422434
getAttributes().put(name, value);
@@ -589,7 +601,12 @@ public ResponseSpec retrieve() {
589601
}
590602

591603
private URI initUri() {
592-
return (this.uri != null ? this.uri : DefaultRestClient.this.uriBuilderFactory.expand(""));
604+
URI uriToUse = this.uri != null ? this.uri : DefaultRestClient.this.uriBuilderFactory.expand("");
605+
if (this.apiVersion != null) {
606+
Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
607+
uriToUse = apiVersionInserter.insertVersion(this.apiVersion, uriToUse);
608+
}
609+
return uriToUse;
593610
}
594611

595612
private @Nullable String serializeCookies() {
@@ -628,18 +645,29 @@ private static String serializeCookies(MultiValueMap<String, String> map) {
628645

629646
private @Nullable HttpHeaders initHeaders() {
630647
HttpHeaders defaultHeaders = DefaultRestClient.this.defaultHeaders;
631-
if (this.headers == null || this.headers.isEmpty()) {
632-
return defaultHeaders;
633-
}
634-
else if (defaultHeaders == null || defaultHeaders.isEmpty()) {
635-
return this.headers;
648+
if (this.apiVersion == null) {
649+
if (this.headers == null || this.headers.isEmpty()) {
650+
return defaultHeaders;
651+
}
652+
else if (defaultHeaders == null || defaultHeaders.isEmpty()) {
653+
return this.headers;
654+
}
636655
}
637-
else {
638-
HttpHeaders result = new HttpHeaders();
656+
657+
HttpHeaders result = new HttpHeaders();
658+
if (defaultHeaders != null) {
639659
result.putAll(defaultHeaders);
660+
}
661+
if (this.headers != null) {
640662
result.putAll(this.headers);
641-
return result;
642663
}
664+
665+
if (this.apiVersion != null) {
666+
Assert.state(apiVersionInserter != null, "No ApiVersionInserter configured");
667+
apiVersionInserter.insertVersion(this.apiVersion, result);
668+
}
669+
670+
return result;
643671
}
644672

645673
private ClientHttpRequest createRequest(URI uri) throws IOException {

0 commit comments

Comments
 (0)