Support API versioning in MockMvc

See gh-34919
This commit is contained in:
rstoyanchev
2025-05-19 08:51:38 +01:00
parent a024e5985a
commit 3095219479
9 changed files with 254 additions and 15 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.
@@ -38,6 +38,7 @@ import org.springframework.test.web.servlet.setup.MockMvcConfigurer;
import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder;
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
import org.springframework.validation.Validator;
import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.accept.ContentNegotiationManager;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
@@ -284,6 +285,14 @@ public interface MockMvcWebTestClient {
*/
ControllerSpec conversionService(FormattingConversionService conversionService);
/**
* Set the {@link ApiVersionStrategy} to use when mapping requests.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setApiVersionStrategy(ApiVersionStrategy)}.
* @since 7.0
*/
ControllerSpec apiVersionStrategy(ApiVersionStrategy versionStrategy);
/**
* Add global interceptors.
* <p>This is delegated to

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.
@@ -26,6 +26,7 @@ import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
import org.springframework.validation.Validator;
import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.accept.ContentNegotiationManager;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
@@ -79,6 +80,12 @@ class StandaloneMockMvcSpec extends AbstractMockMvcServerSpec<MockMvcWebTestClie
return this;
}
@Override
public MockMvcWebTestClient.ControllerSpec apiVersionStrategy(ApiVersionStrategy versionStrategy) {
this.mockMvcBuilder.setApiVersionStrategy(versionStrategy);
return this;
}
@Override
public StandaloneMockMvcSpec interceptors(HandlerInterceptor... interceptors) {
mappedInterceptors(null, interceptors);

View File

@@ -50,11 +50,14 @@ import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.client.ApiVersionFormatter;
import org.springframework.web.client.ApiVersionInserter;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
import org.springframework.web.servlet.DispatcherServlet;
@@ -118,6 +121,10 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
private final List<Locale> locales = new ArrayList<>();
private @Nullable Object version;
private @Nullable ApiVersionInserter versionInserter;
private final Map<String, Object> requestAttributes = new LinkedHashMap<>();
private final Map<String, Object> sessionAttributes = new LinkedHashMap<>();
@@ -469,6 +476,34 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
return self();
}
/**
* Set an API version for the request. The version is inserted into the
* request by the {@link #apiVersionInserter(ApiVersionInserter) configured}
* {@code ApiVersionInserter}.
* @param version the API version of the request; this can be a String or
* some Object that can be formatted the inserter, e.g. through an
* {@link ApiVersionFormatter}.
* @since 7.0
*/
public B apiVersion(Object version) {
this.version = version;
return self();
}
/**
* Configure an {@link ApiVersionInserter} to abstract how an API version
* specified via {@link #apiVersion(Object)} is inserted into the request.
* An inserter may typically be set once (more centrally) via
* {@link org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder#defaultRequest(RequestBuilder)}, or
* {@link org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder#apiVersionInserter(ApiVersionInserter)}.
* @param versionInserter the inserter to use
* @since 7.0
*/
public B apiVersionInserter(ApiVersionInserter versionInserter) {
this.versionInserter = versionInserter;
return self();
}
/**
* Set a request attribute.
* @param name the attribute name
@@ -662,6 +697,14 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
}
}
if (this.version == null) {
this.version = parentBuilder.version;
}
if (this.versionInserter == null) {
this.versionInserter = parentBuilder.versionInserter;
}
for (Map.Entry<String, Object> entry : parentBuilder.requestAttributes.entrySet()) {
String attributeName = entry.getKey();
if (!this.requestAttributes.containsKey(attributeName)) {
@@ -700,7 +743,15 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
*/
@Override
public final MockHttpServletRequest buildRequest(ServletContext servletContext) {
Assert.notNull(this.uri, "'uri' is required");
URI uri = this.uri;
Assert.notNull(uri, "'uri' is required");
if (this.version != null) {
Assert.state(this.versionInserter != null, "No ApiVersionInserter");
uri = this.versionInserter.insertVersion(this.version, uri);
}
MockHttpServletRequest request = createServletRequest(servletContext);
request.setAsyncSupported(true);
@@ -708,17 +759,17 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
request.setUriTemplate(this.uriTemplate);
String requestUri = this.uri.getRawPath();
String requestUri = uri.getRawPath();
request.setRequestURI(requestUri);
if (this.uri.getScheme() != null) {
request.setScheme(this.uri.getScheme());
if (uri.getScheme() != null) {
request.setScheme(uri.getScheme());
}
if (this.uri.getHost() != null) {
request.setServerName(this.uri.getHost());
if (uri.getHost() != null) {
request.setServerName(uri.getHost());
}
if (this.uri.getPort() != -1) {
request.setServerPort(this.uri.getPort());
if (uri.getPort() != -1) {
request.setServerPort(uri.getPort());
}
updatePathRequestProperties(request, requestUri);
@@ -740,6 +791,13 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
request.setContent(this.content);
request.setContentType(this.contentType);
if (this.version != null) {
Assert.state(this.versionInserter != null, "No ApiVersionInserter");
HttpHeaders httpHeaders = new HttpHeaders();
this.versionInserter.insertVersion(this.version, httpHeaders);
httpHeaders.forEach((name, values) -> values.forEach(value -> this.headers.add(name, value)));
}
this.headers.forEach((name, values) -> {
for (Object value : values) {
request.addHeader(name, value);
@@ -753,7 +811,7 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
request.addHeader(HttpHeaders.CONTENT_LENGTH, this.content.length);
}
String query = this.uri.getRawQuery();
String query = uri.getRawQuery();
if (!this.queryParams.isEmpty()) {
String str = UriComponentsBuilder.newInstance().queryParams(this.queryParams).build().encode().getQuery();
query = StringUtils.hasLength(query) ? (query + "&" + str) : str;
@@ -761,7 +819,7 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
if (query != null) {
request.setQueryString(query);
}
addRequestParams(request, UriComponentsBuilder.fromUri(this.uri).build().getQueryParams());
addRequestParams(request, UriComponentsBuilder.fromUri(uri).build().getQueryParams());
this.parameters.forEach((name, values) -> {
for (String value : values) {

View File

@@ -36,10 +36,12 @@ import org.springframework.test.web.servlet.MockMvcBuilderSupport;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.ResultHandler;
import org.springframework.test.web.servlet.ResultMatcher;
import org.springframework.test.web.servlet.request.AbstractMockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.ConfigurableSmartRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import org.springframework.util.Assert;
import org.springframework.web.client.ApiVersionInserter;
import org.springframework.web.context.WebApplicationContext;
/**
@@ -62,6 +64,8 @@ public abstract class AbstractMockMvcBuilder<B extends AbstractMockMvcBuilder<B>
private final List<Filter> filters = new ArrayList<>();
private @Nullable ApiVersionInserter apiVersionInserter;
private @Nullable RequestBuilder defaultRequestBuilder;
private @Nullable Charset defaultResponseCharacterEncoding;
@@ -106,6 +110,12 @@ public abstract class AbstractMockMvcBuilder<B extends AbstractMockMvcBuilder<B>
return self();
}
@Override
public <T extends B> T apiVersionInserter(ApiVersionInserter versionInserter) {
this.apiVersionInserter = versionInserter;
return self();
}
@Override
public final <T extends B> T defaultRequest(RequestBuilder requestBuilder) {
this.defaultRequestBuilder = requestBuilder;
@@ -194,6 +204,15 @@ public abstract class AbstractMockMvcBuilder<B extends AbstractMockMvcBuilder<B>
}
}
if (this.apiVersionInserter != null) {
if (this.defaultRequestBuilder == null) {
this.defaultRequestBuilder = MockMvcRequestBuilders.get("/");
}
if (this.defaultRequestBuilder instanceof AbstractMockHttpServletRequestBuilder<?> srb) {
srb.apiVersionInserter(this.apiVersionInserter);
}
}
return super.createMockMvc(filterArray, mockServletConfig, wac, this.defaultRequestBuilder,
this.defaultResponseCharacterEncoding, this.globalResultMatchers, this.globalResultHandlers,
this.dispatcherServletCustomizers);

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 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.
@@ -30,6 +30,7 @@ import org.springframework.test.web.servlet.MockMvcBuilder;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.ResultHandler;
import org.springframework.test.web.servlet.ResultMatcher;
import org.springframework.web.client.ApiVersionInserter;
/**
* Defines common methods for building a {@code MockMvc}.
@@ -76,6 +77,14 @@ public interface ConfigurableMockMvcBuilder<B extends ConfigurableMockMvcBuilder
Filter filter, @Nullable String filterName, Map<String, String> initParams,
EnumSet<DispatcherType> dispatcherTypes, String... urlPatterns);
/**
* Set the {@link ApiVersionInserter} to use to apply to versions specified via
* {@link org.springframework.test.web.servlet.request.AbstractMockHttpServletRequestBuilder#apiVersion(Object)}.
* @param versionInserter the inserter to use
* @since 7.0
*/
<T extends B> T apiVersionInserter(ApiVersionInserter versionInserter);
/**
* Define default request properties that should be merged into all
* performed requests. In effect this provides a mechanism for defining

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.
@@ -42,6 +42,7 @@ import org.springframework.util.PropertyPlaceholderHelper;
import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver;
import org.springframework.util.StringValueResolver;
import org.springframework.validation.Validator;
import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.accept.ContentNegotiationManager;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationObjectSupport;
@@ -108,6 +109,8 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM
private @Nullable FormattingConversionService conversionService;
private @Nullable ApiVersionStrategy versionStrategy;
private @Nullable List<HandlerExceptionResolver> handlerExceptionResolvers;
private @Nullable Long asyncRequestTimeout;
@@ -189,6 +192,15 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM
return this;
}
/**
* Set the {@link ApiVersionStrategy} to use when mapping requests.
* @since 7.0
*/
public StandaloneMockMvcBuilder setApiVersionStrategy(@Nullable ApiVersionStrategy versionStrategy) {
this.versionStrategy = versionStrategy;
return this;
}
/**
* Add interceptors mapped to all incoming requests.
*/
@@ -449,6 +461,9 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM
else if (patternParser != null) {
handlerMapping.setPatternParser(patternParser);
}
if (versionStrategy != null) {
handlerMapping.setApiVersionStrategy(versionStrategy);
}
handlerMapping.setOrder(0);
handlerMapping.setInterceptors(getInterceptors(mvcConversionService, mvcResourceUrlProvider));
return handlerMapping;

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.
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockServletContext;
import org.springframework.web.client.ApiVersionInserter;
import static org.assertj.core.api.Assertions.assertThat;
@@ -96,6 +97,36 @@ class AbstractMockHttpServletRequestBuilderTests {
assertThat(request.getMethod()).isEqualTo(HttpMethod.POST.name());
}
@Test
void insertVersionInUrl() {
MockHttpServletRequest request = buildRequest(
new TestRequestBuilder(HttpMethod.GET).uri("/test")
.apiVersion(1.1)
.apiVersionInserter(ApiVersionInserter.usePathSegment(0)));
assertThat(request.getRequestURI()).isEqualTo("/1.1/test");
}
@Test
void insertVersionInHeader() {
MockHttpServletRequest request = buildRequest(
new TestRequestBuilder(HttpMethod.GET).uri("/test")
.apiVersion(1.1)
.apiVersionInserter(ApiVersionInserter.useHeader("API-Version")));
assertThat(request.getRequestURI()).isEqualTo("/test");
assertThat(request.getHeader("API-Version")).isEqualTo("1.1");
}
@Test
void mergeVersion() {
TestRequestBuilder builder = new TestRequestBuilder(HttpMethod.GET).uri("/b");
builder.merge(new TestRequestBuilder(HttpMethod.GET).uri("/a")
.apiVersion(1.1)
.apiVersionInserter(ApiVersionInserter.useHeader("API-Version")));
assertThat(buildRequest(builder).getHeader("API-Version")).isEqualTo("1.1");
}
private MockHttpServletRequest buildRequest(AbstractMockHttpServletRequestBuilder<?> builder) {
return builder.buildRequest(this.servletContext);

View File

@@ -0,0 +1,78 @@
/*
* 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.test.web.servlet.samples.standalone;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.Person;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.accept.DefaultApiVersionStrategy;
import org.springframework.web.accept.SemanticApiVersionParser;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.client.ApiVersionInserter;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
/**
* Tests demonstrating the use of API version.
* @author Rossen Stoyanchev
*/
public class ApiVersionTests {
@Test
public void queryParameter() throws Exception {
String header = "API-Version";
DefaultApiVersionStrategy versionStrategy = new DefaultApiVersionStrategy(
List.of(request -> request.getHeader(header)),
new SemanticApiVersionParser(),
true, null);
MockMvc mockMvc = standaloneSetup(new PersonController())
.setApiVersionStrategy(versionStrategy)
.apiVersionInserter(ApiVersionInserter.useHeader(header))
.build();
mockMvc.perform(get("/search?name=George").accept(MediaType.APPLICATION_JSON).apiVersion(1.1))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"))
.andExpect(jsonPath("$.name").value("George"));
}
@Controller
private static class PersonController {
@RequestMapping(path="/search", version = "1.1")
@ResponseBody
public Person get(@RequestParam String name) {
return new Person(name);
}
}
}

View File

@@ -36,6 +36,7 @@ import org.springframework.http.support.JacksonHandlerInstantiator;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.accept.ApiVersionStrategy;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
@@ -74,6 +75,18 @@ class StandaloneMockMvcBuilderTests {
assertThat(((HandlerMethod) chain.getHandler()).getMethod().getName()).isEqualTo("handleWithPlaceholders");
}
@Test
void apiVersionStrategySet() {
ApiVersionStrategy versionStrategy = mock(ApiVersionStrategy.class);
TestStandaloneMockMvcBuilder builder = new TestStandaloneMockMvcBuilder();
builder.setApiVersionStrategy(versionStrategy);
builder.build();
assertThat(builder.wac.getBean(RequestMappingHandlerMapping.class).getApiVersionStrategy())
.isSameAs(versionStrategy);
}
@Test // SPR-12553
void applicationContextAttribute() {
TestStandaloneMockMvcBuilder builder = new TestStandaloneMockMvcBuilder(new PlaceholderController());