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 77c7baae3982..3e28bf585a27 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 @@ * 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 ff8cc2038e6c..93919e21649c 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 @@ * 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,16 @@ public interface ApiVersionStrategy { void validateVersion(@Nullable Comparable requestVersion, ServerWebExchange exchange) throws MissingApiVersionException, InvalidApiVersionException; + private 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 +96,8 @@ void validateVersion(@Nullable Comparable requestVersion, ServerWebExchange e * @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 +116,32 @@ void validateVersion(@Nullable Comparable requestVersion, ServerWebExchange e 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 da641e2e14bd..7d6f6d8e3a47 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.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 void addMappedVersion(String... versions) { } } + @SuppressWarnings("removal") @Override public @Nullable String resolveVersion(ServerWebExchange exchange) { for (ApiVersionResolver resolver : this.versionResolvers) { @@ -163,6 +167,14 @@ public void addMappedVersion(String... versions) { 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 80db15d5ad11..2fef3b052ac6 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 @@ -185,38 +185,51 @@ protected String formatMappingName() { @Override public Mono getHandler(ServerWebExchange exchange) { - ApiVersionHolder versionHolder = initApiVersion(exchange); - return getHandlerInternal(exchange).map(handler -> { - if (logger.isDebugEnabled()) { - logger.debug(exchange.getLogPrefix() + "Mapped to " + handler); - } - if (versionHolder.hasError()) { - throw versionHolder.getError(); - } - ServerHttpRequest request = exchange.getRequest(); - if (hasCorsConfigurationSource(handler) || CorsUtils.isPreFlightRequest(request)) { - CorsConfiguration config = (this.corsConfigurationSource != null ? - this.corsConfigurationSource.getCorsConfiguration(exchange) : null); - CorsConfiguration handlerConfig = getCorsConfiguration(handler, exchange); - config = (config != null ? config.combine(handlerConfig) : handlerConfig); - if (config != null) { - config.validateAllowCredentials(); - config.validateAllowPrivateNetwork(); - } - if (!this.corsProcessor.process(config, exchange) || CorsUtils.isPreFlightRequest(request)) { - return NO_OP_HANDLER; - } - } - if (getApiVersionStrategy() != null) { - if (versionHolder.hasVersion()) { - Comparable version = versionHolder.getVersion(); - getApiVersionStrategy().handleDeprecations(version, handler, exchange); - } - } - return handler; - }); + final var versionHolder = this.initApiVersion(exchange); + return this.initApiVersionAsync(exchange).flatMap( versionHolderAsync-> + getHandlerInternal(exchange) + .handle((handler, sink)->{ + if (logger.isDebugEnabled()) { + logger.debug(exchange.getLogPrefix() + "Mapped to " + handler); + } + if (versionHolder.hasError()){ + sink.error(versionHolder.getError()); + } + else if (versionHolderAsync.hasError()) { + sink.error(versionHolderAsync.getError()); + } + else { + sink.next(handler); + } + }) + .map(handler -> { + ServerHttpRequest request = exchange.getRequest(); + if (hasCorsConfigurationSource(handler) || CorsUtils.isPreFlightRequest(request)) { + CorsConfiguration config = (this.corsConfigurationSource != null ? + this.corsConfigurationSource.getCorsConfiguration(exchange) : + null); + CorsConfiguration handlerConfig = getCorsConfiguration(handler, exchange); + config = (config != null ? config.combine(handlerConfig) : handlerConfig); + if (config != null) { + config.validateAllowCredentials(); + config.validateAllowPrivateNetwork(); + } + if (!this.corsProcessor.process(config, exchange) || CorsUtils.isPreFlightRequest(request)) { + return NO_OP_HANDLER; + } + } + if (getApiVersionStrategy() != null) { + if (versionHolder.hasVersion()) { + Comparable version = versionHolder.getVersion(); + getApiVersionStrategy().handleDeprecations(version, handler, exchange); + } + } + return handler; + })); } + @Deprecated(since = "7.0.3", forRemoval = true) + @SuppressWarnings({"removal", "DeprecatedIsStillUsed"}) private ApiVersionHolder initApiVersion(ServerWebExchange exchange) { ApiVersionHolder versionHolder; if (this.apiVersionStrategy == null) { @@ -235,6 +248,21 @@ private ApiVersionHolder initApiVersion(ServerWebExchange exchange) { return versionHolder; } + private Mono initApiVersionAsync(ServerWebExchange exchange) { + if (this.apiVersionStrategy != null) { + if (exchange.getAttribute(API_VERSION_ATTRIBUTE) == null) { + return this.apiVersionStrategy + .resolveParseAndValidateVersionAsync(exchange) + .map(ApiVersionHolder::fromVersion) + .onErrorResume(ex -> Mono.just(ApiVersionHolder.fromError(new RuntimeException(ex)))) + .doOnNext(holder -> exchange.getAttributes() + .put(API_VERSION_ATTRIBUTE, holder)); + + } + } + return Mono.just(ApiVersionHolder.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 1b46a2bbb0aa..f68867957125 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 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 @@ /** * Unit tests for {@link org.springframework.web.accept.DefaultApiVersionStrategy}. * @author Rossen Stoyanchev + * @author Jonathan Kaplan */ public class DefaultApiVersionStrategiesTests { @@ -48,6 +50,116 @@ void defaultVersionIsParsed() { 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 @@ void validateUnsupportedWithPredicate() { 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) {