Add detectSupportedVersions in spring-webmvc

Closes gh-35105
This commit is contained in:
rstoyanchev 2025-06-25 07:17:02 +01:00
parent 3cb8a833e4
commit 482cfb0b18
8 changed files with 138 additions and 59 deletions

View File

@ -49,9 +49,8 @@ public class ApiVersionTests {
String header = "API-Version"; String header = "API-Version";
DefaultApiVersionStrategy versionStrategy = new DefaultApiVersionStrategy( DefaultApiVersionStrategy versionStrategy = new DefaultApiVersionStrategy(
List.of(request -> request.getHeader(header)), List.of(request -> request.getHeader(header)), new SemanticApiVersionParser(),
new SemanticApiVersionParser(), true, null, true, null);
true, null, null);
MockMvc mockMvc = standaloneSetup(new PersonController()) MockMvc mockMvc = standaloneSetup(new PersonController())
.setApiVersionStrategy(versionStrategy) .setApiVersionStrategy(versionStrategy)

View File

@ -44,10 +44,14 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
private final @Nullable Comparable<?> defaultVersion; private final @Nullable Comparable<?> defaultVersion;
private final @Nullable ApiVersionDeprecationHandler deprecationHandler;
private final Set<Comparable<?>> supportedVersions = new TreeSet<>(); private final Set<Comparable<?>> supportedVersions = new TreeSet<>();
private final boolean detectSupportedVersions;
private final Set<Comparable<?>> detectedVersions = new TreeSet<>();
private final @Nullable ApiVersionDeprecationHandler deprecationHandler;
/** /**
* Create an instance. * Create an instance.
@ -59,12 +63,15 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
* validation fails with {@link MissingApiVersionException} * validation fails with {@link MissingApiVersionException}
* @param defaultVersion a default version to assign to requests that * @param defaultVersion a default version to assign to requests that
* don't specify one * don't specify one
* @param detectSupportedVersions whether to use API versions that appear in
* mappings for supported version validation (true), or use only explicitly
* configured versions (false).
* @param deprecationHandler handler to send hints and information about * @param deprecationHandler handler to send hints and information about
* deprecated API versions to clients * deprecated API versions to clients
*/ */
public DefaultApiVersionStrategy( public DefaultApiVersionStrategy(
List<ApiVersionResolver> versionResolvers, ApiVersionParser<?> versionParser, List<ApiVersionResolver> versionResolvers, ApiVersionParser<?> versionParser,
boolean versionRequired, @Nullable String defaultVersion, boolean versionRequired, @Nullable String defaultVersion, boolean detectSupportedVersions,
@Nullable ApiVersionDeprecationHandler deprecationHandler) { @Nullable ApiVersionDeprecationHandler deprecationHandler) {
Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required"); Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required");
@ -74,6 +81,7 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
this.versionParser = versionParser; this.versionParser = versionParser;
this.versionRequired = (versionRequired && defaultVersion == null); this.versionRequired = (versionRequired && defaultVersion == null);
this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null); this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null);
this.detectSupportedVersions = detectSupportedVersions;
this.deprecationHandler = deprecationHandler; this.deprecationHandler = deprecationHandler;
} }
@ -84,11 +92,15 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
} }
/** /**
* Add to the list of known, supported versions to check against in * Add to the list of supported versions to check against in
* {@link ApiVersionStrategy#validateVersion}. Request versions that are not * {@link ApiVersionStrategy#validateVersion} before raising
* in the supported result in {@link InvalidApiVersionException} * {@link InvalidApiVersionException} for unknown versions.
* in {@link ApiVersionStrategy#validateVersion}. * <p>By default, actual version values that appear in request mappings are
* @param versions the versions to add * considered supported, and use of this method is optional. However, if you
* prefer to use only explicitly configured, supported versions, then set
* {@code detectSupportedVersions} flag to {@code false}.
* @param versions the supported versions to add
* @see #addMappedVersion(String...)
*/ */
public void addSupportedVersion(String... versions) { public void addSupportedVersion(String... versions) {
for (String version : versions) { for (String version : versions) {
@ -96,6 +108,22 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
} }
} }
/**
* Internal method to add to the list of actual version values that appear in
* request mappings, which allows supported versions to be discovered rather
* than {@link #addSupportedVersion(String...) configured}.
* <p>If you prefer to use explicitly configured, supported versions only,
* set the {@code detectSupportedVersions} flag to {@code false}.
* @param versions the versions to add
* @see #addSupportedVersion(String...)
*/
public void addMappedVersion(String... versions) {
for (String version : versions) {
this.detectedVersions.add(parseVersion(version));
}
}
@Override @Override
public @Nullable String resolveVersion(HttpServletRequest request) { public @Nullable String resolveVersion(HttpServletRequest request) {
for (ApiVersionResolver resolver : this.versionResolvers) { for (ApiVersionResolver resolver : this.versionResolvers) {
@ -122,11 +150,16 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
return; return;
} }
if (!this.supportedVersions.contains(requestVersion)) { if (!isSupportedVersion(requestVersion)) {
throw new InvalidApiVersionException(requestVersion.toString()); throw new InvalidApiVersionException(requestVersion.toString());
} }
} }
private boolean isSupportedVersion(Comparable<?> requestVersion) {
return (this.supportedVersions.contains(requestVersion) ||
this.detectSupportedVersions && this.detectedVersions.contains(requestVersion));
}
@Override @Override
public void handleDeprecations(Comparable<?> version, HttpServletRequest request, HttpServletResponse response) { public void handleDeprecations(Comparable<?> version, HttpServletRequest request, HttpServletResponse response) {
if (this.deprecationHandler != null) { if (this.deprecationHandler != null) {
@ -136,8 +169,12 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
@Override @Override
public String toString() { public String toString() {
return "DefaultApiVersionStrategy[supportedVersions=" + this.supportedVersions + return "DefaultApiVersionStrategy[" +
", versionRequired=" + this.versionRequired + ", defaultVersion=" + this.defaultVersion + "]"; "supportedVersions=" + this.supportedVersions + ", " +
"mappedVersions=" + this.detectedVersions + ", " +
"detectSupportedVersions=" + this.detectSupportedVersions + ", " +
"versionRequired=" + this.versionRequired + ", " +
"defaultVersion=" + this.defaultVersion + "]";
} }
} }

View File

@ -32,47 +32,74 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*/ */
public class DefaultApiVersionStrategiesTests { public class DefaultApiVersionStrategiesTests {
private final SemanticApiVersionParser parser = new SemanticApiVersionParser(); private static final SemanticApiVersionParser parser = new SemanticApiVersionParser();
private final MockHttpServletRequest request = new MockHttpServletRequest();
@Test @Test
void defaultVersionIsParsed() { void defaultVersionIsParsed() {
SemanticApiVersionParser.Version version = this.parser.parseVersion("1.2.3"); String version = "1.2.3";
ApiVersionStrategy strategy = initVersionStrategy(version.toString()); ApiVersionStrategy strategy = apiVersionStrategy(version);
assertThat(strategy.getDefaultVersion()).isEqualTo(parser.parseVersion(version));
assertThat(strategy.getDefaultVersion()).isEqualTo(version);
} }
@Test @Test
void validateSupportedVersion() { void validateSupportedVersion() {
SemanticApiVersionParser.Version v12 = this.parser.parseVersion("1.2"); String version = "1.2";
DefaultApiVersionStrategy strategy = apiVersionStrategy();
DefaultApiVersionStrategy strategy = initVersionStrategy(null); strategy.addSupportedVersion(version);
strategy.addSupportedVersion(v12.toString()); validateVersion(version, strategy);
MockHttpServletRequest request = new MockHttpServletRequest();
strategy.validateVersion(v12, request);
} }
@Test @Test
void validateUnsupportedVersion() { void rejectUnsupportedVersion() {
assertThatThrownBy(() -> initVersionStrategy(null).validateVersion("1.2", new MockHttpServletRequest())) assertThatThrownBy(() -> validateVersion("1.2", apiVersionStrategy()))
.isInstanceOf(InvalidApiVersionException.class) .isInstanceOf(InvalidApiVersionException.class)
.hasMessage("400 BAD_REQUEST \"Invalid API version: '1.2'.\""); .hasMessage("400 BAD_REQUEST \"Invalid API version: '1.2.0'.\"");
}
@Test
void validateDetectedVersion() {
String version = "1.2";
DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true);
strategy.addMappedVersion(version);
validateVersion(version, strategy);
}
@Test
void validateWhenDetectedVersionOff() {
String version = "1.2";
DefaultApiVersionStrategy strategy = apiVersionStrategy();
strategy.addMappedVersion(version);
assertThatThrownBy(() -> validateVersion(version, strategy)).isInstanceOf(InvalidApiVersionException.class);
} }
@Test @Test
void missingRequiredVersion() { void missingRequiredVersion() {
DefaultApiVersionStrategy strategy = initVersionStrategy(null); assertThatThrownBy(() -> validateVersion(null, apiVersionStrategy()))
assertThatThrownBy(() -> strategy.validateVersion(null, new MockHttpServletRequest()))
.isInstanceOf(MissingApiVersionException.class) .isInstanceOf(MissingApiVersionException.class)
.hasMessage("400 BAD_REQUEST \"API version is required.\""); .hasMessage("400 BAD_REQUEST \"API version is required.\"");
} }
private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) { private static DefaultApiVersionStrategy apiVersionStrategy() {
return apiVersionStrategy(null, false);
}
private static DefaultApiVersionStrategy apiVersionStrategy(@Nullable String defaultVersion) {
return apiVersionStrategy(defaultVersion, false);
}
private static DefaultApiVersionStrategy apiVersionStrategy(
@Nullable String defaultVersion, boolean detectSupportedVersions) {
return new DefaultApiVersionStrategy( return new DefaultApiVersionStrategy(
List.of(request -> request.getParameter("api-version")), List.of(request -> request.getParameter("api-version")),
new SemanticApiVersionParser(), true, defaultValue, null); new SemanticApiVersionParser(), true, defaultVersion, detectSupportedVersions, null);
}
private void validateVersion(@Nullable String version, DefaultApiVersionStrategy strategy) {
strategy.validateVersion(version != null ? parser.parseVersion(version) : null, request);
} }
} }

View File

@ -39,7 +39,7 @@ public class DefaultApiVersionStrategiesTests {
private static final SemanticApiVersionParser parser = new SemanticApiVersionParser(); private static final SemanticApiVersionParser parser = new SemanticApiVersionParser();
private static final ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); private final ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"));
@Test @Test
@ -58,14 +58,14 @@ public class DefaultApiVersionStrategiesTests {
} }
@Test @Test
void validateUnsupportedVersion() { void rejectUnsupportedVersion() {
assertThatThrownBy(() -> validateVersion("1.2", apiVersionStrategy())) assertThatThrownBy(() -> validateVersion("1.2", apiVersionStrategy()))
.isInstanceOf(InvalidApiVersionException.class) .isInstanceOf(InvalidApiVersionException.class)
.hasMessage("400 BAD_REQUEST \"Invalid API version: '1.2.0'.\""); .hasMessage("400 BAD_REQUEST \"Invalid API version: '1.2.0'.\"");
} }
@Test @Test
void validateDetectedSupportedVersion() { void validateDetectedVersion() {
String version = "1.2"; String version = "1.2";
DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true); DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true);
strategy.addMappedVersion(version); strategy.addMappedVersion(version);
@ -73,19 +73,16 @@ public class DefaultApiVersionStrategiesTests {
} }
@Test @Test
void validateWhenDetectSupportedVersionsIsOff() { void validateWhenDetectedVersionOff() {
String version = "1.2"; String version = "1.2";
DefaultApiVersionStrategy strategy = apiVersionStrategy(); DefaultApiVersionStrategy strategy = apiVersionStrategy();
strategy.addMappedVersion(version); strategy.addMappedVersion(version);
assertThatThrownBy(() -> validateVersion(version, strategy)).isInstanceOf(InvalidApiVersionException.class);
assertThatThrownBy(() -> strategy.validateVersion(version, exchange))
.isInstanceOf(InvalidApiVersionException.class);
} }
@Test @Test
void missingRequiredVersion() { void missingRequiredVersion() {
DefaultApiVersionStrategy strategy = apiVersionStrategy(); assertThatThrownBy(() -> validateVersion(null, apiVersionStrategy()))
assertThatThrownBy(() -> strategy.validateVersion(null, exchange))
.isInstanceOf(MissingApiVersionException.class) .isInstanceOf(MissingApiVersionException.class)
.hasMessage("400 BAD_REQUEST \"API version is required.\""); .hasMessage("400 BAD_REQUEST \"API version is required.\"");
} }
@ -95,15 +92,15 @@ public class DefaultApiVersionStrategiesTests {
} }
private static DefaultApiVersionStrategy apiVersionStrategy( private static DefaultApiVersionStrategy apiVersionStrategy(
@Nullable String defaultValue, boolean detectSupportedVersions) { @Nullable String defaultVersion, boolean detectSupportedVersions) {
return new DefaultApiVersionStrategy( return new DefaultApiVersionStrategy(
List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")),
parser, true, defaultValue, detectSupportedVersions, null); parser, true, defaultVersion, detectSupportedVersions, null);
} }
private static void validateVersion(String version, DefaultApiVersionStrategy strategy) { private void validateVersion(@Nullable String version, DefaultApiVersionStrategy strategy) {
strategy.validateVersion(parser.parseVersion(version), exchange); strategy.validateVersion(version != null ? parser.parseVersion(version) : null, exchange);
} }
} }

View File

@ -48,10 +48,10 @@ public class VersionRequestConditionTests {
this.strategy = initVersionStrategy(null); this.strategy = initVersionStrategy(null);
} }
private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) { private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultVersion) {
return new DefaultApiVersionStrategy( return new DefaultApiVersionStrategy(
List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")),
new SemanticApiVersionParser(), true, defaultValue, false, null); new SemanticApiVersionParser(), true, defaultVersion, false, null);
} }
@Test @Test

View File

@ -31,6 +31,7 @@ import org.springframework.web.accept.ApiVersionParser;
import org.springframework.web.accept.ApiVersionResolver; import org.springframework.web.accept.ApiVersionResolver;
import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.accept.DefaultApiVersionStrategy; import org.springframework.web.accept.DefaultApiVersionStrategy;
import org.springframework.web.accept.InvalidApiVersionException;
import org.springframework.web.accept.MediaTypeParamApiVersionResolver; import org.springframework.web.accept.MediaTypeParamApiVersionResolver;
import org.springframework.web.accept.PathApiVersionResolver; import org.springframework.web.accept.PathApiVersionResolver;
import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.accept.SemanticApiVersionParser;
@ -56,6 +57,8 @@ public class ApiVersionConfigurer {
private final Set<String> supportedVersions = new LinkedHashSet<>(); private final Set<String> supportedVersions = new LinkedHashSet<>();
private boolean detectSupportedVersions = true;
/** /**
* Add resolver to extract the version from a request header. * Add resolver to extract the version from a request header.
@ -155,13 +158,15 @@ public class ApiVersionConfigurer {
} }
/** /**
* Add to the list of supported versions to validate request versions against. * Add to the list of supported versions to check against before raising
* Request versions that are not supported result in * {@link InvalidApiVersionException} for unknown versions.
* {@link org.springframework.web.accept.InvalidApiVersionException}. * <p>By default, actual version values that appear in request mappings are
* <p>Note that the set of supported versions is populated from versions * used for validation. Therefore, use of this method is optional. However,
* listed in controller mappings. Therefore, typically you do not have to * if you prefer to use explicitly configured, supported versions only, then
* manage this list except for the initial API version, when controller * set {@link #detectSupportedVersions} to {@code false}.
* don't have to have a version to start. * <p>Note that the initial API version, if not explicitly declared in any
* request mappings, may need to be declared here instead as a supported
* version.
* @param versions supported versions to add * @param versions supported versions to add
*/ */
public ApiVersionConfigurer addSupportedVersions(String... versions) { public ApiVersionConfigurer addSupportedVersions(String... versions) {
@ -169,6 +174,19 @@ public class ApiVersionConfigurer {
return this; return this;
} }
/**
* Whether to use versions from mappings for supported version validation.
* <p>By default, this is {@code true} in which case mapped versions are
* considered supported versions. Set this to {@code false} if you want to
* use only explicitly configured {@link #addSupportedVersions(String...)
* supported versions}.
* @param detect whether to use detected versions for validation
*/
public ApiVersionConfigurer detectSupportedVersions(boolean detect) {
this.detectSupportedVersions = detect;
return this;
}
protected @Nullable ApiVersionStrategy getApiVersionStrategy() { protected @Nullable ApiVersionStrategy getApiVersionStrategy() {
if (this.versionResolvers.isEmpty()) { if (this.versionResolvers.isEmpty()) {
return null; return null;
@ -176,7 +194,8 @@ public class ApiVersionConfigurer {
DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers, DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers,
(this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()), (this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()),
this.versionRequired, this.defaultVersion, this.deprecationHandler); this.versionRequired, this.defaultVersion, this.detectSupportedVersions,
this.deprecationHandler);
this.supportedVersions.forEach(strategy::addSupportedVersion); this.supportedVersions.forEach(strategy::addSupportedVersion);

View File

@ -306,7 +306,7 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
if (requestMappingInfo != null && this.apiVersionStrategy instanceof DefaultApiVersionStrategy davs) { if (requestMappingInfo != null && this.apiVersionStrategy instanceof DefaultApiVersionStrategy davs) {
String version = requestMappingInfo.getVersionCondition().getVersion(); String version = requestMappingInfo.getVersionCondition().getVersion();
if (version != null) { if (version != null) {
davs.addSupportedVersion(version); davs.addMappedVersion(version);
} }
} }

View File

@ -46,10 +46,10 @@ public class VersionRequestConditionTests {
this.strategy = initVersionStrategy(null); this.strategy = initVersionStrategy(null);
} }
private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) { private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultVersion) {
return new DefaultApiVersionStrategy( return new DefaultApiVersionStrategy(
List.of(request -> request.getParameter("api-version")), List.of(request -> request.getParameter("api-version")),
new SemanticApiVersionParser(), true, defaultValue, null); new SemanticApiVersionParser(), true, defaultVersion, false, null);
} }
@Test @Test