Refactor construction of VersionRequestCondition

The single constructor now supports all combinations of having a version
attribute set or not, and ApiVersionStrategy, configured or not.

In effective, ensure the configured ApiVersionStrategy is passed even
when the RequestMapping version attribute is not set.

See gh-35082
This commit is contained in:
rstoyanchev 2025-06-18 16:48:32 +01:00
parent 5d34f9c87e
commit a0f9872746
6 changed files with 94 additions and 108 deletions

View File

@ -23,6 +23,7 @@ import java.util.Set;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.accept.InvalidApiVersionException;
import org.springframework.web.accept.NotAcceptableApiVersionException; import org.springframework.web.accept.NotAcceptableApiVersionException;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -58,20 +59,24 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
private final Set<String> content; private final Set<String> content;
public VersionRequestCondition() { /**
this.versionValue = null; * Constructor with the version, if set on the {@code @RequestMapping}, and
this.version = null; * the {@code ApiVersionStrategy}, if API versioning is enabled.
this.baselineVersion = false; */
this.versionStrategy = NO_OP_VERSION_STRATEGY; public VersionRequestCondition(@Nullable String version, @Nullable ApiVersionStrategy strategy) {
this.content = Collections.emptySet(); this.versionStrategy = (strategy != null ? strategy : NO_OP_VERSION_STRATEGY);
} if (StringUtils.hasText(version)) {
this.baselineVersion = version.endsWith("+");
public VersionRequestCondition(String configuredVersion, ApiVersionStrategy versionStrategy) { this.versionValue = updateVersion(version, this.baselineVersion);
this.baselineVersion = configuredVersion.endsWith("+"); this.version = this.versionStrategy.parseVersion(this.versionValue);
this.versionValue = updateVersion(configuredVersion, this.baselineVersion); this.content = Set.of(version);
this.version = versionStrategy.parseVersion(this.versionValue); }
this.versionStrategy = versionStrategy; else {
this.content = Set.of(configuredVersion); this.versionValue = null;
this.version = null;
this.baselineVersion = false;
this.content = Collections.emptySet();
}
} }
private static String updateVersion(String version, boolean baselineVersion) { private static String updateVersion(String version, boolean baselineVersion) {
@ -104,23 +109,23 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
return this; return this;
} }
Comparable<?> version = exchange.getAttribute(VERSION_ATTRIBUTE_NAME); Comparable<?> requestVersion = exchange.getAttribute(VERSION_ATTRIBUTE_NAME);
if (version == null) { if (requestVersion == null) {
String value = this.versionStrategy.resolveVersion(exchange); String value = this.versionStrategy.resolveVersion(exchange);
version = (value != null ? parseVersion(value) : this.versionStrategy.getDefaultVersion()); requestVersion = (value != null ? parseVersion(value) : this.versionStrategy.getDefaultVersion());
this.versionStrategy.validateVersion(version, exchange); this.versionStrategy.validateVersion(requestVersion, exchange);
version = (version != null ? version : NO_VERSION_ATTRIBUTE); requestVersion = (requestVersion != null ? requestVersion : NO_VERSION_ATTRIBUTE);
exchange.getAttributes().put(VERSION_ATTRIBUTE_NAME, (version)); exchange.getAttributes().put(VERSION_ATTRIBUTE_NAME, (requestVersion));
} }
if (version == NO_VERSION_ATTRIBUTE) { if (requestVersion == NO_VERSION_ATTRIBUTE) {
return this; return this;
} }
// At this stage, match all versions as baseline versions. // At this stage, match all versions as baseline versions.
// Strict matching for fixed versions is enforced at the end in handleMatch. // Strict matching for fixed versions is enforced at the end in handleMatch.
int result = compareVersions(this.version, version); int result = compareVersions(this.version, requestVersion);
return (result <= 0 ? this : null); return (result <= 0 ? this : null);
} }

View File

@ -71,8 +71,6 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
private static final ProducesRequestCondition EMPTY_PRODUCES = new ProducesRequestCondition(); private static final ProducesRequestCondition EMPTY_PRODUCES = new ProducesRequestCondition();
private static final VersionRequestCondition EMPTY_VERSION = new VersionRequestCondition();
private static final RequestConditionHolder EMPTY_CUSTOM = new RequestConditionHolder(null); private static final RequestConditionHolder EMPTY_CUSTOM = new RequestConditionHolder(null);
@ -112,7 +110,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
this.headersCondition = (headers != null ? headers : EMPTY_HEADERS); this.headersCondition = (headers != null ? headers : EMPTY_HEADERS);
this.consumesCondition = (consumes != null ? consumes : EMPTY_CONSUMES); this.consumesCondition = (consumes != null ? consumes : EMPTY_CONSUMES);
this.producesCondition = (produces != null ? produces : EMPTY_PRODUCES); this.producesCondition = (produces != null ? produces : EMPTY_PRODUCES);
this.versionCondition = (version != null ? version : EMPTY_VERSION); this.versionCondition = (version != null ? version : new VersionRequestCondition(null, null));
this.customConditionHolder = (custom != null ? new RequestConditionHolder(custom) : EMPTY_CUSTOM); this.customConditionHolder = (custom != null ? new RequestConditionHolder(custom) : EMPTY_CUSTOM);
this.options = options; this.options = options;
@ -572,15 +570,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
RequestedContentTypeResolver contentTypeResolver = this.options.getContentTypeResolver(); RequestedContentTypeResolver contentTypeResolver = this.options.getContentTypeResolver();
VersionRequestCondition versionCondition; ApiVersionStrategy strategy = this.options.getApiVersionStrategy();
ApiVersionStrategy versionStrategy = this.options.getApiVersionStrategy(); Assert.state(strategy != null || !StringUtils.hasText(this.version),
if (StringUtils.hasText(this.version)) { "API version specified, but no ApiVersionStrategy configured");
Assert.state(versionStrategy != null, "API version specified, but no ApiVersionStrategy configured"); VersionRequestCondition versionCondition = new VersionRequestCondition(this.version, strategy);
versionCondition = new VersionRequestCondition(this.version, versionStrategy);
}
else {
versionCondition = EMPTY_VERSION;
}
return new RequestMappingInfo(this.mappingName, return new RequestMappingInfo(this.mappingName,
isEmpty(this.paths) ? null : new PatternsRequestCondition(parse(this.paths, parser)), isEmpty(this.paths) ? null : new PatternsRequestCondition(parse(this.paths, parser)),
@ -706,14 +699,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
@Override @Override
public Builder version(@Nullable String version) { public Builder version(@Nullable String version) {
if (version != null) { ApiVersionStrategy strategy = this.options.getApiVersionStrategy();
ApiVersionStrategy strategy = this.options.getApiVersionStrategy(); Assert.state(strategy != null || !StringUtils.hasText(version),
Assert.state(strategy != null, "API version specified, but no ApiVersionStrategy configured"); "API version specified, but no ApiVersionStrategy configured");
this.versionCondition = new VersionRequestCondition(version, strategy); this.versionCondition = new VersionRequestCondition(version, strategy);
}
else {
this.versionCondition = EMPTY_VERSION;
}
return this; return this;
} }

View File

@ -72,32 +72,31 @@ public class VersionRequestConditionTests {
@Test @Test
void fixedVersionMatch() { void fixedVersionMatch() {
String conditionVersion = "1.2"; VersionRequestCondition condition = condition("1.2");
this.strategy.addSupportedVersion("1.1", "1.3"); this.strategy.addSupportedVersion("1.1", "1.3");
testMatch("v1.1", conditionVersion, true, false); testMatch("v1.1", condition, false, false);
testMatch("v1.2", conditionVersion, false, false); testMatch("v1.2", condition, true, false);
testMatch("v1.3", conditionVersion, false, true); testMatch("v1.3", condition, true, true); // match initially, reject if chosen
} }
@Test @Test
void baselineVersionMatch() { void baselineVersionMatch() {
String conditionVersion = "1.2+"; VersionRequestCondition condition = condition("1.2+");
this.strategy.addSupportedVersion("1.1", "1.3"); this.strategy.addSupportedVersion("1.1", "1.3");
testMatch("v1.1", conditionVersion, true, false); testMatch("v1.1", condition, false, false);
testMatch("v1.2", conditionVersion, false, false); testMatch("v1.2", condition, true, false);
testMatch("v1.3", conditionVersion, false, false); testMatch("v1.3", condition, true, false);
} }
private void testMatch( private void testMatch(
String requestVersion, String conditionVersion, boolean notCompatible, boolean notAcceptable) { String requestVersion, VersionRequestCondition condition, boolean matches, boolean notAcceptable) {
ServerWebExchange exchange = exchangeWithVersion(requestVersion); ServerWebExchange exchange = exchangeWithVersion(requestVersion);
VersionRequestCondition condition = condition(conditionVersion);
VersionRequestCondition match = condition.getMatchingCondition(exchange); VersionRequestCondition match = condition.getMatchingCondition(exchange);
if (notCompatible) { if (!matches) {
assertThat(match).isNull(); assertThat(match).isNull();
return; return;
} }
@ -157,7 +156,7 @@ public class VersionRequestConditionTests {
} }
private VersionRequestCondition emptyCondition() { private VersionRequestCondition emptyCondition() {
return new VersionRequestCondition(); return new VersionRequestCondition(null, this.strategy);
} }
private static MockServerWebExchange exchange() { private static MockServerWebExchange exchange() {

View File

@ -24,6 +24,7 @@ import jakarta.servlet.http.HttpServletRequest;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.accept.InvalidApiVersionException;
import org.springframework.web.accept.NotAcceptableApiVersionException; import org.springframework.web.accept.NotAcceptableApiVersionException;
@ -57,20 +58,24 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
private final Set<String> content; private final Set<String> content;
public VersionRequestCondition() { /**
this.versionValue = null; * Constructor with the version, if set on the {@code @RequestMapping}, and
this.version = null; * the {@code ApiVersionStrategy}, if API versioning is enabled.
this.baselineVersion = false; */
this.versionStrategy = NO_OP_VERSION_STRATEGY; public VersionRequestCondition(@Nullable String version, @Nullable ApiVersionStrategy strategy) {
this.content = Collections.emptySet(); this.versionStrategy = (strategy != null ? strategy : NO_OP_VERSION_STRATEGY);
} if (StringUtils.hasText(version)) {
this.baselineVersion = version.endsWith("+");
public VersionRequestCondition(String configuredVersion, ApiVersionStrategy versionStrategy) { this.versionValue = updateVersion(version, this.baselineVersion);
this.baselineVersion = configuredVersion.endsWith("+"); this.version = this.versionStrategy.parseVersion(this.versionValue);
this.versionValue = updateVersion(configuredVersion, this.baselineVersion); this.content = Set.of(version);
this.version = versionStrategy.parseVersion(this.versionValue); }
this.versionStrategy = versionStrategy; else {
this.content = Set.of(configuredVersion); this.versionValue = null;
this.version = null;
this.baselineVersion = false;
this.content = Collections.emptySet();
}
} }
private static String updateVersion(String version, boolean baselineVersion) { private static String updateVersion(String version, boolean baselineVersion) {
@ -103,23 +108,23 @@ public final class VersionRequestCondition extends AbstractRequestCondition<Vers
return this; return this;
} }
Comparable<?> version = (Comparable<?>) request.getAttribute(VERSION_ATTRIBUTE_NAME); Comparable<?> requestVersion = (Comparable<?>) request.getAttribute(VERSION_ATTRIBUTE_NAME);
if (version == null) { if (requestVersion == null) {
String value = this.versionStrategy.resolveVersion(request); String value = this.versionStrategy.resolveVersion(request);
version = (value != null ? parseVersion(value) : this.versionStrategy.getDefaultVersion()); requestVersion = (value != null ? parseVersion(value) : this.versionStrategy.getDefaultVersion());
this.versionStrategy.validateVersion(version, request); this.versionStrategy.validateVersion(requestVersion, request);
version = (version != null ? version : NO_VERSION_ATTRIBUTE); requestVersion = (requestVersion != null ? requestVersion : NO_VERSION_ATTRIBUTE);
request.setAttribute(VERSION_ATTRIBUTE_NAME, (version)); request.setAttribute(VERSION_ATTRIBUTE_NAME, (requestVersion));
} }
if (version == NO_VERSION_ATTRIBUTE) { if (requestVersion == NO_VERSION_ATTRIBUTE) {
return this; return this;
} }
// At this stage, match all versions as baseline versions. // At this stage, match all versions as baseline versions.
// Strict matching for fixed versions is enforced at the end in handleMatch. // Strict matching for fixed versions is enforced at the end in handleMatch.
int result = compareVersions(this.version, version); int result = compareVersions(this.version, requestVersion);
return (result <= 0 ? this : null); return (result <= 0 ? this : null);
} }

View File

@ -80,8 +80,6 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
private static final ProducesRequestCondition EMPTY_PRODUCES = new ProducesRequestCondition(); private static final ProducesRequestCondition EMPTY_PRODUCES = new ProducesRequestCondition();
private static final VersionRequestCondition EMPTY_VERSION = new VersionRequestCondition();
private static final RequestConditionHolder EMPTY_CUSTOM = new RequestConditionHolder(null); private static final RequestConditionHolder EMPTY_CUSTOM = new RequestConditionHolder(null);
@ -130,7 +128,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
(headers != null ? headers : EMPTY_HEADERS), (headers != null ? headers : EMPTY_HEADERS),
(consumes != null ? consumes : EMPTY_CONSUMES), (consumes != null ? consumes : EMPTY_CONSUMES),
(produces != null ? produces : EMPTY_PRODUCES), (produces != null ? produces : EMPTY_PRODUCES),
(version != null ? version : EMPTY_VERSION), (version != null ? version : new VersionRequestCondition(null, null)),
(custom != null ? new RequestConditionHolder(custom) : EMPTY_CUSTOM), (custom != null ? new RequestConditionHolder(custom) : EMPTY_CUSTOM),
new BuilderConfiguration()); new BuilderConfiguration());
} }
@ -771,15 +769,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
ContentNegotiationManager manager = this.options.getContentNegotiationManager(); ContentNegotiationManager manager = this.options.getContentNegotiationManager();
VersionRequestCondition versionCondition; ApiVersionStrategy strategy = this.options.getApiVersionStrategy();
ApiVersionStrategy versionStrategy = this.options.getApiVersionStrategy(); Assert.state(strategy != null || !StringUtils.hasText(this.version),
if (StringUtils.hasText(this.version)) { "API version specified, but no ApiVersionStrategy configured");
Assert.state(versionStrategy != null, "API version specified, but no ApiVersionStrategy configured"); VersionRequestCondition versionCondition = new VersionRequestCondition(this.version, strategy);
versionCondition = new VersionRequestCondition(this.version, versionStrategy);
}
else {
versionCondition = EMPTY_VERSION;
}
return new RequestMappingInfo( return new RequestMappingInfo(
this.mappingName, pathPatternsCondition, patternsCondition, this.mappingName, pathPatternsCondition, patternsCondition,
@ -894,14 +887,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
@Override @Override
public Builder version(@Nullable String version) { public Builder version(@Nullable String version) {
if (version != null) { ApiVersionStrategy strategy = this.options.getApiVersionStrategy();
ApiVersionStrategy strategy = this.options.getApiVersionStrategy(); Assert.state(strategy != null || !StringUtils.hasText(version),
Assert.state(strategy != null, "API version specified, but no ApiVersionStrategy configured"); "API version specified, but no ApiVersionStrategy configured");
this.versionCondition = new VersionRequestCondition(version, strategy); this.versionCondition = new VersionRequestCondition(version, strategy);
}
else {
this.versionCondition = EMPTY_VERSION;
}
return this; return this;
} }

View File

@ -70,32 +70,31 @@ public class VersionRequestConditionTests {
@Test @Test
void fixedVersionMatch() { void fixedVersionMatch() {
String conditionVersion = "1.2"; VersionRequestCondition condition = condition("1.2");
this.strategy.addSupportedVersion("1.1", "1.3"); this.strategy.addSupportedVersion("1.1", "1.3");
testMatch("v1.1", conditionVersion, true, false); testMatch("v1.1", condition, false, false);
testMatch("v1.2", conditionVersion, false, false); testMatch("v1.2", condition, true, false);
testMatch("v1.3", conditionVersion, false, true); testMatch("v1.3", condition, true, true); // match initially, reject if chosen
} }
@Test @Test
void baselineVersionMatch() { void baselineVersionMatch() {
String conditionVersion = "1.2+"; VersionRequestCondition condition = condition("1.2+");
this.strategy.addSupportedVersion("1.1", "1.3"); this.strategy.addSupportedVersion("1.1", "1.3");
testMatch("v1.1", conditionVersion, true, false); testMatch("v1.1", condition, false, false);
testMatch("v1.2", conditionVersion, false, false); testMatch("v1.2", condition, true, false);
testMatch("v1.3", conditionVersion, false, false); testMatch("v1.3", condition, true, false);
} }
private void testMatch( private void testMatch(
String requestVersion, String conditionVersion, boolean notCompatible, boolean notAcceptable) { String requestVersion, VersionRequestCondition condition, boolean matches, boolean notAcceptable) {
MockHttpServletRequest request = requestWithVersion(requestVersion); MockHttpServletRequest request = requestWithVersion(requestVersion);
VersionRequestCondition condition = condition(conditionVersion);
VersionRequestCondition match = condition.getMatchingCondition(request); VersionRequestCondition match = condition.getMatchingCondition(request);
if (notCompatible) { if (!matches) {
assertThat(match).isNull(); assertThat(match).isNull();
return; return;
} }
@ -155,7 +154,7 @@ public class VersionRequestConditionTests {
} }
private VersionRequestCondition emptyCondition() { private VersionRequestCondition emptyCondition() {
return new VersionRequestCondition(); return new VersionRequestCondition(null, this.strategy);
} }
private MockHttpServletRequest requestWithVersion(String v) { private MockHttpServletRequest requestWithVersion(String v) {