Skip to content

Commit 5d34f9c

Browse files
committed
Support API versioning via MediaType parameter
Closes gh-35050
1 parent ba9bef6 commit 5d34f9c

File tree

6 files changed

+331
-3
lines changed

6 files changed

+331
-3
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2002-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.accept;
18+
19+
import java.util.Enumeration;
20+
21+
import jakarta.servlet.http.HttpServletRequest;
22+
import org.jspecify.annotations.Nullable;
23+
24+
import org.springframework.http.HttpHeaders;
25+
import org.springframework.http.MediaType;
26+
27+
/**
28+
* {@link ApiVersionResolver} that extracts the version from a media type
29+
* parameter found in the Accept or Content-Type headers.
30+
*
31+
* @author Rossen Stoyanchev
32+
* @since 7.0
33+
*/
34+
public class MediaTypeParamApiVersionResolver implements ApiVersionResolver {
35+
36+
private final MediaType compatibleMediaType;
37+
38+
private final String parameterName;
39+
40+
41+
/**
42+
* Create an instance.
43+
* @param compatibleMediaType the media type to extract the parameter from with
44+
* the match established via {@link MediaType#isCompatibleWith(MediaType)}
45+
* @param paramName the name of the parameter
46+
*/
47+
public MediaTypeParamApiVersionResolver(MediaType compatibleMediaType, String paramName) {
48+
this.compatibleMediaType = compatibleMediaType;
49+
this.parameterName = paramName;
50+
}
51+
52+
53+
@Override
54+
public @Nullable String resolveVersion(HttpServletRequest request) {
55+
Enumeration<String> headers = request.getHeaders(HttpHeaders.ACCEPT);
56+
while (headers.hasMoreElements()) {
57+
String header = headers.nextElement();
58+
for (MediaType mediaType : MediaType.parseMediaTypes(header)) {
59+
if (this.compatibleMediaType.isCompatibleWith(mediaType)) {
60+
return mediaType.getParameter(this.parameterName);
61+
}
62+
}
63+
}
64+
String header = request.getHeader(HttpHeaders.CONTENT_TYPE);
65+
for (MediaType mediaType : MediaType.parseMediaTypes(header)) {
66+
if (this.compatibleMediaType.isCompatibleWith(mediaType)) {
67+
return mediaType.getParameter(this.parameterName);
68+
}
69+
}
70+
return null;
71+
}
72+
73+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright 2002-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.accept;
18+
19+
import java.util.Map;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
import org.springframework.http.MediaType;
24+
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
25+
import org.springframework.web.util.ServletRequestPathUtils;
26+
27+
import static org.assertj.core.api.Assertions.assertThat;
28+
29+
/**
30+
* Unit tests for {@link MediaTypeParamApiVersionResolver}.
31+
* @author Rossen Stoyanchev
32+
*/
33+
public class MediaTypeParamApiVersionResolverTests {
34+
35+
private final MediaType mediaType = MediaType.parseMediaType("application/x.abc+json");
36+
37+
private final ApiVersionResolver resolver = new MediaTypeParamApiVersionResolver(mediaType, "version");
38+
39+
private final MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path");
40+
41+
42+
@Test
43+
void resolveFromAccept() {
44+
String version = "3";
45+
this.request.addHeader("Accept", getMediaType(version));
46+
testResolve(this.resolver, this.request, version);
47+
}
48+
49+
@Test
50+
void resolveFromContentType() {
51+
String version = "3";
52+
this.request.setContentType(getMediaType(version).toString());
53+
testResolve(this.resolver, this.request, version);
54+
}
55+
56+
@Test
57+
void wildcard() {
58+
MediaType compatibleMediaType = MediaType.parseMediaType("application/*+json");
59+
ApiVersionResolver resolver = new MediaTypeParamApiVersionResolver(compatibleMediaType, "version");
60+
61+
String version = "3";
62+
this.request.addHeader("Accept", getMediaType(version));
63+
testResolve(resolver, this.request, version);
64+
}
65+
66+
private MediaType getMediaType(String version) {
67+
return new MediaType(this.mediaType, Map.of("version", version));
68+
}
69+
70+
private void testResolve(ApiVersionResolver resolver, MockHttpServletRequest request, String expected) {
71+
try {
72+
ServletRequestPathUtils.parseAndCache(request);
73+
String actual = resolver.resolveVersion(request);
74+
assertThat(actual).isEqualTo(expected);
75+
}
76+
finally {
77+
ServletRequestPathUtils.clearParsedRequestPath(request);
78+
}
79+
}
80+
81+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright 2002-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.reactive.accept;
18+
19+
import org.jspecify.annotations.Nullable;
20+
21+
import org.springframework.http.HttpHeaders;
22+
import org.springframework.http.MediaType;
23+
import org.springframework.web.server.ServerWebExchange;
24+
25+
/**
26+
* {@link org.springframework.web.accept.ApiVersionResolver} that extracts the version from a media type
27+
* parameter found in the Accept or Content-Type headers.
28+
*
29+
* @author Rossen Stoyanchev
30+
* @since 7.0
31+
*/
32+
public class MediaTypeParamApiVersionResolver implements ApiVersionResolver {
33+
34+
private final MediaType compatibleMediaType;
35+
36+
private final String parameterName;
37+
38+
39+
/**
40+
* Create an instance.
41+
* @param compatibleMediaType the media type to extract the parameter from with
42+
* the match established via {@link MediaType#isCompatibleWith(MediaType)}
43+
* @param paramName the name of the parameter
44+
*/
45+
public MediaTypeParamApiVersionResolver(MediaType compatibleMediaType, String paramName) {
46+
this.compatibleMediaType = compatibleMediaType;
47+
this.parameterName = paramName;
48+
}
49+
50+
51+
@Override
52+
public @Nullable String resolveVersion(ServerWebExchange exchange) {
53+
HttpHeaders headers = exchange.getRequest().getHeaders();
54+
for (MediaType mediaType : headers.getAccept()) {
55+
if (this.compatibleMediaType.isCompatibleWith(mediaType)) {
56+
return mediaType.getParameter(this.parameterName);
57+
}
58+
}
59+
MediaType mediaType = headers.getContentType();
60+
if (mediaType != null && this.compatibleMediaType.isCompatibleWith(mediaType)) {
61+
return mediaType.getParameter(this.parameterName);
62+
}
63+
return null;
64+
}
65+
66+
}

spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@
2525

2626
import org.jspecify.annotations.Nullable;
2727

28+
import org.springframework.http.MediaType;
2829
import org.springframework.web.accept.ApiVersionParser;
2930
import org.springframework.web.accept.InvalidApiVersionException;
3031
import org.springframework.web.accept.SemanticApiVersionParser;
3132
import org.springframework.web.reactive.accept.ApiVersionResolver;
3233
import org.springframework.web.reactive.accept.ApiVersionStrategy;
3334
import org.springframework.web.reactive.accept.DefaultApiVersionStrategy;
35+
import org.springframework.web.reactive.accept.MediaTypeParamApiVersionResolver;
3436
import org.springframework.web.reactive.accept.PathApiVersionResolver;
3537

3638
/**
@@ -82,6 +84,18 @@ public ApiVersionConfigurer usePathSegment(int index) {
8284
return this;
8385
}
8486

87+
/**
88+
* Add resolver to extract the version from a media type parameter found in
89+
* the Accept or Content-Type headers.
90+
* @param compatibleMediaType the media type to extract the parameter from with
91+
* the match established via {@link MediaType#isCompatibleWith(MediaType)}
92+
* @param paramName the name of the parameter
93+
*/
94+
public ApiVersionConfigurer useMediaTypeParameter(MediaType compatibleMediaType, String paramName) {
95+
this.versionResolvers.add(new MediaTypeParamApiVersionResolver(compatibleMediaType, paramName));
96+
return this;
97+
}
98+
8599
/**
86100
* Add custom resolvers to resolve the API version.
87101
* @param resolvers the resolvers to use
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright 2002-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.reactive.accept;
18+
19+
import java.util.Map;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
import org.springframework.http.HttpHeaders;
24+
import org.springframework.http.MediaType;
25+
import org.springframework.web.server.ServerWebExchange;
26+
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
27+
import org.springframework.web.testfixture.server.MockServerWebExchange;
28+
29+
import static org.assertj.core.api.Assertions.assertThat;
30+
31+
/**
32+
* Unit tests for {@link MediaTypeParamApiVersionResolver}.
33+
* @author Rossen Stoyanchev
34+
*/
35+
public class MediaTypeParamApiVersionResolverTests {
36+
37+
private final MediaType mediaType = MediaType.parseMediaType("application/x.abc+json");
38+
39+
private final ApiVersionResolver resolver = new MediaTypeParamApiVersionResolver(mediaType, "version");
40+
41+
private final MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.get("/path");
42+
43+
44+
@Test
45+
void resolveFromAccept() {
46+
String version = "3";
47+
this.request.accept(getMediaType(version));
48+
testResolve(this.resolver, this.request, version);
49+
}
50+
51+
@Test
52+
void resolveFromContentType() {
53+
String version = "3";
54+
this.request.header(HttpHeaders.CONTENT_TYPE, getMediaType(version).toString());
55+
testResolve(this.resolver, this.request, version);
56+
}
57+
58+
@Test
59+
void wildcard() {
60+
MediaType compatibleMediaType = MediaType.parseMediaType("application/*+json");
61+
ApiVersionResolver resolver = new MediaTypeParamApiVersionResolver(compatibleMediaType, "version");
62+
63+
String version = "3";
64+
this.request.accept(getMediaType(version));
65+
testResolve(resolver, this.request, version);
66+
}
67+
68+
private MediaType getMediaType(String version) {
69+
return new MediaType(this.mediaType, Map.of("version", version));
70+
}
71+
72+
private static void testResolve(
73+
ApiVersionResolver resolver, MockServerHttpRequest.BaseBuilder<?> request, String expected) {
74+
75+
ServerWebExchange exchange = MockServerWebExchange.from(request);
76+
String actual = resolver.resolveVersion(exchange);
77+
assertThat(actual).isEqualTo(expected);
78+
}
79+
80+
}

spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@
2525

2626
import org.jspecify.annotations.Nullable;
2727

28+
import org.springframework.http.MediaType;
2829
import org.springframework.web.accept.ApiVersionParser;
2930
import org.springframework.web.accept.ApiVersionResolver;
3031
import org.springframework.web.accept.ApiVersionStrategy;
3132
import org.springframework.web.accept.DefaultApiVersionStrategy;
33+
import org.springframework.web.accept.MediaTypeParamApiVersionResolver;
3234
import org.springframework.web.accept.PathApiVersionResolver;
3335
import org.springframework.web.accept.SemanticApiVersionParser;
3436

@@ -52,7 +54,7 @@ public class ApiVersionConfigurer {
5254

5355

5456
/**
55-
* Add a resolver that extracts the API version from a request header.
57+
* Add resolver to extract the version from a request header.
5658
* @param headerName the header name to check
5759
*/
5860
public ApiVersionConfigurer useRequestHeader(String headerName) {
@@ -61,7 +63,7 @@ public ApiVersionConfigurer useRequestHeader(String headerName) {
6163
}
6264

6365
/**
64-
* Add a resolver that extracts the API version from a request parameter.
66+
* Add resolver to extract the version from a request parameter.
6567
* @param paramName the parameter name to check
6668
*/
6769
public ApiVersionConfigurer useRequestParam(String paramName) {
@@ -70,7 +72,7 @@ public ApiVersionConfigurer useRequestParam(String paramName) {
7072
}
7173

7274
/**
73-
* Add a resolver that extracts the API version from a path segment.
75+
* Add resolver to extract the version from a path segment.
7476
* @param index the index of the path segment to check; e.g. for URL's like
7577
* "/{version}/..." use index 0, for "/api/{version}/..." index 1.
7678
*/
@@ -79,6 +81,18 @@ public ApiVersionConfigurer usePathSegment(int index) {
7981
return this;
8082
}
8183

84+
/**
85+
* Add resolver to extract the version from a media type parameter found in
86+
* the Accept or Content-Type headers.
87+
* @param compatibleMediaType the media type to extract the parameter from with
88+
* the match established via {@link MediaType#isCompatibleWith(MediaType)}
89+
* @param paramName the name of the parameter
90+
*/
91+
public ApiVersionConfigurer useMediaTypeParameter(MediaType compatibleMediaType, String paramName) {
92+
this.versionResolvers.add(new MediaTypeParamApiVersionResolver(compatibleMediaType, paramName));
93+
return this;
94+
}
95+
8296
/**
8397
* Add custom resolvers to resolve the API version.
8498
* @param resolvers the resolvers to use

0 commit comments

Comments
 (0)