Skip to content

Commit 3095219

Browse files
committed
Support API versioning in MockMvc
See gh-34919
1 parent a024e59 commit 3095219

File tree

9 files changed

+254
-15
lines changed

9 files changed

+254
-15
lines changed

spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -38,6 +38,7 @@
3838
import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder;
3939
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
4040
import org.springframework.validation.Validator;
41+
import org.springframework.web.accept.ApiVersionStrategy;
4142
import org.springframework.web.accept.ContentNegotiationManager;
4243
import org.springframework.web.context.WebApplicationContext;
4344
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
@@ -284,6 +285,14 @@ interface ControllerSpec extends MockMvcServerSpec<ControllerSpec> {
284285
*/
285286
ControllerSpec conversionService(FormattingConversionService conversionService);
286287

288+
/**
289+
* Set the {@link ApiVersionStrategy} to use when mapping requests.
290+
* <p>This is delegated to
291+
* {@link StandaloneMockMvcBuilder#setApiVersionStrategy(ApiVersionStrategy)}.
292+
* @since 7.0
293+
*/
294+
ControllerSpec apiVersionStrategy(ApiVersionStrategy versionStrategy);
295+
287296
/**
288297
* Add global interceptors.
289298
* <p>This is delegated to

spring-test/src/main/java/org/springframework/test/web/servlet/client/StandaloneMockMvcSpec.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@
2626
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
2727
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
2828
import org.springframework.validation.Validator;
29+
import org.springframework.web.accept.ApiVersionStrategy;
2930
import org.springframework.web.accept.ContentNegotiationManager;
3031
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
3132
import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
@@ -79,6 +80,12 @@ public StandaloneMockMvcSpec conversionService(FormattingConversionService conve
7980
return this;
8081
}
8182

83+
@Override
84+
public MockMvcWebTestClient.ControllerSpec apiVersionStrategy(ApiVersionStrategy versionStrategy) {
85+
this.mockMvcBuilder.setApiVersionStrategy(versionStrategy);
86+
return this;
87+
}
88+
8289
@Override
8390
public StandaloneMockMvcSpec interceptors(HandlerInterceptor... interceptors) {
8491
mappedInterceptors(null, interceptors);

spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,14 @@
5050
import org.springframework.mock.web.MockHttpServletResponse;
5151
import org.springframework.mock.web.MockHttpSession;
5252
import org.springframework.test.web.servlet.MockMvc;
53+
import org.springframework.test.web.servlet.RequestBuilder;
5354
import org.springframework.util.Assert;
5455
import org.springframework.util.LinkedMultiValueMap;
5556
import org.springframework.util.MultiValueMap;
5657
import org.springframework.util.ObjectUtils;
5758
import org.springframework.util.StringUtils;
59+
import org.springframework.web.client.ApiVersionFormatter;
60+
import org.springframework.web.client.ApiVersionInserter;
5861
import org.springframework.web.context.WebApplicationContext;
5962
import org.springframework.web.context.support.WebApplicationContextUtils;
6063
import org.springframework.web.servlet.DispatcherServlet;
@@ -118,6 +121,10 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
118121

119122
private final List<Locale> locales = new ArrayList<>();
120123

124+
private @Nullable Object version;
125+
126+
private @Nullable ApiVersionInserter versionInserter;
127+
121128
private final Map<String, Object> requestAttributes = new LinkedHashMap<>();
122129

123130
private final Map<String, Object> sessionAttributes = new LinkedHashMap<>();
@@ -469,6 +476,34 @@ public B locale(@Nullable Locale locale) {
469476
return self();
470477
}
471478

479+
/**
480+
* Set an API version for the request. The version is inserted into the
481+
* request by the {@link #apiVersionInserter(ApiVersionInserter) configured}
482+
* {@code ApiVersionInserter}.
483+
* @param version the API version of the request; this can be a String or
484+
* some Object that can be formatted the inserter, e.g. through an
485+
* {@link ApiVersionFormatter}.
486+
* @since 7.0
487+
*/
488+
public B apiVersion(Object version) {
489+
this.version = version;
490+
return self();
491+
}
492+
493+
/**
494+
* Configure an {@link ApiVersionInserter} to abstract how an API version
495+
* specified via {@link #apiVersion(Object)} is inserted into the request.
496+
* An inserter may typically be set once (more centrally) via
497+
* {@link org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder#defaultRequest(RequestBuilder)}, or
498+
* {@link org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder#apiVersionInserter(ApiVersionInserter)}.
499+
* @param versionInserter the inserter to use
500+
* @since 7.0
501+
*/
502+
public B apiVersionInserter(ApiVersionInserter versionInserter) {
503+
this.versionInserter = versionInserter;
504+
return self();
505+
}
506+
472507
/**
473508
* Set a request attribute.
474509
* @param name the attribute name
@@ -662,6 +697,14 @@ public Object merge(@Nullable Object parent) {
662697
}
663698
}
664699

700+
if (this.version == null) {
701+
this.version = parentBuilder.version;
702+
}
703+
704+
if (this.versionInserter == null) {
705+
this.versionInserter = parentBuilder.versionInserter;
706+
}
707+
665708
for (Map.Entry<String, Object> entry : parentBuilder.requestAttributes.entrySet()) {
666709
String attributeName = entry.getKey();
667710
if (!this.requestAttributes.containsKey(attributeName)) {
@@ -700,25 +743,33 @@ private boolean containsCookie(Cookie cookie) {
700743
*/
701744
@Override
702745
public final MockHttpServletRequest buildRequest(ServletContext servletContext) {
703-
Assert.notNull(this.uri, "'uri' is required");
746+
747+
URI uri = this.uri;
748+
Assert.notNull(uri, "'uri' is required");
749+
750+
if (this.version != null) {
751+
Assert.state(this.versionInserter != null, "No ApiVersionInserter");
752+
uri = this.versionInserter.insertVersion(this.version, uri);
753+
}
754+
704755
MockHttpServletRequest request = createServletRequest(servletContext);
705756

706757
request.setAsyncSupported(true);
707758
request.setMethod(this.method.name());
708759

709760
request.setUriTemplate(this.uriTemplate);
710761

711-
String requestUri = this.uri.getRawPath();
762+
String requestUri = uri.getRawPath();
712763
request.setRequestURI(requestUri);
713764

714-
if (this.uri.getScheme() != null) {
715-
request.setScheme(this.uri.getScheme());
765+
if (uri.getScheme() != null) {
766+
request.setScheme(uri.getScheme());
716767
}
717-
if (this.uri.getHost() != null) {
718-
request.setServerName(this.uri.getHost());
768+
if (uri.getHost() != null) {
769+
request.setServerName(uri.getHost());
719770
}
720-
if (this.uri.getPort() != -1) {
721-
request.setServerPort(this.uri.getPort());
771+
if (uri.getPort() != -1) {
772+
request.setServerPort(uri.getPort());
722773
}
723774

724775
updatePathRequestProperties(request, requestUri);
@@ -740,6 +791,13 @@ public final MockHttpServletRequest buildRequest(ServletContext servletContext)
740791
request.setContent(this.content);
741792
request.setContentType(this.contentType);
742793

794+
if (this.version != null) {
795+
Assert.state(this.versionInserter != null, "No ApiVersionInserter");
796+
HttpHeaders httpHeaders = new HttpHeaders();
797+
this.versionInserter.insertVersion(this.version, httpHeaders);
798+
httpHeaders.forEach((name, values) -> values.forEach(value -> this.headers.add(name, value)));
799+
}
800+
743801
this.headers.forEach((name, values) -> {
744802
for (Object value : values) {
745803
request.addHeader(name, value);
@@ -753,15 +811,15 @@ public final MockHttpServletRequest buildRequest(ServletContext servletContext)
753811
request.addHeader(HttpHeaders.CONTENT_LENGTH, this.content.length);
754812
}
755813

756-
String query = this.uri.getRawQuery();
814+
String query = uri.getRawQuery();
757815
if (!this.queryParams.isEmpty()) {
758816
String str = UriComponentsBuilder.newInstance().queryParams(this.queryParams).build().encode().getQuery();
759817
query = StringUtils.hasLength(query) ? (query + "&" + str) : str;
760818
}
761819
if (query != null) {
762820
request.setQueryString(query);
763821
}
764-
addRequestParams(request, UriComponentsBuilder.fromUri(this.uri).build().getQueryParams());
822+
addRequestParams(request, UriComponentsBuilder.fromUri(uri).build().getQueryParams());
765823

766824
this.parameters.forEach((name, values) -> {
767825
for (String value : values) {

spring-test/src/main/java/org/springframework/test/web/servlet/setup/AbstractMockMvcBuilder.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@
3636
import org.springframework.test.web.servlet.RequestBuilder;
3737
import org.springframework.test.web.servlet.ResultHandler;
3838
import org.springframework.test.web.servlet.ResultMatcher;
39+
import org.springframework.test.web.servlet.request.AbstractMockHttpServletRequestBuilder;
3940
import org.springframework.test.web.servlet.request.ConfigurableSmartRequestBuilder;
4041
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
4142
import org.springframework.test.web.servlet.request.RequestPostProcessor;
4243
import org.springframework.util.Assert;
44+
import org.springframework.web.client.ApiVersionInserter;
4345
import org.springframework.web.context.WebApplicationContext;
4446

4547
/**
@@ -62,6 +64,8 @@ public abstract class AbstractMockMvcBuilder<B extends AbstractMockMvcBuilder<B>
6264

6365
private final List<Filter> filters = new ArrayList<>();
6466

67+
private @Nullable ApiVersionInserter apiVersionInserter;
68+
6569
private @Nullable RequestBuilder defaultRequestBuilder;
6670

6771
private @Nullable Charset defaultResponseCharacterEncoding;
@@ -106,6 +110,12 @@ public <T extends B> T addFilter(
106110
return self();
107111
}
108112

113+
@Override
114+
public <T extends B> T apiVersionInserter(ApiVersionInserter versionInserter) {
115+
this.apiVersionInserter = versionInserter;
116+
return self();
117+
}
118+
109119
@Override
110120
public final <T extends B> T defaultRequest(RequestBuilder requestBuilder) {
111121
this.defaultRequestBuilder = requestBuilder;
@@ -194,6 +204,15 @@ public final MockMvc build() {
194204
}
195205
}
196206

207+
if (this.apiVersionInserter != null) {
208+
if (this.defaultRequestBuilder == null) {
209+
this.defaultRequestBuilder = MockMvcRequestBuilders.get("/");
210+
}
211+
if (this.defaultRequestBuilder instanceof AbstractMockHttpServletRequestBuilder<?> srb) {
212+
srb.apiVersionInserter(this.apiVersionInserter);
213+
}
214+
}
215+
197216
return super.createMockMvc(filterArray, mockServletConfig, wac, this.defaultRequestBuilder,
198217
this.defaultResponseCharacterEncoding, this.globalResultMatchers, this.globalResultHandlers,
199218
this.dispatcherServletCustomizers);

spring-test/src/main/java/org/springframework/test/web/servlet/setup/ConfigurableMockMvcBuilder.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -30,6 +30,7 @@
3030
import org.springframework.test.web.servlet.RequestBuilder;
3131
import org.springframework.test.web.servlet.ResultHandler;
3232
import org.springframework.test.web.servlet.ResultMatcher;
33+
import org.springframework.web.client.ApiVersionInserter;
3334

3435
/**
3536
* Defines common methods for building a {@code MockMvc}.
@@ -76,6 +77,14 @@ <T extends B> T addFilter(
7677
Filter filter, @Nullable String filterName, Map<String, String> initParams,
7778
EnumSet<DispatcherType> dispatcherTypes, String... urlPatterns);
7879

80+
/**
81+
* Set the {@link ApiVersionInserter} to use to apply to versions specified via
82+
* {@link org.springframework.test.web.servlet.request.AbstractMockHttpServletRequestBuilder#apiVersion(Object)}.
83+
* @param versionInserter the inserter to use
84+
* @since 7.0
85+
*/
86+
<T extends B> T apiVersionInserter(ApiVersionInserter versionInserter);
87+
7988
/**
8089
* Define default request properties that should be merged into all
8190
* performed requests. In effect this provides a mechanism for defining

spring-test/src/main/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilder.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -42,6 +42,7 @@
4242
import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver;
4343
import org.springframework.util.StringValueResolver;
4444
import org.springframework.validation.Validator;
45+
import org.springframework.web.accept.ApiVersionStrategy;
4546
import org.springframework.web.accept.ContentNegotiationManager;
4647
import org.springframework.web.context.WebApplicationContext;
4748
import org.springframework.web.context.support.WebApplicationObjectSupport;
@@ -108,6 +109,8 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM
108109

109110
private @Nullable FormattingConversionService conversionService;
110111

112+
private @Nullable ApiVersionStrategy versionStrategy;
113+
111114
private @Nullable List<HandlerExceptionResolver> handlerExceptionResolvers;
112115

113116
private @Nullable Long asyncRequestTimeout;
@@ -189,6 +192,15 @@ public StandaloneMockMvcBuilder setConversionService(FormattingConversionService
189192
return this;
190193
}
191194

195+
/**
196+
* Set the {@link ApiVersionStrategy} to use when mapping requests.
197+
* @since 7.0
198+
*/
199+
public StandaloneMockMvcBuilder setApiVersionStrategy(@Nullable ApiVersionStrategy versionStrategy) {
200+
this.versionStrategy = versionStrategy;
201+
return this;
202+
}
203+
192204
/**
193205
* Add interceptors mapped to all incoming requests.
194206
*/
@@ -449,6 +461,9 @@ public RequestMappingHandlerMapping getHandlerMapping(
449461
else if (patternParser != null) {
450462
handlerMapping.setPatternParser(patternParser);
451463
}
464+
if (versionStrategy != null) {
465+
handlerMapping.setApiVersionStrategy(versionStrategy);
466+
}
452467
handlerMapping.setOrder(0);
453468
handlerMapping.setInterceptors(getInterceptors(mvcConversionService, mvcResourceUrlProvider));
454469
return handlerMapping;

spring-test/src/test/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilderTests.java

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@
2424
import org.springframework.http.HttpMethod;
2525
import org.springframework.mock.web.MockHttpServletRequest;
2626
import org.springframework.mock.web.MockServletContext;
27+
import org.springframework.web.client.ApiVersionInserter;
2728

2829
import static org.assertj.core.api.Assertions.assertThat;
2930

@@ -96,6 +97,36 @@ void mergeUriWhenUriIsSetDoesNotOverride() {
9697
assertThat(request.getMethod()).isEqualTo(HttpMethod.POST.name());
9798
}
9899

100+
@Test
101+
void insertVersionInUrl() {
102+
MockHttpServletRequest request = buildRequest(
103+
new TestRequestBuilder(HttpMethod.GET).uri("/test")
104+
.apiVersion(1.1)
105+
.apiVersionInserter(ApiVersionInserter.usePathSegment(0)));
106+
107+
assertThat(request.getRequestURI()).isEqualTo("/1.1/test");
108+
}
109+
110+
@Test
111+
void insertVersionInHeader() {
112+
MockHttpServletRequest request = buildRequest(
113+
new TestRequestBuilder(HttpMethod.GET).uri("/test")
114+
.apiVersion(1.1)
115+
.apiVersionInserter(ApiVersionInserter.useHeader("API-Version")));
116+
117+
assertThat(request.getRequestURI()).isEqualTo("/test");
118+
assertThat(request.getHeader("API-Version")).isEqualTo("1.1");
119+
}
120+
121+
@Test
122+
void mergeVersion() {
123+
TestRequestBuilder builder = new TestRequestBuilder(HttpMethod.GET).uri("/b");
124+
builder.merge(new TestRequestBuilder(HttpMethod.GET).uri("/a")
125+
.apiVersion(1.1)
126+
.apiVersionInserter(ApiVersionInserter.useHeader("API-Version")));
127+
128+
assertThat(buildRequest(builder).getHeader("API-Version")).isEqualTo("1.1");
129+
}
99130

100131
private MockHttpServletRequest buildRequest(AbstractMockHttpServletRequestBuilder<?> builder) {
101132
return builder.buildRequest(this.servletContext);

0 commit comments

Comments
 (0)