From b878771dcafcdf410d595e87b73cf2b5fc0dd2f9 Mon Sep 17 00:00:00 2001 From: Jonathan Kaplan Date: Tue, 30 Dec 2025 12:47:39 -0500 Subject: [PATCH] Update ApiVersionResolver to return Mono String See gh-36084 Signed-off-by: Jonathan Kaplan --- .../reactive/accept/ApiVersionResolver.java | 13 ++ .../reactive/accept/ApiVersionStrategy.java | 63 +++++++++ .../accept/DefaultApiVersionStrategy.java | 12 ++ .../handler/AbstractHandlerMapping.java | 21 ++- .../DefaultApiVersionStrategiesTests.java | 132 +++++++++++++++--- 5 files changed, 222 insertions(+), 19 deletions(-) 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 index 77c7baae398..3e28bf585a2 100644 --- 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 @@ -17,6 +17,7 @@ package org.springframework.web.reactive.accept; import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; import org.springframework.web.server.ServerWebExchange; @@ -24,6 +25,7 @@ import org.springframework.web.server.ServerWebExchange; * Contract to extract the version from a request. * * @author Rossen Stoyanchev + * @author Jonathan Kaplan * @since 7.0 */ @FunctionalInterface @@ -37,4 +39,15 @@ interface ApiVersionResolver { */ @Nullable String resolveVersion(ServerWebExchange exchange); + /** + * Asynchronously resolve the version for the given request exchange. + * This method wraps the synchronous {@code resolveVersion} method + * and provides a reactive alternative. + * @param exchange the current request exchange + * @return a {@code Mono} emitting the version value, or an empty {@code Mono} if no version is found + */ + default Mono resolveVersionAsync(ServerWebExchange exchange){ + return Mono.justOrEmpty(this.resolveVersion(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 index ff8cc2038e6..a9f66846660 100644 --- 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 @@ -17,6 +17,7 @@ package org.springframework.web.reactive.accept; import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.accept.MissingApiVersionException; @@ -27,6 +28,7 @@ import org.springframework.web.server.ServerWebExchange; * to manage API versioning for an application. * * @author Rossen Stoyanchev + * @author Jonathan Kaplan * @since 7.0 * @see DefaultApiVersionStrategy */ @@ -37,10 +39,24 @@ public interface ApiVersionStrategy { * @param exchange the current exchange * @return the version, if present or {@code null} * @see ApiVersionResolver + * @deprecated as of 7.0.3, in favor of + * {@link #resolveVersionAsync(ServerWebExchange)} */ + @Deprecated(forRemoval = true, since = "7.0.3") @Nullable String resolveVersion(ServerWebExchange exchange); + + /** + * Resolve the version value from a request asynchronously. + * @param exchange the current server exchange containing the request details + * @return a {@code Mono} emitting the resolved version as a {@code String}, + * or an empty {@code Mono} if no version is resolved + */ + default Mono resolveVersionAsync(ServerWebExchange exchange) { + return Mono.justOrEmpty(this.resolveVersion(exchange)); + } + /** * Parse the version of a request into an Object. * @param version the value to parse @@ -59,6 +75,25 @@ public interface ApiVersionStrategy { void validateVersion(@Nullable Comparable requestVersion, ServerWebExchange exchange) throws MissingApiVersionException, InvalidApiVersionException; + /** + * Validates the provided request version against the requirements and constraints + * of the API. If the validation succeeds, the version is returned, otherwise an + * error is emitted. + * @param requestVersion the version to validate, which may be null + * @param exchange the current server exchange representing the HTTP request and response + * @return a {@code Mono} emitting the validated request version as a {@code Comparable}, + * or an error if validation fails + */ + default Mono> validateVersionAsync(@Nullable Comparable requestVersion, ServerWebExchange exchange) { + try { + this.validateVersion(requestVersion, exchange); + return Mono.justOrEmpty(requestVersion); + } + catch (MissingApiVersionException | InvalidApiVersionException ex) { + return Mono.error(ex); + } + } + /** * Return a default version to use for requests that don't specify one. */ @@ -70,6 +105,8 @@ public interface ApiVersionStrategy { * @param exchange the current exchange * @return the parsed request version, or the default version */ + @SuppressWarnings({"DeprecatedIsStillUsed", "DuplicatedCode"}) + @Deprecated(forRemoval = true, since = "7.0.3") default @Nullable Comparable resolveParseAndValidateVersion(ServerWebExchange exchange) { String value = resolveVersion(exchange); Comparable version; @@ -88,6 +125,32 @@ public interface ApiVersionStrategy { return version; } + /** + * Convenience method to return the API version from the given request exchange, parse and validate + * the version, and return the result as a reactive {@code Mono} stream. If no version + * is resolved, the default version is used. + * @param exchange the current server exchange containing the request details + * @return a {@code Mono} emitting the resolved, parsed, and validated version as a {@code Comparable}, + * or an error in case parsing or validation fails + */ + @SuppressWarnings("Convert2MethodRef") + default Mono> resolveParseAndValidateVersionAsync(ServerWebExchange exchange) { + return this.resolveVersionAsync(exchange) + .switchIfEmpty(Mono.justOrEmpty(this.getDefaultVersion()) + .mapNotNull(comparable -> comparable.toString())) + .>handle((version, sink) -> { + try { + sink.next(this.parseVersion(version)); + } + catch (Exception ex) { + sink.error(new InvalidApiVersionException(version, null, ex)); + } + }) + .flatMap(version -> this.validateVersionAsync(version, exchange)) + .switchIfEmpty(this.validateVersionAsync(null, exchange)); + + } + /** * Check if the requested API version is deprecated, and if so handle it * accordingly, e.g. by setting response headers to signal the deprecation, diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java index da641e2e14b..7d6f6d8e3a4 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java @@ -20,9 +20,12 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.TreeSet; +import java.util.function.Function; import java.util.function.Predicate; import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import org.springframework.util.Assert; import org.springframework.web.accept.ApiVersionParser; @@ -152,6 +155,7 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { } } + @SuppressWarnings("removal") @Override public @Nullable String resolveVersion(ServerWebExchange exchange) { for (ApiVersionResolver resolver : this.versionResolvers) { @@ -163,6 +167,14 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { return null; } + @Override + public Mono resolveVersionAsync(ServerWebExchange exchange) { + return Flux.fromIterable(this.versionResolvers) + .mapNotNull(resolver -> resolver.resolveVersionAsync(exchange)) + .flatMap(Function.identity()) + .next(); + } + @Override public Comparable parseVersion(String version) { return this.versionParser.parseVersion(version); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java index b20577bea99..903ec701b40 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java @@ -184,8 +184,9 @@ public abstract class AbstractHandlerMapping extends ApplicationObjectSupport @Override public Mono getHandler(ServerWebExchange exchange) { - initApiVersion(exchange); - return getHandlerInternal(exchange).map(handler -> { + this.initApiVersion(exchange); + return this.initApiVersionAsync(exchange).then( + getHandlerInternal(exchange).map(handler -> { if (logger.isDebugEnabled()) { logger.debug(exchange.getLogPrefix() + "Mapped to " + handler); } @@ -210,9 +211,11 @@ public abstract class AbstractHandlerMapping extends ApplicationObjectSupport } } return handler; - }); + })); } + @Deprecated(since = "7.0.3", forRemoval = true) + @SuppressWarnings({"removal", "DeprecatedIsStillUsed"}) private void initApiVersion(ServerWebExchange exchange) { if (this.apiVersionStrategy != null) { Comparable version = exchange.getAttribute(API_VERSION_ATTRIBUTE); @@ -225,6 +228,18 @@ public abstract class AbstractHandlerMapping extends ApplicationObjectSupport } } + private Mono> initApiVersionAsync(ServerWebExchange exchange) { + if (this.apiVersionStrategy != null) { + if (exchange.getAttribute(API_VERSION_ATTRIBUTE) == null) { + return this.apiVersionStrategy + .resolveParseAndValidateVersionAsync(exchange) + .doOnNext(version -> exchange.getAttributes() + .put(API_VERSION_ATTRIBUTE, version)); + } + } + return Mono.empty(); + } + /** * Look up a handler for the given request, returning an empty {@code Mono} * if no specific one is found. This method is called by {@link #getHandler}. diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java index 1b46a2bbb0a..f6886795712 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java @@ -21,6 +21,7 @@ import java.util.function.Predicate; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.accept.MissingApiVersionException; @@ -35,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Unit tests for {@link org.springframework.web.accept.DefaultApiVersionStrategy}. * @author Rossen Stoyanchev + * @author Jonathan Kaplan */ public class DefaultApiVersionStrategiesTests { @@ -48,6 +50,116 @@ public class DefaultApiVersionStrategiesTests { assertThat(strategy.getDefaultVersion()).isEqualTo(parser.parseVersion(version)); } + @Test + void missingRequiredVersionReactively() { + + testValidateReactively(null, apiVersionStrategy()) + .expectErrorSatisfies(throwable -> + assertThat(throwable).isInstanceOf(MissingApiVersionException.class) + .hasMessage(("400 BAD_REQUEST \"API version is " + + "required.\"") + )) + .verify(); + } + + @Test + void validateSupportedVersionReactively() { + String version = "1.2"; + DefaultApiVersionStrategy strategy = apiVersionStrategy(); + strategy.addSupportedVersion(version); + testValidateReactively(version, strategy) + .expectNextMatches(next -> next.toString().equals("1.2.0")) + .verifyComplete(); + } + + @Test + void validateSupportedVersionForDefaultVersionReactively() { + String defaultVersion = "1.2"; + DefaultApiVersionStrategy strategy = apiVersionStrategy(defaultVersion, false, null); + + testValidateReactively(defaultVersion, strategy) + .expectNextMatches(next -> next.toString().equals("1.2.0")) + .verifyComplete(); + } + + @Test + void validateUnsupportedVersionReactively() { + testValidateReactively("1.2", apiVersionStrategy()) + .expectErrorSatisfies(throwable -> + assertThat(throwable).isInstanceOf(InvalidApiVersionException.class) + .hasMessage(("400 BAD_REQUEST \"Invalid API " + + "version: '1.2.0'.\"") + )) + .verify(); + + } + + @Test + void validateDetectedVersionReactively() { + String version = "1.2"; + DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true, null); + strategy.addMappedVersion(version); + testValidateReactively(version, strategy) + .expectNextMatches(next -> next.toString().equals("1.2.0")) + .verifyComplete(); + } + + @Test + void validateWhenDetectedVersionOffReactively() { + String version = "1.2"; + DefaultApiVersionStrategy strategy = apiVersionStrategy(); + strategy.addMappedVersion(version); + testValidateReactively(version, strategy) + .expectError(InvalidApiVersionException.class) + .verify(); + } + + @Test + void validateSupportedWithPredicateReactively() { + SemanticApiVersionParser.Version parsedVersion = parser.parseVersion("1.2"); + testValidateReactively("1.2", apiVersionStrategy(null, false, version -> version.equals(parsedVersion))) + .expectNextMatches(next -> next.toString().equals("1.2.0")) + .verifyComplete(); + } + + @Test + void validateUnsupportedWithPredicateReactively() { + DefaultApiVersionStrategy strategy = apiVersionStrategy(null, false, version -> version.equals("1.2")); + testValidateReactively("1.2", strategy) + .verifyError(InvalidApiVersionException.class); + } + + @Test + void versionRequiredAndDefaultVersionSetReactively() { + assertThatIllegalArgumentException() + .isThrownBy(() -> + new org.springframework.web.accept.DefaultApiVersionStrategy( + List.of(request -> request.getParameter("api-version")), new SemanticApiVersionParser(), + true, "1.2", true, version -> true, null)) + .withMessage("versionRequired cannot be set to true if a defaultVersion is also configured"); + } + + private static DefaultApiVersionStrategy apiVersionStrategy() { + return apiVersionStrategy(null, false, null); + } + + private static DefaultApiVersionStrategy apiVersionStrategy( + @Nullable String defaultVersion, boolean detectSupportedVersions, + @Nullable Predicate> supportedVersionPredicate) { + + return new DefaultApiVersionStrategy( + List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), + parser, null, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null); + } + + private StepVerifier.FirstStep> testValidateReactively(@Nullable String version, DefaultApiVersionStrategy strategy) { + MockServerHttpRequest.BaseBuilder requestBuilder = MockServerHttpRequest.get("/"); + if (version != null) { + requestBuilder.queryParam("api-version", version); + } + return StepVerifier.create(strategy.resolveParseAndValidateVersionAsync(MockServerWebExchange.builder(requestBuilder).build())); + } + @Test void missingRequiredVersion() { assertThatThrownBy(() -> testValidate(null, apiVersionStrategy())) @@ -109,25 +221,13 @@ public class DefaultApiVersionStrategiesTests { void versionRequiredAndDefaultVersionSet() { assertThatIllegalArgumentException() .isThrownBy(() -> - new org.springframework.web.accept.DefaultApiVersionStrategy( - List.of(request -> request.getParameter("api-version")), new SemanticApiVersionParser(), - true, "1.2", true, version -> true, null)) + new org.springframework.web.accept.DefaultApiVersionStrategy( + List.of(request -> request.getParameter("api-version")), new SemanticApiVersionParser(), + true, "1.2", true, version -> true, null)) .withMessage("versionRequired cannot be set to true if a defaultVersion is also configured"); } - private static DefaultApiVersionStrategy apiVersionStrategy() { - return apiVersionStrategy(null, false, null); - } - - private static DefaultApiVersionStrategy apiVersionStrategy( - @Nullable String defaultVersion, boolean detectSupportedVersions, - @Nullable Predicate> supportedVersionPredicate) { - - return new DefaultApiVersionStrategy( - List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), - parser, null, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null); - } - + @SuppressWarnings("removal") private void testValidate(@Nullable String version, DefaultApiVersionStrategy strategy) { MockServerHttpRequest.BaseBuilder requestBuilder = MockServerHttpRequest.get("/"); if (version != null) {