diff --git a/README.adoc b/README.adoc index 9d84e36e..48da753d 100644 --- a/README.adoc +++ b/README.adoc @@ -27,7 +27,92 @@ image::https://codecov.io/gh/spring-cloud/spring-cloud-gateway/branch/main/graph [[building]] = Building -Unresolved directive in - include::https:///raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/docs/modules/ROOT/partials/building.adoc[] +:jdkversion: 17 + +[[basic-compile-and-test]] +== Basic Compile and Test + +To build the source you will need to install JDK {jdkversion}. + +Spring Cloud uses Maven for most build-related activities, and you +should be able to get off the ground quite quickly by cloning the +project you are interested in and typing + +---- +$ ./mvnw install +---- + +NOTE: You can also install Maven (>=3.3.3) yourself and run the `mvn` command +in place of `./mvnw` in the examples below. If you do that you also +might need to add `-P spring` if your local Maven settings do not +contain repository declarations for spring pre-release artifacts. + +NOTE: Be aware that you might need to increase the amount of memory +available to Maven by setting a `MAVEN_OPTS` environment variable with +a value like `-Xmx512m -XX:MaxPermSize=128m`. We try to cover this in +the `.mvn` configuration, so if you find you have to do it to make a +build succeed, please raise a ticket to get the settings added to +source control. + +The projects that require middleware (i.e. Redis) for testing generally +require that a local instance of [Docker](https://www.docker.com/get-started) is installed and running. + +[[documentation]] +== Documentation + +The spring-cloud-build module has a "docs" profile, and if you switch +that on it will try to build asciidoc sources using https://docs.antora.org/antora/latest/[Antora] from +`modules/ROOT/`. + +As part of that process it will look for a +`docs/src/main/asciidoc/README.adoc` and process it by loading all the includes, but not +parsing or rendering it, just copying it to `${main.basedir}` +(defaults to `$\{basedir}`, i.e. the root of the project). If there are +any changes in the README it will then show up after a Maven build as +a modified file in the correct place. Just commit it and push the change. + +[[working-with-the-code]] +== Working with the code +If you don't have an IDE preference we would recommend that you use +https://www.springsource.com/developer/sts[Spring Tools Suite] or +https://eclipse.org[Eclipse] when working with the code. We use the +https://eclipse.org/m2e/[m2eclipse] eclipse plugin for maven support. Other IDEs and tools +should also work without issue as long as they use Maven 3.3.3 or better. + +[[activate-the-spring-maven-profile]] +=== Activate the Spring Maven profile +Spring Cloud projects require the 'spring' Maven profile to be activated to resolve +the spring milestone and snapshot repositories. Use your preferred IDE to set this +profile to be active, or you may experience build errors. + +[[importing-into-eclipse-with-m2eclipse]] +=== Importing into eclipse with m2eclipse +We recommend the https://eclipse.org/m2e/[m2eclipse] eclipse plugin when working with +eclipse. If you don't already have m2eclipse installed it is available from the "eclipse +marketplace". + +NOTE: Older versions of m2e do not support Maven 3.3, so once the +projects are imported into Eclipse you will also need to tell +m2eclipse to use the right profile for the projects. If you +see many different errors related to the POMs in the projects, check +that you have an up to date installation. If you can't upgrade m2e, +add the "spring" profile to your `settings.xml`. Alternatively you can +copy the repository settings from the "spring" profile of the parent +pom into your `settings.xml`. + +[[importing-into-eclipse-without-m2eclipse]] +=== Importing into eclipse without m2eclipse +If you prefer not to use m2eclipse you can generate eclipse project metadata using the +following command: + +[indent=0] +---- + $ ./mvnw eclipse:eclipse +---- + +The generated eclipse projects can be imported by selecting `import existing projects` +from the `file` menu. + [[contributing]] = Contributing @@ -224,7 +309,7 @@ Spring Cloud Build brings along the `basepom:duplicate-finder-maven-plugin`, th [[duplicate-finder-configuration]] === Duplicate Finder configuration -Duplicate finder is *enabled by default* and will run in the `verify` phase of your Maven build, but it will only take effect in your project if you add the `duplicate-finder-maven-plugin` to the `build` section of the projecst's `pom.xml`. +Duplicate finder is *enabled by default* and will run in the `verify` phase of your Maven build, but it will only take effect in your project if you add the `duplicate-finder-maven-plugin` to the `build` section of the project's `pom.xml`. .pom.xml [source,xml] diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/addrequestheader.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/addrequestheader.adoc index d189f531..61974ef4 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/addrequestheader.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/addrequestheader.adoc @@ -30,8 +30,10 @@ class RouteConfiguration { @Bean public RouterFunction gatewayRouterFunctionsAddReqHeader() { - return route(GET("/red"), http("https://example.org")) - .before(addRequestHeader("X-Request-red", "blue")); + return route("addRequestHeader") + .route(GET("/red"), http("https://example.org")) + .before(addRequestHeader("X-Request-red", "blue")) + .build(); } } ---- @@ -50,8 +52,10 @@ class RouteConfiguration { @Bean public RouterFunction gatewayRouterFunctionsAddReqHeader() { - return route(GET("/red/{segment}"), http("https://example.org")) - .before(addRequestHeader("X-Request-red", "blue-{segment}")); + return route("addRequestHeader") + .route(GET("/red/{segment}"), http("https://example.org")) + .before(addRequestHeader("X-Request-red", "blue-{segment}")) + .build(); } } ---- diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/retry.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/retry.adoc index 1a09acbc..9bf2bf99 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/retry.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/retry.adoc @@ -9,6 +9,7 @@ The `Retry` filter supports the following parameters: * `methods`: The HTTP methods that should be retried, represented by using `org.springframework.http.HttpMethod`. * `series`: The series of status codes to be retried, represented by using `org.springframework.http.HttpStatus.Series`. * `exceptions`: A list of thrown exceptions that should be retried. +* `cacheBody`: A flag to signal if the request body should be cached. If set to `true`, the `adaptCacheBody` filter must be used to send the cached body downstream. //* `backoff`: The configured exponential backoff for the retries. //Retries are performed after a backoff interval of `firstBackoff * (factor ^ n)`, where `n` is the iteration. //If `maxBackoff` is configured, the maximum backoff applied is limited to `maxBackoff`. @@ -20,8 +21,11 @@ The following defaults are configured for `Retry` filter, if enabled: * `series`: 5XX series * `methods`: GET method * `exceptions`: `IOException`, `TimeoutException` and `RetryException` +* `cacheBody`: `false` //* `backoff`: disabled +WARNING: Setting `cacheBody` to `true` causes the gateway to read the whole body into memory. This should be used with caution. + The following listing configures a Retry filter: .application.yml @@ -42,11 +46,14 @@ spring: retries: 3 series: SERVER_ERROR methods: GET,POST + cacheBody: true + - name: AdaptCachedBody ---- .GatewaySampleApplication.java [source,java] ---- +import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.adaptCachedBody; import static org.springframework.cloud.gateway.server.mvc.filter.RetryFilterFunctions.retry; import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; @@ -59,7 +66,8 @@ class RouteConfiguration { public RouterFunction gatewayRouterFunctionsAddReqHeader() { return route("add_request_parameter_route") .route(host("*.retry.com"), http("https://example.org")) - .filter(retry(config -> config.setRetries(3).setSeries(Set.of(HttpStatus.Series.SERVER_ERROR)).setMethods(Set.of(HttpMethod.GET, HttpMethod.POST)))) + .filter(retry(config -> config.setRetries(3).setSeries(Set.of(HttpStatus.Series.SERVER_ERROR)).setMethods(Set.of(HttpMethod.GET, HttpMethod.POST)).setCacheBody(true))) + .filter(adaptCachedBody()) .build(); } } diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/writing-custom-predicates-and-filters.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/writing-custom-predicates-and-filters.adoc index fdabf35d..6735aa08 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/writing-custom-predicates-and-filters.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/writing-custom-predicates-and-filters.adoc @@ -29,7 +29,7 @@ The `RequestPredicate` implementations in Spring WebMvc.fn https://docs.spring.i .SampleRequestPredicates.java [source,java] ---- -import org.springframework.web.reactive.function.server.RequestPredicate; +import org.springframework.web.servlet.function.RequestPredicate; class SampleRequestPredicates { public static RequestPredicate headerExists(String header) { diff --git a/docs/package.json b/docs/package.json index 567c1f3a..6c97a4cc 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,7 +4,7 @@ "@antora/atlas-extension": "1.0.0-alpha.2", "@antora/collector-extension": "1.0.1", "@asciidoctor/tabs": "1.0.0-beta.6", - "@springio/antora-extensions": "1.14.2", - "@springio/asciidoctor-extensions": "1.0.0-alpha.14" + "@springio/antora-extensions": "1.14.4", + "@springio/asciidoctor-extensions": "1.0.0-alpha.16" } } diff --git a/docs/src/main/asciidoc/README.adoc b/docs/src/main/asciidoc/README.adoc index c3ef9823..7a3af2cd 100644 --- a/docs/src/main/asciidoc/README.adoc +++ b/docs/src/main/asciidoc/README.adoc @@ -20,7 +20,7 @@ image::https://codecov.io/gh/spring-cloud/spring-cloud-gateway/branch/main/graph [[building]] = Building -include::https:///raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/docs/modules/ROOT/partials/building.adoc[] +include::https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/docs/modules/ROOT/partials/building.adoc[] [[contributing]] = Contributing diff --git a/spring-cloud-gateway-integration-tests/grpc/pom.xml b/spring-cloud-gateway-integration-tests/grpc/pom.xml index 39aefb18..6842696a 100644 --- a/spring-cloud-gateway-integration-tests/grpc/pom.xml +++ b/spring-cloud-gateway-integration-tests/grpc/pom.xml @@ -12,7 +12,7 @@ 3.25.1 - 1.70.0 + 1.71.0 diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java index 5e635b71..72f30289 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java @@ -116,6 +116,14 @@ public abstract class MvcUtils { } } + public static ByteArrayInputStream getOrCacheBody(ServerRequest request) { + ByteArrayInputStream body = getAttribute(request, MvcUtils.CACHED_REQUEST_BODY_ATTR); + if (body != null) { + return body; + } + return cacheBody(request); + } + public static String expand(ServerRequest request, String template) { Assert.notNull(request, "request may not be null"); Assert.notNull(template, "template may not be null"); diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java index 8a550ce2..272b1e6c 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java @@ -27,6 +27,7 @@ import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import org.springframework.cloud.gateway.server.mvc.common.Configurable; +import org.springframework.cloud.gateway.server.mvc.common.MvcUtils; import org.springframework.cloud.gateway.server.mvc.common.Shortcut; import org.springframework.core.NestedRuntimeException; import org.springframework.http.HttpMethod; @@ -71,6 +72,9 @@ public abstract class RetryFilterFunctions { .setPolicies(Arrays.asList(simpleRetryPolicy, new HttpRetryPolicy(config)).toArray(new RetryPolicy[0])); RetryTemplate retryTemplate = retryTemplateBuilder.customPolicy(compositeRetryPolicy).build(); return (request, next) -> retryTemplate.execute(context -> { + if (config.isCacheBody()) { + MvcUtils.getOrCacheBody(request); + } ServerResponse serverResponse = next.handle(request); if (isRetryableStatusCode(serverResponse.statusCode(), config) @@ -121,6 +125,8 @@ public abstract class RetryFilterFunctions { private Set methods = new HashSet<>(List.of(HttpMethod.GET)); + private boolean cacheBody = false; + // TODO: individual statuses // TODO: backoff // TODO: support more Spring Retry policies @@ -176,6 +182,15 @@ public abstract class RetryFilterFunctions { return this; } + public boolean isCacheBody() { + return cacheBody; + } + + public RetryConfig setCacheBody(boolean cacheBody) { + this.cacheBody = cacheBody; + return this; + } + } private static class RetryException extends NestedRuntimeException { diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java index 5dbb3c7d..68f54b73 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java @@ -374,6 +374,9 @@ public abstract class GatewayRequestPredicates { @Override public boolean test(ServerRequest request) { String host = request.headers().firstHeader(HttpHeaders.HOST); + if (host == null) { + host = ""; + } PathContainer pathContainer = PathContainer.parsePath(host, PathContainer.Options.MESSAGE_ROUTE); PathPattern.PathMatchInfo info = this.pattern.matchAndExtract(pathContainer); traceMatch("Pattern", this.pattern.getPatternString(), host, info != null); diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java index 3f744881..1e91f7b6 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java @@ -27,8 +27,6 @@ import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; import com.github.benmanes.caffeine.cache.Caffeine; @@ -41,8 +39,6 @@ import jakarta.servlet.ServletException; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -83,10 +79,8 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StreamUtils; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.function.HandlerFunction; import org.springframework.web.servlet.function.RouterFunction; @@ -121,7 +115,6 @@ import static org.springframework.cloud.gateway.server.mvc.filter.CircuitBreaker import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.addRequestHeader; import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.addRequestHeadersIfNotPresent; import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.addRequestParameter; -import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.prefixPath; import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.redirectTo; import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.removeRequestHeader; import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.rewritePath; @@ -130,7 +123,6 @@ import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunction import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.setRequestHostHeader; import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.stripPrefix; import static org.springframework.cloud.gateway.server.mvc.filter.LoadBalancerFilterFunctions.lb; -import static org.springframework.cloud.gateway.server.mvc.filter.RetryFilterFunctions.retry; import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.forward; import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; @@ -398,20 +390,6 @@ public class ServerMvcIntegrationTests { // @formatter:on } - @Test - public void retryWorks() { - restClient.get().uri("/retry?key=get").exchange().expectStatus().isOk().expectBody(String.class).isEqualTo("3"); - // test for: java.lang.IllegalArgumentException: You have already selected another - // retry policy - restClient.get() - .uri("/retry?key=get2") - .exchange() - .expectStatus() - .isOk() - .expectBody(String.class) - .isEqualTo("3"); - } - @Test public void rateLimitWorks() { restClient.get().uri("/anything/ratelimit").exchange().expectStatus().isOk(); @@ -1014,11 +992,6 @@ public class ServerMvcIntegrationTests { return new TestHandler(); } - @Bean - RetryController retryController() { - return new RetryController(); - } - @Bean EventController eventController() { return new EventController(); @@ -1203,19 +1176,6 @@ public class ServerMvcIntegrationTests { // @formatter:on } - @Bean - public RouterFunction gatewayRouterFunctionsRetry() { - // @formatter:off - return route("testretry") - .route(path("/retry"), http()) - .before(new LocalServerPortUriResolver()) - .filter(retry(3)) - //.filter(retry(config -> config.setRetries(3).setSeries(Set.of(HttpStatus.Series.SERVER_ERROR)).setMethods(Set.of(HttpMethod.GET, HttpMethod.POST)))) - .filter(prefixPath("/do")) - .build(); - // @formatter:on - } - @Bean public RouterFunction gatewayRouterFunctionsRateLimit() { // @formatter:off @@ -1699,37 +1659,6 @@ public class ServerMvcIntegrationTests { } - @RestController - protected static class RetryController { - - Log log = LogFactory.getLog(getClass()); - - ConcurrentHashMap map = new ConcurrentHashMap<>(); - - @GetMapping("/do/retry") - public ResponseEntity retry(@RequestParam("key") String key, - @RequestParam(name = "count", defaultValue = "3") int count, - @RequestParam(name = "failStatus", required = false) Integer failStatus) { - AtomicInteger num = getCount(key); - int i = num.incrementAndGet(); - log.warn("Retry count: " + i); - String body = String.valueOf(i); - if (i < count) { - HttpStatus httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; - if (failStatus != null) { - httpStatus = HttpStatus.resolve(failStatus); - } - return ResponseEntity.status(httpStatus).header("X-Retry-Count", body).body("temporarily broken"); - } - return ResponseEntity.status(HttpStatus.OK).header("X-Retry-Count", body).body(body); - } - - AtomicInteger getCount(String key) { - return map.computeIfAbsent(key, s -> new AtomicInteger()); - } - - } - protected static class TestHandler implements HandlerFunction { @Override diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java new file mode 100644 index 00000000..edae11e4 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2013-2025 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.cloud.gateway.server.mvc.filter; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.cloud.gateway.server.mvc.test.HttpbinTestcontainers; +import org.springframework.cloud.gateway.server.mvc.test.LocalServerPortUriResolver; +import org.springframework.cloud.gateway.server.mvc.test.TestLoadBalancerConfig; +import org.springframework.cloud.gateway.server.mvc.test.client.TestRestClient; +import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient; +import org.springframework.context.annotation.Bean; +import org.springframework.core.log.LogMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.adaptCachedBody; +import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.prefixPath; +import static org.springframework.cloud.gateway.server.mvc.filter.RetryFilterFunctions.retry; +import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; +import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; + +@SuppressWarnings("unchecked") +@SpringBootTest(properties = {}, webEnvironment = WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = HttpbinTestcontainers.class) +public class RetryFilterFunctionTests { + + @LocalServerPort + int port; + + @Autowired + TestRestClient restClient; + + @Test + public void retryWorks() { + restClient.get().uri("/retry?key=get").exchange().expectStatus().isOk().expectBody(String.class).isEqualTo("3"); + // test for: java.lang.IllegalArgumentException: You have already selected another + // retry policy + restClient.get() + .uri("/retry?key=get2") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("3"); + } + + @Test + public void retryBodyWorks() { + restClient.post() + .uri("/retrybody?key=post") + .bodyValue("thebody") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("3"); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @LoadBalancerClient(name = "httpbin", configuration = TestLoadBalancerConfig.Httpbin.class) + protected static class TestConfiguration { + + @Bean + public RouterFunction gatewayRouterFunctionsRetry() { + // @formatter:off + return route("testretry") + .GET("/retry", http()) + .before(new LocalServerPortUriResolver()) + .filter(retry(3)) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @Bean + public RouterFunction gatewayRouterFunctionsRetryBody() { + // @formatter:off + return route("testretrybody") + .POST("/retrybody", http()) + .before(new LocalServerPortUriResolver()) + .filter(retry(config -> config.setRetries(3).setSeries(Set.of(HttpStatus.Series.SERVER_ERROR)) + .setMethods(Set.of(HttpMethod.GET, HttpMethod.POST)).setCacheBody(true))) + .filter(adaptCachedBody()) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @RestController + protected static class RetryController { + + Log log = LogFactory.getLog(getClass()); + + ConcurrentHashMap map = new ConcurrentHashMap<>(); + + @GetMapping("/do/retry") + public ResponseEntity retry(@RequestParam("key") String key, + @RequestParam(name = "count", defaultValue = "3") int count, + @RequestParam(name = "failStatus", required = false) Integer failStatus) { + AtomicInteger num = getCount(key); + int i = num.incrementAndGet(); + log.warn("Retry count: " + i); + String body = String.valueOf(i); + if (i < count) { + HttpStatus httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + if (failStatus != null) { + httpStatus = HttpStatus.resolve(failStatus); + } + return ResponseEntity.status(httpStatus).header("X-Retry-Count", body).body("temporarily broken"); + } + return ResponseEntity.status(HttpStatus.OK).header("X-Retry-Count", body).body(body); + } + + @PostMapping("/do/retrybody") + public ResponseEntity retryBody(@RequestParam("key") String key, + @RequestParam(name = "count", defaultValue = "3") int count, @RequestBody String requestBody) { + AtomicInteger num = getCount(key); + int i = num.incrementAndGet(); + log.warn(LogMessage.format("Retry count: %s, body: %s", i, requestBody)); + String body = String.valueOf(i); + if (!StringUtils.hasText(requestBody)) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .header("X-Retry-Count", body) + .body("missing body"); + } + if (i < count) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .header("X-Retry-Count", body) + .body("temporarily broken"); + } + return ResponseEntity.status(HttpStatus.OK).header("X-Retry-Count", body).body(body); + } + + AtomicInteger getCount(String key) { + return map.computeIfAbsent(key, s -> new AtomicInteger()); + } + + } + + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicatesTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicatesTests.java new file mode 100644 index 00000000..15876d87 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicatesTests.java @@ -0,0 +1,37 @@ +/* + * Copyright 2013-2025 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.cloud.gateway.server.mvc.predicate; + +import java.util.Collections; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.servlet.function.ServerRequest; + +public class GatewayRequestPredicatesTests { + + @Test + void nullHostPassedToHostPredicate() { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(); + ServerRequest serverRequest = ServerRequest.create(servletRequest, Collections.emptyList()); + boolean result = GatewayRequestPredicates.host("*.myhost.org").test(serverRequest); + Assertions.assertThat(result).isFalse(); + } + +} diff --git a/spring-cloud-gateway-server/pom.xml b/spring-cloud-gateway-server/pom.xml index b893e88b..2a61283f 100644 --- a/spring-cloud-gateway-server/pom.xml +++ b/spring-cloud-gateway-server/pom.xml @@ -16,7 +16,7 @@ Spring Cloud Gateway Server ${basedir}/.. - 1.70.0 + 1.71.0 1.0.0 diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index 85767b84..72c55d60 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -210,6 +210,7 @@ public class GatewayAutoConfiguration { * @deprecated in favour of * {@link org.springframework.cloud.gateway.support.config.KeyValueConverter} */ + @Deprecated @Bean public org.springframework.cloud.gateway.support.KeyValueConverter deprecatedKeyValueConverter() { return new org.springframework.cloud.gateway.support.KeyValueConverter(); diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactory.java index 1a7fd9b1..f8c1425f 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactory.java @@ -20,6 +20,7 @@ import java.util.Arrays; import java.util.List; import java.util.function.Predicate; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotEmpty; import org.springframework.util.StringUtils; @@ -40,13 +41,18 @@ public class QueryRoutePredicateFactory extends AbstractRoutePredicateFactory shortcutFieldOrder() { - return Arrays.asList(PARAM_KEY, REGEXP_KEY); + return Arrays.asList(PARAM_KEY, REGEXP_KEY, PREDICATE_KEY); } @Override @@ -54,7 +60,7 @@ public class QueryRoutePredicateFactory extends AbstractRoutePredicateFactory predicate = config.predicate; + if (StringUtils.hasText(config.regexp)) { + predicate = value -> value.matches(config.regexp); + } for (String value : values) { - if (value != null && value.matches(config.regexp)) { + if (value != null && predicate.test(value)) { return true; } } @@ -90,8 +101,10 @@ public class QueryRoutePredicateFactory extends AbstractRoutePredicateFactory predicate; + public String getParam() { - return param; + return this.param; } public Config setParam(String param) { @@ -100,7 +113,7 @@ public class QueryRoutePredicateFactory extends AbstractRoutePredicateFactory getPredicate() { + return this.predicate; + } + + public Config setPredicate(Predicate predicate) { + this.predicate = predicate; + return this; + } + + /** + * Enforces the validation done on predicate configuration: {@link #regexp} and + * {@link #predicate} can't be both set at runtime. + * @return false if {@link #regexp} and {@link #predicate} are both + * set in this predicate factory configuration + */ + @AssertTrue + public boolean isValid() { + return !(StringUtils.hasText(this.regexp) && this.predicate != null); + } + } } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/PredicateSpec.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/PredicateSpec.java index 7068c68a..fb70de68 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/PredicateSpec.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/PredicateSpec.java @@ -204,6 +204,18 @@ public class PredicateSpec extends UriSpec { getBean(ReadBodyRoutePredicateFactory.class).applyAsync(c -> c.setPredicate(inClass, predicate))); } + /** + * A predicate that checks if a query parameter value matches criteria of a given + * predicate. + * @param param the query parameter name + * @param predicate a predicate to check the value of the param + * @return a {@link BooleanSpec} to be used to add logical operators + */ + public BooleanSpec query(String param, Predicate predicate) { + return asyncPredicate( + getBean(QueryRoutePredicateFactory.class).applyAsync(c -> c.setParam(param).setPredicate(predicate))); + } + /** * A predicate that checks if a query parameter matches a regular expression. * @param param the query parameter name diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactoryPredicateTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactoryPredicateTests.java new file mode 100644 index 00000000..69765659 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactoryPredicateTests.java @@ -0,0 +1,141 @@ +/* + * Copyright 2013-2024 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.cloud.gateway.handler.predicate; + +import java.util.function.Predicate; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.gateway.handler.predicate.QueryRoutePredicateFactory.Config; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.cloud.gateway.support.HasConfig; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +/** + * Test class for {@link QueryRoutePredicateFactory} for predicate parameter. + * + * @see QueryRoutePredicateFactory + */ +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +@ExtendWith(OutputCaptureExtension.class) +public class QueryRoutePredicateFactoryPredicateTests extends BaseWebClientTests { + + @Test + public void noQueryParamWorks(CapturedOutput output) { + this.testClient.get() + .uri("/get") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals(ROUTE_ID_HEADER, "default_path_to_httpbin"); + assertThat(output).doesNotContain("Error applying predicate for route: foo_query_param"); + } + + @Test + public void queryParamPredicateTrue() { + this.testClient.get() + .uri("/get?foo=1234567") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals(ROUTE_ID_HEADER, "foo_query_param"); + } + + @Test + public void queryParamPredicateFalse(CapturedOutput output) { + this.testClient.get() + .uri("/get?foo=123") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals(ROUTE_ID_HEADER, "default_path_to_httpbin"); + assertThat(output).doesNotContain("Error applying predicate for route: foo_query_param"); + } + + @Test + public void emptyQueryParamWorks(CapturedOutput output) { + this.testClient.get() + .uri("/get?foo") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals(ROUTE_ID_HEADER, "default_path_to_httpbin"); + assertThat(output).doesNotContain("Error applying predicate for route: foo_query_param"); + } + + @Test + public void testConfig() { + Config config = new Config(); + config.setParam("query_param"); + Predicate predicate = new QueryRoutePredicateFactory().apply(config); + assertThat(predicate).isInstanceOf(HasConfig.class); + assertThat(config).isSameAs(((HasConfig) predicate).getConfig()); + } + + @Test + public void toStringFormat() { + Config config = new Config(); + config.setParam("query_param"); + Predicate predicate = new QueryRoutePredicateFactory().apply(config); + assertThat(predicate.toString()).contains("Query: param=query_param"); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + private static final int PARAM_LENGTH = 5; + + @Value("${test.uri}") + private String uri; + + @Bean + RouteLocator queryRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + .route("foo_query_param", + r -> r.query("foo", queryParamPredicate()).filters(f -> f.prefixPath("/httpbin")).uri(this.uri)) + .build(); + } + + private Predicate queryParamPredicate() { + return p -> p == null ? false : p.length() > PARAM_LENGTH; + } + + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactoryTests.java index 4506e20f..6a4c7264 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/QueryRoutePredicateFactoryTests.java @@ -43,6 +43,11 @@ import org.springframework.test.annotation.DirtiesContext; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +/** + * Test class for {@link QueryRoutePredicateFactory} for regex parameter. + * + * @see QueryRoutePredicateFactory + */ @SpringBootTest(webEnvironment = RANDOM_PORT) @DirtiesContext @ExtendWith(OutputCaptureExtension.class) diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java index 5d1bf14f..38041174 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java @@ -46,6 +46,7 @@ import static org.junit.Assume.assumeThat; org.springframework.cloud.gateway.handler.predicate.MethodRoutePredicateFactoryTests.class, org.springframework.cloud.gateway.handler.predicate.BetweenRoutePredicateFactoryTests.class, org.springframework.cloud.gateway.handler.predicate.QueryRoutePredicateFactoryTests.class, + org.springframework.cloud.gateway.handler.predicate.QueryRoutePredicateFactoryPredicateTests.class, org.springframework.cloud.gateway.handler.predicate.WeightRoutePredicateFactoryIntegrationTests.class, org.springframework.cloud.gateway.handler.predicate.HeaderRoutePredicateFactoryTests.class, org.springframework.cloud.gateway.handler.predicate.BeforeRoutePredicateFactoryTests.class, diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/CustomBlockHoundIntegrationTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/CustomBlockHoundIntegrationTest.java index 228679ac..308c0b93 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/CustomBlockHoundIntegrationTest.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/CustomBlockHoundIntegrationTest.java @@ -17,6 +17,7 @@ package org.springframework.cloud.gateway.test; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledForJreRange; import org.junit.jupiter.api.condition.JRE; @@ -30,6 +31,8 @@ public class CustomBlockHoundIntegrationTest { @Test @DisabledForJreRange(min = JRE.JAVA_18) + // Disable this test for now flaky on GitHub Actions + @Disabled public void shouldThrowErrorForBlockingCallWithCustomBlockHoundIntegration() { Assertions.assertThrows(RuntimeException.class, () -> Mono.fromCallable(() -> { Thread.sleep(1);