Skip to content

Commit 5a6c019

Browse files
committed
Support for functional routing by API version
See gh-35113
1 parent 224f1af commit 5a6c019

File tree

23 files changed

+986
-23
lines changed

23 files changed

+986
-23
lines changed

spring-test/src/main/java/org/springframework/mock/web/reactive/function/server/MockServerRequest.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import org.springframework.util.LinkedMultiValueMap;
5151
import org.springframework.util.MultiValueMap;
5252
import org.springframework.web.bind.WebDataBinder;
53+
import org.springframework.web.reactive.accept.ApiVersionStrategy;
5354
import org.springframework.web.reactive.function.BodyExtractor;
5455
import org.springframework.web.reactive.function.server.HandlerStrategies;
5556
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -94,6 +95,8 @@ public final class MockServerRequest implements ServerRequest {
9495

9596
private final List<HttpMessageReader<?>> messageReaders;
9697

98+
private final @Nullable ApiVersionStrategy versionStrategy;
99+
97100
private final @Nullable ServerWebExchange exchange;
98101

99102

@@ -102,7 +105,8 @@ private MockServerRequest(HttpMethod method, URI uri, String contextPath, MockHe
102105
Map<String, Object> attributes, MultiValueMap<String, String> queryParams,
103106
Map<String, String> pathVariables, @Nullable WebSession session, @Nullable Principal principal,
104107
@Nullable InetSocketAddress remoteAddress, @Nullable InetSocketAddress localAddress,
105-
List<HttpMessageReader<?>> messageReaders, @Nullable ServerWebExchange exchange) {
108+
List<HttpMessageReader<?>> messageReaders, @Nullable ApiVersionStrategy versionStrategy,
109+
@Nullable ServerWebExchange exchange) {
106110

107111
this.method = method;
108112
this.uri = uri;
@@ -118,6 +122,7 @@ private MockServerRequest(HttpMethod method, URI uri, String contextPath, MockHe
118122
this.remoteAddress = remoteAddress;
119123
this.localAddress = localAddress;
120124
this.messageReaders = messageReaders;
125+
this.versionStrategy = versionStrategy;
121126
this.exchange = exchange;
122127
}
123128

@@ -167,6 +172,11 @@ public List<HttpMessageReader<?>> messageReaders() {
167172
return this.messageReaders;
168173
}
169174

175+
@Override
176+
public @Nullable ApiVersionStrategy apiVersionStrategy() {
177+
return this.versionStrategy;
178+
}
179+
170180
@Override
171181
@SuppressWarnings("unchecked")
172182
public <S> S body(BodyExtractor<S, ? super ServerHttpRequest> extractor) {
@@ -313,6 +323,8 @@ public interface Builder {
313323

314324
Builder messageReaders(List<HttpMessageReader<?>> messageReaders);
315325

326+
Builder apiVersionStrategy(@Nullable ApiVersionStrategy versionStrategy);
327+
316328
Builder exchange(ServerWebExchange exchange);
317329

318330
MockServerRequest body(Object body);
@@ -351,6 +363,8 @@ private static class BuilderImpl implements Builder {
351363

352364
private List<HttpMessageReader<?>> messageReaders = HandlerStrategies.withDefaults().messageReaders();
353365

366+
private @Nullable ApiVersionStrategy versionStrategy;
367+
354368
private @Nullable ServerWebExchange exchange;
355369

356370
@Override
@@ -483,6 +497,12 @@ public Builder messageReaders(List<HttpMessageReader<?>> messageReaders) {
483497
return this;
484498
}
485499

500+
@Override
501+
public Builder apiVersionStrategy(@Nullable ApiVersionStrategy versionStrategy) {
502+
this.versionStrategy = versionStrategy;
503+
return this;
504+
}
505+
486506
@Override
487507
public Builder exchange(ServerWebExchange exchange) {
488508
Assert.notNull(exchange, "'exchange' must not be null");
@@ -496,15 +516,15 @@ public MockServerRequest body(Object body) {
496516
return new MockServerRequest(this.method, this.uri, this.contextPath, this.headers,
497517
this.cookies, this.body, this.attributes, this.queryParams, this.pathVariables,
498518
this.session, this.principal, this.remoteAddress, this.localAddress,
499-
this.messageReaders, this.exchange);
519+
this.messageReaders, this.versionStrategy, this.exchange);
500520
}
501521

502522
@Override
503523
public MockServerRequest build() {
504524
return new MockServerRequest(this.method, this.uri, this.contextPath, this.headers,
505525
this.cookies, null, this.attributes, this.queryParams, this.pathVariables,
506526
this.session, this.principal, this.remoteAddress, this.localAddress,
507-
this.messageReaders, this.exchange);
527+
this.messageReaders, this.versionStrategy, this.exchange);
508528
}
509529
}
510530

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ public DefaultApiVersionStrategy(
9191
return this.defaultVersion;
9292
}
9393

94+
/**
95+
* Whether the strategy is configured to detect supported versions.
96+
* If this is set to {@code false} then {@link #addMappedVersion} is ignored
97+
* and the list of supported versions can be built explicitly through calls
98+
* to {@link #addSupportedVersion}.
99+
*/
100+
public boolean detectSupportedVersions() {
101+
return this.detectSupportedVersions;
102+
}
103+
94104
/**
95105
* Add to the list of supported versions to check against in
96106
* {@link ApiVersionStrategy#validateVersion} before raising

spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,13 @@ public void configurePathMatching(PathMatchConfigurer configurer) {
246246
}
247247

248248
@Bean
249-
public RouterFunctionMapping routerFunctionMapping(ServerCodecConfigurer serverCodecConfigurer) {
249+
public RouterFunctionMapping routerFunctionMapping(
250+
ServerCodecConfigurer serverCodecConfigurer, @Nullable ApiVersionStrategy apiVersionStrategy) {
251+
250252
RouterFunctionMapping mapping = createRouterFunctionMapping();
251253
mapping.setOrder(-1); // go before RequestMappingHandlerMapping
252254
mapping.setMessageReaders(serverCodecConfigurer.getReaders());
255+
mapping.setApiVersionStrategy(apiVersionStrategy);
253256
configureAbstractHandlerMapping(mapping, getPathMatchConfigurer());
254257
return mapping;
255258
}

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequest.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
import org.springframework.validation.BindingResult;
5858
import org.springframework.web.bind.WebDataBinder;
5959
import org.springframework.web.bind.support.WebExchangeDataBinder;
60+
import org.springframework.web.reactive.accept.ApiVersionStrategy;
6061
import org.springframework.web.reactive.function.BodyExtractor;
6162
import org.springframework.web.reactive.function.BodyExtractors;
6263
import org.springframework.web.reactive.function.UnsupportedMediaTypeException;
@@ -92,10 +93,20 @@ class DefaultServerRequest implements ServerRequest {
9293

9394
private final List<HttpMessageReader<?>> messageReaders;
9495

96+
private final @Nullable ApiVersionStrategy versionStrategy;
97+
9598

9699
DefaultServerRequest(ServerWebExchange exchange, List<HttpMessageReader<?>> messageReaders) {
100+
this(exchange, messageReaders, null);
101+
}
102+
103+
DefaultServerRequest(
104+
ServerWebExchange exchange, List<HttpMessageReader<?>> messageReaders,
105+
@Nullable ApiVersionStrategy versionStrategy) {
106+
97107
this.exchange = exchange;
98108
this.messageReaders = List.copyOf(messageReaders);
109+
this.versionStrategy = versionStrategy;
99110
this.headers = new DefaultHeaders();
100111
}
101112

@@ -162,6 +173,11 @@ public List<HttpMessageReader<?>> messageReaders() {
162173
return this.messageReaders;
163174
}
164175

176+
@Override
177+
public @Nullable ApiVersionStrategy apiVersionStrategy() {
178+
return this.versionStrategy;
179+
}
180+
165181
@Override
166182
public <T> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) {
167183
return bodyInternal(extractor, Hints.from(Hints.LOG_PREFIX_HINT, exchange().getLogPrefix()));

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/DefaultServerRequestBuilder.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import org.springframework.util.LinkedMultiValueMap;
5555
import org.springframework.util.MultiValueMap;
5656
import org.springframework.util.StringUtils;
57+
import org.springframework.web.reactive.accept.ApiVersionStrategy;
5758
import org.springframework.web.server.ServerWebExchange;
5859
import org.springframework.web.server.WebSession;
5960
import org.springframework.web.util.UriUtils;
@@ -69,6 +70,8 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder {
6970

7071
private final List<HttpMessageReader<?>> messageReaders;
7172

73+
private final @Nullable ApiVersionStrategy versionStrategy;
74+
7275
private final ServerWebExchange exchange;
7376

7477
private HttpMethod method;
@@ -89,6 +92,7 @@ class DefaultServerRequestBuilder implements ServerRequest.Builder {
8992
DefaultServerRequestBuilder(ServerRequest other) {
9093
Assert.notNull(other, "ServerRequest must not be null");
9194
this.messageReaders = other.messageReaders();
95+
this.versionStrategy = other.apiVersionStrategy();
9296
this.exchange = other.exchange();
9397
this.method = other.method();
9498
this.uri = other.uri();
@@ -195,7 +199,7 @@ public ServerRequest build() {
195199
this.method, this.uri, this.contextPath, this.headers, this.cookies, this.body, this.attributes);
196200
ServerWebExchange exchange = new DelegatingServerWebExchange(
197201
serverHttpRequest, this.attributes, this.exchange, this.messageReaders);
198-
return new DefaultServerRequest(exchange, this.messageReaders);
202+
return new DefaultServerRequest(exchange, this.messageReaders, this.versionStrategy);
199203
}
200204

201205

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
import org.springframework.util.MultiValueMap;
5555
import org.springframework.web.bind.WebDataBinder;
5656
import org.springframework.web.cors.reactive.CorsUtils;
57+
import org.springframework.web.reactive.HandlerMapping;
58+
import org.springframework.web.reactive.accept.ApiVersionStrategy;
5759
import org.springframework.web.reactive.function.BodyExtractor;
5860
import org.springframework.web.server.ServerWebExchange;
5961
import org.springframework.web.server.WebSession;
@@ -182,6 +184,25 @@ public static RequestPredicate accept(MediaType... mediaTypes) {
182184
}
183185
}
184186

187+
/**
188+
* {@code RequestPredicate} to match to the request API version extracted
189+
* from and parsed with the configured {@link ApiVersionStrategy}.
190+
* <p>The version may be one of the following:
191+
* <ul>
192+
* <li>Fixed version ("1.2") -- match this version only.
193+
* <li>Baseline version ("1.2+") -- match this and subsequent versions.
194+
* </ul>
195+
* <p>A baseline version allows n endpoint route to continue to work in
196+
* subsequent versions if it remains compatible until an incompatible change
197+
* eventually leads to the creation of a new route.
198+
* @param version the version to use
199+
* @return the created predicate instance
200+
* @since 7.0
201+
*/
202+
public static RequestPredicate version(Object version) {
203+
return new ApiVersionPredicate(version);
204+
}
205+
185206
/**
186207
* Return a {@code RequestPredicate} that matches if request's HTTP method is {@code GET}
187208
* and the given {@code pattern} matches against the request path.
@@ -390,6 +411,14 @@ public interface Visitor {
390411
*/
391412
void queryParam(String name, String value);
392413

414+
/**
415+
* Receive notification of an API version predicate. The version could
416+
* be fixed ("1.2") or baseline ("1.2+").
417+
* @param version the configured version
418+
* @since 7.0
419+
*/
420+
void version(String version);
421+
393422
/**
394423
* Receive first notification of a logical AND predicate.
395424
* The first subsequent notification will contain the left-hand side of the AND-predicate;
@@ -831,6 +860,69 @@ public String toString() {
831860
}
832861

833862

863+
private static class ApiVersionPredicate implements RequestPredicate {
864+
865+
private final String version;
866+
867+
private final boolean baselineVersion;
868+
869+
private @Nullable Comparable<?> parsedVersion;
870+
871+
public ApiVersionPredicate(Object version) {
872+
if (version instanceof String s) {
873+
this.baselineVersion = s.endsWith("+");
874+
this.version = initVersion(s, this.baselineVersion);
875+
}
876+
else {
877+
this.baselineVersion = false;
878+
this.version = version.toString();
879+
this.parsedVersion = (Comparable<?>) version;
880+
}
881+
}
882+
883+
private static String initVersion(String version, boolean baselineVersion) {
884+
return (baselineVersion ? version.substring(0, version.length() - 1) : version);
885+
}
886+
887+
@Override
888+
public boolean test(ServerRequest request) {
889+
if (this.parsedVersion == null) {
890+
ApiVersionStrategy strategy = request.apiVersionStrategy();
891+
Assert.state(strategy != null, "No ApiVersionStrategy to parse version with");
892+
this.parsedVersion = strategy.parseVersion(this.version);
893+
}
894+
895+
Comparable<?> requestVersion =
896+
(Comparable<?>) request.attribute(HandlerMapping.API_VERSION_ATTRIBUTE).orElse(null);
897+
898+
if (requestVersion == null) {
899+
traceMatch("Version", this.version, null, true);
900+
return true;
901+
}
902+
903+
int result = compareVersions(this.parsedVersion, requestVersion);
904+
boolean match = (this.baselineVersion ? result <= 0 : result == 0);
905+
traceMatch("Version", this.version, requestVersion, match);
906+
return match;
907+
}
908+
909+
@SuppressWarnings("unchecked")
910+
private <V extends Comparable<V>> int compareVersions(Object v1, Object v2) {
911+
return ((V) v1).compareTo((V) v2);
912+
}
913+
914+
@Override
915+
public void accept(Visitor visitor) {
916+
visitor.version(this.version + (this.baselineVersion ? "+" : ""));
917+
}
918+
919+
@Override
920+
public String toString() {
921+
return this.version;
922+
}
923+
}
924+
925+
834926
@Deprecated(since = "7.0", forRemoval = true)
835927
private static class PathExtensionPredicate implements RequestPredicate {
836928

@@ -1189,6 +1281,11 @@ public List<HttpMessageReader<?>> messageReaders() {
11891281
return this.delegate.messageReaders();
11901282
}
11911283

1284+
@Override
1285+
public @Nullable ApiVersionStrategy apiVersionStrategy() {
1286+
return this.delegate.apiVersionStrategy();
1287+
}
1288+
11921289
@Override
11931290
public <T> T body(BodyExtractor<T, ? super ServerHttpRequest> extractor) {
11941291
return this.delegate.body(extractor);

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerRequest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import org.springframework.util.MultiValueMap;
5050
import org.springframework.validation.BindException;
5151
import org.springframework.web.bind.WebDataBinder;
52+
import org.springframework.web.reactive.accept.ApiVersionStrategy;
5253
import org.springframework.web.reactive.function.BodyExtractor;
5354
import org.springframework.web.server.ServerWebExchange;
5455
import org.springframework.web.server.WebSession;
@@ -130,6 +131,12 @@ default RequestPath requestPath() {
130131
*/
131132
List<HttpMessageReader<?>> messageReaders();
132133

134+
/**
135+
* Return the configured {@link ApiVersionStrategy}, or {@code null}.
136+
* @since 7.0
137+
*/
138+
@Nullable ApiVersionStrategy apiVersionStrategy();
139+
133140
/**
134141
* Extract the body with the given {@code BodyExtractor}.
135142
* @param extractor the {@code BodyExtractor} that reads from the request
@@ -424,6 +431,23 @@ static ServerRequest create(ServerWebExchange exchange, List<HttpMessageReader<?
424431
return new DefaultServerRequest(exchange, messageReaders);
425432
}
426433

434+
/**
435+
* Create a new {@code ServerRequest} based on the given {@code ServerWebExchange} and
436+
* message readers.
437+
* @param exchange the exchange
438+
* @param messageReaders the message readers
439+
* @param versionStrategy a strategy to use to parse version
440+
* @return the created {@code ServerRequest}
441+
* @since 7.0
442+
*/
443+
static ServerRequest create(
444+
ServerWebExchange exchange, List<HttpMessageReader<?>> messageReaders,
445+
@Nullable ApiVersionStrategy versionStrategy) {
446+
447+
return new DefaultServerRequest(exchange, messageReaders, versionStrategy);
448+
}
449+
450+
427451
/**
428452
* Create a builder with the {@linkplain HttpMessageReader message readers},
429453
* method name, URI, headers, cookies, and attributes of the given request.

spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ToStringVisitor.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ public void queryParam(String name, String value) {
119119
this.builder.append(String.format("?%s == %s", name, value));
120120
}
121121

122+
@Override
123+
public void version(String version) {
124+
this.builder.append(String.format("version: %s", version));
125+
}
126+
122127
@Override
123128
public void startAnd() {
124129
this.builder.append('(');

0 commit comments

Comments
 (0)