Skip to content

Commit c365535

Browse files
JKaplanEmpty-Nesjonathan.kaplan
authored andcommitted
Change API version resolution from nullable String to Mono String
Signed-off-by: Jonathan Kaplan <[email protected]>
1 parent e7f019b commit c365535

File tree

5 files changed

+199
-47
lines changed

5 files changed

+199
-47
lines changed

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
package org.springframework.web.reactive.accept;
1818

1919
import org.jspecify.annotations.Nullable;
20+
import reactor.core.publisher.Mono;
2021

2122
import org.springframework.web.server.ServerWebExchange;
2223

2324
/**
2425
* Contract to extract the version from a request.
2526
*
2627
* @author Rossen Stoyanchev
28+
* @author Jonathan Kaplan
2729
* @since 7.0
2830
*/
2931
@FunctionalInterface
@@ -37,4 +39,15 @@ interface ApiVersionResolver {
3739
*/
3840
@Nullable String resolveVersion(ServerWebExchange exchange);
3941

42+
/**
43+
* Asynchronously resolve the version for the given request exchange.
44+
* This method wraps the synchronous {@code resolveVersion} method
45+
* and provides a reactive alternative.
46+
* @param exchange the current request exchange
47+
* @return a {@code Mono} emitting the version value, or an empty {@code Mono} if no version is found
48+
*/
49+
default Mono<String> resolveVersionAsync(ServerWebExchange exchange){
50+
return Mono.justOrEmpty(this.resolveVersion(exchange));
51+
}
52+
4053
}

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

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.web.reactive.accept;
1818

1919
import org.jspecify.annotations.Nullable;
20+
import reactor.core.publisher.Mono;
2021

2122
import org.springframework.web.accept.InvalidApiVersionException;
2223
import org.springframework.web.accept.MissingApiVersionException;
@@ -27,6 +28,7 @@
2728
* to manage API versioning for an application.
2829
*
2930
* @author Rossen Stoyanchev
31+
* @author Jonathan Kaplan
3032
* @since 7.0
3133
* @see DefaultApiVersionStrategy
3234
*/
@@ -37,10 +39,24 @@ public interface ApiVersionStrategy {
3739
* @param exchange the current exchange
3840
* @return the version, if present or {@code null}
3941
* @see ApiVersionResolver
42+
* @deprecated as of 7.0.4, in favor of
43+
* {@link #resolveVersionAsync(ServerWebExchange)}
4044
*/
45+
@Deprecated(forRemoval = true, since = "7.0.4")
4146
@Nullable
4247
String resolveVersion(ServerWebExchange exchange);
4348

49+
50+
/**
51+
* Resolve the version value from a request asynchronously.
52+
* @param exchange the current server exchange containing the request details
53+
* @return a {@code Mono} emitting the resolved version as a {@code String},
54+
* or an empty {@code Mono} if no version is resolved
55+
*/
56+
default Mono<String> resolveVersionAsync(ServerWebExchange exchange){
57+
return Mono.empty();
58+
}
59+
4460
/**
4561
* Parse the version of a request into an Object.
4662
* @param version the value to parse
@@ -65,27 +81,38 @@ void validateVersion(@Nullable Comparable<?> requestVersion, ServerWebExchange e
6581
@Nullable Comparable<?> getDefaultVersion();
6682

6783
/**
68-
* Convenience method to return the parsed and validated request version,
69-
* or the default version if configured.
70-
* @param exchange the current exchange
71-
* @return the parsed request version, or the default version
84+
* Convenience method to return the API version from the given request exchange, parse and validate
85+
* the version, and return the result as a reactive {@code Mono} stream. If no version
86+
* is resolved, the default version is used.
87+
* @param exchange the current server exchange containing the request details
88+
* @return a {@code Mono} emitting the resolved, parsed, and validated version as a {@code Comparable<?>},
89+
* or an error in case parsing or validation fails
7290
*/
73-
default @Nullable Comparable<?> resolveParseAndValidateVersion(ServerWebExchange exchange) {
74-
String value = resolveVersion(exchange);
75-
Comparable<?> version;
76-
if (value == null) {
77-
version = getDefaultVersion();
78-
}
79-
else {
80-
try {
81-
version = parseVersion(value);
82-
}
83-
catch (Exception ex) {
84-
throw new InvalidApiVersionException(value, null, ex);
85-
}
86-
}
87-
validateVersion(version, exchange);
88-
return version;
91+
@SuppressWarnings("Convert2MethodRef")
92+
default Mono<Comparable<?>> resolveParseAndValidateVersion(ServerWebExchange exchange) {
93+
94+
return Mono.justOrEmpty(this.resolveVersion(exchange))
95+
.switchIfEmpty(this.resolveVersionAsync(exchange))
96+
.switchIfEmpty(Mono.justOrEmpty(this.getDefaultVersion())
97+
.mapNotNull(comparable -> comparable.toString()))
98+
.<Comparable<?>>handle((version, sink) -> {
99+
try {
100+
sink.next(this.parseVersion(version));
101+
}
102+
catch (Exception ex) {
103+
sink.error(new InvalidApiVersionException(version, null, ex));
104+
}
105+
}).<Comparable<?>>handle((version, sink) -> {
106+
try {
107+
this.validateVersion(version, exchange);
108+
sink.next(version);
109+
}
110+
catch (MissingApiVersionException | InvalidApiVersionException ex) {
111+
sink.error(ex);
112+
}
113+
})
114+
.switchIfEmpty(Mono.error(new MissingApiVersionException()));
115+
89116
}
90117

91118
/**

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@
2020
import java.util.List;
2121
import java.util.Set;
2222
import java.util.TreeSet;
23+
import java.util.function.Function;
2324
import java.util.function.Predicate;
2425

2526
import org.jspecify.annotations.Nullable;
27+
import reactor.core.publisher.Flux;
28+
import reactor.core.publisher.Mono;
2629

2730
import org.springframework.util.Assert;
2831
import org.springframework.web.accept.ApiVersionParser;
@@ -152,6 +155,7 @@ public void addMappedVersion(String... versions) {
152155
}
153156
}
154157

158+
@SuppressWarnings("removal")
155159
@Override
156160
public @Nullable String resolveVersion(ServerWebExchange exchange) {
157161
for (ApiVersionResolver resolver : this.versionResolvers) {
@@ -163,6 +167,14 @@ public void addMappedVersion(String... versions) {
163167
return null;
164168
}
165169

170+
@Override
171+
public Mono<String> resolveVersionAsync(ServerWebExchange exchange) {
172+
return Flux.fromIterable(this.versionResolvers)
173+
.mapNotNull(resolver -> resolver.resolveVersionAsync(exchange))
174+
.flatMap(Function.identity())
175+
.next();
176+
}
177+
166178
@Override
167179
public Comparable<?> parseVersion(String version) {
168180
return this.versionParser.parseVersion(version);

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,8 @@ protected String formatMappingName() {
184184

185185
@Override
186186
public Mono<Object> getHandler(ServerWebExchange exchange) {
187-
initApiVersion(exchange);
188-
return getHandlerInternal(exchange).map(handler -> {
187+
return this.initApiVersion(exchange).then(
188+
getHandlerInternal(exchange).map(handler -> {
189189
if (logger.isDebugEnabled()) {
190190
logger.debug(exchange.getLogPrefix() + "Mapped to " + handler);
191191
}
@@ -210,19 +210,19 @@ public Mono<Object> getHandler(ServerWebExchange exchange) {
210210
}
211211
}
212212
return handler;
213-
});
213+
}));
214214
}
215215

216-
private void initApiVersion(ServerWebExchange exchange) {
216+
private Mono<Comparable<?>> initApiVersion(ServerWebExchange exchange) {
217217
if (this.apiVersionStrategy != null) {
218-
Comparable<?> version = exchange.getAttribute(API_VERSION_ATTRIBUTE);
219-
if (version == null) {
220-
version = this.apiVersionStrategy.resolveParseAndValidateVersion(exchange);
221-
if (version != null) {
222-
exchange.getAttributes().put(API_VERSION_ATTRIBUTE, version);
223-
}
218+
if (exchange.getAttribute(API_VERSION_ATTRIBUTE) == null) {
219+
return this.apiVersionStrategy
220+
.resolveParseAndValidateVersion(exchange)
221+
.doOnNext(version -> exchange.getAttributes()
222+
.put(API_VERSION_ATTRIBUTE, version));
224223
}
225224
}
225+
return Mono.empty();
226226
}
227227

228228
/**

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

Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import org.jspecify.annotations.Nullable;
2323
import org.junit.jupiter.api.Test;
24+
import reactor.test.StepVerifier;
2425

2526
import org.springframework.web.accept.InvalidApiVersionException;
2627
import org.springframework.web.accept.MissingApiVersionException;
@@ -35,6 +36,7 @@
3536
/**
3637
* Unit tests for {@link org.springframework.web.accept.DefaultApiVersionStrategy}.
3738
* @author Rossen Stoyanchev
39+
* @author Jonathan Kaplan
3840
*/
3941
public class DefaultApiVersionStrategiesTests {
4042

@@ -48,6 +50,117 @@ void defaultVersionIsParsed() {
4850
assertThat(strategy.getDefaultVersion()).isEqualTo(parser.parseVersion(version));
4951
}
5052

53+
@Test
54+
void missingRequiredVersionReactively() {
55+
56+
testValidateReactively(null, apiVersionStrategy())
57+
.expectErrorSatisfies(throwable ->
58+
assertThat(throwable).isInstanceOf(MissingApiVersionException.class)
59+
.hasMessage(("400 BAD_REQUEST \"API version is " +
60+
"required.\"")
61+
))
62+
.verify();
63+
}
64+
65+
@Test
66+
void validateSupportedVersionReactively() {
67+
String version = "1.2";
68+
DefaultApiVersionStrategy strategy = apiVersionStrategy();
69+
strategy.addSupportedVersion(version);
70+
testValidateReactively(version, strategy)
71+
.expectNextMatches(next -> next.toString().equals("1.2.0"))
72+
.verifyComplete();
73+
}
74+
75+
@Test
76+
void validateSupportedVersionForDefaultVersionReactively() {
77+
String defaultVersion = "1.2";
78+
DefaultApiVersionStrategy strategy = apiVersionStrategy(defaultVersion, false, null);
79+
80+
testValidateReactively(defaultVersion, strategy)
81+
.expectNextMatches(next -> next.toString().equals("1.2.0"))
82+
.verifyComplete();
83+
}
84+
85+
@Test
86+
void validateUnsupportedVersionReactively() {
87+
testValidateReactively("1.2", apiVersionStrategy())
88+
.expectErrorSatisfies(throwable ->
89+
assertThat(throwable).isInstanceOf(InvalidApiVersionException.class)
90+
.hasMessage(("400 BAD_REQUEST \"Invalid API " +
91+
"version: '1.2.0'.\"")
92+
))
93+
.verify();
94+
95+
}
96+
97+
@Test
98+
void validateDetectedVersionReactively() {
99+
String version = "1.2";
100+
DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true, null);
101+
strategy.addMappedVersion(version);
102+
testValidateReactively(version, strategy)
103+
.expectNextMatches(next -> next.toString().equals("1.2.0"))
104+
.verifyComplete();
105+
}
106+
107+
@Test
108+
void validateWhenDetectedVersionOffReactively() {
109+
String version = "1.2";
110+
DefaultApiVersionStrategy strategy = apiVersionStrategy();
111+
strategy.addMappedVersion(version);
112+
testValidateReactively(version, strategy)
113+
.expectError(InvalidApiVersionException.class)
114+
.verify();
115+
}
116+
117+
@Test
118+
void validateSupportedWithPredicateReactively() {
119+
SemanticApiVersionParser.Version parsedVersion = parser.parseVersion("1.2");
120+
testValidateReactively("1.2", apiVersionStrategy(null, false, version -> version.equals(parsedVersion)))
121+
.expectNextMatches(next -> next.toString().equals("1.2.0"))
122+
.verifyComplete();
123+
}
124+
125+
@Test
126+
void validateUnsupportedWithPredicateReactively() {
127+
DefaultApiVersionStrategy strategy = apiVersionStrategy(null, false, version -> version.equals("1.2"));
128+
testValidateReactively("1.2", strategy)
129+
.verifyError(InvalidApiVersionException.class);
130+
}
131+
132+
@Test
133+
void versionRequiredAndDefaultVersionSetReactively() {
134+
assertThatIllegalArgumentException()
135+
.isThrownBy(() ->
136+
new org.springframework.web.accept.DefaultApiVersionStrategy(
137+
List.of(request -> request.getParameter("api-version")), new SemanticApiVersionParser(),
138+
true, "1.2", true, version -> true, null))
139+
.withMessage("versionRequired cannot be set to true if a defaultVersion is also configured");
140+
}
141+
142+
private static DefaultApiVersionStrategy apiVersionStrategy() {
143+
return apiVersionStrategy(null, false, null);
144+
}
145+
146+
private static DefaultApiVersionStrategy apiVersionStrategy(
147+
@Nullable String defaultVersion, boolean detectSupportedVersions,
148+
@Nullable Predicate<Comparable<?>> supportedVersionPredicate) {
149+
150+
return new DefaultApiVersionStrategy(
151+
List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")),
152+
parser, null, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null);
153+
}
154+
155+
private StepVerifier.FirstStep<Comparable<?>> testValidateReactively(@Nullable String version, DefaultApiVersionStrategy strategy) {
156+
MockServerHttpRequest.BaseBuilder<?> requestBuilder = MockServerHttpRequest.get("/");
157+
if (version != null) {
158+
requestBuilder.queryParam("api-version", version);
159+
}
160+
return StepVerifier.create(strategy.resolveParseAndValidateVersion(MockServerWebExchange.builder(requestBuilder)
161+
.build()));
162+
}
163+
51164
@Test
52165
void missingRequiredVersion() {
53166
assertThatThrownBy(() -> testValidate(null, apiVersionStrategy()))
@@ -109,31 +222,18 @@ void validateUnsupportedWithPredicate() {
109222
void versionRequiredAndDefaultVersionSet() {
110223
assertThatIllegalArgumentException()
111224
.isThrownBy(() ->
112-
new org.springframework.web.accept.DefaultApiVersionStrategy(
113-
List.of(request -> request.getParameter("api-version")), new SemanticApiVersionParser(),
114-
true, "1.2", true, version -> true, null))
225+
new org.springframework.web.accept.DefaultApiVersionStrategy(
226+
List.of(request -> request.getParameter("api-version")), new SemanticApiVersionParser(),
227+
true, "1.2", true, version -> true, null))
115228
.withMessage("versionRequired cannot be set to true if a defaultVersion is also configured");
116229
}
117230

118-
private static DefaultApiVersionStrategy apiVersionStrategy() {
119-
return apiVersionStrategy(null, false, null);
120-
}
121-
122-
private static DefaultApiVersionStrategy apiVersionStrategy(
123-
@Nullable String defaultVersion, boolean detectSupportedVersions,
124-
@Nullable Predicate<Comparable<?>> supportedVersionPredicate) {
125-
126-
return new DefaultApiVersionStrategy(
127-
List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")),
128-
parser, null, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null);
129-
}
130-
131231
private void testValidate(@Nullable String version, DefaultApiVersionStrategy strategy) {
132232
MockServerHttpRequest.BaseBuilder<?> requestBuilder = MockServerHttpRequest.get("/");
133233
if (version != null) {
134234
requestBuilder.queryParam("api-version", version);
135235
}
136-
strategy.resolveParseAndValidateVersion(MockServerWebExchange.builder(requestBuilder).build());
236+
strategy.resolveParseAndValidateVersion(MockServerWebExchange.builder(requestBuilder).build()).block();
137237
}
138238

139239
}

0 commit comments

Comments
 (0)