API versioning support for Spring MVC

See gh-34566
This commit is contained in:
rstoyanchev
2025-03-06 14:09:43 +00:00
parent e9701a9ce3
commit 51d34fff64
32 changed files with 1673 additions and 38 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-2025 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.
@@ -264,9 +264,8 @@ public class DelegatingWebMvcConfigurationTests {
};
RequestMappingHandlerMapping annotationsMapping = webMvcConfig.requestMappingHandlerMapping(
webMvcConfig.mvcContentNegotiationManager(),
webMvcConfig.mvcConversionService(),
webMvcConfig.mvcResourceUrlProvider());
webMvcConfig.mvcContentNegotiationManager(), webMvcConfig.mvcApiVersionStrategy(),
webMvcConfig.mvcConversionService(), webMvcConfig.mvcResourceUrlProvider());
assertThat(annotationsMapping).isNotNull();
configAssertion.accept(annotationsMapping.getUrlPathHelper(), annotationsMapping.getPathMatcher());
@@ -330,9 +329,8 @@ public class DelegatingWebMvcConfigurationTests {
};
RequestMappingHandlerMapping annotationsMapping = webMvcConfig.requestMappingHandlerMapping(
webMvcConfig.mvcContentNegotiationManager(),
webMvcConfig.mvcConversionService(),
webMvcConfig.mvcResourceUrlProvider());
webMvcConfig.mvcContentNegotiationManager(), webMvcConfig.mvcApiVersionStrategy(),
webMvcConfig.mvcConversionService(), webMvcConfig.mvcResourceUrlProvider());
assertThat(annotationsMapping).isNotNull();
assertThat(annotationsMapping.getPatternParser())

View File

@@ -126,7 +126,7 @@ class WebMvcConfigurationSupportExtensionTests {
@Test
void handlerMappings() throws Exception {
RequestMappingHandlerMapping rmHandlerMapping = this.config.requestMappingHandlerMapping(
this.config.mvcContentNegotiationManager(),
this.config.mvcContentNegotiationManager(), this.config.mvcApiVersionStrategy(),
this.config.mvcConversionService(), this.config.mvcResourceUrlProvider());
rmHandlerMapping.setApplicationContext(this.context);
rmHandlerMapping.afterPropertiesSet();
@@ -266,8 +266,8 @@ class WebMvcConfigurationSupportExtensionTests {
NativeWebRequest webRequest = new ServletWebRequest(request);
RequestMappingHandlerMapping mapping = this.config.requestMappingHandlerMapping(
this.config.mvcContentNegotiationManager(), this.config.mvcConversionService(),
this.config.mvcResourceUrlProvider());
this.config.mvcContentNegotiationManager(), this.config.mvcApiVersionStrategy(),
this.config.mvcConversionService(), this.config.mvcResourceUrlProvider());
request.setParameter("f", "json");
ContentNegotiationManager manager = mapping.getContentNegotiationManager();
@@ -347,7 +347,7 @@ class WebMvcConfigurationSupportExtensionTests {
* plus WebMvcConfigurer can switch to extending WebMvcConfigurationSupport directly for
* more advanced configuration needs.
*/
private class TestWebMvcConfigurationSupport extends WebMvcConfigurationSupport implements WebMvcConfigurer {
private static class TestWebMvcConfigurationSupport extends WebMvcConfigurationSupport implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
@@ -384,6 +384,11 @@ class WebMvcConfigurationSupportExtensionTests {
configurer.favorParameter(true).parameterName("f");
}
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer.useRequestHeader("X-API-Version");
}
@Override
@SuppressWarnings("deprecation")
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {

View File

@@ -0,0 +1,167 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.servlet.mvc.condition;
import java.util.Arrays;
import java.util.List;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.accept.DefaultApiVersionStrategy;
import org.springframework.web.accept.NotAcceptableApiVersionException;
import org.springframework.web.accept.SemanticApiVersionParser;
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
/**
* Unit tests for {@link VersionRequestCondition}.
* @author Rossen Stoyanchev
*/
public class VersionRequestConditionTests {
private DefaultApiVersionStrategy strategy;
@BeforeEach
void setUp() {
this.strategy = initVersionStrategy(null);
}
private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultValue) {
return new DefaultApiVersionStrategy(
List.of(request -> request.getParameter("api-version")),
new SemanticApiVersionParser(), true, defaultValue);
}
@Test
void combineMethodLevelOnly() {
VersionRequestCondition condition = emptyCondition().combine(condition("1.1"));
assertThat(condition.getVersion()).isEqualTo("1.1");
}
@Test
void combineTypeLevelOnly() {
VersionRequestCondition condition = condition("1.1").combine(emptyCondition());
assertThat(condition.getVersion()).isEqualTo("1.1");
}
@Test
void combineTypeAndMethodLevel() {
assertThat(condition("1.1").combine(condition("1.2")).getVersion()).isEqualTo("1.2");
}
@Test
void fixedVersionMatch() {
String conditionVersion = "1.2";
this.strategy.addSupportedVersion("1.1", "1.3");
testMatch("v1.1", conditionVersion, true, false);
testMatch("v1.2", conditionVersion, false, false);
testMatch("v1.3", conditionVersion, false, true);
}
@Test
void baselineVersionMatch() {
String conditionVersion = "1.2+";
this.strategy.addSupportedVersion("1.1", "1.3");
testMatch("v1.1", conditionVersion, true, false);
testMatch("v1.2", conditionVersion, false, false);
testMatch("v1.3", conditionVersion, false, false);
}
private void testMatch(
String requestVersion, String conditionVersion, boolean notCompatible, boolean notAcceptable) {
MockHttpServletRequest request = requestWithVersion(requestVersion);
VersionRequestCondition condition = condition(conditionVersion);
VersionRequestCondition match = condition.getMatchingCondition(request);
if (notCompatible) {
assertThat(match).isNull();
return;
}
assertThat(match).isSameAs(condition);
if (notAcceptable) {
assertThatThrownBy(() -> condition.handleMatch(request)).isInstanceOf(NotAcceptableApiVersionException.class);
return;
}
condition.handleMatch(request);
}
@Test
void missingRequiredVersion() {
assertThatThrownBy(() -> condition("1.2").getMatchingCondition(new MockHttpServletRequest("GET", "/path")))
.hasMessage("400 BAD_REQUEST \"API version is required.\"");
}
@Test
void defaultVersion() {
String version = "1.2";
this.strategy = initVersionStrategy(version);
VersionRequestCondition condition = condition(version);
VersionRequestCondition match = condition.getMatchingCondition(new MockHttpServletRequest("GET", "/path"));
assertThat(match).isSameAs(condition);
}
@Test
void unsupportedVersion() {
assertThatThrownBy(() -> condition("1.2").getMatchingCondition(requestWithVersion("1.3")))
.hasMessage("400 BAD_REQUEST \"Invalid API version: '1.3.0'.\"");
}
@Test
void compare() {
testCompare("1.1", "1", "1.1");
testCompare("1.1.1", "1", "1.1", "1.1.1");
testCompare("10", "1.1", "10");
testCompare("10", "2", "10");
}
private void testCompare(String expected, String... versions) {
List<VersionRequestCondition> list = Arrays.stream(versions)
.map(this::condition)
.sorted((c1, c2) -> c1.compareTo(c2, new MockHttpServletRequest()))
.toList();
assertThat(list.get(0)).isEqualTo(condition(expected));
}
private VersionRequestCondition condition(String v) {
this.strategy.addSupportedVersion(v.endsWith("+") ? v.substring(0, v.length() - 1) : v);
return new VersionRequestCondition(v, this.strategy);
}
private VersionRequestCondition emptyCondition() {
return new VersionRequestCondition();
}
private MockHttpServletRequest requestWithVersion(String v) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path");
request.addParameter("api-version", v);
return request;
}
}

View File

@@ -40,7 +40,7 @@ class RequestMappingInfoHandlerMethodMappingNamingStrategyTests {
HandlerMethod handlerMethod = new HandlerMethod(new TestController(), method);
@SuppressWarnings("deprecation")
RequestMappingInfo rmi = new RequestMappingInfo("foo", null, null, null, null, null, null, null);
RequestMappingInfo rmi = new RequestMappingInfo("foo", null, null, null, null, null, null, null, null);
HandlerMethodMappingNamingStrategy<RequestMappingInfo> strategy = new RequestMappingInfoHandlerMethodMappingNamingStrategy();

View File

@@ -0,0 +1,121 @@
/*
* Copyright 2002-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.servlet.mvc.method.annotation;
import java.io.IOException;
import jakarta.servlet.ServletException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
import org.springframework.web.testfixture.servlet.MockServletConfig;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
/**
* Integration tests for API versioning.
* @author Rossen Stoyanchev
*/
public class RequestMappingVersionHandlerMethodTests {
private DispatcherServlet dispatcherServlet;
@BeforeEach
void setUp() throws ServletException {
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.setServletConfig(new MockServletConfig());
context.register(WebConfig.class, TestController.class);
context.afterPropertiesSet();
this.dispatcherServlet = new DispatcherServlet(context);
this.dispatcherServlet.init(new MockServletConfig());
}
@Test
void initialVersion() throws Exception {
assertThat(requestWithVersion("1.0").getContentAsString()).isEqualTo("none");
assertThat(requestWithVersion("1.1").getContentAsString()).isEqualTo("none");
}
@Test
void baselineVersion() throws Exception {
assertThat(requestWithVersion("1.2").getContentAsString()).isEqualTo("1.2");
assertThat(requestWithVersion("1.3").getContentAsString()).isEqualTo("1.2");
}
@Test
void fixedVersion() throws Exception {
assertThat(requestWithVersion("1.5").getContentAsString()).isEqualTo("1.5");
MockHttpServletResponse response = requestWithVersion("1.6");
assertThat(response.getStatus()).isEqualTo(400);
}
private MockHttpServletResponse requestWithVersion(String version) throws ServletException, IOException {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/");
request.addHeader("X-API-VERSION", version);
MockHttpServletResponse response = new MockHttpServletResponse();
this.dispatcherServlet.service(request, response);
return response;
}
@EnableWebMvc
private static class WebConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer.useRequestHeader("X-API-Version").addSupportedVersions("1", "1.1", "1.3", "1.6");
}
}
@RestController
private static class TestController {
@GetMapping
String noVersion() {
return getBody("none");
}
@GetMapping(version = "1.2+")
String version1_2() {
return getBody("1.2");
}
@GetMapping(version = "1.5")
String version1_5() {
return getBody("1.5");
}
private static String getBody(String version) {
return version;
}
}
}