From 1aaef074482b1748c7587accc471c8b098676bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Mon, 20 Apr 2026 08:10:06 +0200 Subject: [PATCH] Apply API versioning path segment strategy last This commit adapts the auto-configuration for API versioning to apply the path segment last as this strategy is not meant to yield. It also clarifies that configuration properties should not be used if ordering of the strategies is important. Closes gh-49800 --- .../modules/reference/pages/web/reactive.adoc | 6 ++-- .../modules/reference/pages/web/servlet.adoc | 6 ++-- .../WebFluxAutoConfiguration.java | 3 +- .../WebFluxAutoConfigurationTests.java | 29 +++++++++++++++ .../WebMvcAutoConfiguration.java | 3 +- .../WebMvcAutoConfigurationTests.java | 36 +++++++++++++++++++ 6 files changed, 75 insertions(+), 8 deletions(-) diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/reactive.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/reactive.adoc index c8c25c2187a..390f82507da 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/reactive.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/reactive.adoc @@ -284,7 +284,7 @@ The same `@Controller` path can be mapped multiple times to support different ve For more details see {url-spring-framework-docs}/web/webflux/controller/ann-requestmapping.html#webflux-ann-requestmapping-version[Spring Framework's reference documentation]. Once mappings have been added, you additionally need to configure Spring WebFlux so that it is able to use any version information sent with a request. -Typically, versions are sent as HTTP headers, query parameters or as part of the path. +Typically, versions are sent as HTTP headers, query parameters, media type parameters, or as part of the path. To configure Spring WebFlux, you can either use a javadoc:org.springframework.web.reactive.config.WebFluxConfigurer[] bean and override the `configureApiVersioning(...)` method, or you can use properties. @@ -300,7 +300,9 @@ spring: header: X-Version ---- -For more complete control, you can also define javadoc:org.springframework.web.reactive.accept.ApiVersionResolver[], javadoc:org.springframework.web.accept.ApiVersionParser[] and javadoc:org.springframework.web.reactive.accept.ApiVersionDeprecationHandler[] beans which will be injected into the auto-configured Spring MVC configuration. +NOTE: If your setup requires multiple strategies, such as header and query parameter, consider declaring the order programmatically by overriding the `configureApiVersioning` method. + +For more complete control, you can also define javadoc:org.springframework.web.reactive.accept.ApiVersionResolver[], javadoc:org.springframework.web.accept.ApiVersionParser[] and javadoc:org.springframework.web.reactive.accept.ApiVersionDeprecationHandler[] beans which will be injected into the auto-configured Spring WebFlux configuration. TIP: API versioning is also supported on the client-side with both `WebClient` and `RestClient`. See xref:io/rest-client.adoc#io.rest-client.apiversioning[] for details. diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc index 352737eca14..07b460a9687 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc @@ -474,8 +474,8 @@ The same `@Controller` path can be mapped multiple times to support different ve For more details see {url-spring-framework-docs}/web/webmvc/mvc-controller/ann-requestmapping.html#mvc-ann-requestmapping-version[Spring Framework's reference documentation]. -One mappings have been added, you additionally need to configure Spring MVC so that it is able to use any version information sent with a request. -Typically, versions are sent as HTTP headers, query parameters or as part of the path. +Once mappings have been added, you additionally need to configure Spring MVC so that it is able to use any version information sent with a request. +Typically, versions are sent as HTTP headers, query parameters, media type parameters, or as part of the path. To configure Spring MVC, you can either use a javadoc:org.springframework.web.servlet.config.annotation.WebMvcConfigurer[] bean and override the `configureApiVersioning(...)` method, or you can use properties. @@ -491,6 +491,8 @@ spring: header: X-Version ---- +NOTE: If your setup requires multiple strategies, such as header and query parameter, consider declaring the order programmatically by overriding the `configureApiVersioning` method. + For more complete control, you can also define javadoc:org.springframework.web.accept.ApiVersionResolver[], javadoc:org.springframework.web.accept.ApiVersionParser[] and javadoc:org.springframework.web.accept.ApiVersionDeprecationHandler[] beans which will be injected into the auto-configured Spring MVC configuration. TIP: API versioning is also supported with both `WebClient` and `RestClient`. diff --git a/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfiguration.java b/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfiguration.java index d19f3a49b06..ef40291edf0 100644 --- a/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfiguration.java +++ b/module/spring-boot-webflux/src/main/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfiguration.java @@ -299,9 +299,8 @@ public final class WebFluxAutoConfiguration { PropertyMapper map = PropertyMapper.get(); map.from(use::getHeader).whenHasText().to(configurer::useRequestHeader); map.from(use::getQueryParameter).whenHasText().to(configurer::useQueryParam); + use.getMediaTypeParameter().forEach(configurer::useMediaTypeParameter); map.from(use::getPathSegment).to(configurer::usePathSegment); - use.getMediaTypeParameter() - .forEach((mediaType, parameterName) -> configurer.useMediaTypeParameter(mediaType, parameterName)); } } diff --git a/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfigurationTests.java b/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfigurationTests.java index 43084d0f1c4..83af949ac5e 100644 --- a/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfigurationTests.java +++ b/module/spring-boot-webflux/src/test/java/org/springframework/boot/webflux/autoconfigure/WebFluxAutoConfigurationTests.java @@ -896,6 +896,35 @@ class WebFluxAutoConfigurationTests { }); } + @Test + void apiVersionUsesPathSegmentLast() { + this.contextRunner + .withPropertyValues("spring.webflux.apiversion.use.path-segment=1", + "spring.webflux.apiversion.use.header=hv", "spring.webflux.apiversion.use.query-parameter=rpv", + "spring.webflux.apiversion.use.media-type-parameter[application/json]=mtpv") + .run((context) -> { + DefaultApiVersionStrategy versionStrategy = context.getBean("webFluxApiVersionStrategy", + DefaultApiVersionStrategy.class); + + MockServerWebExchange requestWithHeader = MockServerWebExchange + .from(MockServerHttpRequest.get("https://example.com/test/456").header("hv", "123")); + assertThat(versionStrategy.resolveVersion(requestWithHeader)).isEqualTo("123"); + + MockServerWebExchange requestWithQueryParameter = MockServerWebExchange + .from(MockServerHttpRequest.get("https://example.com?rpv=123")); + assertThat(versionStrategy.resolveVersion(requestWithQueryParameter)).isEqualTo("123"); + + MockServerWebExchange requestWithMediaType = MockServerWebExchange + .from(MockServerHttpRequest.get("https://example.com/test/456") + .header("content-type", "application/json;mtpv=123")); + assertThat(versionStrategy.resolveVersion(requestWithMediaType)).isEqualTo("123"); + + MockServerWebExchange requestFallbacksToApiSegment = MockServerWebExchange + .from(MockServerHttpRequest.get("https://example.com/test/456")); + assertThat(versionStrategy.resolveVersion(requestFallbacksToApiSegment)).isEqualTo("456"); + }); + } + @Test void apiVersionBeansAreInjected() { this.contextRunner.withUserConfiguration(ApiVersionConfiguration.class).run((context) -> { diff --git a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration.java b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration.java index da1e334a302..7db7e2565e7 100644 --- a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration.java +++ b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration.java @@ -419,9 +419,8 @@ public final class WebMvcAutoConfiguration { PropertyMapper map = PropertyMapper.get(); map.from(use::getHeader).whenHasText().to(configurer::useRequestHeader); map.from(use::getQueryParameter).whenHasText().to(configurer::useQueryParam); + use.getMediaTypeParameter().forEach(configurer::useMediaTypeParameter); map.from(use::getPathSegment).to(configurer::usePathSegment); - use.getMediaTypeParameter() - .forEach((mediaType, parameterName) -> configurer.useMediaTypeParameter(mediaType, parameterName)); } @Bean diff --git a/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfigurationTests.java b/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfigurationTests.java index 38c329ca428..fd9652e37c6 100644 --- a/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfigurationTests.java +++ b/module/spring-boot-webmvc/src/test/java/org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfigurationTests.java @@ -1109,6 +1109,42 @@ class WebMvcAutoConfigurationTests { }); } + @Test + void apiVersionUsesPathSegmentLast() { + this.contextRunner + .withPropertyValues("spring.mvc.apiversion.use.path-segment=1", "spring.mvc.apiversion.use.header=hv", + "spring.mvc.apiversion.use.query-parameter=rpv", + "spring.mvc.apiversion.use.media-type-parameter[application/json]=mtpv") + .run((context) -> { + ApiVersionStrategy versionStrategy = context.getBean("mvcApiVersionStrategy", ApiVersionStrategy.class); + + MockHttpServletRequest requestWithHeader = new MockHttpServletRequest("GET", + "https://example.com/test/456"); + requestWithHeader.addHeader("hv", "123"); + ServletRequestPathUtils.setParsedRequestPath(RequestPath.parse("/test/456", "/"), requestWithHeader); + assertThat(versionStrategy.resolveVersion(requestWithHeader)).isEqualTo("123"); + + MockHttpServletRequest requestWithQueryParameter = new MockHttpServletRequest("GET", + "https://example.com/test/456"); + requestWithQueryParameter.setQueryString("rpv=123"); + ServletRequestPathUtils.setParsedRequestPath(RequestPath.parse("/test/456", "/"), + requestWithQueryParameter); + assertThat(versionStrategy.resolveVersion(requestWithQueryParameter)).isEqualTo("123"); + + MockHttpServletRequest requestWithMediaType = new MockHttpServletRequest("GET", + "https://example.com/test/456"); + ServletRequestPathUtils.setParsedRequestPath(RequestPath.parse("/test/456", "/"), requestWithMediaType); + requestWithMediaType.addHeader(HttpHeaders.CONTENT_TYPE, "application/json;mtpv=123"); + assertThat(versionStrategy.resolveVersion(requestWithMediaType)).isEqualTo("123"); + + MockHttpServletRequest requestFallbacksToApiSegment = new MockHttpServletRequest("GET", + "https://example.com/test/456"); + ServletRequestPathUtils.setParsedRequestPath(RequestPath.parse("/test/456", "/"), + requestFallbacksToApiSegment); + assertThat(versionStrategy.resolveVersion(requestFallbacksToApiSegment)).isEqualTo("456"); + }); + } + @Test void apiVersionBeansAreInjected() { this.contextRunner.withUserConfiguration(ApiVersionConfiguration.class).run((context) -> {