Browse Source

Update ApiVersionResolver to return Mono String

See gh-36084

Signed-off-by: Jonathan Kaplan <jkaplan@empty-nes.com>
pull/36116/head
Jonathan Kaplan 1 month ago committed by rstoyanchev
parent
commit
b878771dca
  1. 13
      spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionResolver.java
  2. 63
      spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java
  3. 12
      spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java
  4. 21
      spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java
  5. 132
      spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java

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

@ -17,6 +17,7 @@ @@ -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; @@ -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 { @@ -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<String> resolveVersionAsync(ServerWebExchange exchange){
return Mono.justOrEmpty(this.resolveVersion(exchange));
}
}

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

@ -17,6 +17,7 @@ @@ -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; @@ -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 { @@ -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<String> 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 { @@ -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<Comparable<?>> 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 { @@ -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 { @@ -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<Comparable<?>> resolveParseAndValidateVersionAsync(ServerWebExchange exchange) {
return this.resolveVersionAsync(exchange)
.switchIfEmpty(Mono.justOrEmpty(this.getDefaultVersion())
.mapNotNull(comparable -> comparable.toString()))
.<Comparable<?>>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,

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

@ -20,9 +20,12 @@ import java.util.ArrayList; @@ -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 { @@ -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 { @@ -163,6 +167,14 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy {
return null;
}
@Override
public Mono<String> 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);

21
spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java

@ -184,8 +184,9 @@ public abstract class AbstractHandlerMapping extends ApplicationObjectSupport @@ -184,8 +184,9 @@ public abstract class AbstractHandlerMapping extends ApplicationObjectSupport
@Override
public Mono<Object> 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 @@ -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 @@ -225,6 +228,18 @@ public abstract class AbstractHandlerMapping extends ApplicationObjectSupport
}
}
private Mono<Comparable<?>> 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}.

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

@ -21,6 +21,7 @@ import java.util.function.Predicate; @@ -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; @@ -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 { @@ -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<Comparable<?>> supportedVersionPredicate) {
return new DefaultApiVersionStrategy(
List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")),
parser, null, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null);
}
private StepVerifier.FirstStep<Comparable<?>> 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 { @@ -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<Comparable<?>> 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) {

Loading…
Cancel
Save