diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionResolver.java new file mode 100644 index 0000000000..27c7253449 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionResolver.java @@ -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); + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java new file mode 100644 index 0000000000..211bbc0bf3 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java @@ -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(); + +} 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 new file mode 100644 index 0000000000..fb7ad6da28 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java @@ -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 versionResolvers; + + private final ApiVersionParser versionParser; + + private final boolean versionRequired; + + private final @Nullable Comparable defaultVersion; + + private final Set> 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 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 + "]"; + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java new file mode 100644 index 0000000000..a03675dd16 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java @@ -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; + } + +} 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 new file mode 100644 index 0000000000..e55f0a29a3 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java @@ -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 versionResolvers = new ArrayList<>(); + + private @Nullable ApiVersionParser versionParser; + + private boolean versionRequired = true; + + private @Nullable String defaultVersion; + + private final Set 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. + *

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. + *

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}. + *

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; + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/DelegatingWebFluxConfiguration.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/DelegatingWebFluxConfiguration.java index e2aaa147bb..99aa46fce6 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/DelegatingWebFluxConfiguration.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/DelegatingWebFluxConfiguration.java @@ -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 this.configurers.configureContentTypeResolver(builder); } + @Override + protected void configureApiVersioning(ApiVersionConfigurer configurer) { + this.configurers.configureApiVersioning(configurer); + } + @Override public void configurePathMatching(PathMatchConfigurer configurer) { this.configurers.configurePathMatching(configurer); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java index 025979f0d6..765d6f2fa8 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java @@ -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; 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 { private @Nullable BlockingExecutionConfigurer blockingExecutionConfigurer; + private @Nullable ApiVersionStrategy apiVersionStrategy; + private @Nullable List errorResponseInterceptors; private @Nullable ViewResolverRegistry viewResolverRegistry; @@ -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>> pathPrefixes = configurer.getPathPrefixes(); if (pathPrefixes != null) { mapping.setPathPrefixes(pathPrefixes); @@ -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. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurer.java index af14b1f434..12b0de4dfa 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurer.java @@ -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 { 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. *

The configured path matching options will be used for mapping to diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurerComposite.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurerComposite.java index 3a3e9f2757..a6bc48bfcc 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurerComposite.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurerComposite.java @@ -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)); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java new file mode 100644 index 0000000000..e617a82d9f --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java @@ -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 { + + 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 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 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 > 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. + *

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. + *

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; + } + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfo.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfo.java index 1938b43051..a49494250f 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfo.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfo.java @@ -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 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 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 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); + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java new file mode 100644 index 0000000000..b6665e4cf3 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java @@ -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); + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/config/DelegatingWebFluxConfigurationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/config/DelegatingWebFluxConfigurationTests.java index 9c5d52414b..61b578e296 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/config/DelegatingWebFluxConfigurationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/config/DelegatingWebFluxConfigurationTests.java @@ -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)); 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 new file mode 100644 index 0000000000..7e674d7ba9 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java @@ -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 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)); + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java new file mode 100644 index 0000000000..f8edb90f6b --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java @@ -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 exchangeWithVersion(String version) { + String url = "http://localhost:" + this.port; + RequestEntity 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; + } + } + +}