Skip to content

Commit e73dc37

Browse files
committed
API versioning support for Spring WebFlux
Closes gh-34566
1 parent 51d34ff commit e73dc37

18 files changed

+1244
-14
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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.reactive.accept;
18+
19+
import org.jspecify.annotations.Nullable;
20+
21+
import org.springframework.web.server.ServerWebExchange;
22+
23+
/**
24+
* Contract to extract the version from a request.
25+
*
26+
* @author Rossen Stoyanchev
27+
* @since 7.0
28+
*/
29+
@FunctionalInterface
30+
public
31+
interface ApiVersionResolver {
32+
33+
/**
34+
* Resolve the version for the given exchange.
35+
* @param exchange the current exchange
36+
* @return the version value, or {@code null} if not found
37+
*/
38+
@Nullable String resolveVersion(ServerWebExchange exchange);
39+
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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.reactive.accept;
18+
19+
import org.jspecify.annotations.Nullable;
20+
21+
import org.springframework.web.accept.InvalidApiVersionException;
22+
import org.springframework.web.accept.MissingApiVersionException;
23+
import org.springframework.web.server.ServerWebExchange;
24+
25+
/**
26+
* The main component that encapsulates configuration preferences and strategies
27+
* to manage API versioning for an application.
28+
*
29+
* @author Rossen Stoyanchev
30+
* @since 7.0
31+
*/
32+
public interface ApiVersionStrategy {
33+
34+
/**
35+
* Resolve the version value from a request, e.g. from a request header.
36+
* @param exchange the current exchange
37+
* @return the version, if present or {@code null}
38+
*/
39+
@Nullable
40+
String resolveVersion(ServerWebExchange exchange);
41+
42+
/**
43+
* Parse the version of a request into an Object.
44+
* @param version the value to parse
45+
* @return an Object that represents the version
46+
*/
47+
Comparable<?> parseVersion(String version);
48+
49+
/**
50+
* Validate a request version, including required and supported version checks.
51+
* @param requestVersion the version to validate
52+
* @param exchange the exchange
53+
* @throws MissingApiVersionException if the version is required, but not specified
54+
* @throws InvalidApiVersionException if the version is not supported
55+
*/
56+
void validateVersion(@Nullable Comparable<?> requestVersion, ServerWebExchange exchange)
57+
throws MissingApiVersionException, InvalidApiVersionException;
58+
59+
/**
60+
* Return a default version to use for requests that don't specify one.
61+
*/
62+
@Nullable Comparable<?> getDefaultVersion();
63+
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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.reactive.accept;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
import java.util.Set;
22+
import java.util.TreeSet;
23+
24+
import org.jspecify.annotations.Nullable;
25+
26+
import org.springframework.util.Assert;
27+
import org.springframework.web.accept.ApiVersionParser;
28+
import org.springframework.web.accept.InvalidApiVersionException;
29+
import org.springframework.web.accept.MissingApiVersionException;
30+
import org.springframework.web.server.ServerWebExchange;
31+
32+
/**
33+
* Default implementation of {@link ApiVersionStrategy} that delegates to the
34+
* configured version resolvers and version parser.
35+
*
36+
* @author Rossen Stoyanchev
37+
* @since 7.0
38+
*/
39+
public class DefaultApiVersionStrategy implements ApiVersionStrategy {
40+
41+
private final List<ApiVersionResolver> versionResolvers;
42+
43+
private final ApiVersionParser<?> versionParser;
44+
45+
private final boolean versionRequired;
46+
47+
private final @Nullable Comparable<?> defaultVersion;
48+
49+
private final Set<Comparable<?>> supportedVersions = new TreeSet<>();
50+
51+
52+
/**
53+
* Create an instance.
54+
* @param versionResolvers one or more resolvers to try; the first non-null
55+
* value returned by any resolver becomes the resolved used
56+
* @param versionParser parser for to raw version values
57+
* @param versionRequired whether a version is required; if a request
58+
* does not have a version, and a {@code defaultVersion} is not specified,
59+
* validation fails with {@link MissingApiVersionException}
60+
* @param defaultVersion a default version to assign to requests that
61+
* don't specify one
62+
*/
63+
public DefaultApiVersionStrategy(
64+
List<ApiVersionResolver> versionResolvers, ApiVersionParser<?> versionParser,
65+
boolean versionRequired, @Nullable String defaultVersion) {
66+
67+
Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required");
68+
Assert.notNull(versionParser, "ApiVersionParser is required");
69+
70+
this.versionResolvers = new ArrayList<>(versionResolvers);
71+
this.versionParser = versionParser;
72+
this.versionRequired = (versionRequired && defaultVersion == null);
73+
this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null);
74+
}
75+
76+
77+
@Override
78+
public @Nullable Comparable<?> getDefaultVersion() {
79+
return this.defaultVersion;
80+
}
81+
82+
/**
83+
* Add to the list of known, supported versions to check against in
84+
* {@link ApiVersionStrategy#validateVersion}. Request versions that are not
85+
* in the supported result in {@link InvalidApiVersionException}
86+
* in {@link ApiVersionStrategy#validateVersion}.
87+
* @param versions the versions to add
88+
*/
89+
public void addSupportedVersion(String... versions) {
90+
for (String version : versions) {
91+
this.supportedVersions.add(parseVersion(version));
92+
}
93+
}
94+
95+
@Override
96+
public @Nullable String resolveVersion(ServerWebExchange exchange) {
97+
for (ApiVersionResolver resolver : this.versionResolvers) {
98+
String version = resolver.resolveVersion(exchange);
99+
if (version != null) {
100+
return version;
101+
}
102+
}
103+
return null;
104+
}
105+
106+
@Override
107+
public Comparable<?> parseVersion(String version) {
108+
return this.versionParser.parseVersion(version);
109+
}
110+
111+
public void validateVersion(@Nullable Comparable<?> requestVersion, ServerWebExchange exchange)
112+
throws MissingApiVersionException, InvalidApiVersionException {
113+
114+
if (requestVersion == null) {
115+
if (this.versionRequired) {
116+
throw new MissingApiVersionException();
117+
}
118+
return;
119+
}
120+
121+
if (!this.supportedVersions.contains(requestVersion)) {
122+
throw new InvalidApiVersionException(requestVersion.toString());
123+
}
124+
}
125+
126+
@Override
127+
public String toString() {
128+
return "DefaultApiVersionStrategy[supportedVersions=" + this.supportedVersions +
129+
", versionRequired=" + this.versionRequired + ", defaultVersion=" + this.defaultVersion + "]";
130+
}
131+
132+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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.reactive.accept;
18+
19+
import org.jspecify.annotations.Nullable;
20+
21+
import org.springframework.http.server.PathContainer;
22+
import org.springframework.util.Assert;
23+
import org.springframework.web.server.ServerWebExchange;
24+
25+
/**
26+
* {@link ApiVersionResolver} that extract the version from a path segment.
27+
*
28+
* @author Rossen Stoyanchev
29+
* @since 7.0
30+
*/
31+
public class PathApiVersionResolver implements ApiVersionResolver {
32+
33+
private final int pathSegmentIndex;
34+
35+
36+
/**
37+
* Create a resolver instance.
38+
* @param pathSegmentIndex the index of the path segment that contains
39+
* the API version
40+
*/
41+
public PathApiVersionResolver(int pathSegmentIndex) {
42+
Assert.isTrue(pathSegmentIndex >= 0, "'pathSegmentIndex' must be >= 0");
43+
this.pathSegmentIndex = pathSegmentIndex;
44+
}
45+
46+
47+
@Override
48+
public @Nullable String resolveVersion(ServerWebExchange exchange) {
49+
int i = 0;
50+
for (PathContainer.Element e : exchange.getRequest().getPath().pathWithinApplication().elements()) {
51+
if (e instanceof PathContainer.PathSegment && i++ == this.pathSegmentIndex) {
52+
return e.value();
53+
}
54+
}
55+
return null;
56+
}
57+
58+
}

0 commit comments

Comments
 (0)