diff --git a/spring-test/src/main/java/org/springframework/mock/web/reactive/function/server/MockServerRequest.java b/spring-test/src/main/java/org/springframework/mock/web/reactive/function/server/MockServerRequest.java index f4b4e604741..bdc461ed031 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/reactive/function/server/MockServerRequest.java +++ b/spring-test/src/main/java/org/springframework/mock/web/reactive/function/server/MockServerRequest.java @@ -50,6 +50,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.reactive.accept.ApiVersionStrategy; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.reactive.function.server.HandlerStrategies; import org.springframework.web.reactive.function.server.ServerRequest; @@ -94,6 +95,8 @@ public final class MockServerRequest implements ServerRequest { private final List> messageReaders; + private final @Nullable ApiVersionStrategy versionStrategy; + private final @Nullable ServerWebExchange exchange; @@ -102,7 +105,8 @@ public final class MockServerRequest implements ServerRequest { Map attributes, MultiValueMap queryParams, Map pathVariables, @Nullable WebSession session, @Nullable Principal principal, @Nullable InetSocketAddress remoteAddress, @Nullable InetSocketAddress localAddress, - List> messageReaders, @Nullable ServerWebExchange exchange) { + List> messageReaders, @Nullable ApiVersionStrategy versionStrategy, + @Nullable ServerWebExchange exchange) { this.method = method; this.uri = uri; @@ -118,6 +122,7 @@ public final class MockServerRequest implements ServerRequest { this.remoteAddress = remoteAddress; this.localAddress = localAddress; this.messageReaders = messageReaders; + this.versionStrategy = versionStrategy; this.exchange = exchange; } @@ -167,6 +172,11 @@ public final class MockServerRequest implements ServerRequest { return this.messageReaders; } + @Override + public @Nullable ApiVersionStrategy apiVersionStrategy() { + return this.versionStrategy; + } + @Override @SuppressWarnings("unchecked") public S body(BodyExtractor extractor) { @@ -313,6 +323,8 @@ public final class MockServerRequest implements ServerRequest { Builder messageReaders(List> messageReaders); + Builder apiVersionStrategy(@Nullable ApiVersionStrategy versionStrategy); + Builder exchange(ServerWebExchange exchange); MockServerRequest body(Object body); @@ -351,6 +363,8 @@ public final class MockServerRequest implements ServerRequest { private List> messageReaders = HandlerStrategies.withDefaults().messageReaders(); + private @Nullable ApiVersionStrategy versionStrategy; + private @Nullable ServerWebExchange exchange; @Override @@ -483,6 +497,12 @@ public final class MockServerRequest implements ServerRequest { return this; } + @Override + public Builder apiVersionStrategy(@Nullable ApiVersionStrategy versionStrategy) { + this.versionStrategy = versionStrategy; + return this; + } + @Override public Builder exchange(ServerWebExchange exchange) { Assert.notNull(exchange, "'exchange' must not be null"); @@ -496,7 +516,7 @@ public final class MockServerRequest implements ServerRequest { return new MockServerRequest(this.method, this.uri, this.contextPath, this.headers, this.cookies, this.body, this.attributes, this.queryParams, this.pathVariables, this.session, this.principal, this.remoteAddress, this.localAddress, - this.messageReaders, this.exchange); + this.messageReaders, this.versionStrategy, this.exchange); } @Override @@ -504,7 +524,7 @@ public final class MockServerRequest implements ServerRequest { return new MockServerRequest(this.method, this.uri, this.contextPath, this.headers, this.cookies, null, this.attributes, this.queryParams, this.pathVariables, this.session, this.principal, this.remoteAddress, this.localAddress, - this.messageReaders, this.exchange); + this.messageReaders, this.versionStrategy, this.exchange); } } diff --git a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java index dff7c6e99af..a85dff6ed3c 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java +++ b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java @@ -91,6 +91,16 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { return this.defaultVersion; } + /** + * Whether the strategy is configured to detect supported versions. + * If this is set to {@code false} then {@link #addMappedVersion} is ignored + * and the list of supported versions can be built explicitly through calls + * to {@link #addSupportedVersion}. + */ + public boolean detectSupportedVersions() { + return this.detectSupportedVersions; + } + /** * Add to the list of supported versions to check against in * {@link ApiVersionStrategy#validateVersion} before raising 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 0b8418a25da..049e3f79083 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 @@ -246,10 +246,13 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware { } @Bean - public RouterFunctionMapping routerFunctionMapping(ServerCodecConfigurer serverCodecConfigurer) { + public RouterFunctionMapping routerFunctionMapping( + ServerCodecConfigurer serverCodecConfigurer, @Nullable ApiVersionStrategy apiVersionStrategy) { + RouterFunctionMapping mapping = createRouterFunctionMapping(); mapping.setOrder(-1); // go before RequestMappingHandlerMapping mapping.setMessageReaders(serverCodecConfigurer.getReaders()); + mapping.setApiVersionStrategy(apiVersionStrategy); configureAbstractHandlerMapping(mapping, getPathMatchConfigurer()); return mapping; } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java index 8722afa7280..1a894f5c30d 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java @@ -57,6 +57,7 @@ import org.springframework.validation.BindException; import org.springframework.validation.BindingResult; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.support.WebExchangeDataBinder; +import org.springframework.web.reactive.accept.ApiVersionStrategy; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.UnsupportedMediaTypeException; @@ -92,10 +93,20 @@ class DefaultServerRequest implements ServerRequest { private final List> messageReaders; + private final @Nullable ApiVersionStrategy versionStrategy; + DefaultServerRequest(ServerWebExchange exchange, List> messageReaders) { + this(exchange, messageReaders, null); + } + + DefaultServerRequest( + ServerWebExchange exchange, List> messageReaders, + @Nullable ApiVersionStrategy versionStrategy) { + this.exchange = exchange; this.messageReaders = List.copyOf(messageReaders); + this.versionStrategy = versionStrategy; this.headers = new DefaultHeaders(); } @@ -162,6 +173,11 @@ class DefaultServerRequest implements ServerRequest { return this.messageReaders; } + @Override + public @Nullable ApiVersionStrategy apiVersionStrategy() { + return this.versionStrategy; + } + @Override public T body(BodyExtractor extractor) { return bodyInternal(extractor, Hints.from(Hints.LOG_PREFIX_HINT, exchange().getLogPrefix())); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequestBuilder.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequestBuilder.java index bf97fd6297a..2a0929f416a 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequestBuilder.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequestBuilder.java @@ -54,6 +54,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; +import org.springframework.web.reactive.accept.ApiVersionStrategy; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebSession; import org.springframework.web.util.UriUtils; @@ -69,6 +70,8 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { private final List> messageReaders; + private final @Nullable ApiVersionStrategy versionStrategy; + private final ServerWebExchange exchange; private HttpMethod method; @@ -89,6 +92,7 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { DefaultServerRequestBuilder(ServerRequest other) { Assert.notNull(other, "ServerRequest must not be null"); this.messageReaders = other.messageReaders(); + this.versionStrategy = other.apiVersionStrategy(); this.exchange = other.exchange(); this.method = other.method(); this.uri = other.uri(); @@ -195,7 +199,7 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { this.method, this.uri, this.contextPath, this.headers, this.cookies, this.body, this.attributes); ServerWebExchange exchange = new DelegatingServerWebExchange( serverHttpRequest, this.attributes, this.exchange, this.messageReaders); - return new DefaultServerRequest(exchange, this.messageReaders); + return new DefaultServerRequest(exchange, this.messageReaders, this.versionStrategy); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java index c8c711c9015..2fd02b0a35a 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java @@ -54,6 +54,8 @@ import org.springframework.util.MimeTypeUtils; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.cors.reactive.CorsUtils; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.accept.ApiVersionStrategy; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebSession; @@ -182,6 +184,25 @@ public abstract class RequestPredicates { } } + /** + * {@code RequestPredicate} to match to the request API version extracted + * from and parsed with the configured {@link ApiVersionStrategy}. + *

The version may be one of the following: + *

    + *
  • Fixed version ("1.2") -- match this version only. + *
  • Baseline version ("1.2+") -- match this and subsequent versions. + *
+ *

A baseline version allows n endpoint route to continue to work in + * subsequent versions if it remains compatible until an incompatible change + * eventually leads to the creation of a new route. + * @param version the version to use + * @return the created predicate instance + * @since 7.0 + */ + public static RequestPredicate version(Object version) { + return new ApiVersionPredicate(version); + } + /** * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code GET} * and the given {@code pattern} matches against the request path. @@ -390,6 +411,14 @@ public abstract class RequestPredicates { */ void queryParam(String name, String value); + /** + * Receive notification of an API version predicate. The version could + * be fixed ("1.2") or baseline ("1.2+"). + * @param version the configured version + * @since 7.0 + */ + void version(String version); + /** * Receive first notification of a logical AND predicate. * The first subsequent notification will contain the left-hand side of the AND-predicate; @@ -831,6 +860,69 @@ public abstract class RequestPredicates { } + private static class ApiVersionPredicate implements RequestPredicate { + + private final String version; + + private final boolean baselineVersion; + + private @Nullable Comparable parsedVersion; + + public ApiVersionPredicate(Object version) { + if (version instanceof String s) { + this.baselineVersion = s.endsWith("+"); + this.version = initVersion(s, this.baselineVersion); + } + else { + this.baselineVersion = false; + this.version = version.toString(); + this.parsedVersion = (Comparable) version; + } + } + + private static String initVersion(String version, boolean baselineVersion) { + return (baselineVersion ? version.substring(0, version.length() - 1) : version); + } + + @Override + public boolean test(ServerRequest request) { + if (this.parsedVersion == null) { + ApiVersionStrategy strategy = request.apiVersionStrategy(); + Assert.state(strategy != null, "No ApiVersionStrategy to parse version with"); + this.parsedVersion = strategy.parseVersion(this.version); + } + + Comparable requestVersion = + (Comparable) request.attribute(HandlerMapping.API_VERSION_ATTRIBUTE).orElse(null); + + if (requestVersion == null) { + traceMatch("Version", this.version, null, true); + return true; + } + + int result = compareVersions(this.parsedVersion, requestVersion); + boolean match = (this.baselineVersion ? result <= 0 : result == 0); + traceMatch("Version", this.version, requestVersion, match); + return match; + } + + @SuppressWarnings("unchecked") + private > int compareVersions(Object v1, Object v2) { + return ((V) v1).compareTo((V) v2); + } + + @Override + public void accept(Visitor visitor) { + visitor.version(this.version + (this.baselineVersion ? "+" : "")); + } + + @Override + public String toString() { + return this.version; + } + } + + @Deprecated(since = "7.0", forRemoval = true) private static class PathExtensionPredicate implements RequestPredicate { @@ -1189,6 +1281,11 @@ public abstract class RequestPredicates { return this.delegate.messageReaders(); } + @Override + public @Nullable ApiVersionStrategy apiVersionStrategy() { + return this.delegate.apiVersionStrategy(); + } + @Override public T body(BodyExtractor extractor) { return this.delegate.body(extractor); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java index c401d6aa18d..d6b581c24cb 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java @@ -49,6 +49,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.validation.BindException; import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.reactive.accept.ApiVersionStrategy; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebSession; @@ -130,6 +131,12 @@ public interface ServerRequest { */ List> messageReaders(); + /** + * Return the configured {@link ApiVersionStrategy}, or {@code null}. + * @since 7.0 + */ + @Nullable ApiVersionStrategy apiVersionStrategy(); + /** * Extract the body with the given {@code BodyExtractor}. * @param extractor the {@code BodyExtractor} that reads from the request @@ -424,6 +431,23 @@ public interface ServerRequest { return new DefaultServerRequest(exchange, messageReaders); } + /** + * Create a new {@code ServerRequest} based on the given {@code ServerWebExchange} and + * message readers. + * @param exchange the exchange + * @param messageReaders the message readers + * @param versionStrategy a strategy to use to parse version + * @return the created {@code ServerRequest} + * @since 7.0 + */ + static ServerRequest create( + ServerWebExchange exchange, List> messageReaders, + @Nullable ApiVersionStrategy versionStrategy) { + + return new DefaultServerRequest(exchange, messageReaders, versionStrategy); + } + + /** * Create a builder with the {@linkplain HttpMessageReader message readers}, * method name, URI, headers, cookies, and attributes of the given request. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ToStringVisitor.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ToStringVisitor.java index 051ed26391e..bd52b4c407b 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ToStringVisitor.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ToStringVisitor.java @@ -119,6 +119,11 @@ class ToStringVisitor implements RouterFunctions.Visitor, RequestPredicates.Visi this.builder.append(String.format("?%s == %s", name, value)); } + @Override + public void version(String version) { + this.builder.append(String.format("version: %s", version)); + } + @Override public void startAnd() { this.builder.append('('); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/RouterFunctionMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/RouterFunctionMapping.java index 1e4b1628532..97a27124b4d 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/RouterFunctionMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/RouterFunctionMapping.java @@ -29,6 +29,8 @@ import org.springframework.http.codec.HttpMessageReader; import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.http.server.reactive.observation.ServerRequestObservationContext; import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.accept.ApiVersionStrategy; +import org.springframework.web.reactive.accept.DefaultApiVersionStrategy; import org.springframework.web.reactive.function.server.HandlerFunction; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; @@ -54,6 +56,8 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini private List> messageReaders = Collections.emptyList(); + private @Nullable ApiVersionStrategy versionStrategy; + /** * Create an empty {@code RouterFunctionMapping}. @@ -92,6 +96,16 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini this.messageReaders = messageReaders; } + /** + * Configure a strategy to manage API versioning. + * @param strategy the strategy to use + * @since 7.0 + */ + public void setApiVersionStrategy(@Nullable ApiVersionStrategy strategy) { + this.versionStrategy = strategy; + } + + @Override public void afterPropertiesSet() throws Exception { if (CollectionUtils.isEmpty(this.messageReaders)) { @@ -102,8 +116,14 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini if (this.routerFunction == null) { initRouterFunctions(); } + if (this.routerFunction != null) { RouterFunctions.changeParser(this.routerFunction, getPathPatternParser()); + if (this.versionStrategy instanceof DefaultApiVersionStrategy davs) { + if (davs.detectSupportedVersions()) { + this.routerFunction.accept(new SupportedVersionVisitor(davs)); + } + } } } @@ -149,14 +169,26 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini @Override protected Mono getHandlerInternal(ServerWebExchange exchange) { - if (this.routerFunction != null) { - ServerRequest request = ServerRequest.create(exchange, this.messageReaders); - return this.routerFunction.route(request) - .doOnNext(handler -> setAttributes(exchange.getAttributes(), request, handler)); - } - else { + + if (this.routerFunction == null) { return Mono.empty(); } + + if (this.versionStrategy != null) { + Comparable version = exchange.getAttribute(API_VERSION_ATTRIBUTE); + if (version == null) { + version = this.versionStrategy.resolveParseAndValidateVersion(exchange); + if (version != null) { + exchange.getAttributes().put(API_VERSION_ATTRIBUTE, version); + } + } + } + + ServerRequest request = ServerRequest.create( + exchange, this.messageReaders, this.versionStrategy); + + return this.routerFunction.route(request) + .doOnNext(handler -> setAttributes(exchange.getAttributes(), request, handler)); } @SuppressWarnings("unchecked") diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerRequestWrapper.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerRequestWrapper.java index 0b34faa7153..9c5b92d0ae7 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerRequestWrapper.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/ServerRequestWrapper.java @@ -44,6 +44,7 @@ import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.reactive.accept.ApiVersionStrategy; import org.springframework.web.reactive.function.BodyExtractor; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.server.ServerWebExchange; @@ -131,6 +132,11 @@ public class ServerRequestWrapper implements ServerRequest { return this.delegate.messageReaders(); } + @Override + public @Nullable ApiVersionStrategy apiVersionStrategy() { + return this.delegate.apiVersionStrategy(); + } + @Override public T body(BodyExtractor extractor) { return this.delegate.body(extractor); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/SupportedVersionVisitor.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/SupportedVersionVisitor.java new file mode 100644 index 00000000000..f16823f9445 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/SupportedVersionVisitor.java @@ -0,0 +1,146 @@ +/* + * Copyright 2002-present 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.function.server.support; + +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import reactor.core.publisher.Mono; + +import org.springframework.core.io.Resource; +import org.springframework.http.HttpMethod; +import org.springframework.web.reactive.accept.DefaultApiVersionStrategy; +import org.springframework.web.reactive.function.server.HandlerFunction; +import org.springframework.web.reactive.function.server.RequestPredicate; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; + +/** + * {@link RequestPredicates.Visitor} that discovers versions used in routes in + * order to add them to the list of supported versions. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +final class SupportedVersionVisitor implements RouterFunctions.Visitor, RequestPredicates.Visitor { + + private final DefaultApiVersionStrategy versionStrategy; + + + SupportedVersionVisitor(DefaultApiVersionStrategy versionStrategy) { + this.versionStrategy = versionStrategy; + } + + + // RouterFunctions.Visitor + + @Override + public void startNested(RequestPredicate predicate) { + predicate.accept(this); + } + + @Override + public void endNested(RequestPredicate predicate) { + } + + @Override + public void route(RequestPredicate predicate, HandlerFunction handlerFunction) { + predicate.accept(this); + } + + @Override + public void resources(Function> lookupFunction) { + } + + @Override + public void attributes(Map attributes) { + } + + @Override + public void unknown(RouterFunction routerFunction) { + } + + + // RequestPredicates.Visitor + + @Override + public void method(Set methods) { + } + + @Override + public void path(String pattern) { + } + + @SuppressWarnings("removal") + @Override + public void pathExtension(String extension) { + } + + @Override + public void header(String name, String value) { + } + + @Override + public void queryParam(String name, String value) { + } + + @Override + public void version(String version) { + this.versionStrategy.addMappedVersion( + (version.endsWith("+") ? version.substring(0, version.length() - 1) : version)); + } + + @Override + public void startAnd() { + } + + @Override + public void and() { + } + + @Override + public void endAnd() { + } + + @Override + public void startOr() { + } + + @Override + public void or() { + } + + @Override + public void endOr() { + } + + @Override + public void startNegate() { + } + + @Override + public void endNegate() { + } + + @Override + public void unknown(RequestPredicate predicate) { + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java index e8f729cdff9..7f73b5e121a 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java @@ -26,6 +26,10 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.web.accept.SemanticApiVersionParser; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.accept.ApiVersionStrategy; +import org.springframework.web.reactive.accept.DefaultApiVersionStrategy; import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; import org.springframework.web.testfixture.server.MockServerWebExchange; import org.springframework.web.util.pattern.PathPatternParser; @@ -359,4 +363,24 @@ class RequestPredicatesTests { assertThat(predicate.test(request)).isFalse(); } + @Test + void version() { + assertThat(RequestPredicates.version("1.1").test(serverRequest("1.1"))).isTrue(); + assertThat(RequestPredicates.version("1.1+").test(serverRequest("1.5"))).isTrue(); + assertThat(RequestPredicates.version("1.1").test(serverRequest("1.5"))).isFalse(); + } + + private static DefaultServerRequest serverRequest(String version) { + ApiVersionStrategy versionStrategy = apiVersionStrategy(); + Comparable parsedVersion = versionStrategy.parseVersion(version); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("https://localhost")); + exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, parsedVersion); + return new DefaultServerRequest(exchange, Collections.emptyList(), versionStrategy); + } + + private static DefaultApiVersionStrategy apiVersionStrategy() { + return new DefaultApiVersionStrategy( + List.of(exchange -> null), new SemanticApiVersionParser(), true, null, false, null); + } + } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/support/RouterFunctionMappingVersionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/support/RouterFunctionMappingVersionTests.java new file mode 100644 index 00000000000..b3d6cff6635 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/support/RouterFunctionMappingVersionTests.java @@ -0,0 +1,110 @@ +/* + * Copyright 2002-present 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.function.server.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +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.reactive.function.server.HandlerFunction; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.testfixture.server.MockServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.web.reactive.function.server.RequestPredicates.version; + +/** + * {@link RouterFunctionMapping} integration tests for API versioning. + * @author Rossen Stoyanchev + */ +public class RouterFunctionMappingVersionTests { + + private RouterFunctionMapping mapping; + + + @BeforeEach + void setUp() { + AnnotationConfigApplicationContext wac = new AnnotationConfigApplicationContext(); + wac.register(WebConfig.class); + wac.refresh(); + + this.mapping = wac.getBean(RouterFunctionMapping.class); + } + + + @Test + void mapVersion() { + testGetHandler("1.0", "none"); + testGetHandler("1.1", "none"); + testGetHandler("1.2", "1.2"); + testGetHandler("1.3", "1.2"); + testGetHandler("1.5", "1.5"); + } + + + private void testGetHandler(String version, String expectedBody) { + + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/").header("X-API-Version", version)); + + Mono result = this.mapping.getHandler(exchange); + + StepVerifier.create(result) + .consumeNextWith(handler -> assertThat(((TestHandler) handler).body()).isEqualTo(expectedBody)) + .verifyComplete(); + } + + + @EnableWebFlux + private static class WebConfig implements WebFluxConfigurer { + + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + configurer.useRequestHeader("X-API-Version").addSupportedVersions("1", "1.1", "1.3"); + } + + @Bean + RouterFunction routerFunction() { + return RouterFunctions.route() + .path("/", builder -> builder + .GET(version("1.5"), new TestHandler("1.5")) + .GET(version("1.2+"), new TestHandler("1.2")) + .GET(new TestHandler("none"))) + .build(); + } + } + + + private record TestHandler(String body) implements HandlerFunction { + + @Override + public Mono handle(ServerRequest request) { + return ServerResponse.ok().bodyValue(body); + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java index cabcd4d104f..7f6c110a5a5 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java @@ -544,13 +544,15 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv @Bean public RouterFunctionMapping routerFunctionMapping( @Qualifier("mvcConversionService") FormattingConversionService conversionService, - @Qualifier("mvcResourceUrlProvider") ResourceUrlProvider resourceUrlProvider) { + @Qualifier("mvcResourceUrlProvider") ResourceUrlProvider resourceUrlProvider, + @Qualifier("mvcApiVersionStrategy") @Nullable ApiVersionStrategy versionStrategy) { RouterFunctionMapping mapping = new RouterFunctionMapping(); mapping.setOrder(-1); // go before RequestMappingHandlerMapping mapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider)); mapping.setCorsConfigurations(getCorsConfigurations()); mapping.setMessageConverters(getMessageConverters()); + mapping.setApiVersionStrategy(versionStrategy); PathPatternParser patternParser = getPathMatchConfigurer().getPatternParser(); if (patternParser != null) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java index 593740a256c..ca4e100bab8 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java @@ -71,6 +71,7 @@ import org.springframework.util.ObjectUtils; import org.springframework.validation.BindException; import org.springframework.validation.BindingResult; import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.bind.ServletRequestDataBinder; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.context.request.ServletWebRequest; @@ -97,6 +98,8 @@ class DefaultServerRequest implements ServerRequest { private final List> messageConverters; + private final @Nullable ApiVersionStrategy versionStrategy; + private final MultiValueMap params; private final Map attributes; @@ -105,8 +108,16 @@ class DefaultServerRequest implements ServerRequest { public DefaultServerRequest(HttpServletRequest servletRequest, List> messageConverters) { + this(servletRequest, messageConverters, null); + } + + public DefaultServerRequest( + HttpServletRequest servletRequest, List> messageConverters, + @Nullable ApiVersionStrategy versionStrategy) { + this.serverHttpRequest = new ServletServerHttpRequest(servletRequest); this.messageConverters = List.copyOf(messageConverters); + this.versionStrategy = versionStrategy; this.headers = new DefaultRequestHeaders(this.serverHttpRequest.getHeaders()); this.params = CollectionUtils.toMultiValueMap(new ServletParametersMap(servletRequest)); @@ -172,6 +183,11 @@ class DefaultServerRequest implements ServerRequest { return this.messageConverters; } + @Override + public @Nullable ApiVersionStrategy apiVersionStrategy() { + return this.versionStrategy; + } + @Override public T body(Class bodyType) throws IOException, ServletException { return bodyInternal(bodyType, bodyType); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java index 807d2a09624..452f0b88ff2 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequestBuilder.java @@ -57,6 +57,7 @@ import org.springframework.util.MultiValueMap; import org.springframework.validation.BindException; import org.springframework.validation.BindingResult; import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.bind.ServletRequestDataBinder; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.util.UriBuilder; @@ -74,6 +75,8 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { private final List> messageConverters; + private final @Nullable ApiVersionStrategy versionStrategy; + private HttpMethod method; private URI uri; @@ -95,6 +98,7 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { Assert.notNull(other, "ServerRequest must not be null"); this.servletRequest = other.servletRequest(); this.messageConverters = new ArrayList<>(other.messageConverters()); + this.versionStrategy = other.apiVersionStrategy(); this.method = other.method(); this.uri = other.uri(); headers(headers -> headers.addAll(other.headers().asHttpHeaders())); @@ -203,7 +207,8 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { @Override public ServerRequest build() { return new BuiltServerRequest(this.servletRequest, this.method, this.uri, this.headers, this.cookies, - this.attributes, this.params, this.remoteAddress, this.body, this.messageConverters); + this.attributes, this.params, this.remoteAddress, this.body, + this.messageConverters, this.versionStrategy); } @@ -225,6 +230,8 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { private final List> messageConverters; + private final @Nullable ApiVersionStrategy versionStrategy; + private final MultiValueMap params; private final @Nullable InetSocketAddress remoteAddress; @@ -232,7 +239,9 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { public BuiltServerRequest(HttpServletRequest servletRequest, HttpMethod method, URI uri, HttpHeaders headers, MultiValueMap cookies, Map attributes, MultiValueMap params, - @Nullable InetSocketAddress remoteAddress, byte[] body, List> messageConverters) { + @Nullable InetSocketAddress remoteAddress, byte[] body, + List> messageConverters, + @Nullable ApiVersionStrategy versionStrategy) { this.servletRequest = servletRequest; this.method = method; @@ -244,6 +253,7 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { this.remoteAddress = remoteAddress; this.body = body; this.messageConverters = messageConverters; + this.versionStrategy = versionStrategy; } @Override @@ -289,6 +299,11 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder { return this.messageConverters; } + @Override + public @Nullable ApiVersionStrategy apiVersionStrategy() { + return this.versionStrategy; + } + @Override public T body(Class bodyType) throws IOException, ServletException { return bodyInternal(bodyType, bodyType); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java index 901d15e373e..3cf40bd24ae 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java @@ -54,8 +54,10 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.MimeTypeUtils; import org.springframework.util.MultiValueMap; import org.springframework.validation.BindException; +import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.cors.CorsUtils; +import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.util.UriBuilder; import org.springframework.web.util.UriUtils; import org.springframework.web.util.pattern.PathPattern; @@ -181,6 +183,25 @@ public abstract class RequestPredicates { } } + /** + * {@code RequestPredicate} to match to the request API version extracted + * from and parsed with the configured {@link ApiVersionStrategy}. + *

The version may be one of the following: + *

    + *
  • Fixed version ("1.2") -- match this version only. + *
  • Baseline version ("1.2+") -- match this and subsequent versions. + *
+ *

A baseline version allows n endpoint route to continue to work in + * subsequent versions if it remains compatible until an incompatible change + * eventually leads to the creation of a new route. + * @param version the version to use + * @return the created predicate instance + * @since 7.0 + */ + public static RequestPredicate version(Object version) { + return new ApiVersionPredicate(version); + } + /** * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code GET} * and the given {@code pattern} matches against the request path. @@ -388,6 +409,14 @@ public abstract class RequestPredicates { */ void param(String name, String value); + /** + * Receive notification of an API version predicate. The version could + * be fixed ("1.2") or baseline ("1.2+"). + * @param version the configured version + * @since 7.0 + */ + void version(String version); + /** * Receive first notification of a logical AND predicate. * The first subsequent notification will contain the left-hand side of the AND-predicate; @@ -829,6 +858,69 @@ public abstract class RequestPredicates { } + private static class ApiVersionPredicate implements RequestPredicate { + + private final String version; + + private final boolean baselineVersion; + + private @Nullable Comparable parsedVersion; + + public ApiVersionPredicate(Object version) { + if (version instanceof String s) { + this.baselineVersion = s.endsWith("+"); + this.version = initVersion(s, this.baselineVersion); + } + else { + this.baselineVersion = false; + this.version = version.toString(); + this.parsedVersion = (Comparable) version; + } + } + + private static String initVersion(String version, boolean baselineVersion) { + return (baselineVersion ? version.substring(0, version.length() - 1) : version); + } + + @Override + public boolean test(ServerRequest request) { + if (this.parsedVersion == null) { + ApiVersionStrategy strategy = request.apiVersionStrategy(); + Assert.state(strategy != null, "No ApiVersionStrategy to parse version with"); + this.parsedVersion = strategy.parseVersion(this.version); + } + + Comparable requestVersion = + (Comparable) request.attribute(HandlerMapping.API_VERSION_ATTRIBUTE).orElse(null); + + if (requestVersion == null) { + traceMatch("Version", this.version, null, true); + return true; + } + + int result = compareVersions(this.parsedVersion, requestVersion); + boolean match = (this.baselineVersion ? result <= 0 : result == 0); + traceMatch("Version", this.version, requestVersion, match); + return match; + } + + @SuppressWarnings("unchecked") + private > int compareVersions(Object v1, Object v2) { + return ((V) v1).compareTo((V) v2); + } + + @Override + public void accept(Visitor visitor) { + visitor.version(this.version + (this.baselineVersion ? "+" : "")); + } + + @Override + public String toString() { + return this.version; + } + } + + @Deprecated(since = "7.0", forRemoval = true) private static class PathExtensionPredicate implements RequestPredicate { @@ -1182,6 +1274,11 @@ public abstract class RequestPredicates { return this.delegate.messageConverters(); } + @Override + public @Nullable ApiVersionStrategy apiVersionStrategy() { + return this.delegate.apiVersionStrategy(); + } + @Override public T body(Class bodyType) throws ServletException, IOException { return this.delegate.body(bodyType); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java index 9e131a89fb4..fc91ee457e6 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerRequest.java @@ -48,6 +48,7 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.validation.BindException; +import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.util.ServletRequestPathUtils; import org.springframework.web.util.UriBuilder; @@ -116,6 +117,13 @@ public interface ServerRequest { */ List> messageConverters(); + /** + * Return the configured {@link ApiVersionStrategy}, or {@code null}. + * @since 7.0 + */ + @Nullable + ApiVersionStrategy apiVersionStrategy(); + /** * Extract the body as an object of the given type. * @param bodyType the type of return value @@ -373,6 +381,22 @@ public interface ServerRequest { return new DefaultServerRequest(servletRequest, messageReaders); } + /** + * Create a new {@code ServerRequest} based on the given {@code HttpServletRequest} and + * message converters. + * @param servletRequest the request + * @param messageReaders the message readers + * @param versionStrategy a strategy to use to parse version + * @return the created {@code ServerRequest} + * @since 7.0 + */ + static ServerRequest create( + HttpServletRequest servletRequest, List> messageReaders, + @Nullable ApiVersionStrategy versionStrategy) { + + return new DefaultServerRequest(servletRequest, messageReaders, versionStrategy); + } + /** * Create a builder with the status, headers, and cookies of the given request. * @param other the response to copy the status, headers, and cookies from diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ToStringVisitor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ToStringVisitor.java index 598d35d8f13..a0d3f9638eb 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ToStringVisitor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ToStringVisitor.java @@ -118,6 +118,11 @@ class ToStringVisitor implements RouterFunctions.Visitor, RequestPredicates.Visi this.builder.append(String.format("?%s == %s", name, value)); } + @Override + public void version(String version) { + this.builder.append(String.format("version: %s", version)); + } + @Override public void startAnd() { this.builder.append('('); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java index 7aaabe16c58..d7e0979c0dc 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java @@ -31,6 +31,8 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; import org.springframework.util.CollectionUtils; +import org.springframework.web.accept.ApiVersionStrategy; +import org.springframework.web.accept.DefaultApiVersionStrategy; import org.springframework.web.filter.ServerHttpObservationFilter; import org.springframework.web.servlet.function.HandlerFunction; import org.springframework.web.servlet.function.RouterFunction; @@ -62,6 +64,8 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini private List> messageConverters = Collections.emptyList(); + private @Nullable ApiVersionStrategy versionStrategy; + private boolean detectHandlerFunctionsInAncestorContexts = false; @@ -110,6 +114,15 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini this.messageConverters = messageConverters; } + /** + * Configure a strategy to manage API versioning. + * @param strategy the strategy to use + * @since 7.0 + */ + public void setApiVersionStrategy(@Nullable ApiVersionStrategy strategy) { + this.versionStrategy = strategy; + } + /** * Set whether to detect handler functions in ancestor ApplicationContexts. *

Default is "false": Only handler functions in the current ApplicationContext @@ -128,9 +141,11 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini if (this.routerFunction == null) { initRouterFunctions(); } + if (CollectionUtils.isEmpty(this.messageConverters)) { initMessageConverters(); } + if (this.routerFunction != null) { PathPatternParser patternParser = getPatternParser(); if (patternParser == null) { @@ -138,6 +153,12 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini setPatternParser(patternParser); } RouterFunctions.changeParser(this.routerFunction, patternParser); + + if (this.versionStrategy instanceof DefaultApiVersionStrategy davs) { + if (davs.detectSupportedVersions()) { + this.routerFunction.accept(new SupportedVersionVisitor(davs)); + } + } } } @@ -197,15 +218,27 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini @Override protected @Nullable Object getHandlerInternal(HttpServletRequest servletRequest) throws Exception { - if (this.routerFunction != null) { - ServerRequest request = ServerRequest.create(servletRequest, this.messageConverters); - HandlerFunction handlerFunction = this.routerFunction.route(request).orElse(null); - setAttributes(servletRequest, request, handlerFunction); - return handlerFunction; - } - else { + + if (this.routerFunction == null) { return null; } + + if (this.versionStrategy != null) { + Comparable version = (Comparable) servletRequest.getAttribute(API_VERSION_ATTRIBUTE); + if (version == null) { + version = this.versionStrategy.resolveParseAndValidateVersion(servletRequest); + if (version != null) { + servletRequest.setAttribute(API_VERSION_ATTRIBUTE, version); + } + } + } + + ServerRequest request = + ServerRequest.create(servletRequest, this.messageConverters, this.versionStrategy); + + HandlerFunction handlerFunction = this.routerFunction.route(request).orElse(null); + setAttributes(servletRequest, request, handlerFunction); + return handlerFunction; } private void setAttributes(HttpServletRequest servletRequest, ServerRequest request, diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/SupportedVersionVisitor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/SupportedVersionVisitor.java new file mode 100644 index 00000000000..8c2eb43c4d7 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/SupportedVersionVisitor.java @@ -0,0 +1,146 @@ +/* + * Copyright 2002-present 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.servlet.function.support; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.core.io.Resource; +import org.springframework.http.HttpMethod; +import org.springframework.web.accept.DefaultApiVersionStrategy; +import org.springframework.web.servlet.function.HandlerFunction; +import org.springframework.web.servlet.function.RequestPredicate; +import org.springframework.web.servlet.function.RequestPredicates; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.RouterFunctions; +import org.springframework.web.servlet.function.ServerRequest; + +/** + * {@link RequestPredicates.Visitor} that discovers versions used in routes in + * order to add them to the list of supported versions. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +final class SupportedVersionVisitor implements RouterFunctions.Visitor, RequestPredicates.Visitor { + + private final DefaultApiVersionStrategy versionStrategy; + + + SupportedVersionVisitor(DefaultApiVersionStrategy versionStrategy) { + this.versionStrategy = versionStrategy; + } + + + // RouterFunctions.Visitor + + @Override + public void startNested(RequestPredicate predicate) { + predicate.accept(this); + } + + @Override + public void endNested(RequestPredicate predicate) { + } + + @Override + public void route(RequestPredicate predicate, HandlerFunction handlerFunction) { + predicate.accept(this); + } + + @Override + public void resources(Function> lookupFunction) { + } + + @Override + public void attributes(Map attributes) { + } + + @Override + public void unknown(RouterFunction routerFunction) { + } + + + // RequestPredicates.Visitor + + @Override + public void method(Set methods) { + } + + @Override + public void path(String pattern) { + } + + @SuppressWarnings("removal") + @Override + public void pathExtension(String extension) { + } + + @Override + public void header(String name, String value) { + } + + @Override + public void param(String name, String value) { + + } + + @Override + public void version(String version) { + this.versionStrategy.addMappedVersion( + (version.endsWith("+") ? version.substring(0, version.length() - 1) : version)); + } + + @Override + public void startAnd() { + } + + @Override + public void and() { + } + + @Override + public void endAnd() { + } + + @Override + public void startOr() { + } + + @Override + public void or() { + } + + @Override + public void endOr() { + } + + @Override + public void startNegate() { + } + + @Override + public void endNegate() { + } + + @Override + public void unknown(RequestPredicate predicate) { + } + +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java index c94e4239408..508ca3b46ab 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java @@ -27,11 +27,15 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.web.accept.ApiVersionStrategy; +import org.springframework.web.accept.DefaultApiVersionStrategy; +import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.servlet.handler.PathPatternsTestUtils; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import org.springframework.web.util.pattern.PathPatternParser; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.web.servlet.HandlerMapping.API_VERSION_ATTRIBUTE; /** * @author Arjen Poutsma @@ -266,12 +270,30 @@ class RequestPredicatesTests { assertThat(predicate.test(request)).isFalse(); } + @Test + void version() { + assertThat(RequestPredicates.version("1.1").test(serverRequest("1.1"))).isTrue(); + assertThat(RequestPredicates.version("1.1+").test(serverRequest("1.5"))).isTrue(); + assertThat(RequestPredicates.version("1.1").test(serverRequest("1.5"))).isFalse(); + } + + private static ServerRequest serverRequest(String version) { + + ApiVersionStrategy strategy = new DefaultApiVersionStrategy( + List.of(exchange -> null), new SemanticApiVersionParser(), true, null, false, null); + + MockHttpServletRequest servletRequest = + PathPatternsTestUtils.initRequest("GET", null, "/path", true, + req -> req.setAttribute(API_VERSION_ATTRIBUTE, strategy.parseVersion(version))); + + return new DefaultServerRequest(servletRequest, Collections.emptyList(), strategy); + } - private ServerRequest initRequest(String httpMethod, String requestUri) { + private static ServerRequest initRequest(String httpMethod, String requestUri) { return initRequest(httpMethod, requestUri, null); } - private ServerRequest initRequest( + private static ServerRequest initRequest( String httpMethod, String requestUri, @Nullable Consumer initializer) { return new DefaultServerRequest( diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingVersionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingVersionTests.java new file mode 100644 index 00000000000..093389d9a91 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingVersionTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2002-present 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.servlet.function.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.function.HandlerFunction; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.RouterFunctions; +import org.springframework.web.servlet.function.ServerRequest; +import org.springframework.web.servlet.function.ServerResponse; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockServletContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.web.servlet.function.RequestPredicates.version; + +/** + * {@link RouterFunctionMapping} integration tests for API versioning. + * @author Rossen Stoyanchev + */ +public class RouterFunctionMappingVersionTests { + + private final MockServletContext servletContext = new MockServletContext(); + + private RouterFunctionMapping mapping; + + + @BeforeEach + void setUp() { + AnnotationConfigWebApplicationContext wac = new AnnotationConfigWebApplicationContext(); + wac.setServletContext(this.servletContext); + wac.register(WebConfig.class); + wac.refresh(); + + this.mapping = wac.getBean(RouterFunctionMapping.class); + } + + + @Test + void mapVersion() throws Exception { + testGetHandler("1.0", "none"); + testGetHandler("1.1", "none"); + testGetHandler("1.2", "1.2"); + testGetHandler("1.3", "1.2"); + testGetHandler("1.5", "1.5"); + } + + + private void testGetHandler(String version, String expectedBody) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/"); + request.addHeader("X-API-Version", version); + HandlerFunction handler = (HandlerFunction) this.mapping.getHandler(request).getHandler(); + assertThat(((TestHandler) handler).body()).isEqualTo(expectedBody); + } + + + @EnableWebMvc + private static class WebConfig implements WebMvcConfigurer { + + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + configurer.useRequestHeader("X-API-Version").addSupportedVersions("1", "1.1", "1.3"); + } + + @Bean + RouterFunction routerFunction() { + return RouterFunctions.route() + .path("/", builder -> builder + .GET(version("1.5"), new TestHandler("1.5")) + .GET(version("1.2+"), new TestHandler("1.2")) + .GET(new TestHandler("none"))) + .build(); + } + } + + + private record TestHandler(String body) implements HandlerFunction { + + @Override + public ServerResponse handle(ServerRequest request) { + return ServerResponse.ok().body(body); + } + } + +}