Skip to content

Commit 18ad806

Browse files
committed
Adds API Version Predicate support in Server WebMVC
Ensures that gateway returns a 404, not 400 if a version isn't configured and that there is a shortcut definition.
1 parent fc7096c commit 18ad806

File tree

8 files changed

+358
-5
lines changed

8 files changed

+358
-5
lines changed

spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,17 @@
1616

1717
package org.springframework.cloud.gateway.server.mvc;
1818

19+
import java.util.ArrayList;
20+
import java.util.List;
1921
import java.util.Map;
2022

23+
import jakarta.servlet.http.HttpServletRequest;
24+
import org.jspecify.annotations.Nullable;
25+
26+
import org.springframework.beans.BeansException;
2127
import org.springframework.beans.factory.BeanFactory;
2228
import org.springframework.beans.factory.ObjectProvider;
29+
import org.springframework.beans.factory.config.BeanPostProcessor;
2330
import org.springframework.boot.SpringApplication;
2431
import org.springframework.boot.autoconfigure.AutoConfiguration;
2532
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -31,6 +38,8 @@
3138
import org.springframework.boot.restclient.RestClientCustomizer;
3239
import org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration;
3340
import org.springframework.boot.restclient.autoconfigure.RestTemplateAutoConfiguration;
41+
import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties;
42+
import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties.Apiversion;
3443
import org.springframework.cloud.gateway.server.mvc.common.ArgumentSupplierBeanPostProcessor;
3544
import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties;
3645
import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcPropertiesBeanDefinitionRegistrar;
@@ -67,7 +76,19 @@
6776
import org.springframework.core.env.MapPropertySource;
6877
import org.springframework.http.client.ClientHttpRequestFactory;
6978
import org.springframework.util.ClassUtils;
79+
import org.springframework.util.CollectionUtils;
7080
import org.springframework.util.StringUtils;
81+
import org.springframework.web.accept.ApiVersionDeprecationHandler;
82+
import org.springframework.web.accept.ApiVersionParser;
83+
import org.springframework.web.accept.ApiVersionResolver;
84+
import org.springframework.web.accept.ApiVersionStrategy;
85+
import org.springframework.web.accept.DefaultApiVersionStrategy;
86+
import org.springframework.web.accept.InvalidApiVersionException;
87+
import org.springframework.web.accept.MediaTypeParamApiVersionResolver;
88+
import org.springframework.web.accept.MissingApiVersionException;
89+
import org.springframework.web.accept.PathApiVersionResolver;
90+
import org.springframework.web.accept.QueryApiVersionResolver;
91+
import org.springframework.web.accept.SemanticApiVersionParser;
7192
import org.springframework.web.client.RestClient;
7293

7394
/**
@@ -214,6 +235,18 @@ static GatewayMvcRuntimeHintsProcessor gatewayMvcRuntimeHintsProcessor() {
214235
return new GatewayMvcRuntimeHintsProcessor();
215236
}
216237

238+
@Bean
239+
GatewayServerWebMvcBeanPostProcessor gatewayServerWebMvcBeanPostProcessor(
240+
ObjectProvider<WebMvcProperties> properties,
241+
ObjectProvider<ApiVersionDeprecationHandler> deprecationHandlerProvider,
242+
ObjectProvider<ApiVersionParser<?>> versionParserProvider,
243+
ObjectProvider<ApiVersionResolver> versionResolvers) {
244+
return new GatewayServerWebMvcBeanPostProcessor(
245+
properties.getIfAvailable(WebMvcProperties::new).getApiversion(),
246+
deprecationHandlerProvider.getIfAvailable(), versionParserProvider.getIfAvailable(),
247+
versionResolvers.orderedStream().toList());
248+
}
249+
217250
static class GatewayHttpClientEnvironmentPostProcessor implements EnvironmentPostProcessor {
218251

219252
static final boolean APACHE = ClassUtils.isPresent("org.apache.hc.client5.http.impl.classic.HttpClients", null);
@@ -258,4 +291,105 @@ else if (StringUtils.hasText(restrictedHeaders) && !restrictedHeaders.contains("
258291

259292
}
260293

294+
protected static class GatewayServerWebMvcBeanPostProcessor implements BeanPostProcessor {
295+
296+
private final Apiversion versionProperties;
297+
298+
private final ApiVersionDeprecationHandler deprecationHandler;
299+
300+
private final ApiVersionParser<?> versionParser;
301+
302+
private final List<ApiVersionResolver> apiVersionResolvers;
303+
304+
public GatewayServerWebMvcBeanPostProcessor(Apiversion versionProperties,
305+
ApiVersionDeprecationHandler deprecationHandler, ApiVersionParser<?> versionParser,
306+
List<ApiVersionResolver> apiVersionResolvers) {
307+
this.versionProperties = versionProperties;
308+
this.deprecationHandler = deprecationHandler;
309+
this.versionParser = (versionParser != null) ? versionParser : new SemanticApiVersionParser();
310+
this.apiVersionResolvers = apiVersionResolvers;
311+
}
312+
313+
@Override
314+
public @Nullable Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
315+
316+
// TODO: Use custom ApiVersionConfigurer when able to
317+
if (bean instanceof ApiVersionStrategy && beanName.equals("mvcApiVersionStrategy")) {
318+
List<ApiVersionResolver> versionResolvers = new ArrayList<>();
319+
if (StringUtils.hasText(versionProperties.getUse().getHeader())) {
320+
versionResolvers.add(request -> request.getHeader(versionProperties.getUse().getHeader()));
321+
}
322+
if (!CollectionUtils.isEmpty(versionProperties.getUse().getMediaTypeParameter())) {
323+
versionProperties.getUse().getMediaTypeParameter().forEach((mediaType, param) -> {
324+
versionResolvers.add(new MediaTypeParamApiVersionResolver(mediaType, param));
325+
});
326+
}
327+
if (versionProperties.getUse().getPathSegment() != null) {
328+
versionResolvers.add(new PathApiVersionResolver(versionProperties.getUse().getPathSegment()));
329+
}
330+
if (StringUtils.hasText(versionProperties.getUse().getQueryParameter())) {
331+
versionResolvers.add(new QueryApiVersionResolver(versionProperties.getUse().getQueryParameter()));
332+
}
333+
334+
if (apiVersionResolvers != null && !apiVersionResolvers.isEmpty()) {
335+
versionResolvers.addAll(apiVersionResolvers);
336+
}
337+
338+
if (versionResolvers.isEmpty()) {
339+
return bean;
340+
}
341+
342+
Boolean required = versionProperties.getRequired();
343+
if (required == null) {
344+
required = false;
345+
}
346+
Boolean detectSupported = versionProperties.getDetectSupported();
347+
if (detectSupported == null) {
348+
detectSupported = true;
349+
}
350+
GatewayApiVersionStrategy strategy = new GatewayApiVersionStrategy(versionResolvers, versionParser,
351+
required, versionProperties.getDefaultVersion(), detectSupported, deprecationHandler);
352+
if (!CollectionUtils.isEmpty(versionProperties.getSupported())) {
353+
strategy.addSupportedVersion(versionProperties.getSupported().toArray(new String[0]));
354+
}
355+
return strategy;
356+
}
357+
return bean;
358+
}
359+
360+
}
361+
362+
protected static class GatewayApiVersionStrategy extends DefaultApiVersionStrategy {
363+
364+
public GatewayApiVersionStrategy(List<ApiVersionResolver> versionResolvers, ApiVersionParser<?> versionParser,
365+
boolean versionRequired, @Nullable String defaultVersion, boolean detectSupportedVersions,
366+
@Nullable ApiVersionDeprecationHandler deprecationHandler) {
367+
super(versionResolvers, versionParser, versionRequired, defaultVersion, detectSupportedVersions,
368+
deprecationHandler);
369+
}
370+
371+
@Override
372+
public @Nullable Comparable<?> resolveParseAndValidateVersion(HttpServletRequest request) {
373+
try {
374+
return super.resolveParseAndValidateVersion(request);
375+
}
376+
catch (InvalidApiVersionException e) {
377+
// ignore, so gateway will 404, not 400
378+
return null;
379+
}
380+
}
381+
382+
@Override
383+
public void validateVersion(@Nullable Comparable<?> requestVersion, HttpServletRequest request)
384+
throws MissingApiVersionException, InvalidApiVersionException {
385+
try {
386+
super.validateVersion(requestVersion, request);
387+
}
388+
catch (InvalidApiVersionException e) {
389+
// ignore, so gateway will 404, not 400
390+
}
391+
}
392+
393+
}
394+
261395
}

spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/DefaultFunctionConfiguration.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828

2929
@Configuration
3030
@ConditionalOnClass(name = "org.springframework.cloud.function.context.FunctionCatalog")
31-
@ConditionalOnProperty(name = "spring.cloud.gateway.function.enabled", havingValue = "true", matchIfMissing = true)
31+
@ConditionalOnProperty(name = GatewayMvcProperties.PREFIX + ".function.enabled", havingValue = "true",
32+
matchIfMissing = true)
3233
public class DefaultFunctionConfiguration {
3334

3435
@Bean

spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import org.springframework.util.Assert;
4747
import org.springframework.util.ObjectUtils;
4848
import org.springframework.util.StringUtils;
49+
import org.springframework.web.accept.ApiVersionStrategy;
4950
import org.springframework.web.cors.CorsUtils;
5051
import org.springframework.web.servlet.function.HandlerFunction;
5152
import org.springframework.web.servlet.function.RequestPredicate;
@@ -217,6 +218,28 @@ public static <T> RequestPredicate readBody(Class<T> inClass, Predicate<T> predi
217218
return new ReadBodyPredicate<>(inClass, predicate);
218219
}
219220

221+
/**
222+
* {@code RequestPredicate} to match to the request API version extracted from and
223+
* parsed with the configured {@link ApiVersionStrategy}.
224+
* <p>
225+
* The version may be one of the following:
226+
* <ul>
227+
* <li>Fixed version ("1.2") -- match this version only.
228+
* <li>Baseline version ("1.2+") -- match this and subsequent versions.
229+
* </ul>
230+
* <p>
231+
* A baseline version allows n endpoint route to continue to work in subsequent
232+
* versions if it remains compatible until an incompatible change eventually leads to
233+
* the creation of a new route.
234+
* @param version the version to use
235+
* @return the created predicate instance
236+
* @since 5.0
237+
*/
238+
@Shortcut
239+
public static RequestPredicate version(String version) {
240+
return RequestPredicates.version(version);
241+
}
242+
220243
/**
221244
* A predicate which will select a route based on its assigned weight.
222245
* @param group the group the route belongs to

spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import org.springframework.boot.web.server.test.client.TestRestTemplate;
5353
import org.springframework.boot.web.servlet.FilterRegistrationBean;
5454
import org.springframework.cloud.gateway.server.mvc.common.MvcUtils;
55+
import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties;
5556
import org.springframework.cloud.gateway.server.mvc.filter.FormFilter;
5657
import org.springframework.cloud.gateway.server.mvc.filter.ForwardedRequestHeadersFilter;
5758
import org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter;
@@ -145,8 +146,9 @@
145146
import static org.springframework.web.servlet.function.RequestPredicates.path;
146147

147148
@SuppressWarnings("unchecked")
148-
@SpringBootTest(properties = { "spring.http.client.factory=jdk", "spring.cloud.gateway.function.enabled=false",
149-
"logging.level.org.springframework.security=TRACE" }, webEnvironment = WebEnvironment.RANDOM_PORT)
149+
@SpringBootTest(properties = { "spring.http.client.factory=jdk",
150+
GatewayMvcProperties.PREFIX + ".function.enabled=false", "logging.level.org.springframework.security=TRACE" },
151+
webEnvironment = WebEnvironment.RANDOM_PORT)
150152
@ContextConfiguration(initializers = HttpbinTestcontainers.class)
151153
public class ServerMvcIntegrationTests {
152154

spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcPropertiesBeanDefinitionRegistrarTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
import static org.springframework.cloud.gateway.server.mvc.test.TestUtils.getMap;
5757

5858
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT,
59-
properties = { "spring.cloud.gateway.function.enabled=false" })
59+
properties = { GatewayMvcProperties.PREFIX + ".function.enabled=false" })
6060
@ActiveProfiles("propertiesbeandefinitionregistrartests")
6161
@ContextConfiguration(initializers = HttpbinTestcontainers.class)
6262
public class GatewayMvcPropertiesBeanDefinitionRegistrarTests {

spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.springframework.boot.test.context.SpringBootTest;
3131
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
3232
import org.springframework.boot.web.server.test.LocalServerPort;
33+
import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties;
3334
import org.springframework.cloud.gateway.server.mvc.test.HttpbinTestcontainers;
3435
import org.springframework.cloud.gateway.server.mvc.test.LocalServerPortUriResolver;
3536
import org.springframework.cloud.gateway.server.mvc.test.PermitAllSecurityConfiguration;
@@ -58,7 +59,7 @@
5859
import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
5960
import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http;
6061

61-
@SpringBootTest(properties = { "spring.cloud.gateway.function.enabled=false" },
62+
@SpringBootTest(properties = { GatewayMvcProperties.PREFIX + ".function.enabled=false" },
6263
webEnvironment = WebEnvironment.RANDOM_PORT)
6364
@ContextConfiguration(initializers = HttpbinTestcontainers.class)
6465
public class RetryFilterFunctionTests {

0 commit comments

Comments
 (0)