diff --git a/spring-web/src/main/java/org/springframework/web/accept/ApiVersionHolder.java b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionHolder.java new file mode 100644 index 00000000000..a0140a6ad23 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionHolder.java @@ -0,0 +1,84 @@ +/* + * 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.accept; + +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; + +/** + * Simple container of the API version for a request (possibly {@code null}), + * or an exception that resulted from trying to resolve, parse, and validate + * the version. + * + *

While an API version needs to be initialized early, given that each + * {@code HandlerMapping} may or may not expect an API version, it is important + * to defer raising API version errors until it is known if the + * {@code HandlerMapping} will handle the request. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public final class ApiVersionHolder { + + /** Static instance for a request without an API version. */ + public static final ApiVersionHolder EMPTY = ApiVersionHolder.fromVersion(null); + + + private final @Nullable Comparable version; + + private final @Nullable RuntimeException exception; + + + private ApiVersionHolder(@Nullable Comparable version, @Nullable RuntimeException ex) { + this.version = version; + this.exception = ex; + } + + + public boolean hasVersion() { + return (this.version != null); + } + + public boolean hasError() { + return (this.exception != null); + } + + public Comparable getVersion() { + Assert.state(this.version != null, "No version"); + return this.version; + } + + public @Nullable Comparable getVersionIfPresent() { + return this.version; + } + + public RuntimeException getError() { + Assert.state(this.exception != null, "No error"); + return this.exception; + } + + + public static ApiVersionHolder fromVersion(@Nullable Comparable version) { + return new ApiVersionHolder(version, null); + } + + public static ApiVersionHolder fromError(@Nullable RuntimeException ex) { + return new ApiVersionHolder(null, ex); + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerMapping.java index bb249c3c4a7..184316949fe 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/HandlerMapping.java @@ -18,6 +18,7 @@ package org.springframework.web.reactive; import reactor.core.publisher.Mono; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.server.ServerWebExchange; /** @@ -84,8 +85,9 @@ public interface HandlerMapping { String PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE = HandlerMapping.class.getName() + ".producibleMediaTypes"; /** - * Name of the {@link ServerWebExchange#getAttributes() attribute} containing - * the resolved and parsed API version. + * Name of the {@link ServerWebExchange#getAttributes() attribute} that + * contains an {@link ApiVersionHolder} with the result of obtaining and + * parsing the API version of the request. * @since 7.0 */ String API_VERSION_ATTRIBUTE = HandlerMapping.class.getName() + ".apiVersion"; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java index 2fd02b0a35a..3355ed81fe0 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RequestPredicates.java @@ -52,6 +52,7 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MimeTypeUtils; import org.springframework.util.MultiValueMap; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.cors.reactive.CorsUtils; import org.springframework.web.reactive.HandlerMapping; @@ -892,14 +893,17 @@ public abstract class RequestPredicates { this.parsedVersion = strategy.parseVersion(this.version); } - Comparable requestVersion = - (Comparable) request.attribute(HandlerMapping.API_VERSION_ATTRIBUTE).orElse(null); + ApiVersionHolder requestVersionHolder = + (ApiVersionHolder) request.attribute(HandlerMapping.API_VERSION_ATTRIBUTE) + .orElseThrow(() -> new IllegalStateException("Expect API version attribute")); - if (requestVersion == null) { + if (!requestVersionHolder.hasVersion()) { traceMatch("Version", this.version, null, true); return true; } + Comparable requestVersion = requestVersionHolder.getVersion(); + int result = compareVersions(this.parsedVersion, requestVersion); boolean match = (this.baselineVersion ? result <= 0 : result == 0); traceMatch("Version", this.version, requestVersion, match); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java index b20577bea99..e54dce96fe3 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java @@ -28,6 +28,7 @@ import org.springframework.core.Ordered; import org.springframework.core.log.LogDelegateFactory; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.Assert; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.reactive.CorsConfigurationSource; import org.springframework.web.cors.reactive.CorsProcessor; @@ -184,11 +185,14 @@ public abstract class AbstractHandlerMapping extends ApplicationObjectSupport @Override public Mono getHandler(ServerWebExchange exchange) { - initApiVersion(exchange); + ApiVersionHolder versionHolder = initApiVersion(exchange); return getHandlerInternal(exchange).map(handler -> { if (logger.isDebugEnabled()) { logger.debug(exchange.getLogPrefix() + "Mapped to " + handler); } + if (versionHolder.hasError()) { + throw versionHolder.getError(); + } ServerHttpRequest request = exchange.getRequest(); if (hasCorsConfigurationSource(handler) || CorsUtils.isPreFlightRequest(request)) { CorsConfiguration config = (this.corsConfigurationSource != null ? @@ -204,8 +208,8 @@ public abstract class AbstractHandlerMapping extends ApplicationObjectSupport } } if (getApiVersionStrategy() != null) { - Comparable version = exchange.getAttribute(API_VERSION_ATTRIBUTE); - if (version != null) { + if (versionHolder.hasVersion()) { + Comparable version = versionHolder.getVersion(); getApiVersionStrategy().handleDeprecations(version, handler, exchange); } } @@ -213,16 +217,25 @@ public abstract class AbstractHandlerMapping extends ApplicationObjectSupport }); } - private void initApiVersion(ServerWebExchange exchange) { - if (this.apiVersionStrategy != null) { - Comparable version = exchange.getAttribute(API_VERSION_ATTRIBUTE); - if (version == null) { - version = this.apiVersionStrategy.resolveParseAndValidateVersion(exchange); - if (version != null) { - exchange.getAttributes().put(API_VERSION_ATTRIBUTE, version); + private ApiVersionHolder initApiVersion(ServerWebExchange exchange) { + ApiVersionHolder versionHolder = exchange.getAttribute(API_VERSION_ATTRIBUTE); + if (versionHolder == null) { + if (this.apiVersionStrategy == null) { + versionHolder = ApiVersionHolder.EMPTY; + } + else { + Comparable version; + try { + version = this.apiVersionStrategy.resolveParseAndValidateVersion(exchange); + versionHolder = ApiVersionHolder.fromVersion(version); + } + catch (RuntimeException ex) { + versionHolder = ApiVersionHolder.fromError(ex); } } } + exchange.getAttributes().put(API_VERSION_ATTRIBUTE, versionHolder); + return versionHolder; } /** diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java index 1d0843f289a..e3dbb29237d 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java @@ -24,6 +24,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.NotAcceptableApiVersionException; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.reactive.HandlerMapping; @@ -99,12 +100,14 @@ public final class VersionRequestCondition extends AbstractRequestCondition requestVersion = exchange.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + ApiVersionHolder versionHolder = exchange.getRequiredAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); - if (this.version == null || requestVersion == null) { + if (this.version == null || !versionHolder.hasVersion()) { return this; } + Comparable requestVersion = versionHolder.getVersion(); + // Always use a baseline match here in order to select the highest version (baseline or fixed) // The fixed version match is enforced at the end in handleMatch() @@ -130,8 +133,8 @@ public final class VersionRequestCondition extends AbstractRequestCondition version = exchange.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); - return (version == null ? -1 * result : result); + ApiVersionHolder holder = exchange.getRequiredAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + return (!holder.hasVersion() ? -1 * result : result); } } @@ -151,7 +154,8 @@ public final class VersionRequestCondition extends AbstractRequestCondition version = exchange.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + ApiVersionHolder holder = exchange.getRequiredAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + Comparable version = holder.getVersionIfPresent(); if (version != null && !this.version.equals(version)) { throw new NotAcceptableApiVersionException(version.toString()); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ApiVersionMethodArgumentResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ApiVersionMethodArgumentResolver.java index 80341cc83e9..911fff8ca6b 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ApiVersionMethodArgumentResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ApiVersionMethodArgumentResolver.java @@ -21,6 +21,7 @@ import java.util.Optional; import org.jspecify.annotations.Nullable; import org.springframework.core.MethodParameter; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.MissingApiVersionException; import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.reactive.BindingContext; @@ -45,7 +46,8 @@ public class ApiVersionMethodArgumentResolver implements SyncHandlerMethodArgume public @Nullable Object resolveArgumentValue( MethodParameter parameter, BindingContext bindingContext, ServerWebExchange exchange) { - Object version = exchange.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + ApiVersionHolder versionHolder = exchange.getRequiredAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + Object version = versionHolder.getVersionIfPresent(); if (parameter.getParameterType() == Optional.class) { return Optional.ofNullable(version); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java index ec779c637f9..7a2fbc496ad 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.reactive.accept.ApiVersionStrategy; @@ -374,7 +375,7 @@ class RequestPredicatesTests { ApiVersionStrategy versionStrategy = apiVersionStrategy(); Comparable parsedVersion = versionStrategy.parseVersion(version); MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("https://localhost")); - exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, parsedVersion); + exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.fromVersion(parsedVersion)); return new DefaultServerRequest(exchange, Collections.emptyList(), versionStrategy); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/RequestMappingInfoTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/RequestMappingInfoTests.java index 500fd6d36b7..540e3b4758e 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/RequestMappingInfoTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/RequestMappingInfoTests.java @@ -26,7 +26,9 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.reactive.result.method.RequestMappingInfo; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; @@ -99,7 +101,7 @@ class RequestMappingInfoTests { @Test void matchPatternsCondition() { - MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/foo")); + MockServerWebExchange exchange = initExchange(MockServerHttpRequest.get("/foo")); RequestMappingInfo info = paths("/foo*", "/bar").build(); RequestMappingInfo expected = paths("/foo*").build(); @@ -114,7 +116,7 @@ class RequestMappingInfoTests { @Test void matchParamsCondition() { - ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/foo?foo=bar")); + ServerWebExchange exchange = initExchange(MockServerHttpRequest.get("/foo?foo=bar")); RequestMappingInfo info = paths("/foo").params("foo=bar").build(); RequestMappingInfo match = info.getMatchingCondition(exchange); @@ -129,8 +131,7 @@ class RequestMappingInfoTests { @Test void matchHeadersCondition() { - MockServerHttpRequest request = MockServerHttpRequest.get("/foo").header("foo", "bar").build(); - ServerWebExchange exchange = MockServerWebExchange.from(request); + ServerWebExchange exchange = initExchange(MockServerHttpRequest.get("/foo").header("foo", "bar")); RequestMappingInfo info = paths("/foo").headers("foo=bar").build(); RequestMappingInfo match = info.getMatchingCondition(exchange); @@ -145,8 +146,8 @@ class RequestMappingInfoTests { @Test void matchConsumesCondition() { - MockServerHttpRequest request = MockServerHttpRequest.post("/foo").contentType(MediaType.TEXT_PLAIN).build(); - ServerWebExchange exchange = MockServerWebExchange.from(request); + ServerWebExchange exchange = initExchange( + MockServerHttpRequest.post("/foo").contentType(MediaType.TEXT_PLAIN)); RequestMappingInfo info = paths("/foo").consumes("text/plain").build(); RequestMappingInfo match = info.getMatchingCondition(exchange); @@ -161,8 +162,7 @@ class RequestMappingInfoTests { @Test void matchProducesCondition() { - MockServerHttpRequest request = MockServerHttpRequest.get("/foo").accept(MediaType.TEXT_PLAIN).build(); - ServerWebExchange exchange = MockServerWebExchange.from(request); + ServerWebExchange exchange = initExchange(MockServerHttpRequest.get("/foo").accept(MediaType.TEXT_PLAIN)); RequestMappingInfo info = paths("/foo").produces("text/plain").build(); RequestMappingInfo match = info.getMatchingCondition(exchange); @@ -177,7 +177,7 @@ class RequestMappingInfoTests { @Test void matchCustomCondition() { - ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/foo?foo=bar")); + ServerWebExchange exchange = initExchange(MockServerHttpRequest.get("/foo?foo=bar")); RequestMappingInfo info = paths("/foo").params("foo=bar").build(); RequestMappingInfo match = info.getMatchingCondition(exchange); @@ -198,7 +198,7 @@ class RequestMappingInfoTests { RequestMappingInfo oneMethod = paths().methods(RequestMethod.GET).build(); RequestMappingInfo oneMethodOneParam = paths().methods(RequestMethod.GET).params("foo").build(); - ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/foo")); + ServerWebExchange exchange = initExchange(MockServerHttpRequest.get("/foo")); Comparator comparator = (info, otherInfo) -> info.compareTo(otherInfo, exchange); List list = asList(none, oneMethod, oneMethodOneParam); @@ -327,4 +327,10 @@ class RequestMappingInfoTests { .containsOnly(MediaType.parseMediaType("application/hal+json")); } + private static MockServerWebExchange initExchange(MockServerHttpRequest.BaseBuilder requestBuilder) { + MockServerWebExchange exchange = MockServerWebExchange.from(requestBuilder.build()); + exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.EMPTY); + return exchange; + } + } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java index e658ec41da0..4fe3ba26221 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java @@ -24,6 +24,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.NotAcceptableApiVersionException; import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.reactive.HandlerMapping; @@ -179,13 +180,15 @@ public class VersionRequestConditionTests { } private static MockServerWebExchange exchange() { - return MockServerWebExchange.from(MockServerHttpRequest.get("/path")); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path")); + exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.EMPTY); + return exchange; } private ServerWebExchange exchangeWithVersion(String v) { Comparable version = this.strategy.parseVersion(v); MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path")); - exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, version); + exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.fromVersion(version)); return exchange; } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ApiVersionMethodArgumentResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ApiVersionMethodArgumentResolverTests.java index a1044162f5c..a62501b5d2d 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ApiVersionMethodArgumentResolverTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ApiVersionMethodArgumentResolverTests.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.core.MethodParameter; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.accept.SemanticApiVersionParser.Version; import org.springframework.web.reactive.BindingContext; @@ -43,7 +44,7 @@ class ApiVersionMethodArgumentResolverTests { private final ApiVersionMethodArgumentResolver resolver = new ApiVersionMethodArgumentResolver(); - private final MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); + private MockServerWebExchange exchange; private MethodParameter param; private MethodParameter nullableParam; @@ -54,6 +55,9 @@ class ApiVersionMethodArgumentResolverTests { @BeforeEach void setUp() throws Exception { + this.exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); + this.exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.EMPTY); + Method method = getClass().getDeclaredMethod( "handle", Version.class, Version.class, Optional.class, int.class); @@ -74,7 +78,7 @@ class ApiVersionMethodArgumentResolverTests { @Test void resolveArgument() throws Exception { Version version = new SemanticApiVersionParser().parseVersion("1.2"); - this.exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, version); + this.exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.fromVersion(version)); Object actual = this.resolver.resolveArgumentValue(this.param, new BindingContext(), exchange); @@ -92,7 +96,7 @@ class ApiVersionMethodArgumentResolverTests { @Test void resolveOptionalArgument() { Version version = new SemanticApiVersionParser().parseVersion("1.2"); - this.exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, version); + this.exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.fromVersion(version)); Object actual = this.resolver.resolveArgumentValue(this.optionalParam, new BindingContext(), exchange); assertThat(actual).asInstanceOf(OPTIONAL).hasValue(version); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java index 107aa33c700..ea5225e8b41 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java @@ -22,11 +22,13 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.Method; import java.security.Principal; +import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; import org.springframework.cglib.proxy.Enhancer; import org.springframework.cglib.proxy.NoOp; @@ -35,6 +37,9 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; +import org.springframework.web.accept.ApiVersionHolder; +import org.springframework.web.accept.InvalidApiVersionException; +import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -44,7 +49,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.support.StaticWebApplicationContext; +import org.springframework.web.method.HandlerMethod; import org.springframework.web.method.HandlerTypePredicate; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.accept.DefaultApiVersionStrategy; +import org.springframework.web.reactive.accept.HeaderApiVersionResolver; import org.springframework.web.reactive.result.condition.ConsumesRequestCondition; import org.springframework.web.reactive.result.condition.MediaTypeExpression; import org.springframework.web.reactive.result.method.RequestMappingInfo; @@ -107,6 +116,35 @@ class RequestMappingHandlerMappingTests { assertThat(info.getPatternsCondition().getPatterns()).containsOnly(new PathPatternParser().parse("/api/user/{id}")); } + @Test + void version() { + ServerWebExchange exchange = initExchangeForVersionTest("1.1"); + HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); + assertThat(handlerMethod.getMethod().getName()).isEqualTo("foo1_1"); + } + + @Test + void versionInvalid() { + ServerWebExchange exchange = initExchangeForVersionTest("99"); + StepVerifier.create(this.handlerMapping.getHandler(exchange)) + .verifyError(InvalidApiVersionException.class); + } + + private ServerWebExchange initExchangeForVersionTest(String version) { + + ((StaticWebApplicationContext) this.handlerMapping.getApplicationContext()) + .registerSingleton("controller", VersionController.class); + + DefaultApiVersionStrategy versionStrategy = new DefaultApiVersionStrategy( + List.of(new HeaderApiVersionResolver("API-Version")), new SemanticApiVersionParser(), + true, null, true, null, null); + this.handlerMapping.setApiVersionStrategy(versionStrategy); + this.handlerMapping.afterPropertiesSet(); + + MockServerHttpRequest request = MockServerHttpRequest.get("/foo").header("API-Version", version).build(); + return MockServerWebExchange.from(request); + } + @Test void resolveRequestMappingViaComposedAnnotation() { RequestMappingInfo info = assertComposedAnnotationMapping("postJson", "/postJson", RequestMethod.POST); @@ -367,6 +405,7 @@ class RequestMappingHandlerMappingTests { MockServerHttpRequest request = MockServerHttpRequest.post(path) .contentType(mediaType).build(); ServerWebExchange exchange = MockServerWebExchange.from(request); + exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.EMPTY); RequestMappingInfo matchingInfo = info.getMatchingCondition(exchange); // Since the request has no body AND the required flag is false, the // ConsumesCondition in the matching condition in an EMPTY_CONDITION. @@ -409,6 +448,7 @@ class RequestMappingHandlerMappingTests { MockServerHttpRequest request = MockServerHttpRequest.post(path) .contentType(mediaType).build(); ServerWebExchange exchange = MockServerWebExchange.from(request); + exchange.getAttributes().put(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.EMPTY); RequestMappingInfo matchingInfo = info.getMatchingCondition(exchange); assertThat(matchingInfo).isEqualTo(paths(path).methods(POST).consumes(mediaType.toString()).build()); } @@ -568,6 +608,22 @@ class RequestMappingHandlerMappingTests { } + @RestController + @RequestMapping("/foo") + static class VersionController { + + @GetMapping + public String foo() { + return "foo"; + } + + @GetMapping(version = "1.1") + public String foo1_1() { + return "foo1_1"; + } + } + + @RestController @HttpExchange("/exchange") static class HttpExchangeController { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/HandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/HandlerMapping.java index 081ac53ff02..fb9cb372694 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/HandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/HandlerMapping.java @@ -20,6 +20,8 @@ import jakarta.servlet.ServletRequest; import jakarta.servlet.http.HttpServletRequest; import org.jspecify.annotations.Nullable; +import org.springframework.web.accept.ApiVersionHolder; + /** * Interface to be implemented by objects that define a mapping between * requests and handler objects. @@ -136,8 +138,9 @@ public interface HandlerMapping { String PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE = HandlerMapping.class.getName() + ".producibleMediaTypes"; /** - * Name of the {@link HttpServletRequest} attribute that contains the - * resolved and parsed API version. + * Name of the {@link HttpServletRequest} attribute that contains an + * {@link ApiVersionHolder} with the + * result of obtaining and parsing the API version of the request. * @since 7.0 */ String API_VERSION_ATTRIBUTE = HandlerMapping.class.getName() + ".apiVersion"; diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java index 3cf40bd24ae..a56aedabb0e 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/RequestPredicates.java @@ -54,6 +54,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.MimeTypeUtils; import org.springframework.util.MultiValueMap; import org.springframework.validation.BindException; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.cors.CorsUtils; @@ -890,14 +891,17 @@ public abstract class RequestPredicates { this.parsedVersion = strategy.parseVersion(this.version); } - Comparable requestVersion = - (Comparable) request.attribute(HandlerMapping.API_VERSION_ATTRIBUTE).orElse(null); + ApiVersionHolder requestVersionHolder = + (ApiVersionHolder) request.attribute(HandlerMapping.API_VERSION_ATTRIBUTE) + .orElseThrow(() -> new IllegalStateException("Expect API version attribute")); - if (requestVersion == null) { + if (!requestVersionHolder.hasVersion()) { traceMatch("Version", this.version, null, true); return true; } + Comparable requestVersion = requestVersionHolder.getVersion(); + int result = compareVersions(this.parsedVersion, requestVersion); boolean match = (this.baselineVersion ? result <= 0 : result == 0); traceMatch("Version", this.version, requestVersion, match); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java index da4aaa0f269..d250df937cb 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java @@ -39,6 +39,7 @@ import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.PathMatcher; import org.springframework.web.HttpRequestHandler; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.context.request.WebRequestInterceptor; import org.springframework.web.context.request.async.WebAsyncManager; @@ -539,7 +540,7 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport */ @Override public final @Nullable HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception { - initApiVersion(request); + ApiVersionHolder versionHolder = initApiVersion(request); Object handler = getHandlerInternal(request); if (handler == null) { handler = getDefaultHandler(); @@ -547,6 +548,11 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport if (handler == null) { return null; } + + if (versionHolder.hasError()) { + throw versionHolder.getError(); + } + // Bean name or resolved handler? if (handler instanceof String handlerName) { handler = obtainApplicationContext().getBean(handlerName); @@ -584,16 +590,25 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport return executionChain; } - private void initApiVersion(HttpServletRequest request) { - if (this.versionStrategy != null) { - Comparable version = (Comparable) request.getAttribute(API_VERSION_ATTRIBUTE); - if (version == null) { - version = this.versionStrategy.resolveParseAndValidateVersion(request); - if (version != null) { - request.setAttribute(API_VERSION_ATTRIBUTE, version); + private ApiVersionHolder initApiVersion(HttpServletRequest request) { + ApiVersionHolder versionHolder = (ApiVersionHolder) request.getAttribute(API_VERSION_ATTRIBUTE); + if (versionHolder == null) { + if (this.versionStrategy == null) { + versionHolder = ApiVersionHolder.EMPTY; + } + else { + Comparable version; + try { + version = this.versionStrategy.resolveParseAndValidateVersion(request); + versionHolder = ApiVersionHolder.fromVersion(version); + } + catch (RuntimeException ex) { + versionHolder = ApiVersionHolder.fromError(ex); } } } + request.setAttribute(API_VERSION_ATTRIBUTE, versionHolder); + return versionHolder; } /** @@ -683,8 +698,9 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport } if (this.versionStrategy != null) { - Comparable version = (Comparable) request.getAttribute(API_VERSION_ATTRIBUTE); - if (version != null) { + ApiVersionHolder versionHolder = (ApiVersionHolder) request.getAttribute(API_VERSION_ATTRIBUTE); + if (versionHolder.hasVersion()) { + Comparable version = versionHolder.getVersion(); chain.addInterceptor(new ApiVersionDeprecationHandlerInterceptor(this.versionStrategy, version)); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java index cc71d473bf7..825d82f0921 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java @@ -25,6 +25,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.NotAcceptableApiVersionException; import org.springframework.web.bind.annotation.RequestMapping; @@ -98,12 +99,14 @@ public final class VersionRequestCondition extends AbstractRequestCondition requestVersion = (Comparable) request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + ApiVersionHolder versionHolder = (ApiVersionHolder) request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); - if (this.version == null || requestVersion == null) { + if (this.version == null || !versionHolder.hasVersion()) { return this; } + Comparable requestVersion = versionHolder.getVersion(); + // Always use a baseline match here in order to select the highest version (baseline or fixed) // The fixed version match is enforced at the end in handleMatch() @@ -129,8 +132,8 @@ public final class VersionRequestCondition extends AbstractRequestCondition version = (Comparable) request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); - return (version == null ? -1 * result : result); + ApiVersionHolder holder = (ApiVersionHolder) request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + return (!holder.hasVersion() ? -1 * result : result); } } @@ -150,7 +153,8 @@ public final class VersionRequestCondition extends AbstractRequestCondition version = (Comparable) request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + ApiVersionHolder holder = (ApiVersionHolder) request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + Comparable version = holder.getVersionIfPresent(); if (version != null && !this.version.equals(version)) { throw new NotAcceptableApiVersionException(version.toString()); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ApiVersionMethodArgumentResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ApiVersionMethodArgumentResolver.java index 09fcca92d6f..b58f2cf935b 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ApiVersionMethodArgumentResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ApiVersionMethodArgumentResolver.java @@ -23,6 +23,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.core.MethodParameter; import org.springframework.util.Assert; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.MissingApiVersionException; import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.bind.support.WebDataBinderFactory; @@ -51,7 +52,8 @@ public class ApiVersionMethodArgumentResolver implements HandlerMethodArgumentRe HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); Assert.state(request != null, "No HttpServletRequest"); - Object version = request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + ApiVersionHolder versionHolder = (ApiVersionHolder) request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + Object version = versionHolder.getVersionIfPresent(); if (parameter.getParameterType() == Optional.class) { return Optional.ofNullable(version); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java index 55f9eb0d22d..763328d6c7c 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.DefaultApiVersionStrategy; import org.springframework.web.accept.SemanticApiVersionParser; @@ -284,7 +285,8 @@ class RequestPredicatesTests { MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest("GET", null, "/path", true, - req -> req.setAttribute(API_VERSION_ATTRIBUTE, strategy.parseVersion(version))); + req -> req.setAttribute(API_VERSION_ATTRIBUTE, + ApiVersionHolder.fromVersion(strategy.parseVersion(version)))); return new DefaultServerRequest(servletRequest, Collections.emptyList(), strategy); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java index 5fbb0769826..0c5142f049d 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java @@ -24,6 +24,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.DefaultApiVersionStrategy; import org.springframework.web.accept.NotAcceptableApiVersionException; import org.springframework.web.accept.SemanticApiVersionParser; @@ -125,7 +126,8 @@ public class VersionRequestConditionTests { String version = "1.2"; this.strategy = initVersionStrategy(version); VersionRequestCondition condition = condition(version); - VersionRequestCondition match = condition.getMatchingCondition(new MockHttpServletRequest("GET", "/path")); + + VersionRequestCondition match = condition.getMatchingCondition(requestWithVersion(null)); assertThat(match).isSameAs(condition); } @@ -141,7 +143,7 @@ public class VersionRequestConditionTests { private void testCompare(String expected, String... versions) { List list = Arrays.stream(versions) .map(this::condition) - .sorted((c1, c2) -> c1.compareTo(c2, new MockHttpServletRequest())) + .sorted((c1, c2) -> c1.compareTo(c2, requestWithVersion(null))) .toList(); assertThat(list.get(0)).isEqualTo(condition(expected)); @@ -150,7 +152,7 @@ public class VersionRequestConditionTests { @Test void compareWithoutRequestVersion() { VersionRequestCondition condition = Stream.of(condition("1.1"), condition("1.2"), emptyCondition()) - .min((c1, c2) -> c1.compareTo(c2, new MockHttpServletRequest())) + .min((c1, c2) -> c1.compareTo(c2, requestWithVersion(null))) .get(); assertThat(condition).isEqualTo(emptyCondition()); @@ -158,7 +160,7 @@ public class VersionRequestConditionTests { @Test // gh-35236 void noRequestVersion() { - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path"); + MockHttpServletRequest request = requestWithVersion(null); VersionRequestCondition condition = condition("1.1"); VersionRequestCondition match = condition.getMatchingCondition(request); @@ -178,7 +180,8 @@ public class VersionRequestConditionTests { private MockHttpServletRequest requestWithVersion(String v) { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path"); - request.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, this.strategy.parseVersion(v)); + Comparable version = (v != null ? strategy.parseVersion(v) : null); + request.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.fromVersion(version)); return request; } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ApiVersionMethodArgumentResolverTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ApiVersionMethodArgumentResolverTests.java index 18663afee2b..10d298a4191 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ApiVersionMethodArgumentResolverTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ApiVersionMethodArgumentResolverTests.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.core.MethodParameter; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.accept.SemanticApiVersionParser.Version; import org.springframework.web.context.request.ServletWebRequest; @@ -82,7 +83,7 @@ class ApiVersionMethodArgumentResolverTests { @Test void resolveArgument() throws Exception { Version version = new SemanticApiVersionParser().parseVersion("1.2"); - this.servletRequest.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, version); + this.servletRequest.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.fromVersion(version)); Object actual = this.resolver.resolveArgument(this.param, this.mav, this.webRequest, null); @@ -93,6 +94,7 @@ class ApiVersionMethodArgumentResolverTests { @Test void resolveNullableArgument() throws Exception { + this.servletRequest.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.EMPTY); Object actual = this.resolver.resolveArgument(this.nullableParam, this.mav, this.webRequest, null); assertThat(actual).isNull(); } @@ -100,7 +102,7 @@ class ApiVersionMethodArgumentResolverTests { @Test void resolveOptionalArgument() throws Exception { Version version = new SemanticApiVersionParser().parseVersion("1.2"); - this.servletRequest.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, version); + this.servletRequest.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.fromVersion(version)); Object actual = this.resolver.resolveArgument(this.optionalParam, this.mav, this.webRequest, null); assertThat(actual).asInstanceOf(OPTIONAL).hasValue(version); @@ -108,6 +110,7 @@ class ApiVersionMethodArgumentResolverTests { @Test void resolveOptionalArgumentWhenEmpty() throws Exception { + this.servletRequest.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.EMPTY); Object actual = this.resolver.resolveArgument(this.optionalParam, this.mav, this.webRequest, null); assertThat(actual).asInstanceOf(OPTIONAL).isEmpty(); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterIntegrationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterIntegrationTests.java index 6e63ffe1eb2..c4cb0d7ad5c 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterIntegrationTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterIntegrationTests.java @@ -57,6 +57,7 @@ import org.springframework.ui.ModelMap; import org.springframework.validation.BindingResult; import org.springframework.validation.Errors; import org.springframework.validation.Validator; +import org.springframework.web.accept.ApiVersionHolder; import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.CookieValue; @@ -176,7 +177,7 @@ class RequestMappingHandlerAdapterIntegrationTests { request.getSession().setAttribute("sessionAttribute", sessionAttribute); request.setAttribute("requestAttribute", requestAttribute); SemanticApiVersionParser.Version version = new SemanticApiVersionParser().parseVersion("1.2"); - request.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, version); + request.setAttribute(HandlerMapping.API_VERSION_ATTRIBUTE, ApiVersionHolder.fromVersion(version)); HandlerMethod handlerMethod = handlerMethod("handle", parameterTypes); ModelAndView mav = handlerAdapter.handle(request, response, handlerMethod); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMappingTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMappingTests.java index 7663dc2767f..249cab04570 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMappingTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMappingTests.java @@ -23,6 +23,7 @@ import java.lang.annotation.Target; import java.lang.reflect.Method; import java.security.Principal; import java.util.Collections; +import java.util.List; import java.util.Set; import java.util.stream.Stream; @@ -35,6 +36,10 @@ import org.springframework.core.annotation.AliasFor; import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.util.ClassUtils; +import org.springframework.web.accept.DefaultApiVersionStrategy; +import org.springframework.web.accept.HeaderApiVersionResolver; +import org.springframework.web.accept.InvalidApiVersionException; +import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -44,6 +49,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.support.StaticWebApplicationContext; +import org.springframework.web.method.HandlerMethod; import org.springframework.web.method.HandlerTypePredicate; import org.springframework.web.service.annotation.HttpExchange; import org.springframework.web.service.annotation.PostExchange; @@ -60,6 +66,7 @@ import org.springframework.web.util.pattern.PathPatternParser; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Named.named; import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.mockito.Mockito.mock; @@ -160,6 +167,36 @@ class RequestMappingHandlerMappingTests { } } + @PathPatternsParameterizedTest + void version(RequestMappingHandlerMapping mapping) throws Exception { + MockHttpServletRequest request = initRequestForVersionTest(mapping, "1.1"); + HandlerMethod handlerMethod = (HandlerMethod) mapping.getHandler(request).getHandler(); + assertThat(handlerMethod.getMethod().getName()).isEqualTo("foo1_1"); + } + + @PathPatternsParameterizedTest + void versionInvalid(RequestMappingHandlerMapping mapping) throws Exception { + MockHttpServletRequest request = initRequestForVersionTest(mapping, "99"); + assertThatThrownBy(() -> mapping.getHandler(request)).isInstanceOf(InvalidApiVersionException.class); + } + + private static MockHttpServletRequest initRequestForVersionTest( + RequestMappingHandlerMapping mapping, String version) { + + ((StaticWebApplicationContext) mapping.getApplicationContext()) + .registerSingleton("controller", VersionController.class); + + DefaultApiVersionStrategy versionStrategy = new DefaultApiVersionStrategy( + List.of(new HeaderApiVersionResolver("API-Version")), new SemanticApiVersionParser(), + true, null, true, null, null); + mapping.setApiVersionStrategy(versionStrategy); + mapping.afterPropertiesSet(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/foo"); + request.addHeader("API-Version", version); + return request; + } + @PathPatternsParameterizedTest void resolveRequestMappingViaComposedAnnotation(RequestMappingHandlerMapping mapping) { RequestMappingInfo info = assertComposedAnnotationMapping( @@ -626,6 +663,22 @@ class RequestMappingHandlerMappingTests { } + @RestController + @RequestMapping("/foo") + static class VersionController { + + @GetMapping + public String foo() { + return "foo"; + } + + @GetMapping(version = "1.1") + public String foo1_1() { + return "foo1_1"; + } + } + + @RestController @HttpExchange("/exchange") static class HttpExchangeController {