diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java index fb7ad6da28..91ce69855c 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java @@ -48,6 +48,10 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { private final Set> supportedVersions = new TreeSet<>(); + private final boolean detectSupportedVersions; + + private final Set> detectedVersions = new TreeSet<>(); + /** * Create an instance. @@ -59,10 +63,13 @@ 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). */ public DefaultApiVersionStrategy( List versionResolvers, ApiVersionParser versionParser, - boolean versionRequired, @Nullable String defaultVersion) { + boolean versionRequired, @Nullable String defaultVersion, boolean detectSupportedVersions) { Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required"); Assert.notNull(versionParser, "ApiVersionParser is required"); @@ -71,6 +78,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; } @@ -80,11 +88,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) { @@ -92,6 +104,21 @@ 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(ServerWebExchange exchange) { for (ApiVersionResolver resolver : this.versionResolvers) { @@ -118,15 +145,24 @@ 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 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-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java index e55f0a29a3..c65dfdd52c 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java @@ -26,6 +26,7 @@ import java.util.Set; import org.jspecify.annotations.Nullable; import org.springframework.web.accept.ApiVersionParser; +import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.reactive.accept.ApiVersionResolver; import org.springframework.web.reactive.accept.ApiVersionStrategy; @@ -50,6 +51,8 @@ public class ApiVersionConfigurer { private final Set supportedVersions = new LinkedHashSet<>(); + private boolean detectSupportedVersions = true; + /** * Add a resolver that extracts the API version from a request header. @@ -125,20 +128,35 @@ 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. - * @param versions supported versions to add + * 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 version values to add */ public ApiVersionConfigurer addSupportedVersions(String... versions) { Collections.addAll(this.supportedVersions, versions); 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; @@ -146,7 +164,7 @@ public class ApiVersionConfigurer { DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers, (this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()), - this.versionRequired, this.defaultVersion); + this.versionRequired, this.defaultVersion, this.detectSupportedVersions); this.supportedVersions.forEach(strategy::addSupportedVersion); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java index 5601bc219b..181a75de67 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java @@ -239,7 +239,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-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 128020c130..504d6f55d4 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 @@ -36,39 +36,64 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; */ public class DefaultApiVersionStrategiesTests { - private final SemanticApiVersionParser parser = new SemanticApiVersionParser(); + private static final SemanticApiVersionParser parser = new SemanticApiVersionParser(); + + private static final ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); @Test - void defaultVersion() { - SemanticApiVersionParser.Version version = this.parser.parseVersion("1.2.3"); - ApiVersionStrategy strategy = initVersionStrategy(version.toString()); - - assertThat(strategy.getDefaultVersion()).isEqualTo(version); + void defaultVersionIsParsed() { + String version = "1.2.3"; + ApiVersionStrategy strategy = apiVersionStrategy(version, false); + assertThat(strategy.getDefaultVersion()).isEqualTo(parser.parseVersion(version)); } @Test - void supportedVersions() { - SemanticApiVersionParser.Version v1 = this.parser.parseVersion("1"); - SemanticApiVersionParser.Version v2 = this.parser.parseVersion("2"); - SemanticApiVersionParser.Version v9 = this.parser.parseVersion("9"); + void validateSupportedVersion() { + String version = "1.2"; + DefaultApiVersionStrategy strategy = apiVersionStrategy(); + strategy.addSupportedVersion(version); + validateVersion(version, strategy); + } - DefaultApiVersionStrategy strategy = initVersionStrategy(null); - strategy.addSupportedVersion(v1.toString()); - strategy.addSupportedVersion(v2.toString()); - - ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); - strategy.validateVersion(v1, exchange); - strategy.validateVersion(v2, exchange); - - assertThatThrownBy(() -> strategy.validateVersion(v9, exchange)) + @Test + void validateUnsupportedVersion() { + assertThatThrownBy(() -> validateVersion("1.2", apiVersionStrategy())) .isInstanceOf(InvalidApiVersionException.class); } - private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) { - return new DefaultApiVersionStrategy( + @Test + void validateDetectedSupportedVersion() { + String version = "1.2"; + DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true); + strategy.addMappedVersion(version); + validateVersion(version, strategy); + } + + @Test + void validateWhenDetectSupportedVersionsIsOff() { + String version = "1.2"; + DefaultApiVersionStrategy strategy = apiVersionStrategy(); + strategy.addMappedVersion(version); + + assertThatThrownBy(() -> strategy.validateVersion(version, exchange)) + .isInstanceOf(InvalidApiVersionException.class); + } + + private static DefaultApiVersionStrategy apiVersionStrategy() { + return apiVersionStrategy(null, false); + } + + private static DefaultApiVersionStrategy apiVersionStrategy( + @Nullable String defaultValue, boolean detectSupportedVersions) { + + return new DefaultApiVersionStrategy( List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), - new SemanticApiVersionParser(), true, defaultValue); + parser, true, defaultValue, detectSupportedVersions); + } + + private static void validateVersion(String version, DefaultApiVersionStrategy strategy) { + strategy.validateVersion(parser.parseVersion(version), 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 7e674d7ba9..e5b6cdc9b4 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 @@ -50,7 +50,7 @@ public class VersionRequestConditionTests { private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) { return new DefaultApiVersionStrategy( List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), - new SemanticApiVersionParser(), true, defaultValue); + new SemanticApiVersionParser(), true, defaultValue, false); } @Test