diff --git a/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java b/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java index 9d5a93d26eee..dd21646b6560 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java +++ b/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java @@ -16,13 +16,17 @@ package org.springframework.web.accept; +import java.util.function.Predicate; + import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; import org.springframework.http.server.PathContainer; import org.springframework.http.server.RequestPath; import org.springframework.util.Assert; import org.springframework.web.util.ServletRequestPathUtils; + /** * {@link ApiVersionResolver} that extract the version from a path segment. * @@ -37,6 +41,7 @@ public class PathApiVersionResolver implements ApiVersionResolver { private final int pathSegmentIndex; + private @Nullable Predicate includePath; /** @@ -49,13 +54,25 @@ public PathApiVersionResolver(int pathSegmentIndex) { this.pathSegmentIndex = pathSegmentIndex; } + /** + * Create a resolver instance. + * @param pathSegmentIndex the index of the path segment that contains the API version + * @param includePath a {@link Predicate} that tests if the given path should be included + */ + public PathApiVersionResolver(int pathSegmentIndex, Predicate includePath) { + this(pathSegmentIndex); + this.includePath = includePath; + } @Override - public String resolveVersion(HttpServletRequest request) { + public @Nullable String resolveVersion(HttpServletRequest request) { if (!ServletRequestPathUtils.hasParsedRequestPath(request)) { throw new IllegalStateException("Expected parsed request path"); } RequestPath path = ServletRequestPathUtils.getParsedRequestPath(request); + if (this.includePath != null && !this.includePath.test(path)) { + return null; + } int i = 0; for (PathContainer.Element element : path.pathWithinApplication().elements()) { if (element instanceof PathContainer.PathSegment && i++ == this.pathSegmentIndex) { diff --git a/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java b/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java index 2b5c56b78d3a..bd8ffa67aa2f 100644 --- a/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java @@ -16,8 +16,11 @@ package org.springframework.web.accept; +import java.util.List; + import org.junit.jupiter.api.Test; +import org.springframework.http.server.PathContainer; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import org.springframework.web.util.ServletRequestPathUtils; @@ -41,6 +44,59 @@ void insufficientPathSegments() { assertThatThrownBy(() -> testResolve(0, "/", "1.0")).isInstanceOf(InvalidApiVersionException.class); } + @Test + void includePathFalse() { + String requestUri = "/v3/api-docs"; + testResolveWithIncludePath(requestUri, null); + } + + @Test + void includePathTrue() { + String requestUri = "/app/1.0/path"; + testResolveWithIncludePath(requestUri, "1.0"); + } + + @Test + void includePathFalseShortPath() { + String requestUri = "/app"; + testResolveWithIncludePath(requestUri, null); + } + + @Test + void includePathInsufficientPathSegments() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/app"); + try { + ServletRequestPathUtils.parseAndCache(request); + assertThatThrownBy(() -> new PathApiVersionResolver(1, requestPath -> true) + .resolveVersion(request)) + .isInstanceOf(InvalidApiVersionException.class); + } + finally { + ServletRequestPathUtils.clearParsedRequestPath(request); + } + } + + private static void testResolveWithIncludePath(String requestUri, String expected) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); + try { + ServletRequestPathUtils.parseAndCache(request); + String actual = new PathApiVersionResolver(1, requestPath -> { + List elements = requestPath.elements(); + if (elements.size() < 4) { + return false; + } + return elements.get(0).value().equals("/") && + elements.get(1).value().equals("app") && + elements.get(2).value().equals("/") && + elements.get(3).value().equals("1.0"); + }).resolveVersion(request); + assertThat(actual).isEqualTo(expected); + } + finally { + ServletRequestPathUtils.clearParsedRequestPath(request); + } + } + private static void testResolve(int index, String requestUri, String expected) { MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); try { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java index 2da6819498bf..2aa0284118fe 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java @@ -16,7 +16,12 @@ package org.springframework.web.reactive.accept; +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; + import org.springframework.http.server.PathContainer; +import org.springframework.http.server.RequestPath; import org.springframework.util.Assert; import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.server.ServerWebExchange; @@ -30,11 +35,13 @@ * cannot yield to other resolvers. * * @author Rossen Stoyanchev + * @author Martin Mois * @since 7.0 */ public class PathApiVersionResolver implements ApiVersionResolver { private final int pathSegmentIndex; + private @Nullable Predicate includePath = null; /** @@ -47,11 +54,25 @@ public PathApiVersionResolver(int pathSegmentIndex) { this.pathSegmentIndex = pathSegmentIndex; } + /** + * Create a resolver instance. + * @param pathSegmentIndex the index of the path segment that contains the API version + * @param includePath a {@link Predicate} that tests if the given path should be included + */ + public PathApiVersionResolver(int pathSegmentIndex, Predicate includePath) { + this(pathSegmentIndex); + this.includePath = includePath; + } + @Override - public String resolveVersion(ServerWebExchange exchange) { + public @Nullable String resolveVersion(ServerWebExchange exchange) { int i = 0; - for (PathContainer.Element e : exchange.getRequest().getPath().pathWithinApplication().elements()) { + RequestPath path = exchange.getRequest().getPath(); + if (this.includePath != null && !this.includePath.test(path)) { + return null; + } + for (PathContainer.Element e : path.pathWithinApplication().elements()) { if (e instanceof PathContainer.PathSegment && i++ == this.pathSegmentIndex) { return e.value(); } 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 59351a43aa3c..51524d662c73 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 @@ -27,6 +27,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.http.MediaType; +import org.springframework.http.server.RequestPath; import org.springframework.util.Assert; import org.springframework.web.accept.ApiVersionParser; import org.springframework.web.accept.InvalidApiVersionException; @@ -108,6 +109,20 @@ public ApiVersionConfigurer usePathSegment(int index) { return this; } + /** + * Add a resolver that extracts the API version from a path segment + * and that allows to include only certain paths based on the provided {@link Predicate}. + *

Note that this resolver never returns {@code null}, and therefore + * cannot yield to other resolvers, see {@link org.springframework.web.accept.PathApiVersionResolver}. + * @param index the index of the path segment to check; e.g. for URL's like + * {@code "/{version}/..."} use index 0, for {@code "/api/{version}/..."} index 1. + * @param includePath a {@link Predicate} that allows to include a certain path + */ + public ApiVersionConfigurer usePathSegment(int index, Predicate includePath) { + this.versionResolvers.add(new PathApiVersionResolver(index, includePath)); + 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/PathApiVersionResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java index 3e3ec3076fa5..d75d37940e66 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java @@ -16,8 +16,11 @@ package org.springframework.web.reactive.accept; +import java.util.List; + import org.junit.jupiter.api.Test; +import org.springframework.http.server.PathContainer; import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; @@ -29,6 +32,7 @@ /** * Unit tests for {@link org.springframework.web.accept.PathApiVersionResolver}. * @author Rossen Stoyanchev + * @author Martin Mois */ public class PathApiVersionResolverTests { @@ -43,10 +47,49 @@ void insufficientPathSegments() { assertThatThrownBy(() -> testResolve(0, "/", "1.0")).isInstanceOf(InvalidApiVersionException.class); } + @Test + void includePathFalse() { + String requestUri = "/v3/api-docs"; + testResolveWithIncludePath(requestUri, null); + } + + @Test + void includePathTrue() { + String requestUri = "/app/1.0/path"; + testResolveWithIncludePath(requestUri, "1.0"); + } + + @Test + void includePathFalseShortPath() { + String requestUri = "/app"; + testResolveWithIncludePath(requestUri, null); + } + + @Test + void includePathInsufficientPathSegments() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/too-short")); + assertThatThrownBy(() -> new PathApiVersionResolver(1, requestPath -> true).resolveVersion(exchange)) + .isInstanceOf(InvalidApiVersionException.class); + } + + private static void testResolveWithIncludePath(String requestUri, String expected) { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(requestUri)); + String actual = new PathApiVersionResolver(1, requestPath -> { + List elements = requestPath.elements(); + if (elements.size() < 4) { + return false; + } + return elements.get(0).value().equals("/") && + elements.get(1).value().equals("app") && + elements.get(2).value().equals("/") && + elements.get(3).value().equals("1.0"); + }).resolveVersion(exchange); + assertThat(actual).isEqualTo(expected); + } + private static void testResolve(int index, String requestUri, String expected) { ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(requestUri)); String actual = new PathApiVersionResolver(index).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 92f06a09281d..46f59cc4615a 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 @@ -27,6 +27,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.http.MediaType; +import org.springframework.http.server.RequestPath; import org.springframework.util.Assert; import org.springframework.web.accept.ApiVersionDeprecationHandler; import org.springframework.web.accept.ApiVersionParser; @@ -108,6 +109,20 @@ public ApiVersionConfigurer usePathSegment(int index) { return this; } + /** + * Add a resolver that extracts the API version from a path segment + * and that allows to include only certain paths based on the provided {@link Predicate}. + *

Note that this resolver never returns {@code null}, and therefore + * cannot yield to other resolvers, see {@link org.springframework.web.accept.PathApiVersionResolver}. + * @param index the index of the path segment to check; e.g. for URL's like + * {@code "/{version}/..."} use index 0, for {@code "/api/{version}/..."} index 1. + * @param includePath a {@link Predicate} that allows to include a certain path + */ + public ApiVersionConfigurer usePathSegment(int index, Predicate includePath) { + this.versionResolvers.add(new PathApiVersionResolver(index, includePath)); + return this; + } + /** * Add custom resolvers to resolve the API version. * @param resolvers the resolvers to use