Skip to content

Commit 51d34ff

Browse files
committed
API versioning support for Spring MVC
See gh-34566
1 parent e9701a9 commit 51d34ff

32 files changed

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

0 commit comments

Comments
 (0)