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.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.WebDataBinder; 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.BodyExtractor;
import org.springframework.web.reactive.function.server.HandlerStrategies; import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.ServerRequest; 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 List<HttpMessageReader<?>> messageReaders;
private final @Nullable ApiVersionStrategy versionStrategy;
private final @Nullable ServerWebExchange exchange; 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, Object> attributes, MultiValueMap<String, String> queryParams,
Map<String, String> pathVariables, @Nullable WebSession session, @Nullable Principal principal, Map<String, String> pathVariables, @Nullable WebSession session, @Nullable Principal principal,
@Nullable InetSocketAddress remoteAddress, @Nullable InetSocketAddress localAddress, @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.method = method;
this.uri = uri; this.uri = uri;
@ -118,6 +122,7 @@ public final class MockServerRequest implements ServerRequest {
this.remoteAddress = remoteAddress; this.remoteAddress = remoteAddress;
this.localAddress = localAddress; this.localAddress = localAddress;
this.messageReaders = messageReaders; this.messageReaders = messageReaders;
this.versionStrategy = versionStrategy;
this.exchange = exchange; this.exchange = exchange;
} }
@ -167,6 +172,11 @@ public final class MockServerRequest implements ServerRequest {
return this.messageReaders; return this.messageReaders;
} }
@Override
public @Nullable ApiVersionStrategy apiVersionStrategy() {
return this.versionStrategy;
}
@Override @Override
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <S> S body(BodyExtractor<S, ? super ServerHttpRequest> extractor) { 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 messageReaders(List<HttpMessageReader<?>> messageReaders);
Builder apiVersionStrategy(@Nullable ApiVersionStrategy versionStrategy);
Builder exchange(ServerWebExchange exchange); Builder exchange(ServerWebExchange exchange);
MockServerRequest body(Object body); MockServerRequest body(Object body);
@ -351,6 +363,8 @@ public final class MockServerRequest implements ServerRequest {
private List<HttpMessageReader<?>> messageReaders = HandlerStrategies.withDefaults().messageReaders(); private List<HttpMessageReader<?>> messageReaders = HandlerStrategies.withDefaults().messageReaders();
private @Nullable ApiVersionStrategy versionStrategy;
private @Nullable ServerWebExchange exchange; private @Nullable ServerWebExchange exchange;
@Override @Override
@ -483,6 +497,12 @@ public final class MockServerRequest implements ServerRequest {
return this; return this;
} }
@Override
public Builder apiVersionStrategy(@Nullable ApiVersionStrategy versionStrategy) {
this.versionStrategy = versionStrategy;
return this;
}
@Override @Override
public Builder exchange(ServerWebExchange exchange) { public Builder exchange(ServerWebExchange exchange) {
Assert.notNull(exchange, "'exchange' must not be null"); 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, return new MockServerRequest(this.method, this.uri, this.contextPath, this.headers,
this.cookies, this.body, this.attributes, this.queryParams, this.pathVariables, this.cookies, this.body, this.attributes, this.queryParams, this.pathVariables,
this.session, this.principal, this.remoteAddress, this.localAddress, this.session, this.principal, this.remoteAddress, this.localAddress,
this.messageReaders, this.exchange); this.messageReaders, this.versionStrategy, this.exchange);
} }
@Override @Override
@ -504,7 +524,7 @@ public final class MockServerRequest implements ServerRequest {
return new MockServerRequest(this.method, this.uri, this.contextPath, this.headers, return new MockServerRequest(this.method, this.uri, this.contextPath, this.headers,
this.cookies, null, this.attributes, this.queryParams, this.pathVariables, this.cookies, null, this.attributes, this.queryParams, this.pathVariables,
this.session, this.principal, this.remoteAddress, this.localAddress, 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; 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 * Add to the list of supported versions to check against in
* {@link ApiVersionStrategy#validateVersion} before raising * {@link ApiVersionStrategy#validateVersion} before raising

View File

@ -246,10 +246,13 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware {
} }
@Bean @Bean
public RouterFunctionMapping routerFunctionMapping(ServerCodecConfigurer serverCodecConfigurer) { public RouterFunctionMapping routerFunctionMapping(
ServerCodecConfigurer serverCodecConfigurer, @Nullable ApiVersionStrategy apiVersionStrategy) {
RouterFunctionMapping mapping = createRouterFunctionMapping(); RouterFunctionMapping mapping = createRouterFunctionMapping();
mapping.setOrder(-1); // go before RequestMappingHandlerMapping mapping.setOrder(-1); // go before RequestMappingHandlerMapping
mapping.setMessageReaders(serverCodecConfigurer.getReaders()); mapping.setMessageReaders(serverCodecConfigurer.getReaders());
mapping.setApiVersionStrategy(apiVersionStrategy);
configureAbstractHandlerMapping(mapping, getPathMatchConfigurer()); configureAbstractHandlerMapping(mapping, getPathMatchConfigurer());
return mapping; return mapping;
} }

View File

@ -57,6 +57,7 @@ import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.support.WebExchangeDataBinder; 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.BodyExtractor;
import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.BodyExtractors;
import org.springframework.web.reactive.function.UnsupportedMediaTypeException; import org.springframework.web.reactive.function.UnsupportedMediaTypeException;
@ -92,10 +93,20 @@ class DefaultServerRequest implements ServerRequest {
private final List<HttpMessageReader<?>> messageReaders; private final List<HttpMessageReader<?>> messageReaders;
private final @Nullable ApiVersionStrategy versionStrategy;
DefaultServerRequest(ServerWebExchange exchange, List<HttpMessageReader<?>> messageReaders) { DefaultServerRequest(ServerWebExchange exchange, List<HttpMessageReader<?>> messageReaders) {
this(exchange, messageReaders, null);
}
DefaultServerRequest(
ServerWebExchange exchange, List<HttpMessageReader<?>> messageReaders,
@Nullable ApiVersionStrategy versionStrategy) {
this.exchange = exchange; this.exchange = exchange;
this.messageReaders = List.copyOf(messageReaders); this.messageReaders = List.copyOf(messageReaders);
this.versionStrategy = versionStrategy;
this.headers = new DefaultHeaders(); this.headers = new DefaultHeaders();
} }
@ -162,6 +173,11 @@ class DefaultServerRequest implements ServerRequest {
return this.messageReaders; return this.messageReaders;
} }
@Override
public @Nullable ApiVersionStrategy apiVersionStrategy() {
return this.versionStrategy;
}
@Override @Override
public <T> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) { public <T> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) {
return bodyInternal(extractor, Hints.from(Hints.LOG_PREFIX_HINT, exchange().getLogPrefix())); 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.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.reactive.accept.ApiVersionStrategy;
import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebSession; import org.springframework.web.server.WebSession;
import org.springframework.web.util.UriUtils; import org.springframework.web.util.UriUtils;
@ -69,6 +70,8 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder {
private final List<HttpMessageReader<?>> messageReaders; private final List<HttpMessageReader<?>> messageReaders;
private final @Nullable ApiVersionStrategy versionStrategy;
private final ServerWebExchange exchange; private final ServerWebExchange exchange;
private HttpMethod method; private HttpMethod method;
@ -89,6 +92,7 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder {
DefaultServerRequestBuilder(ServerRequest other) { DefaultServerRequestBuilder(ServerRequest other) {
Assert.notNull(other, "ServerRequest must not be null"); Assert.notNull(other, "ServerRequest must not be null");
this.messageReaders = other.messageReaders(); this.messageReaders = other.messageReaders();
this.versionStrategy = other.apiVersionStrategy();
this.exchange = other.exchange(); this.exchange = other.exchange();
this.method = other.method(); this.method = other.method();
this.uri = other.uri(); 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); this.method, this.uri, this.contextPath, this.headers, this.cookies, this.body, this.attributes);
ServerWebExchange exchange = new DelegatingServerWebExchange( ServerWebExchange exchange = new DelegatingServerWebExchange(
serverHttpRequest, this.attributes, this.exchange, this.messageReaders); 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.util.MultiValueMap;
import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.cors.reactive.CorsUtils; 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.reactive.function.BodyExtractor;
import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebSession; 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} * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code GET}
* and the given {@code pattern} matches against the request path. * and the given {@code pattern} matches against the request path.
@ -390,6 +411,14 @@ public abstract class RequestPredicates {
*/ */
void queryParam(String name, String value); 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. * Receive first notification of a logical AND predicate.
* The first subsequent notification will contain the left-hand side of the 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) @Deprecated(since = "7.0", forRemoval = true)
private static class PathExtensionPredicate implements RequestPredicate { private static class PathExtensionPredicate implements RequestPredicate {
@ -1189,6 +1281,11 @@ public abstract class RequestPredicates {
return this.delegate.messageReaders(); return this.delegate.messageReaders();
} }
@Override
public @Nullable ApiVersionStrategy apiVersionStrategy() {
return this.delegate.apiVersionStrategy();
}
@Override @Override
public <T> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) { public <T> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) {
return this.delegate.body(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.util.MultiValueMap;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.web.bind.WebDataBinder; 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.BodyExtractor;
import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebSession; import org.springframework.web.server.WebSession;
@ -130,6 +131,12 @@ public interface ServerRequest {
*/ */
List<HttpMessageReader<?>> messageReaders(); 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}. * Extract the body with the given {@code BodyExtractor}.
* @param extractor the {@code BodyExtractor} that reads from the request * @param extractor the {@code BodyExtractor} that reads from the request
@ -424,6 +431,23 @@ public interface ServerRequest {
return new DefaultServerRequest(exchange, messageReaders); 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}, * Create a builder with the {@linkplain HttpMessageReader message readers},
* method name, URI, headers, cookies, and attributes of the given request. * 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)); this.builder.append(String.format("?%s == %s", name, value));
} }
@Override
public void version(String version) {
this.builder.append(String.format("version: %s", version));
}
@Override @Override
public void startAnd() { public void startAnd() {
this.builder.append('('); 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.codec.ServerCodecConfigurer;
import org.springframework.http.server.reactive.observation.ServerRequestObservationContext; import org.springframework.http.server.reactive.observation.ServerRequestObservationContext;
import org.springframework.util.CollectionUtils; 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.HandlerFunction;
import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions; 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 List<HttpMessageReader<?>> messageReaders = Collections.emptyList();
private @Nullable ApiVersionStrategy versionStrategy;
/** /**
* Create an empty {@code RouterFunctionMapping}. * Create an empty {@code RouterFunctionMapping}.
@ -92,6 +96,16 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini
this.messageReaders = messageReaders; 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 @Override
public void afterPropertiesSet() throws Exception { public void afterPropertiesSet() throws Exception {
if (CollectionUtils.isEmpty(this.messageReaders)) { if (CollectionUtils.isEmpty(this.messageReaders)) {
@ -102,8 +116,14 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini
if (this.routerFunction == null) { if (this.routerFunction == null) {
initRouterFunctions(); initRouterFunctions();
} }
if (this.routerFunction != null) { if (this.routerFunction != null) {
RouterFunctions.changeParser(this.routerFunction, getPathPatternParser()); 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 @Override
protected Mono<?> getHandlerInternal(ServerWebExchange exchange) { protected Mono<?> getHandlerInternal(ServerWebExchange exchange) {
if (this.routerFunction != null) {
ServerRequest request = ServerRequest.create(exchange, this.messageReaders); if (this.routerFunction == null) {
return this.routerFunction.route(request)
.doOnNext(handler -> setAttributes(exchange.getAttributes(), request, handler));
}
else {
return Mono.empty(); 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") @SuppressWarnings("unchecked")

View File

@ -44,6 +44,7 @@ import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.WebDataBinder; 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.BodyExtractor;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebExchange;
@ -131,6 +132,11 @@ public class ServerRequestWrapper implements ServerRequest {
return this.delegate.messageReaders(); return this.delegate.messageReaders();
} }
@Override
public @Nullable ApiVersionStrategy apiVersionStrategy() {
return this.delegate.apiVersionStrategy();
}
@Override @Override
public <T> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) { public <T> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) {
return this.delegate.body(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.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; 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.http.server.reactive.MockServerHttpRequest;
import org.springframework.web.testfixture.server.MockServerWebExchange; import org.springframework.web.testfixture.server.MockServerWebExchange;
import org.springframework.web.util.pattern.PathPatternParser; import org.springframework.web.util.pattern.PathPatternParser;
@ -359,4 +363,24 @@ class RequestPredicatesTests {
assertThat(predicate.test(request)).isFalse(); 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 @Bean
public RouterFunctionMapping routerFunctionMapping( public RouterFunctionMapping routerFunctionMapping(
@Qualifier("mvcConversionService") FormattingConversionService conversionService, @Qualifier("mvcConversionService") FormattingConversionService conversionService,
@Qualifier("mvcResourceUrlProvider") ResourceUrlProvider resourceUrlProvider) { @Qualifier("mvcResourceUrlProvider") ResourceUrlProvider resourceUrlProvider,
@Qualifier("mvcApiVersionStrategy") @Nullable ApiVersionStrategy versionStrategy) {
RouterFunctionMapping mapping = new RouterFunctionMapping(); RouterFunctionMapping mapping = new RouterFunctionMapping();
mapping.setOrder(-1); // go before RequestMappingHandlerMapping mapping.setOrder(-1); // go before RequestMappingHandlerMapping
mapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider)); mapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider));
mapping.setCorsConfigurations(getCorsConfigurations()); mapping.setCorsConfigurations(getCorsConfigurations());
mapping.setMessageConverters(getMessageConverters()); mapping.setMessageConverters(getMessageConverters());
mapping.setApiVersionStrategy(versionStrategy);
PathPatternParser patternParser = getPathMatchConfigurer().getPatternParser(); PathPatternParser patternParser = getPathMatchConfigurer().getPatternParser();
if (patternParser != null) { if (patternParser != null) {

View File

@ -71,6 +71,7 @@ import org.springframework.util.ObjectUtils;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.bind.ServletRequestDataBinder; import org.springframework.web.bind.ServletRequestDataBinder;
import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.ServletWebRequest;
@ -97,6 +98,8 @@ class DefaultServerRequest implements ServerRequest {
private final List<HttpMessageConverter<?>> messageConverters; private final List<HttpMessageConverter<?>> messageConverters;
private final @Nullable ApiVersionStrategy versionStrategy;
private final MultiValueMap<String, String> params; private final MultiValueMap<String, String> params;
private final Map<String, Object> attributes; private final Map<String, Object> attributes;
@ -105,8 +108,16 @@ class DefaultServerRequest implements ServerRequest {
public DefaultServerRequest(HttpServletRequest servletRequest, List<HttpMessageConverter<?>> messageConverters) { 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.serverHttpRequest = new ServletServerHttpRequest(servletRequest);
this.messageConverters = List.copyOf(messageConverters); this.messageConverters = List.copyOf(messageConverters);
this.versionStrategy = versionStrategy;
this.headers = new DefaultRequestHeaders(this.serverHttpRequest.getHeaders()); this.headers = new DefaultRequestHeaders(this.serverHttpRequest.getHeaders());
this.params = CollectionUtils.toMultiValueMap(new ServletParametersMap(servletRequest)); this.params = CollectionUtils.toMultiValueMap(new ServletParametersMap(servletRequest));
@ -172,6 +183,11 @@ class DefaultServerRequest implements ServerRequest {
return this.messageConverters; return this.messageConverters;
} }
@Override
public @Nullable ApiVersionStrategy apiVersionStrategy() {
return this.versionStrategy;
}
@Override @Override
public <T> T body(Class<T> bodyType) throws IOException, ServletException { public <T> T body(Class<T> bodyType) throws IOException, ServletException {
return bodyInternal(bodyType, bodyType); return bodyInternal(bodyType, bodyType);

View File

@ -57,6 +57,7 @@ import org.springframework.util.MultiValueMap;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.bind.ServletRequestDataBinder; import org.springframework.web.bind.ServletRequestDataBinder;
import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.util.UriBuilder; import org.springframework.web.util.UriBuilder;
@ -74,6 +75,8 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder {
private final List<HttpMessageConverter<?>> messageConverters; private final List<HttpMessageConverter<?>> messageConverters;
private final @Nullable ApiVersionStrategy versionStrategy;
private HttpMethod method; private HttpMethod method;
private URI uri; private URI uri;
@ -95,6 +98,7 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder {
Assert.notNull(other, "ServerRequest must not be null"); Assert.notNull(other, "ServerRequest must not be null");
this.servletRequest = other.servletRequest(); this.servletRequest = other.servletRequest();
this.messageConverters = new ArrayList<>(other.messageConverters()); this.messageConverters = new ArrayList<>(other.messageConverters());
this.versionStrategy = other.apiVersionStrategy();
this.method = other.method(); this.method = other.method();
this.uri = other.uri(); this.uri = other.uri();
headers(headers -> headers.addAll(other.headers().asHttpHeaders())); headers(headers -> headers.addAll(other.headers().asHttpHeaders()));
@ -203,7 +207,8 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder {
@Override @Override
public ServerRequest build() { public ServerRequest build() {
return new BuiltServerRequest(this.servletRequest, this.method, this.uri, this.headers, this.cookies, 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 List<HttpMessageConverter<?>> messageConverters;
private final @Nullable ApiVersionStrategy versionStrategy;
private final MultiValueMap<String, String> params; private final MultiValueMap<String, String> params;
private final @Nullable InetSocketAddress remoteAddress; private final @Nullable InetSocketAddress remoteAddress;
@ -232,7 +239,9 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder {
public BuiltServerRequest(HttpServletRequest servletRequest, HttpMethod method, URI uri, public BuiltServerRequest(HttpServletRequest servletRequest, HttpMethod method, URI uri,
HttpHeaders headers, MultiValueMap<String, Cookie> cookies, HttpHeaders headers, MultiValueMap<String, Cookie> cookies,
Map<String, Object> attributes, MultiValueMap<String, String> params, 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.servletRequest = servletRequest;
this.method = method; this.method = method;
@ -244,6 +253,7 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder {
this.remoteAddress = remoteAddress; this.remoteAddress = remoteAddress;
this.body = body; this.body = body;
this.messageConverters = messageConverters; this.messageConverters = messageConverters;
this.versionStrategy = versionStrategy;
} }
@Override @Override
@ -289,6 +299,11 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder {
return this.messageConverters; return this.messageConverters;
} }
@Override
public @Nullable ApiVersionStrategy apiVersionStrategy() {
return this.versionStrategy;
}
@Override @Override
public <T> T body(Class<T> bodyType) throws IOException, ServletException { public <T> T body(Class<T> bodyType) throws IOException, ServletException {
return bodyInternal(bodyType, bodyType); return bodyInternal(bodyType, bodyType);

View File

@ -54,8 +54,10 @@ import org.springframework.util.CollectionUtils;
import org.springframework.util.MimeTypeUtils; import org.springframework.util.MimeTypeUtils;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.cors.CorsUtils; import org.springframework.web.cors.CorsUtils;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.util.UriBuilder; import org.springframework.web.util.UriBuilder;
import org.springframework.web.util.UriUtils; import org.springframework.web.util.UriUtils;
import org.springframework.web.util.pattern.PathPattern; 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} * Return a {@code RequestPredicate} that matches if request's HTTP method is {@code GET}
* and the given {@code pattern} matches against the request path. * and the given {@code pattern} matches against the request path.
@ -388,6 +409,14 @@ public abstract class RequestPredicates {
*/ */
void param(String name, String value); 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. * Receive first notification of a logical AND predicate.
* The first subsequent notification will contain the left-hand side of the 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) @Deprecated(since = "7.0", forRemoval = true)
private static class PathExtensionPredicate implements RequestPredicate { private static class PathExtensionPredicate implements RequestPredicate {
@ -1182,6 +1274,11 @@ public abstract class RequestPredicates {
return this.delegate.messageConverters(); return this.delegate.messageConverters();
} }
@Override
public @Nullable ApiVersionStrategy apiVersionStrategy() {
return this.delegate.apiVersionStrategy();
}
@Override @Override
public <T> T body(Class<T> bodyType) throws ServletException, IOException { public <T> T body(Class<T> bodyType) throws ServletException, IOException {
return this.delegate.body(bodyType); 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.CollectionUtils;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.util.ServletRequestPathUtils; import org.springframework.web.util.ServletRequestPathUtils;
import org.springframework.web.util.UriBuilder; import org.springframework.web.util.UriBuilder;
@ -116,6 +117,13 @@ public interface ServerRequest {
*/ */
List<HttpMessageConverter<?>> messageConverters(); 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. * Extract the body as an object of the given type.
* @param bodyType the type of return value * @param bodyType the type of return value
@ -373,6 +381,22 @@ public interface ServerRequest {
return new DefaultServerRequest(servletRequest, messageReaders); 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. * 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 * @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)); this.builder.append(String.format("?%s == %s", name, value));
} }
@Override
public void version(String version) {
this.builder.append(String.format("version: %s", version));
}
@Override @Override
public void startAnd() { public void startAnd() {
this.builder.append('('); 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.StringHttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.util.CollectionUtils; 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.filter.ServerHttpObservationFilter;
import org.springframework.web.servlet.function.HandlerFunction; import org.springframework.web.servlet.function.HandlerFunction;
import org.springframework.web.servlet.function.RouterFunction; 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 List<HttpMessageConverter<?>> messageConverters = Collections.emptyList();
private @Nullable ApiVersionStrategy versionStrategy;
private boolean detectHandlerFunctionsInAncestorContexts = false; private boolean detectHandlerFunctionsInAncestorContexts = false;
@ -110,6 +114,15 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini
this.messageConverters = messageConverters; 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. * Set whether to detect handler functions in ancestor ApplicationContexts.
* <p>Default is "false": Only handler functions in the current ApplicationContext * <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) { if (this.routerFunction == null) {
initRouterFunctions(); initRouterFunctions();
} }
if (CollectionUtils.isEmpty(this.messageConverters)) { if (CollectionUtils.isEmpty(this.messageConverters)) {
initMessageConverters(); initMessageConverters();
} }
if (this.routerFunction != null) { if (this.routerFunction != null) {
PathPatternParser patternParser = getPatternParser(); PathPatternParser patternParser = getPatternParser();
if (patternParser == null) { if (patternParser == null) {
@ -138,6 +153,12 @@ public class RouterFunctionMapping extends AbstractHandlerMapping implements Ini
setPatternParser(patternParser); setPatternParser(patternParser);
} }
RouterFunctions.changeParser(this.routerFunction, 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 @Override
protected @Nullable Object getHandlerInternal(HttpServletRequest servletRequest) throws Exception { protected @Nullable Object getHandlerInternal(HttpServletRequest servletRequest) throws Exception {
if (this.routerFunction != null) {
ServerRequest request = ServerRequest.create(servletRequest, this.messageConverters); if (this.routerFunction == null) {
HandlerFunction<?> handlerFunction = this.routerFunction.route(request).orElse(null);
setAttributes(servletRequest, request, handlerFunction);
return handlerFunction;
}
else {
return 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, 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.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; 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.servlet.handler.PathPatternsTestUtils;
import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
import org.springframework.web.util.pattern.PathPatternParser; import org.springframework.web.util.pattern.PathPatternParser;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.web.servlet.HandlerMapping.API_VERSION_ATTRIBUTE;
/** /**
* @author Arjen Poutsma * @author Arjen Poutsma
@ -266,12 +270,30 @@ class RequestPredicatesTests {
assertThat(predicate.test(request)).isFalse(); 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); return initRequest(httpMethod, requestUri, null);
} }
private ServerRequest initRequest( private static ServerRequest initRequest(
String httpMethod, String requestUri, @Nullable Consumer<MockHttpServletRequest> initializer) { String httpMethod, String requestUri, @Nullable Consumer<MockHttpServletRequest> initializer) {
return new DefaultServerRequest( 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);
}
}
}