From 5d34f9c87eb9a0a8e6e3472b6d6cb67d3fd12c87 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 12 Jun 2025 16:11:07 +0100 Subject: [PATCH] Support API versioning via MediaType parameter Closes gh-35050 --- .../MediaTypeParamApiVersionResolver.java | 73 +++++++++++++++++ ...MediaTypeParamApiVersionResolverTests.java | 81 +++++++++++++++++++ .../MediaTypeParamApiVersionResolver.java | 66 +++++++++++++++ .../reactive/config/ApiVersionConfigurer.java | 14 ++++ ...MediaTypeParamApiVersionResolverTests.java | 80 ++++++++++++++++++ .../annotation/ApiVersionConfigurer.java | 20 ++++- 6 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/accept/MediaTypeParamApiVersionResolver.java create mode 100644 spring-web/src/test/java/org/springframework/web/accept/MediaTypeParamApiVersionResolverTests.java create mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/accept/MediaTypeParamApiVersionResolver.java create mode 100644 spring-webflux/src/test/java/org/springframework/web/reactive/accept/MediaTypeParamApiVersionResolverTests.java diff --git a/spring-web/src/main/java/org/springframework/web/accept/MediaTypeParamApiVersionResolver.java b/spring-web/src/main/java/org/springframework/web/accept/MediaTypeParamApiVersionResolver.java new file mode 100644 index 00000000000..b134c3e001d --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/MediaTypeParamApiVersionResolver.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-present 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.accept; + +import java.util.Enumeration; + +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +/** + * {@link ApiVersionResolver} that extracts the version from a media type + * parameter found in the Accept or Content-Type headers. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class MediaTypeParamApiVersionResolver implements ApiVersionResolver { + + private final MediaType compatibleMediaType; + + private final String parameterName; + + + /** + * Create an instance. + * @param compatibleMediaType the media type to extract the parameter from with + * the match established via {@link MediaType#isCompatibleWith(MediaType)} + * @param paramName the name of the parameter + */ + public MediaTypeParamApiVersionResolver(MediaType compatibleMediaType, String paramName) { + this.compatibleMediaType = compatibleMediaType; + this.parameterName = paramName; + } + + + @Override + public @Nullable String resolveVersion(HttpServletRequest request) { + Enumeration headers = request.getHeaders(HttpHeaders.ACCEPT); + while (headers.hasMoreElements()) { + String header = headers.nextElement(); + for (MediaType mediaType : MediaType.parseMediaTypes(header)) { + if (this.compatibleMediaType.isCompatibleWith(mediaType)) { + return mediaType.getParameter(this.parameterName); + } + } + } + String header = request.getHeader(HttpHeaders.CONTENT_TYPE); + for (MediaType mediaType : MediaType.parseMediaTypes(header)) { + if (this.compatibleMediaType.isCompatibleWith(mediaType)) { + return mediaType.getParameter(this.parameterName); + } + } + return null; + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/accept/MediaTypeParamApiVersionResolverTests.java b/spring-web/src/test/java/org/springframework/web/accept/MediaTypeParamApiVersionResolverTests.java new file mode 100644 index 00000000000..6f77dfba367 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/accept/MediaTypeParamApiVersionResolverTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2002-present 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.accept; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.MediaType; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.util.ServletRequestPathUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link MediaTypeParamApiVersionResolver}. + * @author Rossen Stoyanchev + */ +public class MediaTypeParamApiVersionResolverTests { + + private final MediaType mediaType = MediaType.parseMediaType("application/x.abc+json"); + + private final ApiVersionResolver resolver = new MediaTypeParamApiVersionResolver(mediaType, "version"); + + private final MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path"); + + + @Test + void resolveFromAccept() { + String version = "3"; + this.request.addHeader("Accept", getMediaType(version)); + testResolve(this.resolver, this.request, version); + } + + @Test + void resolveFromContentType() { + String version = "3"; + this.request.setContentType(getMediaType(version).toString()); + testResolve(this.resolver, this.request, version); + } + + @Test + void wildcard() { + MediaType compatibleMediaType = MediaType.parseMediaType("application/*+json"); + ApiVersionResolver resolver = new MediaTypeParamApiVersionResolver(compatibleMediaType, "version"); + + String version = "3"; + this.request.addHeader("Accept", getMediaType(version)); + testResolve(resolver, this.request, version); + } + + private MediaType getMediaType(String version) { + return new MediaType(this.mediaType, Map.of("version", version)); + } + + private void testResolve(ApiVersionResolver resolver, MockHttpServletRequest request, String expected) { + try { + ServletRequestPathUtils.parseAndCache(request); + String actual = resolver.resolveVersion(request); + assertThat(actual).isEqualTo(expected); + } + finally { + ServletRequestPathUtils.clearParsedRequestPath(request); + } + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/MediaTypeParamApiVersionResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/MediaTypeParamApiVersionResolver.java new file mode 100644 index 00000000000..c9967091088 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/MediaTypeParamApiVersionResolver.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-present 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.reactive.accept; + +import org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.server.ServerWebExchange; + +/** + * {@link org.springframework.web.accept.ApiVersionResolver} that extracts the version from a media type + * parameter found in the Accept or Content-Type headers. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class MediaTypeParamApiVersionResolver implements ApiVersionResolver { + + private final MediaType compatibleMediaType; + + private final String parameterName; + + + /** + * Create an instance. + * @param compatibleMediaType the media type to extract the parameter from with + * the match established via {@link MediaType#isCompatibleWith(MediaType)} + * @param paramName the name of the parameter + */ + public MediaTypeParamApiVersionResolver(MediaType compatibleMediaType, String paramName) { + this.compatibleMediaType = compatibleMediaType; + this.parameterName = paramName; + } + + + @Override + public @Nullable String resolveVersion(ServerWebExchange exchange) { + HttpHeaders headers = exchange.getRequest().getHeaders(); + for (MediaType mediaType : headers.getAccept()) { + if (this.compatibleMediaType.isCompatibleWith(mediaType)) { + return mediaType.getParameter(this.parameterName); + } + } + MediaType mediaType = headers.getContentType(); + if (mediaType != null && this.compatibleMediaType.isCompatibleWith(mediaType)) { + return mediaType.getParameter(this.parameterName); + } + return null; + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java index 3d3f45dfbba..7b97a9b9f53 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java @@ -25,12 +25,14 @@ import java.util.Set; import org.jspecify.annotations.Nullable; +import org.springframework.http.MediaType; import org.springframework.web.accept.ApiVersionParser; import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.reactive.accept.ApiVersionResolver; import org.springframework.web.reactive.accept.ApiVersionStrategy; import org.springframework.web.reactive.accept.DefaultApiVersionStrategy; +import org.springframework.web.reactive.accept.MediaTypeParamApiVersionResolver; import org.springframework.web.reactive.accept.PathApiVersionResolver; /** @@ -82,6 +84,18 @@ public class ApiVersionConfigurer { return this; } + /** + * Add resolver to extract the version from a media type parameter found in + * the Accept or Content-Type headers. + * @param compatibleMediaType the media type to extract the parameter from with + * the match established via {@link MediaType#isCompatibleWith(MediaType)} + * @param paramName the name of the parameter + */ + public ApiVersionConfigurer useMediaTypeParameter(MediaType compatibleMediaType, String paramName) { + this.versionResolvers.add(new MediaTypeParamApiVersionResolver(compatibleMediaType, paramName)); + return this; + } + /** * Add custom resolvers to resolve the API version. * @param resolvers the resolvers to use diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/MediaTypeParamApiVersionResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/MediaTypeParamApiVersionResolverTests.java new file mode 100644 index 00000000000..f7b47da0ec5 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/MediaTypeParamApiVersionResolverTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2002-present 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.reactive.accept; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.testfixture.server.MockServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link MediaTypeParamApiVersionResolver}. + * @author Rossen Stoyanchev + */ +public class MediaTypeParamApiVersionResolverTests { + + private final MediaType mediaType = MediaType.parseMediaType("application/x.abc+json"); + + private final ApiVersionResolver resolver = new MediaTypeParamApiVersionResolver(mediaType, "version"); + + private final MockServerHttpRequest.BaseBuilder request = MockServerHttpRequest.get("/path"); + + + @Test + void resolveFromAccept() { + String version = "3"; + this.request.accept(getMediaType(version)); + testResolve(this.resolver, this.request, version); + } + + @Test + void resolveFromContentType() { + String version = "3"; + this.request.header(HttpHeaders.CONTENT_TYPE, getMediaType(version).toString()); + testResolve(this.resolver, this.request, version); + } + + @Test + void wildcard() { + MediaType compatibleMediaType = MediaType.parseMediaType("application/*+json"); + ApiVersionResolver resolver = new MediaTypeParamApiVersionResolver(compatibleMediaType, "version"); + + String version = "3"; + this.request.accept(getMediaType(version)); + testResolve(resolver, this.request, version); + } + + private MediaType getMediaType(String version) { + return new MediaType(this.mediaType, Map.of("version", version)); + } + + private static void testResolve( + ApiVersionResolver resolver, MockServerHttpRequest.BaseBuilder request, String expected) { + + ServerWebExchange exchange = MockServerWebExchange.from(request); + String actual = resolver.resolveVersion(exchange); + assertThat(actual).isEqualTo(expected); + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java index cf573ed61f6..cc552bf2485 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java @@ -25,10 +25,12 @@ import java.util.Set; import org.jspecify.annotations.Nullable; +import org.springframework.http.MediaType; import org.springframework.web.accept.ApiVersionParser; import org.springframework.web.accept.ApiVersionResolver; import org.springframework.web.accept.ApiVersionStrategy; import org.springframework.web.accept.DefaultApiVersionStrategy; +import org.springframework.web.accept.MediaTypeParamApiVersionResolver; import org.springframework.web.accept.PathApiVersionResolver; import org.springframework.web.accept.SemanticApiVersionParser; @@ -52,7 +54,7 @@ public class ApiVersionConfigurer { /** - * Add a resolver that extracts the API version from a request header. + * Add resolver to extract the version from a request header. * @param headerName the header name to check */ public ApiVersionConfigurer useRequestHeader(String headerName) { @@ -61,7 +63,7 @@ public class ApiVersionConfigurer { } /** - * Add a resolver that extracts the API version from a request parameter. + * Add resolver to extract the version from a request parameter. * @param paramName the parameter name to check */ public ApiVersionConfigurer useRequestParam(String paramName) { @@ -70,7 +72,7 @@ public class ApiVersionConfigurer { } /** - * Add a resolver that extracts the API version from a path segment. + * Add resolver to extract the version from a path segment. * @param index the index of the path segment to check; e.g. for URL's like * "/{version}/..." use index 0, for "/api/{version}/..." index 1. */ @@ -79,6 +81,18 @@ public class ApiVersionConfigurer { return this; } + /** + * Add resolver to extract the version from a media type parameter found in + * the Accept or Content-Type headers. + * @param compatibleMediaType the media type to extract the parameter from with + * the match established via {@link MediaType#isCompatibleWith(MediaType)} + * @param paramName the name of the parameter + */ + public ApiVersionConfigurer useMediaTypeParameter(MediaType compatibleMediaType, String paramName) { + this.versionResolvers.add(new MediaTypeParamApiVersionResolver(compatibleMediaType, paramName)); + return this; + } + /** * Add custom resolvers to resolve the API version. * @param resolvers the resolvers to use