Merge branch 'main' into pr/3693
This commit is contained in:
89
README.adoc
89
README.adoc
@@ -27,7 +27,92 @@ image::https://codecov.io/gh/spring-cloud/spring-cloud-gateway/branch/main/graph
|
||||
[[building]]
|
||||
= Building
|
||||
|
||||
Unresolved directive in <stdin> - 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]
|
||||
|
||||
@@ -30,8 +30,10 @@ class RouteConfiguration {
|
||||
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> 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<ServerResponse> 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();
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
@@ -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<ServerResponse> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<properties>
|
||||
<protoc.version>3.25.1</protoc.version>
|
||||
<grpc.version>1.70.0</grpc.version>
|
||||
<grpc.version>1.71.0</grpc.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<HttpMethod> 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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ServerResponse> 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<ServerResponse> gatewayRouterFunctionsRateLimit() {
|
||||
// @formatter:off
|
||||
@@ -1699,37 +1659,6 @@ public class ServerMvcIntegrationTests {
|
||||
|
||||
}
|
||||
|
||||
@RestController
|
||||
protected static class RetryController {
|
||||
|
||||
Log log = LogFactory.getLog(getClass());
|
||||
|
||||
ConcurrentHashMap<String, AtomicInteger> map = new ConcurrentHashMap<>();
|
||||
|
||||
@GetMapping("/do/retry")
|
||||
public ResponseEntity<String> 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<ServerResponse> {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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<ServerResponse> gatewayRouterFunctionsRetry() {
|
||||
// @formatter:off
|
||||
return route("testretry")
|
||||
.GET("/retry", http())
|
||||
.before(new LocalServerPortUriResolver())
|
||||
.filter(retry(3))
|
||||
.filter(prefixPath("/do"))
|
||||
.build();
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> 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<String, AtomicInteger> map = new ConcurrentHashMap<>();
|
||||
|
||||
@GetMapping("/do/retry")
|
||||
public ResponseEntity<String> 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<String> 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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
<description>Spring Cloud Gateway Server</description>
|
||||
<properties>
|
||||
<main.basedir>${basedir}/..</main.basedir>
|
||||
<grpc.version>1.70.0</grpc.version>
|
||||
<grpc.version>1.71.0</grpc.version>
|
||||
<context-propagation.version>1.0.0</context-propagation.version>
|
||||
</properties>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Qu
|
||||
*/
|
||||
public static final String REGEXP_KEY = "regexp";
|
||||
|
||||
/**
|
||||
* Predicate key.
|
||||
*/
|
||||
public static final String PREDICATE_KEY = "predicate";
|
||||
|
||||
public QueryRoutePredicateFactory() {
|
||||
super(Config.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> 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<Qu
|
||||
return new GatewayPredicate() {
|
||||
@Override
|
||||
public boolean test(ServerWebExchange exchange) {
|
||||
if (!StringUtils.hasText(config.regexp)) {
|
||||
if (!StringUtils.hasText(config.regexp) && config.predicate == null) {
|
||||
// check existence of header
|
||||
return exchange.getRequest().getQueryParams().containsKey(config.param);
|
||||
}
|
||||
@@ -63,8 +69,13 @@ public class QueryRoutePredicateFactory extends AbstractRoutePredicateFactory<Qu
|
||||
if (values == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Predicate<String> 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<Qu
|
||||
|
||||
private String regexp;
|
||||
|
||||
private Predicate<String> predicate;
|
||||
|
||||
public String getParam() {
|
||||
return param;
|
||||
return this.param;
|
||||
}
|
||||
|
||||
public Config setParam(String param) {
|
||||
@@ -100,7 +113,7 @@ public class QueryRoutePredicateFactory extends AbstractRoutePredicateFactory<Qu
|
||||
}
|
||||
|
||||
public String getRegexp() {
|
||||
return regexp;
|
||||
return this.regexp;
|
||||
}
|
||||
|
||||
public Config setRegexp(String regexp) {
|
||||
@@ -108,6 +121,26 @@ public class QueryRoutePredicateFactory extends AbstractRoutePredicateFactory<Qu
|
||||
return this;
|
||||
}
|
||||
|
||||
public Predicate<String> getPredicate() {
|
||||
return this.predicate;
|
||||
}
|
||||
|
||||
public Config setPredicate(Predicate<String> 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 <code>false</code> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<String> 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
|
||||
|
||||
@@ -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 <code>predicate</code> 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<ServerWebExchange> 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<ServerWebExchange> 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<String> queryParamPredicate() {
|
||||
return p -> p == null ? false : p.length() > PARAM_LENGTH;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 <code>regex</code> parameter.
|
||||
*
|
||||
* @see QueryRoutePredicateFactory
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = RANDOM_PORT)
|
||||
@DirtiesContext
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user