Support for functional routing by API version

See gh-35113
This commit is contained in:
rstoyanchev 2025-06-27 16:22:18 +01:00
parent 224f1af08e
commit 5a6c019413
23 changed files with 986 additions and 23 deletions

View File

@ -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<HttpMessageReader<?>> messageReaders;
private final @Nullable ApiVersionStrategy versionStrategy;
private final @Nullable ServerWebExchange exchange;
@ -102,7 +105,8 @@ public final class MockServerRequest implements ServerRequest {
Map<String, Object> attributes, MultiValueMap<String, String> queryParams,
Map<String, String> pathVariables, @Nullable WebSession session, @Nullable Principal principal,
@Nullable InetSocketAddress remoteAddress, @Nullable InetSocketAddress localAddress,
List<HttpMessageReader<?>> messageReaders, @Nullable ServerWebExchange exchange) {
List<HttpMessageReader<?>> 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> S body(BodyExtractor<S, ? super ServerHttpRequest> extractor) {
@ -313,6 +323,8 @@ public final class MockServerRequest implements ServerRequest {
Builder messageReaders(List<HttpMessageReader<?>> 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<HttpMessageReader<?>> 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);
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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<HttpMessageReader<?>> messageReaders;
private final @Nullable ApiVersionStrategy versionStrategy;
DefaultServerRequest(ServerWebExchange exchange, List<HttpMessageReader<?>> messageReaders) {
this(exchange, messageReaders, null);
}
DefaultServerRequest(
ServerWebExchange exchange, List<HttpMessageReader<?>> 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> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) {
return bodyInternal(extractor, Hints.from(Hints.LOG_PREFIX_HINT, exchange().getLogPrefix()));

View File

@ -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<HttpMessageReader<?>> 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);
}

View File

@ -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}.
* <p>The version may be one of the following:
* <ul>
* <li>Fixed version ("1.2") -- match this version only.
* <li>Baseline version ("1.2+") -- match this and subsequent versions.
* </ul>
* <p>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 <V extends Comparable<V>> 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> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) {
return this.delegate.body(extractor);

View File

@ -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<HttpMessageReader<?>> 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<HttpMessageReader<?>> 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.

View File

@ -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('(');

View File

@ -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<HttpMessageReader<?>> 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")

View File

@ -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> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) {
return this.delegate.body(extractor);

View File

@ -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<ServerRequest, Mono<Resource>> lookupFunction) {
}
@Override
public void attributes(Map<String, Object> attributes) {
}
@Override
public void unknown(RouterFunction<?> routerFunction) {
}
// RequestPredicates.Visitor
@Override
public void method(Set<HttpMethod> 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) {
}
}

View File

@ -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);
}
}

View File

@ -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<ServerResponse> {
@Override
public Mono<ServerResponse> handle(ServerRequest request) {
return ServerResponse.ok().bodyValue(body);
}
}
}

View File

@ -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) {

View File

@ -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<HttpMessageConverter<?>> messageConverters;
private final @Nullable ApiVersionStrategy versionStrategy;
private final MultiValueMap<String, String> params;
private final Map<String, Object> attributes;
@ -105,8 +108,16 @@ class DefaultServerRequest implements ServerRequest {
public DefaultServerRequest(HttpServletRequest servletRequest, List<HttpMessageConverter<?>> messageConverters) {
this(servletRequest, messageConverters, null);
}
public DefaultServerRequest(
HttpServletRequest servletRequest, List<HttpMessageConverter<?>> 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> T body(Class<T> bodyType) throws IOException, ServletException {
return bodyInternal(bodyType, bodyType);

View File

@ -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<HttpMessageConverter<?>> 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<HttpMessageConverter<?>> messageConverters;
private final @Nullable ApiVersionStrategy versionStrategy;
private final MultiValueMap<String, String> 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<String, Cookie> cookies,
Map<String, Object> attributes, MultiValueMap<String, String> params,
@Nullable InetSocketAddress remoteAddress, byte[] body, List<HttpMessageConverter<?>> messageConverters) {
@Nullable InetSocketAddress remoteAddress, byte[] body,
List<HttpMessageConverter<?>> 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> T body(Class<T> bodyType) throws IOException, ServletException {
return bodyInternal(bodyType, bodyType);

View File

@ -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}.
* <p>The version may be one of the following:
* <ul>
* <li>Fixed version ("1.2") -- match this version only.
* <li>Baseline version ("1.2+") -- match this and subsequent versions.
* </ul>
* <p>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 <V extends Comparable<V>> 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> T body(Class<T> bodyType) throws ServletException, IOException {
return this.delegate.body(bodyType);

View File

@ -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<HttpMessageConverter<?>> 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<HttpMessageConverter<?>> 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

View File

@ -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('(');

View File

@ -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<HttpMessageConverter<?>> 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.
* <p>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,

View File

@ -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<ServerRequest, Optional<Resource>> lookupFunction) {
}
@Override
public void attributes(Map<String, Object> attributes) {
}
@Override
public void unknown(RouterFunction<?> routerFunction) {
}
// RequestPredicates.Visitor
@Override
public void method(Set<HttpMethod> 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) {
}
}

View File

@ -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 ServerRequest initRequest(String httpMethod, String requestUri) {
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 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<MockHttpServletRequest> initializer) {
return new DefaultServerRequest(

View File

@ -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<ServerResponse> {
@Override
public ServerResponse handle(ServerRequest request) {
return ServerResponse.ok().body(body);
}
}
}