Browse Source

API versioning support for Spring WebFlux

Closes gh-34566
pull/34571/head
rstoyanchev 10 months ago
parent
commit
e73dc37513
  1. 40
      spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionResolver.java
  2. 64
      spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java
  3. 132
      spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java
  4. 58
      spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java
  5. 156
      spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java
  6. 7
      spring-webflux/src/main/java/org/springframework/web/reactive/config/DelegatingWebFluxConfiguration.java
  7. 36
      spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java
  8. 11
      spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurer.java
  9. 7
      spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurerComposite.java
  10. 203
      spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java
  11. 108
      spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfo.java
  12. 2
      spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java
  13. 30
      spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java
  14. 74
      spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java
  15. 45
      spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java
  16. 3
      spring-webflux/src/test/java/org/springframework/web/reactive/config/DelegatingWebFluxConfigurationTests.java
  17. 172
      spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java
  18. 110
      spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java

40
spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionResolver.java

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.accept;
import org.jspecify.annotations.Nullable;
import org.springframework.web.server.ServerWebExchange;
/**
* Contract to extract the version from a request.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
@FunctionalInterface
public
interface ApiVersionResolver {
/**
* Resolve the version for the given exchange.
* @param exchange the current exchange
* @return the version value, or {@code null} if not found
*/
@Nullable String resolveVersion(ServerWebExchange exchange);
}

64
spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.accept;
import org.jspecify.annotations.Nullable;
import org.springframework.web.accept.InvalidApiVersionException;
import org.springframework.web.accept.MissingApiVersionException;
import org.springframework.web.server.ServerWebExchange;
/**
* The main component that encapsulates configuration preferences and strategies
* to manage API versioning for an application.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
public interface ApiVersionStrategy {
/**
* Resolve the version value from a request, e.g. from a request header.
* @param exchange the current exchange
* @return the version, if present or {@code null}
*/
@Nullable
String resolveVersion(ServerWebExchange exchange);
/**
* Parse the version of a request into an Object.
* @param version the value to parse
* @return an Object that represents the version
*/
Comparable<?> parseVersion(String version);
/**
* Validate a request version, including required and supported version checks.
* @param requestVersion the version to validate
* @param exchange the exchange
* @throws MissingApiVersionException if the version is required, but not specified
* @throws InvalidApiVersionException if the version is not supported
*/
void validateVersion(@Nullable Comparable<?> requestVersion, ServerWebExchange exchange)
throws MissingApiVersionException, InvalidApiVersionException;
/**
* Return a default version to use for requests that don't specify one.
*/
@Nullable Comparable<?> getDefaultVersion();
}

132
spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java

@ -0,0 +1,132 @@ @@ -0,0 +1,132 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.accept;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
import org.springframework.web.accept.ApiVersionParser;
import org.springframework.web.accept.InvalidApiVersionException;
import org.springframework.web.accept.MissingApiVersionException;
import org.springframework.web.server.ServerWebExchange;
/**
* Default implementation of {@link ApiVersionStrategy} that delegates to the
* configured version resolvers and version parser.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
public class DefaultApiVersionStrategy implements ApiVersionStrategy {
private final List<ApiVersionResolver> versionResolvers;
private final ApiVersionParser<?> versionParser;
private final boolean versionRequired;
private final @Nullable Comparable<?> defaultVersion;
private final Set<Comparable<?>> supportedVersions = new TreeSet<>();
/**
* Create an instance.
* @param versionResolvers one or more resolvers to try; the first non-null
* value returned by any resolver becomes the resolved used
* @param versionParser parser for to raw version values
* @param versionRequired whether a version is required; if a request
* does not have a version, and a {@code defaultVersion} is not specified,
* validation fails with {@link MissingApiVersionException}
* @param defaultVersion a default version to assign to requests that
* don't specify one
*/
public DefaultApiVersionStrategy(
List<ApiVersionResolver> versionResolvers, ApiVersionParser<?> versionParser,
boolean versionRequired, @Nullable String defaultVersion) {
Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required");
Assert.notNull(versionParser, "ApiVersionParser is required");
this.versionResolvers = new ArrayList<>(versionResolvers);
this.versionParser = versionParser;
this.versionRequired = (versionRequired && defaultVersion == null);
this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null);
}
@Override
public @Nullable Comparable<?> getDefaultVersion() {
return this.defaultVersion;
}
/**
* 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
*/
public void addSupportedVersion(String... versions) {
for (String version : versions) {
this.supportedVersions.add(parseVersion(version));
}
}
@Override
public @Nullable String resolveVersion(ServerWebExchange exchange) {
for (ApiVersionResolver resolver : this.versionResolvers) {
String version = resolver.resolveVersion(exchange);
if (version != null) {
return version;
}
}
return null;
}
@Override
public Comparable<?> parseVersion(String version) {
return this.versionParser.parseVersion(version);
}
public void validateVersion(@Nullable Comparable<?> requestVersion, ServerWebExchange exchange)
throws MissingApiVersionException, InvalidApiVersionException {
if (requestVersion == null) {
if (this.versionRequired) {
throw new MissingApiVersionException();
}
return;
}
if (!this.supportedVersions.contains(requestVersion)) {
throw new InvalidApiVersionException(requestVersion.toString());
}
}
@Override
public String toString() {
return "DefaultApiVersionStrategy[supportedVersions=" + this.supportedVersions +
", versionRequired=" + this.versionRequired + ", defaultVersion=" + this.defaultVersion + "]";
}
}

58
spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.accept;
import org.jspecify.annotations.Nullable;
import org.springframework.http.server.PathContainer;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;
/**
* {@link ApiVersionResolver} that extract the version from a path segment.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
public class PathApiVersionResolver implements ApiVersionResolver {
private final int pathSegmentIndex;
/**
* Create a resolver instance.
* @param pathSegmentIndex the index of the path segment that contains
* the API version
*/
public PathApiVersionResolver(int pathSegmentIndex) {
Assert.isTrue(pathSegmentIndex >= 0, "'pathSegmentIndex' must be >= 0");
this.pathSegmentIndex = pathSegmentIndex;
}
@Override
public @Nullable String resolveVersion(ServerWebExchange exchange) {
int i = 0;
for (PathContainer.Element e : exchange.getRequest().getPath().pathWithinApplication().elements()) {
if (e instanceof PathContainer.PathSegment && i++ == this.pathSegmentIndex) {
return e.value();
}
}
return null;
}
}

156
spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java

@ -0,0 +1,156 @@ @@ -0,0 +1,156 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.config;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.jspecify.annotations.Nullable;
import org.springframework.web.accept.ApiVersionParser;
import org.springframework.web.accept.SemanticApiVersionParser;
import org.springframework.web.reactive.accept.ApiVersionResolver;
import org.springframework.web.reactive.accept.ApiVersionStrategy;
import org.springframework.web.reactive.accept.DefaultApiVersionStrategy;
import org.springframework.web.reactive.accept.PathApiVersionResolver;
/**
* Configure API versioning.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
public class ApiVersionConfigurer {
private final List<ApiVersionResolver> versionResolvers = new ArrayList<>();
private @Nullable ApiVersionParser<?> versionParser;
private boolean versionRequired = true;
private @Nullable String defaultVersion;
private final Set<String> supportedVersions = new LinkedHashSet<>();
/**
* Add a resolver that extracts the API version from a request header.
* @param headerName the header name to check
*/
public ApiVersionConfigurer useRequestHeader(String headerName) {
this.versionResolvers.add(exchange -> exchange.getRequest().getHeaders().getFirst(headerName));
return this;
}
/**
* Add a resolver that extracts the API version from a request parameter.
* @param paramName the parameter name to check
*/
public ApiVersionConfigurer useRequestParam(String paramName) {
this.versionResolvers.add(exchange -> exchange.getRequest().getQueryParams().getFirst(paramName));
return this;
}
/**
* Add a resolver that extracts the API version from a path segment.
* @param index the index of the path segment to check; e.g. for URL's like
* "/{version}/..." use index 0, for "/api/{version}/..." index 1.
*/
public ApiVersionConfigurer usePathSegment(int index) {
this.versionResolvers.add(new PathApiVersionResolver(index));
return this;
}
/**
* Add custom resolvers to resolve the API version.
* @param resolvers the resolvers to use
*/
public ApiVersionConfigurer useVersionResolver(ApiVersionResolver... resolvers) {
this.versionResolvers.addAll(Arrays.asList(resolvers));
return this;
}
/**
* Configure a parser to parse API versions with.
* <p>By default, {@link SemanticApiVersionParser} is used.
* @param versionParser the parser to user
*/
public ApiVersionConfigurer setVersionParser(@Nullable ApiVersionParser<?> versionParser) {
this.versionParser = versionParser;
return this;
}
/**
* Whether requests are required to have an API version. When set to
* {@code true}, {@link org.springframework.web.accept.MissingApiVersionException}
* is raised, resulting in a 400 response if the request doesn't have an API
* version. When set to false, a request without a version is considered to
* accept any version.
* <p>By default, this is set to {@code true} when API versioning is enabled
* by adding at least one {@link ApiVersionResolver}). When a
* {@link #setDefaultVersion defaultVersion} is also set, this is
* automatically set to {@code false}.
* @param required whether an API version is required.
*/
public ApiVersionConfigurer setVersionRequired(boolean required) {
this.versionRequired = required;
return this;
}
/**
* Configure a default version to assign to requests that don't specify one.
* @param defaultVersion the default version to use
*/
public ApiVersionConfigurer setDefaultVersion(@Nullable String defaultVersion) {
this.defaultVersion = defaultVersion;
return this;
}
/**
* 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}.
* <p>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
*/
public ApiVersionConfigurer addSupportedVersions(String... versions) {
Collections.addAll(this.supportedVersions, versions);
return this;
}
protected @Nullable ApiVersionStrategy getApiVersionStrategy() {
if (this.versionResolvers.isEmpty()) {
return null;
}
DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers,
(this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()),
this.versionRequired, this.defaultVersion);
this.supportedVersions.forEach(strategy::addSupportedVersion);
return strategy;
}
}

7
spring-webflux/src/main/java/org/springframework/web/reactive/config/DelegatingWebFluxConfiguration.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -92,6 +92,11 @@ public class DelegatingWebFluxConfiguration extends WebFluxConfigurationSupport @@ -92,6 +92,11 @@ public class DelegatingWebFluxConfiguration extends WebFluxConfigurationSupport
this.configurers.configureContentTypeResolver(builder);
}
@Override
protected void configureApiVersioning(ApiVersionConfigurer configurer) {
this.configurers.configureApiVersioning(configurer);
}
@Override
public void configurePathMatching(PathMatchConfigurer configurer) {
this.configurers.configurePathMatching(configurer);

36
spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -51,6 +51,7 @@ import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; @@ -51,6 +51,7 @@ import org.springframework.web.bind.support.ConfigurableWebBindingInitializer;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.reactive.DispatcherHandler;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.accept.ApiVersionStrategy;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
import org.springframework.web.reactive.function.server.support.HandlerFunctionAdapter;
@ -97,6 +98,8 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware { @@ -97,6 +98,8 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware {
private @Nullable BlockingExecutionConfigurer blockingExecutionConfigurer;
private @Nullable ApiVersionStrategy apiVersionStrategy;
private @Nullable List<ErrorResponse.Interceptor> errorResponseInterceptors;
private @Nullable ViewResolverRegistry viewResolverRegistry;
@ -132,13 +135,17 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware { @@ -132,13 +135,17 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware {
@Bean
public RequestMappingHandlerMapping requestMappingHandlerMapping(
@Qualifier("webFluxContentTypeResolver") RequestedContentTypeResolver contentTypeResolver) {
@Qualifier("webFluxContentTypeResolver") RequestedContentTypeResolver contentTypeResolver,
@Qualifier("mvcApiVersionStrategy") @Nullable ApiVersionStrategy apiVersionStrategy) {
RequestMappingHandlerMapping mapping = createRequestMappingHandlerMapping();
mapping.setOrder(0);
mapping.setContentTypeResolver(contentTypeResolver);
mapping.setApiVersionStrategy(apiVersionStrategy);
PathMatchConfigurer configurer = getPathMatchConfigurer();
configureAbstractHandlerMapping(mapping, configurer);
Map<String, Predicate<Class<?>>> pathPrefixes = configurer.getPathPrefixes();
if (pathPrefixes != null) {
mapping.setPathPrefixes(pathPrefixes);
@ -175,6 +182,31 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware { @@ -175,6 +182,31 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware {
protected void configureContentTypeResolver(RequestedContentTypeResolverBuilder builder) {
}
/**
* Return the central strategy to manage API versioning with, or {@code null}
* if the application does not use versioning.
* @since 7.0
*/
@Bean
public @Nullable ApiVersionStrategy mvcApiVersionStrategy() {
if (this.apiVersionStrategy == null) {
ApiVersionConfigurer configurer = new ApiVersionConfigurer();
configureApiVersioning(configurer);
ApiVersionStrategy strategy = configurer.getApiVersionStrategy();
if (strategy != null) {
this.apiVersionStrategy = strategy;
}
}
return this.apiVersionStrategy;
}
/**
* Override this method to configure API versioning.
* @since 7.0
*/
protected void configureApiVersioning(ApiVersionConfigurer configurer) {
}
/**
* Callback for building the global CORS configuration. This method is final.
* Use {@link #addCorsMappings(CorsRegistry)} to customize the CORS config.

11
spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurer.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -118,6 +118,15 @@ public interface WebFluxConfigurer { @@ -118,6 +118,15 @@ public interface WebFluxConfigurer {
default void configureContentTypeResolver(RequestedContentTypeResolverBuilder builder) {
}
/**
* Configure API versioning for the application. In order for versioning to
* be enabled, you must configure at least one way to resolve the API
* version from a request (e.g. via request header).
* @since 7.0
*/
default void configureApiVersioning(ApiVersionConfigurer configurer) {
}
/**
* Configure path matching options.
* <p>The configured path matching options will be used for mapping to

7
spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurerComposite.java

@ -89,6 +89,13 @@ public class WebFluxConfigurerComposite implements WebFluxConfigurer { @@ -89,6 +89,13 @@ public class WebFluxConfigurerComposite implements WebFluxConfigurer {
this.delegates.forEach(delegate -> delegate.configureContentTypeResolver(builder));
}
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
for (WebFluxConfigurer delegate : this.delegates) {
delegate.configureApiVersioning(configurer);
}
}
@Override
public void configurePathMatching(PathMatchConfigurer configurer) {
this.delegates.forEach(delegate -> delegate.configurePathMatching(configurer));

203
spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java

@ -0,0 +1,203 @@ @@ -0,0 +1,203 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.result.condition;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
import org.springframework.web.accept.InvalidApiVersionException;
import org.springframework.web.accept.NotAcceptableApiVersionException;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.reactive.accept.ApiVersionStrategy;
import org.springframework.web.server.ServerWebExchange;
/**
* Request condition to map based on the API version of the request.
* Versions can be fixed (e.g. "1.2") or baseline (e.g. "1.2+") as described
* in {@link RequestMapping#version()}.
*
*
* @author Rossen Stoyanchev
* @since 7.0
*/
public final class VersionRequestCondition extends AbstractRequestCondition<VersionRequestCondition> {
private static final String VERSION_ATTRIBUTE_NAME = VersionRequestCondition.class.getName() + ".VERSION";
private static final String NO_VERSION_ATTRIBUTE = "NO_VERSION";
private static final ApiVersionStrategy NO_OP_VERSION_STRATEGY = new NoOpApiVersionStrategy();
private final @Nullable String versionValue;
private final @Nullable Object version;
private final boolean baselineVersion;
private final ApiVersionStrategy versionStrategy;
private final Set<String> content;
public VersionRequestCondition() {
this.versionValue = null;
this.version = null;
this.baselineVersion = false;
this.versionStrategy = NO_OP_VERSION_STRATEGY;
this.content = Collections.emptySet();
}
public VersionRequestCondition(String configuredVersion, ApiVersionStrategy versionStrategy) {
this.baselineVersion = configuredVersion.endsWith("+");
this.versionValue = updateVersion(configuredVersion, this.baselineVersion);
this.version = versionStrategy.parseVersion(this.versionValue);
this.versionStrategy = versionStrategy;
this.content = Set.of(configuredVersion);
}
private static String updateVersion(String version, boolean baselineVersion) {
return (baselineVersion ? version.substring(0, version.length() - 1) : version);
}
@Override
protected Collection<String> getContent() {
return this.content;
}
@Override
protected String getToStringInfix() {
return " && ";
}
public @Nullable String getVersion() {
return this.versionValue;
}
@Override
public VersionRequestCondition combine(VersionRequestCondition other) {
return (other.version != null ? other : this);
}
@Override
public @Nullable VersionRequestCondition getMatchingCondition(ServerWebExchange exchange) {
if (this.version == null) {
return this;
}
Comparable<?> version = exchange.getAttribute(VERSION_ATTRIBUTE_NAME);
if (version == null) {
String value = this.versionStrategy.resolveVersion(exchange);
version = (value != null ? parseVersion(value) : this.versionStrategy.getDefaultVersion());
this.versionStrategy.validateVersion(version, exchange);
version = (version != null ? version : NO_VERSION_ATTRIBUTE);
exchange.getAttributes().put(VERSION_ATTRIBUTE_NAME, (version));
}
if (version == NO_VERSION_ATTRIBUTE) {
return this;
}
// At this stage, match all versions as baseline versions.
// Strict matching for fixed versions is enforced at the end in handleMatch.
int result = compareVersions(this.version, version);
return (result <= 0 ? this : null);
}
private Comparable<?> parseVersion(String value) {
try {
return this.versionStrategy.parseVersion(value);
}
catch (Exception ex) {
throw new InvalidApiVersionException(value, null, ex);
}
}
@SuppressWarnings("unchecked")
private <V extends Comparable<V>> int compareVersions(Object v1, Object v2) {
return ((V) v1).compareTo((V) v2);
}
@Override
public int compareTo(VersionRequestCondition other, ServerWebExchange exchange) {
Object otherVersion = other.version;
if (this.version == null && otherVersion == null) {
return 0;
}
else if (this.version != null && otherVersion != null) {
// make higher version bubble up
return (-1 * compareVersions(this.version, otherVersion));
}
else {
return (this.version != null ? -1 : 1);
}
}
/**
* Perform a final check on the matched request mapping version.
* <p>In order to ensure baseline versions are properly capped by higher
* fixed versions, initially we match all versions as baseline versions in
* {@link #getMatchingCondition(ServerWebExchange)}. Once the highest of
* potentially multiple matches is selected, we enforce the strict match
* for fixed versions.
* <p>For example, given controller methods for "1.2+" and "1.5", and
* a request for "1.6", both are matched, allowing "1.5" to be selected, but
* that is then rejected as not acceptable since it is not an exact match.
* @param exchange the current exchange
* @throws NotAcceptableApiVersionException if the matched condition has a
* fixed version that is not equal to the request version
*/
public void handleMatch(ServerWebExchange exchange) {
if (this.version != null && !this.baselineVersion) {
Comparable<?> version = exchange.getAttribute(VERSION_ATTRIBUTE_NAME);
Assert.state(version != null, "No API version attribute");
if (!this.version.equals(version)) {
throw new NotAcceptableApiVersionException(version.toString());
}
}
}
private static final class NoOpApiVersionStrategy implements ApiVersionStrategy {
@Override
public @Nullable String resolveVersion(ServerWebExchange exchange) {
return null;
}
@Override
public String parseVersion(String version) {
return version;
}
@Override
public void validateVersion(@Nullable Comparable<?> requestVersion, ServerWebExchange exchange) {
}
@Override
public @Nullable Comparable<?> getDefaultVersion() {
return null;
}
}
}

108
spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfo.java

@ -23,9 +23,11 @@ import java.util.Set; @@ -23,9 +23,11 @@ import java.util.Set;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.reactive.accept.ApiVersionStrategy;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.result.condition.ConsumesRequestCondition;
import org.springframework.web.reactive.result.condition.HeadersRequestCondition;
@ -35,6 +37,7 @@ import org.springframework.web.reactive.result.condition.ProducesRequestConditio @@ -35,6 +37,7 @@ import org.springframework.web.reactive.result.condition.ProducesRequestConditio
import org.springframework.web.reactive.result.condition.RequestCondition;
import org.springframework.web.reactive.result.condition.RequestConditionHolder;
import org.springframework.web.reactive.result.condition.RequestMethodsRequestCondition;
import org.springframework.web.reactive.result.condition.VersionRequestCondition;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.pattern.PathPattern;
import org.springframework.web.util.pattern.PathPatternParser;
@ -68,6 +71,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -68,6 +71,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
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);
@ -85,6 +90,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -85,6 +90,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
private final ProducesRequestCondition producesCondition;
private final VersionRequestCondition versionCondition;
private final RequestConditionHolder customConditionHolder;
private final int hashCode;
@ -95,8 +102,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -95,8 +102,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
private RequestMappingInfo(@Nullable String name, @Nullable PatternsRequestCondition patterns,
@Nullable RequestMethodsRequestCondition methods, @Nullable ParamsRequestCondition params,
@Nullable HeadersRequestCondition headers, @Nullable ConsumesRequestCondition consumes,
@Nullable ProducesRequestCondition produces, @Nullable RequestCondition<?> custom,
BuilderConfiguration options) {
@Nullable ProducesRequestCondition produces, @Nullable VersionRequestCondition version,
@Nullable RequestCondition<?> custom, BuilderConfiguration options) {
this.name = (StringUtils.hasText(name) ? name : null);
this.patternsCondition = (patterns != null ? patterns : EMPTY_PATTERNS);
@ -105,12 +112,13 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -105,12 +112,13 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
this.headersCondition = (headers != null ? headers : EMPTY_HEADERS);
this.consumesCondition = (consumes != null ? consumes : EMPTY_CONSUMES);
this.producesCondition = (produces != null ? produces : EMPTY_PRODUCES);
this.versionCondition = (version != null ? version : EMPTY_VERSION);
this.customConditionHolder = (custom != null ? new RequestConditionHolder(custom) : EMPTY_CUSTOM);
this.options = options;
this.hashCode = calculateHashCode(
this.patternsCondition, this.methodsCondition, this.paramsCondition, this.headersCondition,
this.consumesCondition, this.producesCondition, this.customConditionHolder);
this.consumesCondition, this.producesCondition, this.versionCondition, this.customConditionHolder);
}
@ -177,6 +185,15 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -177,6 +185,15 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
return this.producesCondition;
}
/**
* Returns the version condition of this {@link RequestMappingInfo},
* or an instance without a version.
* @since 7.0
*/
public VersionRequestCondition getVersionCondition() {
return this.versionCondition;
}
/**
* Returns the "custom" condition of this {@link RequestMappingInfo}; or {@code null}.
*/
@ -199,10 +216,11 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -199,10 +216,11 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
HeadersRequestCondition headers = this.headersCondition.combine(other.headersCondition);
ConsumesRequestCondition consumes = this.consumesCondition.combine(other.consumesCondition);
ProducesRequestCondition produces = this.producesCondition.combine(other.producesCondition);
VersionRequestCondition version = this.versionCondition.combine(other.versionCondition);
RequestConditionHolder custom = this.customConditionHolder.combine(other.customConditionHolder);
return new RequestMappingInfo(name, patterns,
methods, params, headers, consumes, produces, custom.getCondition(), this.options);
methods, params, headers, consumes, produces, version, custom.getCondition(), this.options);
}
private @Nullable String combineNames(RequestMappingInfo other) {
@ -247,6 +265,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -247,6 +265,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
if (produces == null) {
return null;
}
VersionRequestCondition version = this.versionCondition.getMatchingCondition(exchange);
if (version == null) {
return null;
}
PatternsRequestCondition patterns = this.patternsCondition.getMatchingCondition(exchange);
if (patterns == null) {
return null;
@ -256,7 +278,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -256,7 +278,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
return null;
}
return new RequestMappingInfo(this.name, patterns,
methods, params, headers, consumes, produces, custom.getCondition(), this.options);
methods, params, headers, consumes, produces, version, custom.getCondition(), this.options);
}
/**
@ -287,6 +309,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -287,6 +309,10 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
if (result != 0) {
return result;
}
result = this.versionCondition.compareTo(other.getVersionCondition(), exchange);
if (result != 0) {
return result;
}
result = this.methodsCondition.compareTo(other.getMethodsCondition(), exchange);
if (result != 0) {
return result;
@ -307,6 +333,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -307,6 +333,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
this.headersCondition.equals(that.headersCondition) &&
this.consumesCondition.equals(that.consumesCondition) &&
this.producesCondition.equals(that.producesCondition) &&
this.versionCondition.equals(that.versionCondition) &&
this.customConditionHolder.equals(that.customConditionHolder)));
}
@ -319,10 +346,11 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -319,10 +346,11 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
PatternsRequestCondition patterns, RequestMethodsRequestCondition methods,
ParamsRequestCondition params, HeadersRequestCondition headers,
ConsumesRequestCondition consumes, ProducesRequestCondition produces,
RequestConditionHolder custom) {
VersionRequestCondition version, RequestConditionHolder custom) {
return patterns.hashCode() * 31 + methods.hashCode() + params.hashCode() +
headers.hashCode() + consumes.hashCode() + produces.hashCode() + custom.hashCode();
headers.hashCode() + consumes.hashCode() + produces.hashCode() +
version.hashCode() + custom.hashCode();
}
@Override
@ -348,6 +376,9 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -348,6 +376,9 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
if (!this.producesCondition.isEmpty()) {
builder.append(", produces ").append(this.producesCondition);
}
if (!this.versionCondition.isEmpty()) {
builder.append(", version ").append(this.versionCondition);
}
if (!this.customConditionHolder.isEmpty()) {
builder.append(", and ").append(this.customConditionHolder);
}
@ -410,6 +441,12 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -410,6 +441,12 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
*/
Builder produces(String... produces);
/**
* Set the API version condition.
* @since 7.0
*/
Builder version(String version);
/**
* Set the mapping name.
*/
@ -446,6 +483,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -446,6 +483,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
private String @Nullable [] produces;
private @Nullable String version;
private boolean hasContentType;
private boolean hasAccept;
@ -456,7 +495,6 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -456,7 +495,6 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
private BuilderConfiguration options = new BuilderConfiguration();
public DefaultBuilder(String... paths) {
this.paths = paths;
}
@ -503,6 +541,12 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -503,6 +541,12 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
return this;
}
@Override
public Builder version(String version) {
this.version = version;
return this;
}
@Override
public DefaultBuilder mappingName(String name) {
this.mappingName = name;
@ -528,6 +572,16 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -528,6 +572,16 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
RequestedContentTypeResolver contentTypeResolver = this.options.getContentTypeResolver();
VersionRequestCondition versionCondition;
ApiVersionStrategy versionStrategy = this.options.getApiVersionStrategy();
if (StringUtils.hasText(this.version)) {
Assert.state(versionStrategy != null, "API version specified, but no ApiVersionStrategy configured");
versionCondition = new VersionRequestCondition(this.version, versionStrategy);
}
else {
versionCondition = EMPTY_VERSION;
}
return new RequestMappingInfo(this.mappingName,
isEmpty(this.paths) ? null : new PatternsRequestCondition(parse(this.paths, parser)),
ObjectUtils.isEmpty(this.methods) ?
@ -540,6 +594,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -540,6 +594,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
null : new ConsumesRequestCondition(this.consumes, this.headers),
ObjectUtils.isEmpty(this.produces) && !this.hasAccept ?
null : new ProducesRequestCondition(this.produces, this.headers, contentTypeResolver),
versionCondition,
this.customCondition,
this.options);
}
@ -585,6 +640,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -585,6 +640,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
private ProducesRequestCondition producesCondition;
private VersionRequestCondition versionCondition;
private RequestConditionHolder customConditionHolder;
private BuilderConfiguration options;
@ -597,6 +654,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -597,6 +654,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
this.headersCondition = other.headersCondition;
this.consumesCondition = other.consumesCondition;
this.producesCondition = other.producesCondition;
this.versionCondition = other.versionCondition;
this.customConditionHolder = other.customConditionHolder;
this.options = other.options;
}
@ -646,6 +704,19 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -646,6 +704,19 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
return this;
}
@Override
public Builder version(@Nullable String version) {
if (version != null) {
ApiVersionStrategy strategy = this.options.getApiVersionStrategy();
Assert.state(strategy != null, "API version specified, but no ApiVersionStrategy configured");
this.versionCondition = new VersionRequestCondition(version, strategy);
}
else {
this.versionCondition = EMPTY_VERSION;
}
return this;
}
@Override
public Builder mappingName(String name) {
this.name = name;
@ -668,7 +739,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -668,7 +739,7 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
public RequestMappingInfo build() {
return new RequestMappingInfo(this.name, this.patternsCondition,
this.methodsCondition, this.paramsCondition, this.headersCondition,
this.consumesCondition, this.producesCondition,
this.consumesCondition, this.producesCondition, this.versionCondition,
this.customConditionHolder, this.options);
}
}
@ -686,6 +757,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -686,6 +757,8 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
private @Nullable RequestedContentTypeResolver contentTypeResolver;
private @Nullable ApiVersionStrategy apiVersionStrategy;
public void setPatternParser(PathPatternParser patternParser) {
this.patternParser = patternParser;
}
@ -705,6 +778,23 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping @@ -705,6 +778,23 @@ public final class RequestMappingInfo implements RequestCondition<RequestMapping
public @Nullable RequestedContentTypeResolver getContentTypeResolver() {
return this.contentTypeResolver;
}
/**
* Set the strategy for API versioning.
* @param apiVersionStrategy the strategy to use
* @since 7.0
*/
public void setApiVersionStrategy(@Nullable ApiVersionStrategy apiVersionStrategy) {
this.apiVersionStrategy = apiVersionStrategy;
}
/**
* Return the configured strategy for API versioning.
* @since 7.0
*/
public @Nullable ApiVersionStrategy getApiVersionStrategy() {
return this.apiVersionStrategy;
}
}
}

2
spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java

@ -120,6 +120,8 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe @@ -120,6 +120,8 @@ public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMe
super.handleMatch(info, handlerMethod, exchange);
info.getVersionCondition().handleMatch(exchange);
PathContainer lookupPath = exchange.getRequest().getPath().pathWithinApplication();
PathPattern bestPattern;

30
spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java

@ -47,6 +47,8 @@ import org.springframework.web.bind.annotation.RequestMapping; @@ -47,6 +47,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.reactive.accept.ApiVersionStrategy;
import org.springframework.web.reactive.accept.DefaultApiVersionStrategy;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
import org.springframework.web.reactive.result.condition.ConsumesRequestCondition;
@ -78,6 +80,8 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi @@ -78,6 +80,8 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
private RequestedContentTypeResolver contentTypeResolver = new RequestedContentTypeResolverBuilder().build();
private @Nullable ApiVersionStrategy apiVersionStrategy;
private @Nullable StringValueResolver embeddedValueResolver;
private RequestMappingInfo.BuilderConfiguration config = new RequestMappingInfo.BuilderConfiguration();
@ -126,6 +130,23 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi @@ -126,6 +130,23 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
return this.contentTypeResolver;
}
/**
* Configure a strategy to manage API versioning.
* @param strategy the strategy to use
* @since 7.0
*/
public void setApiVersionStrategy(@Nullable ApiVersionStrategy strategy) {
this.apiVersionStrategy = strategy;
}
/**
* Return the configured {@link ApiVersionStrategy} strategy.
* @since 7.0
*/
public @Nullable ApiVersionStrategy getApiVersionStrategy() {
return this.apiVersionStrategy;
}
@Override
public void setEmbeddedValueResolver(StringValueResolver resolver) {
this.embeddedValueResolver = resolver;
@ -136,6 +157,7 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi @@ -136,6 +157,7 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
this.config = new RequestMappingInfo.BuilderConfiguration();
this.config.setPatternParser(getPathPatternParser());
this.config.setContentTypeResolver(getContentTypeResolver());
this.config.setApiVersionStrategy(getApiVersionStrategy());
super.afterPropertiesSet();
}
@ -214,6 +236,13 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi @@ -214,6 +236,13 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
requestMappingInfo = createRequestMappingInfo((HttpExchange) httpExchanges.get(0).annotation, customCondition);
}
if (requestMappingInfo != null && this.apiVersionStrategy instanceof DefaultApiVersionStrategy davs) {
String version = requestMappingInfo.getVersionCondition().getVersion();
if (version != null) {
davs.addSupportedVersion(version);
}
}
return requestMappingInfo;
}
@ -269,6 +298,7 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi @@ -269,6 +298,7 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi
.headers(requestMapping.headers())
.consumes(requestMapping.consumes())
.produces(requestMapping.produces())
.version(requestMapping.version())
.mappingName(requestMapping.name());
if (customCondition != null) {

74
spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java

@ -0,0 +1,74 @@ @@ -0,0 +1,74 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.accept;
import java.util.List;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.springframework.web.accept.InvalidApiVersionException;
import org.springframework.web.accept.SemanticApiVersionParser;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
import org.springframework.web.testfixture.server.MockServerWebExchange;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
/**
* Unit tests for {@link org.springframework.web.accept.DefaultApiVersionStrategy}.
* @author Rossen Stoyanchev
*/
public class DefaultApiVersionStrategiesTests {
private final SemanticApiVersionParser parser = new SemanticApiVersionParser();
@Test
void defaultVersion() {
SemanticApiVersionParser.Version version = this.parser.parseVersion("1.2.3");
ApiVersionStrategy strategy = initVersionStrategy(version.toString());
assertThat(strategy.getDefaultVersion()).isEqualTo(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");
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))
.isInstanceOf(InvalidApiVersionException.class);
}
private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) {
return new DefaultApiVersionStrategy(
List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")),
new SemanticApiVersionParser(), true, defaultValue);
}
}

45
spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.accept;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
import org.springframework.web.testfixture.server.MockServerWebExchange;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
/**
* Unit tests for {@link org.springframework.web.accept.PathApiVersionResolver}.
* @author Rossen Stoyanchev
*/
public class PathApiVersionResolverTests {
@Test
void resolve() {
testResolve(0, "/1.0/path", "1.0");
testResolve(1, "/app/1.1/path", "1.1");
}
private static void testResolve(int index, String requestUri, String expected) {
ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(requestUri));
String actual = new PathApiVersionResolver(index).resolveVersion(exchange);
assertThat(actual).isEqualTo(expected);
}
}

3
spring-webflux/src/test/java/org/springframework/web/reactive/config/DelegatingWebFluxConfigurationTests.java

@ -86,7 +86,8 @@ public class DelegatingWebFluxConfigurationTests { @@ -86,7 +86,8 @@ public class DelegatingWebFluxConfigurationTests {
@Test
void requestMappingHandlerMapping() {
delegatingConfig.setConfigurers(Collections.singletonList(webFluxConfigurer));
delegatingConfig.requestMappingHandlerMapping(delegatingConfig.webFluxContentTypeResolver());
delegatingConfig.requestMappingHandlerMapping(
delegatingConfig.webFluxContentTypeResolver(), delegatingConfig.mvcApiVersionStrategy());
verify(webFluxConfigurer).configureContentTypeResolver(any(RequestedContentTypeResolverBuilder.class));
verify(webFluxConfigurer).addCorsMappings(any(CorsRegistry.class));

172
spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java

@ -0,0 +1,172 @@ @@ -0,0 +1,172 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.result.condition;
import java.util.Arrays;
import java.util.List;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.accept.NotAcceptableApiVersionException;
import org.springframework.web.accept.SemanticApiVersionParser;
import org.springframework.web.reactive.accept.DefaultApiVersionStrategy;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
import org.springframework.web.testfixture.server.MockServerWebExchange;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
/**
* Unit tests for {@link VersionRequestCondition}.
* @author Rossen Stoyanchev
*/
public class VersionRequestConditionTests {
private DefaultApiVersionStrategy strategy;
@BeforeEach
void setUp() {
this.strategy = initVersionStrategy(null);
}
private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) {
return new DefaultApiVersionStrategy(
List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")),
new SemanticApiVersionParser(), true, defaultValue);
}
@Test
void combineMethodLevelOnly() {
VersionRequestCondition condition = emptyCondition().combine(condition("1.1"));
assertThat(condition.getVersion()).isEqualTo("1.1");
}
@Test
void combineTypeLevelOnly() {
VersionRequestCondition condition = condition("1.1").combine(emptyCondition());
assertThat(condition.getVersion()).isEqualTo("1.1");
}
@Test
void combineTypeAndMethodLevel() {
assertThat(condition("1.1").combine(condition("1.2")).getVersion()).isEqualTo("1.2");
}
@Test
void fixedVersionMatch() {
String conditionVersion = "1.2";
this.strategy.addSupportedVersion("1.1", "1.3");
testMatch("v1.1", conditionVersion, true, false);
testMatch("v1.2", conditionVersion, false, false);
testMatch("v1.3", conditionVersion, false, true);
}
@Test
void baselineVersionMatch() {
String conditionVersion = "1.2+";
this.strategy.addSupportedVersion("1.1", "1.3");
testMatch("v1.1", conditionVersion, true, false);
testMatch("v1.2", conditionVersion, false, false);
testMatch("v1.3", conditionVersion, false, false);
}
private void testMatch(
String requestVersion, String conditionVersion, boolean notCompatible, boolean notAcceptable) {
ServerWebExchange exchange = exchangeWithVersion(requestVersion);
VersionRequestCondition condition = condition(conditionVersion);
VersionRequestCondition match = condition.getMatchingCondition(exchange);
if (notCompatible) {
assertThat(match).isNull();
return;
}
assertThat(match).isSameAs(condition);
if (notAcceptable) {
assertThatThrownBy(() -> condition.handleMatch(exchange)).isInstanceOf(NotAcceptableApiVersionException.class);
return;
}
condition.handleMatch(exchange);
}
@Test
void missingRequiredVersion() {
assertThatThrownBy(() -> condition("1.2").getMatchingCondition(exchange()))
.hasMessage("400 BAD_REQUEST \"API version is required.\"");
}
@Test
void defaultVersion() {
String version = "1.2";
this.strategy = initVersionStrategy(version);
VersionRequestCondition condition = condition(version);
VersionRequestCondition match = condition.getMatchingCondition(exchange());
assertThat(match).isSameAs(condition);
}
@Test
void unsupportedVersion() {
assertThatThrownBy(() -> condition("1.2").getMatchingCondition(exchangeWithVersion("1.3")))
.hasMessage("400 BAD_REQUEST \"Invalid API version: '1.3.0'.\"");
}
@Test
void compare() {
testCompare("1.1", "1", "1.1");
testCompare("1.1.1", "1", "1.1", "1.1.1");
testCompare("10", "1.1", "10");
testCompare("10", "2", "10");
}
private void testCompare(String expected, String... versions) {
List<VersionRequestCondition> list = Arrays.stream(versions)
.map(this::condition)
.sorted((c1, c2) -> c1.compareTo(c2, exchange()))
.toList();
assertThat(list.get(0)).isEqualTo(condition(expected));
}
private VersionRequestCondition condition(String v) {
this.strategy.addSupportedVersion(v.endsWith("+") ? v.substring(0, v.length() - 1) : v);
return new VersionRequestCondition(v, this.strategy);
}
private VersionRequestCondition emptyCondition() {
return new VersionRequestCondition();
}
private static MockServerWebExchange exchange() {
return MockServerWebExchange.from(MockServerHttpRequest.get("/path"));
}
private ServerWebExchange exchangeWithVersion(String v) {
return MockServerWebExchange.from(
MockServerHttpRequest.get("/path").queryParam("api-version", v));
}
}

110
spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java

@ -0,0 +1,110 @@ @@ -0,0 +1,110 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.reactive.result.method.annotation;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.reactive.config.ApiVersionConfigurer;
import org.springframework.web.reactive.config.EnableWebFlux;
import org.springframework.web.reactive.config.WebFluxConfigurer;
import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* {@code @RequestMapping} integration focusing on API versioning.
* @author Rossen Stoyanchev
*/
public class RequestMappingVersionIntegrationTests extends AbstractRequestMappingIntegrationTests {
@Override
protected ApplicationContext initApplicationContext() {
AnnotationConfigApplicationContext wac = new AnnotationConfigApplicationContext();
wac.register(WebConfig.class, TestController.class);
wac.refresh();
return wac;
}
@ParameterizedHttpServerTest
void initialVersion(HttpServer httpServer) throws Exception {
startServer(httpServer);
assertThat(exchangeWithVersion("1.0").getBody()).isEqualTo("none");
assertThat(exchangeWithVersion("1.1").getBody()).isEqualTo("none");
}
@ParameterizedHttpServerTest
void baselineVersion(HttpServer httpServer) throws Exception {
startServer(httpServer);
assertThat(exchangeWithVersion("1.2").getBody()).isEqualTo("1.2");
assertThat(exchangeWithVersion("1.3").getBody()).isEqualTo("1.2");
}
@ParameterizedHttpServerTest
void fixedVersion(HttpServer httpServer) throws Exception {
startServer(httpServer);
assertThat(exchangeWithVersion("1.5").getBody()).isEqualTo("1.5");
assertThatThrownBy(() -> exchangeWithVersion("1.6")).isInstanceOf(HttpClientErrorException.BadRequest.class);
}
private ResponseEntity<String> exchangeWithVersion(String version) {
String url = "http://localhost:" + this.port;
RequestEntity<Void> requestEntity = RequestEntity.get(url).header("X-API-Version", version).build();
return getRestTemplate().exchange(requestEntity, String.class);
}
@EnableWebFlux
private static class WebConfig implements WebFluxConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer.useRequestHeader("X-API-Version").addSupportedVersions("1", "1.1", "1.3", "1.6");
}
}
@RestController
private static class TestController {
@GetMapping
String noVersion() {
return getBody("none");
}
@GetMapping(version = "1.2+")
String version1_2() {
return getBody("1.2");
}
@GetMapping(version = "1.5")
String version1_5() {
return getBody("1.5");
}
private static String getBody(String version) {
return version;
}
}
}
Loading…
Cancel
Save