diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ApiVersionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ApiVersionTests.java index 15ce39e1b2..25637ed8b0 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ApiVersionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ApiVersionTests.java @@ -49,9 +49,8 @@ public class ApiVersionTests { String header = "API-Version"; DefaultApiVersionStrategy versionStrategy = new DefaultApiVersionStrategy( - List.of(request -> request.getHeader(header)), - new SemanticApiVersionParser(), - true, null, null); + List.of(request -> request.getHeader(header)), new SemanticApiVersionParser(), + true, null, true, null); MockMvc mockMvc = standaloneSetup(new PersonController()) .setApiVersionStrategy(versionStrategy) diff --git a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java index 7f199318bd..dff7c6e99a 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java +++ b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java @@ -44,10 +44,14 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { private final @Nullable Comparable defaultVersion; - private final @Nullable ApiVersionDeprecationHandler deprecationHandler; - private final Set> supportedVersions = new TreeSet<>(); + private final boolean detectSupportedVersions; + + private final Set> detectedVersions = new TreeSet<>(); + + private final @Nullable ApiVersionDeprecationHandler deprecationHandler; + /** * Create an instance. @@ -59,12 +63,15 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { * validation fails with {@link MissingApiVersionException} * @param defaultVersion a default version to assign to requests that * 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 * deprecated API versions to clients */ public DefaultApiVersionStrategy( List versionResolvers, ApiVersionParser versionParser, - boolean versionRequired, @Nullable String defaultVersion, + boolean versionRequired, @Nullable String defaultVersion, boolean detectSupportedVersions, @Nullable ApiVersionDeprecationHandler deprecationHandler) { Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required"); @@ -74,6 +81,7 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { this.versionParser = versionParser; this.versionRequired = (versionRequired && defaultVersion == null); this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null); + this.detectSupportedVersions = detectSupportedVersions; this.deprecationHandler = deprecationHandler; } @@ -84,11 +92,15 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { } /** - * Add to the list of known, supported versions to check against in - * {@link ApiVersionStrategy#validateVersion}. Request versions that are not - * in the supported result in {@link InvalidApiVersionException} - * in {@link ApiVersionStrategy#validateVersion}. - * @param versions the versions to add + * Add to the list of supported versions to check against in + * {@link ApiVersionStrategy#validateVersion} before raising + * {@link InvalidApiVersionException} for unknown versions. + *

By default, actual version values that appear in request mappings are + * 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) { 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}. + *

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 public @Nullable String resolveVersion(HttpServletRequest request) { for (ApiVersionResolver resolver : this.versionResolvers) { @@ -122,11 +150,16 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { return; } - if (!this.supportedVersions.contains(requestVersion)) { + if (!isSupportedVersion(requestVersion)) { throw new InvalidApiVersionException(requestVersion.toString()); } } + private boolean isSupportedVersion(Comparable requestVersion) { + return (this.supportedVersions.contains(requestVersion) || + this.detectSupportedVersions && this.detectedVersions.contains(requestVersion)); + } + @Override public void handleDeprecations(Comparable version, HttpServletRequest request, HttpServletResponse response) { if (this.deprecationHandler != null) { @@ -136,8 +169,12 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { @Override public String toString() { - return "DefaultApiVersionStrategy[supportedVersions=" + this.supportedVersions + - ", versionRequired=" + this.versionRequired + ", defaultVersion=" + this.defaultVersion + "]"; + return "DefaultApiVersionStrategy[" + + "supportedVersions=" + this.supportedVersions + ", " + + "mappedVersions=" + this.detectedVersions + ", " + + "detectSupportedVersions=" + this.detectSupportedVersions + ", " + + "versionRequired=" + this.versionRequired + ", " + + "defaultVersion=" + this.defaultVersion + "]"; } } diff --git a/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java index d24f139b69..b35282bee9 100644 --- a/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java @@ -32,47 +32,74 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; */ public class DefaultApiVersionStrategiesTests { - private final SemanticApiVersionParser parser = new SemanticApiVersionParser(); + private static final SemanticApiVersionParser parser = new SemanticApiVersionParser(); + + private final MockHttpServletRequest request = new MockHttpServletRequest(); @Test void defaultVersionIsParsed() { - SemanticApiVersionParser.Version version = this.parser.parseVersion("1.2.3"); - ApiVersionStrategy strategy = initVersionStrategy(version.toString()); - - assertThat(strategy.getDefaultVersion()).isEqualTo(version); + String version = "1.2.3"; + ApiVersionStrategy strategy = apiVersionStrategy(version); + assertThat(strategy.getDefaultVersion()).isEqualTo(parser.parseVersion(version)); } @Test void validateSupportedVersion() { - SemanticApiVersionParser.Version v12 = this.parser.parseVersion("1.2"); - - DefaultApiVersionStrategy strategy = initVersionStrategy(null); - strategy.addSupportedVersion(v12.toString()); - - MockHttpServletRequest request = new MockHttpServletRequest(); - strategy.validateVersion(v12, request); + String version = "1.2"; + DefaultApiVersionStrategy strategy = apiVersionStrategy(); + strategy.addSupportedVersion(version); + validateVersion(version, strategy); } @Test - void validateUnsupportedVersion() { - assertThatThrownBy(() -> initVersionStrategy(null).validateVersion("1.2", new MockHttpServletRequest())) + void rejectUnsupportedVersion() { + assertThatThrownBy(() -> validateVersion("1.2", apiVersionStrategy())) .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 void missingRequiredVersion() { - DefaultApiVersionStrategy strategy = initVersionStrategy(null); - assertThatThrownBy(() -> strategy.validateVersion(null, new MockHttpServletRequest())) + assertThatThrownBy(() -> validateVersion(null, apiVersionStrategy())) .isInstanceOf(MissingApiVersionException.class) .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( 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); } } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java index 82d62ef6c7..b9e21028c9 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java @@ -39,7 +39,7 @@ public class DefaultApiVersionStrategiesTests { 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 @@ -58,14 +58,14 @@ public class DefaultApiVersionStrategiesTests { } @Test - void validateUnsupportedVersion() { + void rejectUnsupportedVersion() { assertThatThrownBy(() -> validateVersion("1.2", apiVersionStrategy())) .isInstanceOf(InvalidApiVersionException.class) .hasMessage("400 BAD_REQUEST \"Invalid API version: '1.2.0'.\""); } @Test - void validateDetectedSupportedVersion() { + void validateDetectedVersion() { String version = "1.2"; DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true); strategy.addMappedVersion(version); @@ -73,19 +73,16 @@ public class DefaultApiVersionStrategiesTests { } @Test - void validateWhenDetectSupportedVersionsIsOff() { + void validateWhenDetectedVersionOff() { String version = "1.2"; DefaultApiVersionStrategy strategy = apiVersionStrategy(); strategy.addMappedVersion(version); - - assertThatThrownBy(() -> strategy.validateVersion(version, exchange)) - .isInstanceOf(InvalidApiVersionException.class); + assertThatThrownBy(() -> validateVersion(version, strategy)).isInstanceOf(InvalidApiVersionException.class); } @Test void missingRequiredVersion() { - DefaultApiVersionStrategy strategy = apiVersionStrategy(); - assertThatThrownBy(() -> strategy.validateVersion(null, exchange)) + assertThatThrownBy(() -> validateVersion(null, apiVersionStrategy())) .isInstanceOf(MissingApiVersionException.class) .hasMessage("400 BAD_REQUEST \"API version is required.\""); } @@ -95,15 +92,15 @@ public class DefaultApiVersionStrategiesTests { } private static DefaultApiVersionStrategy apiVersionStrategy( - @Nullable String defaultValue, boolean detectSupportedVersions) { + @Nullable String defaultVersion, boolean detectSupportedVersions) { return new DefaultApiVersionStrategy( 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) { - strategy.validateVersion(parser.parseVersion(version), exchange); + private void validateVersion(@Nullable String version, DefaultApiVersionStrategy strategy) { + strategy.validateVersion(version != null ? parser.parseVersion(version) : null, exchange); } } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java index ef5fa63548..2103ccc354 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java @@ -48,10 +48,10 @@ public class VersionRequestConditionTests { this.strategy = initVersionStrategy(null); } - private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) { + private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultVersion) { return new DefaultApiVersionStrategy( List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), - new SemanticApiVersionParser(), true, defaultValue, false, null); + new SemanticApiVersionParser(), true, defaultVersion, false, null); } @Test diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java index d2a62e1931..0994f8b039 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java @@ -31,6 +31,7 @@ import org.springframework.web.accept.ApiVersionParser; import org.springframework.web.accept.ApiVersionResolver; import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.DefaultApiVersionStrategy; +import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.accept.MediaTypeParamApiVersionResolver; import org.springframework.web.accept.PathApiVersionResolver; import org.springframework.web.accept.SemanticApiVersionParser; @@ -56,6 +57,8 @@ public class ApiVersionConfigurer { private final Set supportedVersions = new LinkedHashSet<>(); + private boolean detectSupportedVersions = true; + /** * 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. - * Request versions that are not supported result in - * {@link org.springframework.web.accept.InvalidApiVersionException}. - *

Note that the set of supported versions is populated from versions - * listed in controller mappings. Therefore, typically you do not have to - * manage this list except for the initial API version, when controller - * don't have to have a version to start. + * Add to the list of supported versions to check against before raising + * {@link InvalidApiVersionException} for unknown versions. + *

By default, actual version values that appear in request mappings are + * used for validation. Therefore, use of this method is optional. However, + * if you prefer to use explicitly configured, supported versions only, then + * set {@link #detectSupportedVersions} to {@code false}. + *

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 */ public ApiVersionConfigurer addSupportedVersions(String... versions) { @@ -169,6 +174,19 @@ public class ApiVersionConfigurer { return this; } + /** + * Whether to use versions from mappings for supported version validation. + *

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() { if (this.versionResolvers.isEmpty()) { return null; @@ -176,7 +194,8 @@ public class ApiVersionConfigurer { DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers, (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); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java index ffbff6537b..d1eeab8f35 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java @@ -306,7 +306,7 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi if (requestMappingInfo != null && this.apiVersionStrategy instanceof DefaultApiVersionStrategy davs) { String version = requestMappingInfo.getVersionCondition().getVersion(); if (version != null) { - davs.addSupportedVersion(version); + davs.addMappedVersion(version); } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java index 5e14295c48..27c7096008 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java @@ -46,10 +46,10 @@ public class VersionRequestConditionTests { this.strategy = initVersionStrategy(null); } - private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) { + private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultVersion) { return new DefaultApiVersionStrategy( List.of(request -> request.getParameter("api-version")), - new SemanticApiVersionParser(), true, defaultValue, null); + new SemanticApiVersionParser(), true, defaultVersion, false, null); } @Test