Merge branch '4.1.x' into fix-rewritepath-double-encoding

Signed-off-by: Jens Mallien <108389225+jensmatw@users.noreply.github.com>
This commit is contained in:
Jens Mallien
2025-03-18 13:50:02 +01:00
113 changed files with 1629 additions and 569 deletions

2
.github/dco.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
require:
members: false

View File

@@ -5,9 +5,9 @@ name: Build
on:
push:
branches: [ main, 3.1.x ]
branches: [ main, 4.1.x, 3.1.x ]
pull_request:
branches: [ main, 3.1.x ]
branches: [ main, 4.1.x, 3.1.x ]
jobs:
build:

View File

@@ -27,246 +27,7 @@ image::https://codecov.io/gh/spring-cloud/spring-cloud-gateway/branch/main/graph
[[building]]
= Building
:spring-cloud-build-branch: main
Spring Cloud is released under the non-restrictive Apache 2.0 license,
and follows a very standard Github development process, using Github
tracker for issues and merging pull requests into main. If you want
to contribute even something trivial please do not hesitate, but
follow the guidelines below.
[[sign-the-contributor-license-agreement]]
== Sign the Contributor License Agreement
Before we accept a non-trivial patch or pull request we will need you to sign the
https://cla.pivotal.io/sign/spring[Contributor License Agreement].
Signing the contributor's agreement does not grant anyone commit rights to the main
repository, but it does mean that we can accept your contributions, and you will get an
author credit if we do. Active contributors might be asked to join the core team, and
given the ability to merge pull requests.
[[code-of-conduct]]
== Code of Conduct
This project adheres to the Contributor Covenant https://github.com/spring-cloud/spring-cloud-build/blob/main/docs/src/main/asciidoc/code-of-conduct.adoc[code of
conduct]. By participating, you are expected to uphold this code. Please report
unacceptable behavior to spring-code-of-conduct@pivotal.io.
[[code-conventions-and-housekeeping]]
== Code Conventions and Housekeeping
None of these is essential for a pull request, but they will all help. They can also be
added after the original pull request but before a merge.
* Use the Spring Framework code format conventions. If you use Eclipse
you can import formatter settings using the
`eclipse-code-formatter.xml` file from the
https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/spring-cloud-dependencies-parent/eclipse-code-formatter.xml[Spring
Cloud Build] project. If using IntelliJ, you can use the
https://plugins.jetbrains.com/plugin/6546[Eclipse Code Formatter
Plugin] to import the same file.
* Make sure all new `.java` files to have a simple Javadoc class comment with at least an
`@author` tag identifying you, and preferably at least a paragraph on what the class is
for.
* Add the ASF license header comment to all new `.java` files (copy from existing files
in the project)
* Add yourself as an `@author` to the .java files that you modify substantially (more
than cosmetic changes).
* Add some Javadocs and, if you change the namespace, some XSD doc elements.
* A few unit tests would help a lot as well -- someone has to do it.
* If no-one else is using your branch, please rebase it against the current main (or
other target branch in the main project).
* When writing a commit message please follow https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html[these conventions],
if you are fixing an existing issue please add `Fixes gh-XXXX` at the end of the commit
message (where XXXX is the issue number).
[[checkstyle]]
== Checkstyle
Spring Cloud Build comes with a set of checkstyle rules. You can find them in the `spring-cloud-build-tools` module. The most notable files under the module are:
.spring-cloud-build-tools/
----
└── src
   ├── checkstyle
   │   └── checkstyle-suppressions.xml <3>
   └── main
   └── resources
   ├── checkstyle-header.txt <2>
   └── checkstyle.xml <1>
----
<1> Default Checkstyle rules
<2> File header setup
<3> Default suppression rules
[[checkstyle-configuration]]
=== Checkstyle configuration
Checkstyle rules are *disabled by default*. To add checkstyle to your project just define the following properties and plugins.
.pom.xml
----
<properties>
<maven-checkstyle-plugin.failsOnError>true</maven-checkstyle-plugin.failsOnError> <1>
<maven-checkstyle-plugin.failsOnViolation>true
</maven-checkstyle-plugin.failsOnViolation> <2>
<maven-checkstyle-plugin.includeTestSourceDirectory>true
</maven-checkstyle-plugin.includeTestSourceDirectory> <3>
</properties>
<build>
<plugins>
<plugin> <4>
<groupId>io.spring.javaformat</groupId>
<artifactId>spring-javaformat-maven-plugin</artifactId>
</plugin>
<plugin> <5>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
</plugin>
</plugins>
<reporting>
<plugins>
<plugin> <5>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
</plugin>
</plugins>
</reporting>
</build>
----
<1> Fails the build upon Checkstyle errors
<2> Fails the build upon Checkstyle violations
<3> Checkstyle analyzes also the test sources
<4> Add the Spring Java Format plugin that will reformat your code to pass most of the Checkstyle formatting rules
<5> Add checkstyle plugin to your build and reporting phases
If you need to suppress some rules (e.g. line length needs to be longer), then it's enough for you to define a file under `${project.root}/src/checkstyle/checkstyle-suppressions.xml` with your suppressions. Example:
.projectRoot/src/checkstyle/checkstyle-suppresions.xml
----
<?xml version="1.0"?>
<!DOCTYPE suppressions PUBLIC
"-//Puppy Crawl//DTD Suppressions 1.1//EN"
"https://www.puppycrawl.com/dtds/suppressions_1_1.dtd">
<suppressions>
<suppress files=".*ConfigServerApplication\.java" checks="HideUtilityClassConstructor"/>
<suppress files=".*ConfigClientWatch\.java" checks="LineLengthCheck"/>
</suppressions>
----
It's advisable to copy the `${spring-cloud-build.rootFolder}/.editorconfig` and `${spring-cloud-build.rootFolder}/.springformat` to your project. That way, some default formatting rules will be applied. You can do so by running this script:
```bash
$ curl https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/.editorconfig -o .editorconfig
$ touch .springformat
```
[[ide-setup]]
== IDE setup
[[intellij-idea]]
=== Intellij IDEA
In order to setup Intellij you should import our coding conventions, inspection profiles and set up the checkstyle plugin.
The following files can be found in the https://github.com/spring-cloud/spring-cloud-build/tree/main/spring-cloud-build-tools[Spring Cloud Build] project.
.spring-cloud-build-tools/
----
└── src
   ├── checkstyle
   │   └── checkstyle-suppressions.xml <3>
   └── main
   └── resources
   ├── checkstyle-header.txt <2>
   ├── checkstyle.xml <1>
   └── intellij
      ├── Intellij_Project_Defaults.xml <4>
      └── Intellij_Spring_Boot_Java_Conventions.xml <5>
----
<1> Default Checkstyle rules
<2> File header setup
<3> Default suppression rules
<4> Project defaults for Intellij that apply most of Checkstyle rules
<5> Project style conventions for Intellij that apply most of Checkstyle rules
.Code style
image::https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/docs/modules/ROOT/assets/images/intellij-code-style.png[Code style]
Go to `File` -> `Settings` -> `Editor` -> `Code style`. There click on the icon next to the `Scheme` section. There, click on the `Import Scheme` value and pick the `Intellij IDEA code style XML` option. Import the `spring-cloud-build-tools/src/main/resources/intellij/Intellij_Spring_Boot_Java_Conventions.xml` file.
.Inspection profiles
image::https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/docs/modules/ROOT/assets/images/intellij-inspections.png[Code style]
Go to `File` -> `Settings` -> `Editor` -> `Inspections`. There click on the icon next to the `Profile` section. There, click on the `Import Profile` and import the `spring-cloud-build-tools/src/main/resources/intellij/Intellij_Project_Defaults.xml` file.
.Checkstyle
To have Intellij work with Checkstyle, you have to install the `Checkstyle` plugin. It's advisable to also install the `Assertions2Assertj` to automatically convert the JUnit assertions
image::https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/docs/modules/ROOT/assets/images/intellij-checkstyle.png[Checkstyle]
Go to `File` -> `Settings` -> `Other settings` -> `Checkstyle`. There click on the `+` icon in the `Configuration file` section. There, you'll have to define where the checkstyle rules should be picked from. In the image above, we've picked the rules from the cloned Spring Cloud Build repository. However, you can point to the Spring Cloud Build's GitHub repository (e.g. for the `checkstyle.xml` : `https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/spring-cloud-build-tools/src/main/resources/checkstyle.xml`). We need to provide the following variables:
- `checkstyle.header.file` - please point it to the Spring Cloud Build's, `spring-cloud-build-tools/src/main/resources/checkstyle-header.txt` file either in your cloned repo or via the `https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/spring-cloud-build-tools/src/main/resources/checkstyle-header.txt` URL.
- `checkstyle.suppressions.file` - default suppressions. Please point it to the Spring Cloud Build's, `spring-cloud-build-tools/src/checkstyle/checkstyle-suppressions.xml` file either in your cloned repo or via the `https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/spring-cloud-build-tools/src/checkstyle/checkstyle-suppressions.xml` URL.
- `checkstyle.additional.suppressions.file` - this variable corresponds to suppressions in your local project. E.g. you're working on `spring-cloud-contract`. Then point to the `project-root/src/checkstyle/checkstyle-suppressions.xml` folder. Example for `spring-cloud-contract` would be: `/home/username/spring-cloud-contract/src/checkstyle/checkstyle-suppressions.xml`.
IMPORTANT: Remember to set the `Scan Scope` to `All sources` since we apply checkstyle rules for production and test sources.
[[duplicate-finder]]
== Duplicate Finder
Spring Cloud Build brings along the `basepom:duplicate-finder-maven-plugin`, that enables flagging duplicate and conflicting classes and resources on the java classpath.
[[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`.
.pom.xml
[source,xml]
----
<build>
<plugins>
<plugin>
<groupId>org.basepom.maven</groupId>
<artifactId>duplicate-finder-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
----
For other properties, we have set defaults as listed in the https://github.com/basepom/duplicate-finder-maven-plugin/wiki[plugin documentation].
You can easily override them but setting the value of the selected property prefixed with `duplicate-finder-maven-plugin`. For example, set `duplicate-finder-maven-plugin.skip` to `true` in order to skip duplicates check in your build.
If you need to add `ignoredClassPatterns` or `ignoredResourcePatterns` to your setup, make sure to add them in the plugin configuration section of your project:
[source,xml]
----
<build>
<plugins>
<plugin>
<groupId>org.basepom.maven</groupId>
<artifactId>duplicate-finder-maven-plugin</artifactId>
<configuration>
<ignoredClassPatterns>
<ignoredClassPattern>org.joda.time.base.BaseDateTime</ignoredClassPattern>
<ignoredClassPattern>.*module-info</ignoredClassPattern>
</ignoredClassPatterns>
<ignoredResourcePatterns>
<ignoredResourcePattern>changelog.txt</ignoredResourcePattern>
</ignoredResourcePatterns>
</configuration>
</plugin>
</plugins>
</build>
----
Unresolved directive in <stdin> - include::https:///raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/docs/modules/ROOT/partials/building.adoc[]
[[contributing]]
= Contributing
@@ -279,21 +40,17 @@ tracker for issues and merging pull requests into main. If you want
to contribute even something trivial please do not hesitate, but
follow the guidelines below.
[[sign-the-contributor-license-agreement]]
== Sign the Contributor License Agreement
[[developer-certificate-of-origin]]
== Developer Certificate of Origin (DCO)
Before we accept a non-trivial patch or pull request we will need you to sign the
https://cla.pivotal.io/sign/spring[Contributor License Agreement].
Signing the contributor's agreement does not grant anyone commit rights to the main
repository, but it does mean that we can accept your contributions, and you will get an
author credit if we do. Active contributors might be asked to join the core team, and
given the ability to merge pull requests.
All commits must include a __Signed-off-by__ trailer at the end of each commit message to indicate that the contributor agrees to the Developer Certificate of Origin.
For additional details, please refer to the blog post https://spring.io/blog/2025/01/06/hello-dco-goodbye-cla-simplifying-contributions-to-spring[Hello DCO, Goodbye CLA: Simplifying Contributions to Spring].
[[code-of-conduct]]
== Code of Conduct
This project adheres to the Contributor Covenant https://github.com/spring-cloud/spring-cloud-build/blob/main/docs/src/main/asciidoc/code-of-conduct.adoc[code of
This project adheres to the Contributor Covenant https://github.com/spring-cloud/spring-cloud-build/blob/main/docs/modules/ROOT/partials/code-of-conduct.adoc[code of
conduct]. By participating, you are expected to uphold this code. Please report
unacceptable behavior to spring-code-of-conduct@pivotal.io.
unacceptable behavior to code-of-conduct@spring.io.
[[code-conventions-and-housekeeping]]
== Code Conventions and Housekeeping
@@ -467,7 +224,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]

View File

@@ -6,7 +6,7 @@ nav:
ext:
collector:
run:
command: ./mvnw --no-transfer-progress -B process-resources -Pdocs -pl docs -Dantora-maven-plugin.phase=none -Dgenerate-docs.phase=none -Dgenerate-readme.phase=none -Dgenerate-cloud-resources.phase=none -Dmaven-dependency-plugin-for-docs.phase=none -Dmaven-dependency-plugin-for-docs-classes.phase=none -DskipTests
command: ./mvnw --no-transfer-progress -B process-resources -Pdocs -pl docs -Dantora-maven-plugin.phase=none -Dgenerate-docs.phase=none -Dgenerate-readme.phase=none -Dgenerate-cloud-resources.phase=none -Dmaven-dependency-plugin-for-docs.phase=none -Dmaven-dependency-plugin-for-docs-classes.phase=none -DskipTests -DdisableConfigurationProperties
local: true
scan:
dir: ./target/classes/antora-resources/

View File

@@ -50,6 +50,7 @@
** xref:spring-cloud-gateway/global-filters.adoc[]
** xref:spring-cloud-gateway/httpheadersfilters.adoc[]
** xref:spring-cloud-gateway/tls-and-ssl.adoc[]
** xref:spring-cloud-gateway/http-client.adoc[]
** xref:spring-cloud-gateway/configuration.adoc[]
** xref:spring-cloud-gateway/route-metadata-configuration.adoc[]
** xref:spring-cloud-gateway/http-timeouts-configuration.adoc[]

View File

@@ -43,6 +43,8 @@ spring:
- Path=/api/**
----
WARNING: If using the `lb()` filter, it needs to be after any filter that manipulates the path such as `setPath()` or `stripPrefix()`, otherwise the resulting url could be incorrect. The `lb:` scheme handler in configuration, automatically puts the filter in the highest precedence order.
NOTE: By default, when a service instance cannot be found by the `ReactorLoadBalancer`, a `503` is returned.
// TODO: implement use404
// You can configure the gateway to return a `404` by setting `spring.cloud.gateway.loadbalancer.use404=true`.

View File

@@ -33,7 +33,7 @@ class RouteConfiguration {
public RouterFunction<ServerResponse> gatewayRouterFunctionsPrefixPath() {
return route("prefixpath_route")
.GET("/**", http("https://example.org"))
.before("/mypath")
.before(prefixPath("/mypath"))
.build();
}
}
@@ -42,3 +42,4 @@ class RouteConfiguration {
This prefixes `/mypath` to the path of all matching requests.
So a request to `/hello` is sent to `/mypath/hello`.
WARNING: If using the `lb()` filter, it needs to be after the `prefixPath()` filter, otherwise the resulting url could be incorrect. The `lb:` scheme handler in configuration, automatically puts the filter in the highest precedence order.

View File

@@ -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();
}
}

View File

@@ -43,3 +43,4 @@ class RouteConfiguration {
For a request path of `/red/blue`, this sets the path to `/blue` before making the downstream request. Note that in `application.yml` the `$` should be replaced with `$\` because of the YAML specification.
WARNING: If using the `lb()` filter, it needs to be after the `rewritePath()` filter, otherwise the resulting url could be incorrect. The `lb:` scheme handler in configuration, automatically puts the filter in the highest precedence order.

View File

@@ -45,3 +45,4 @@ class RouteConfiguration {
For a request path of `/red/blue`, this sets the path to `/blue` before making the downstream request.
WARNING: If using the `lb()` filter, it needs to be after the `setPath()` filter, otherwise the resulting url could be incorrect. The `lb:` scheme handler in configuration, automatically puts the filter in the highest precedence order.

View File

@@ -43,3 +43,4 @@ class RouteConfiguration {
When a request is made through the gateway to `/name/blue/red`, the request made to `nameservice` looks like `https://nameservice/red`.
WARNING: If using the `lb()` filter, it needs to be after the `stripPrefix()` filter, otherwise the resulting url could be incorrect. The `lb:` scheme handler in configuration, automatically puts the filter in the highest precedence order.

View File

@@ -94,7 +94,7 @@ import org.springframework.web.servlet.function.ServerResponse;
class SampleHandlerFilterFunctions {
public static HandlerFilterFunction<ServerResponse, ServerResponse> instrument(String requestHeader, String responseHeader) {
return (request, next) -> {
ServerRequest modified = ServerRequest.from(request).header(requestHeader, generateId());
ServerRequest modified = ServerRequest.from(request).header(requestHeader, generateId()).build();
ServerResponse response = next.handle(modified);
response.headers().add(responseHeader, generateId());
return response;
@@ -147,7 +147,7 @@ import org.springframework.web.servlet.function.ServerRequest;
class SampleBeforeFilterFunctions {
public static Function<ServerRequest, ServerRequest> instrument(String header) {
return request -> ServerRequest.from(request).header(header, generateId());;
return request -> ServerRequest.from(request).header(header, generateId()).build();
}
}
----

View File

@@ -2,4 +2,4 @@
= Configuration properties
:page-section-summary-toc: 1
To see the list of all Spring Cloud Gateway related configuration properties, see link:appendix.html[the appendix].
To see the list of all Spring Cloud Gateway related configuration properties, see link:../appendix.html[the appendix].

View File

@@ -0,0 +1,49 @@
[[http-client]]
= The HttpClientCustomizer
The HttpClientCustomizer interface in spring-cloud-gateway allows for the customization of the HTTP client used by the gateway. It provides a single method, customize, which takes an HttpClient as an argument and returns a customized version of it.
This interface is useful for scenarios where you need to configure specific settings or behaviors for the HTTP client, such as setting timeouts, adding custom headers, or enabling specific features. By implementing this interface, you can provide a custom implementation that meets your specific requirements.
Here's an example of how you might use the HttpClientCustomizer interface:
.MyHttpClientCustomizer.java
[source,java]
----
import org.springframework.cloud.gateway.config.HttpClientCustomizer;
import reactor.netty.http.client.HttpClient;
public class MyHttpClientCustomizer implements HttpClientCustomizer {
@Override
public HttpClient customize(HttpClient httpClient) {
// Customize the HTTP client here
return httpClient.tcpConfiguration(tcpClient -> {
// Set the connect timeout to 5 seconds
tcpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000);
// Set the read timeout to 10 seconds
tcpClient.option(ChannelOption.SO_TIMEOUT, 10000);
return tcpClient;
});
}
}
----
In this example, the MyHttpClientCustomizer class implements the HttpClientCustomizer interface and overrides the customize method. Within this method, the HTTP client is customized by setting the connect timeout to 5 seconds and the read timeout to 10 seconds.
To use this customizer, you would need to register it with the Spring Cloud Gateway configuration:
.GatewayConfiguration.java
[source,java]
----
@Configuration
public class GatewayConfiguration {
@Bean
public HttpClientCustomizer myHttpClientCustomizer() {
return new MyHttpClientCustomizer();
}
}
----
By registering the customizer as a bean, it will be automatically applied to the HTTP client used by the gateway.

View File

@@ -68,4 +68,3 @@ spring:
close-notify-flush-timeout-millis: 3000
close-notify-read-timeout-millis: 0
----

View File

@@ -89,12 +89,12 @@
|spring.cloud.gateway.httpclient.pool.max-life-time | | Duration after which the channel will be closed. If NULL, there is no max life time.
|spring.cloud.gateway.httpclient.pool.metrics | `+++false+++` | Enables channel pools metrics to be collected and registered in Micrometer. Disabled by default.
|spring.cloud.gateway.httpclient.pool.name | `+++proxy+++` | The channel pool map name, defaults to proxy.
|spring.cloud.gateway.httpclient.pool.type | | Type of pool for HttpClient to use, defaults to ELASTIC.
|spring.cloud.gateway.httpclient.pool.type | | Type of pool for HttpClient to use (elastic, fixed or disabled).
|spring.cloud.gateway.httpclient.proxy.host | | Hostname for proxy configuration of Netty HttpClient.
|spring.cloud.gateway.httpclient.proxy.non-proxy-hosts-pattern | | Regular expression (Java) for a configured list of hosts. that should be reached directly, bypassing the proxy
|spring.cloud.gateway.httpclient.proxy.password | | Password for proxy configuration of Netty HttpClient.
|spring.cloud.gateway.httpclient.proxy.port | | Port for proxy configuration of Netty HttpClient.
|spring.cloud.gateway.httpclient.proxy.type | | proxyType for proxy configuration of Netty HttpClient.
|spring.cloud.gateway.httpclient.proxy.type | | proxyType for proxy configuration of Netty HttpClient (http, socks4 or socks5).
|spring.cloud.gateway.httpclient.proxy.username | | Username for proxy configuration of Netty HttpClient.
|spring.cloud.gateway.httpclient.response-timeout | | The response timeout.
|spring.cloud.gateway.httpclient.ssl.close-notify-flush-timeout | `+++3000ms+++` | SSL close_notify flush timeout. Default to 3000 ms.
@@ -126,6 +126,8 @@
|spring.cloud.gateway.mvc.remove-hop-by-hop-response-headers-filter.enabled | `+++true+++` | Enables the forwarded-request-headers-filter.
|spring.cloud.gateway.mvc.routes | | List of Routes.
|spring.cloud.gateway.mvc.routes-map | | Map of Routes.
|spring.cloud.gateway.mvc.streaming-buffer-size | `+++16384+++` | Buffer size for streaming media mime-types.
|spring.cloud.gateway.mvc.streaming-media-types | | Mime-types that are streaming.
|spring.cloud.gateway.mvc.transfer-encoding-normalization-request-headers-filter.enabled | `+++true+++` | Enables the transfer-encoding-normalization-request-headers-filter.
|spring.cloud.gateway.mvc.weight-calculator-filter.enabled | `+++true+++` | Enables the weight-calculator-filter.
|spring.cloud.gateway.mvc.x-forwarded-request-headers-filter.enabled | `+++true+++` | If the XForwardedHeadersFilter is enabled.

View File

@@ -1,10 +1,10 @@
{
"dependencies": {
"antora": "3.2.0-alpha.4",
"antora": "3.2.0-alpha.8",
"@antora/atlas-extension": "1.0.0-alpha.2",
"@antora/collector-extension": "1.0.0-alpha.3",
"@antora/collector-extension": "1.0.1",
"@asciidoctor/tabs": "1.0.0-beta.6",
"@springio/antora-extensions": "1.11.1",
"@springio/asciidoctor-extensions": "1.0.0-alpha.10"
"@springio/antora-extensions": "1.14.4",
"@springio/asciidoctor-extensions": "1.0.0-alpha.16"
}
}

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
</parent>
<artifactId>spring-cloud-gateway-docs</artifactId>
<packaging>jar</packaging>
@@ -25,20 +25,28 @@
<micrometer-docs-generator.inclusionPattern>.*</micrometer-docs-generator.inclusionPattern>
<micrometer-docs-generator.outputPath>${maven.multiModuleProjectDirectory}/docs/modules/ROOT/partials/</micrometer-docs-generator.outputPath>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>spring-cloud-starter-gateway-mvc</artifactId>
</dependency>
</dependencies>
<build>
<sourceDirectory>src/main/asciidoc</sourceDirectory>
</build>
<profiles>
<profile>
<id>enable-configuration-properties</id>
<activation>
<property>
<name>!disableConfigurationProperties</name>
</property>
</activation>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>spring-cloud-starter-gateway-mvc</artifactId>
</dependency>
</dependencies>
</profile>
<profile>
<id>docs</id>
<build>

View File

@@ -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/contributing.adoc[]
include::https:///raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/docs/modules/ROOT/partials/building.adoc[]
[[contributing]]
= Contributing

View File

@@ -6,7 +6,7 @@
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
<packaging>pom</packaging>
<name>Spring Cloud Gateway</name>
@@ -15,7 +15,7 @@
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-build</artifactId>
<version>4.1.4-SNAPSHOT</version>
<version>4.1.6-SNAPSHOT</version>
<relativePath/>
</parent>
<scm>
@@ -55,8 +55,8 @@
<blockhound.version>1.0.8.RELEASE</blockhound.version>
<java.version>17</java.version>
<junit-pioneer.version>1.9.1</junit-pioneer.version>
<spring-cloud-circuitbreaker.version>3.1.3-SNAPSHOT</spring-cloud-circuitbreaker.version>
<spring-cloud-commons.version>4.1.5-SNAPSHOT</spring-cloud-commons.version>
<spring-cloud-circuitbreaker.version>3.1.5-SNAPSHOT</spring-cloud-circuitbreaker.version>
<spring-cloud-commons.version>4.1.6-SNAPSHOT</spring-cloud-commons.version>
</properties>
<dependencyManagement>

View File

@@ -6,12 +6,12 @@
<parent>
<artifactId>spring-cloud-dependencies-parent</artifactId>
<groupId>org.springframework.cloud</groupId>
<version>4.1.4-SNAPSHOT</version>
<version>4.1.6-SNAPSHOT</version>
<relativePath/>
</parent>
<artifactId>spring-cloud-gateway-dependencies</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
<packaging>pom</packaging>
<name>spring-cloud-gateway-dependencies</name>

View File

@@ -18,7 +18,7 @@
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway-integration-tests</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
<relativePath>..</relativePath> <!-- lookup parent from repository -->
</parent>

View File

@@ -16,7 +16,7 @@
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway-integration-tests</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
<relativePath>..</relativePath> <!-- lookup parent from repository -->
</parent>

View File

@@ -16,7 +16,7 @@
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway-integration-tests</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
<relativePath>..</relativePath> <!-- lookup parent from repository -->
</parent>

View File

@@ -16,7 +16,7 @@
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
<relativePath>..</relativePath> <!-- lookup parent from repository -->
</parent>

View File

@@ -11,7 +11,7 @@
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
<relativePath>..</relativePath>
</parent>

View File

@@ -31,6 +31,7 @@ import java.util.Enumeration;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.Vector;
import java.util.function.Function;
@@ -234,7 +235,7 @@ public class ProxyExchange<T> {
this.excluded.clear();
for (String name : names) {
this.excluded.add(name.toLowerCase());
this.excluded.add(name.toLowerCase(Locale.ROOT));
}
return this;
}
@@ -384,7 +385,7 @@ public class ProxyExchange<T> {
private Set<String> filterHeaderKeys(Collection<String> headerNames) {
final Set<String> excludedHeaders = this.excluded != null ? this.excluded : Collections.emptySet();
return headerNames.stream()
.filter(header -> !excludedHeaders.contains(header.toLowerCase()))
.filter(header -> !excludedHeaders.contains(header.toLowerCase(Locale.ROOT)))
.collect(Collectors.toSet());
}

View File

@@ -20,6 +20,7 @@ import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Locale;
import java.util.Set;
import jakarta.servlet.http.HttpServletRequest;
@@ -101,7 +102,7 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
HttpHeaders headers = new HttpHeaders();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
if (this.autoForwardedHeaders.contains(header.toLowerCase())) {
if (this.autoForwardedHeaders.contains(header.toLowerCase(Locale.ROOT))) {
headers.addAll(header, Collections.list(nativeRequest.getHeaders(header)));
}
}

View File

@@ -16,7 +16,7 @@
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
<relativePath>..</relativePath> <!-- lookup parent from repository -->
</parent>

View File

@@ -16,6 +16,7 @@
package org.springframework.cloud.gateway.sample;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -77,7 +78,7 @@ public class GatewaySampleApplication {
.addResponseHeader("X-TestHeader", "rewrite_request")
.modifyRequestBody(String.class, Hello.class, MediaType.APPLICATION_JSON_VALUE,
(exchange, s) -> {
return Mono.just(new Hello(s.toUpperCase()));
return Mono.just(new Hello(s.toUpperCase(Locale.ROOT)));
})
).uri(uri)
)
@@ -86,7 +87,7 @@ public class GatewaySampleApplication {
.addResponseHeader("X-TestHeader", "rewrite_request_upper")
.modifyRequestBody(String.class, String.class,
(exchange, s) -> {
return Mono.just(s.toUpperCase() + s.toUpperCase());
return Mono.just(s.toUpperCase(Locale.ROOT) + s.toUpperCase(Locale.ROOT));
})
).uri(uri)
)
@@ -95,7 +96,7 @@ public class GatewaySampleApplication {
.addResponseHeader("X-TestHeader", "rewrite_response_upper")
.modifyResponseBody(String.class, String.class,
(exchange, s) -> {
return Mono.just(s.toUpperCase());
return Mono.just(s.toUpperCase(Locale.ROOT));
})
).uri(uri)
)
@@ -107,7 +108,7 @@ public class GatewaySampleApplication {
if (s == null) {
return Mono.just("emptybody");
}
return Mono.just(s.toUpperCase());
return Mono.just(s.toUpperCase(Locale.ROOT));
})
).uri(uri)
@@ -120,7 +121,7 @@ public class GatewaySampleApplication {
if (s == null) {
return Mono.error(new IllegalArgumentException("this should not happen"));
}
return Mono.just(s.toUpperCase());
return Mono.just(s.toUpperCase(Locale.ROOT));
})
).uri(uri)
)

View File

@@ -22,7 +22,7 @@
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
<relativePath>..</relativePath> <!-- lookup parent from repository -->
</parent>
<artifactId>spring-cloud-gateway-server-mvc</artifactId>

View File

@@ -39,6 +39,7 @@ import org.springframework.cloud.gateway.server.mvc.filter.HttpHeadersFilter.Res
import org.springframework.cloud.gateway.server.mvc.filter.RemoveContentLengthRequestHeadersFilter;
import org.springframework.cloud.gateway.server.mvc.filter.RemoveHopByHopRequestHeadersFilter;
import org.springframework.cloud.gateway.server.mvc.filter.RemoveHopByHopResponseHeadersFilter;
import org.springframework.cloud.gateway.server.mvc.filter.RemoveHttp2StatusResponseHeadersFilter;
import org.springframework.cloud.gateway.server.mvc.filter.TransferEncodingNormalizationRequestHeadersFilter;
import org.springframework.cloud.gateway.server.mvc.filter.WeightCalculatorFilter;
import org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter;
@@ -87,8 +88,9 @@ public class GatewayServerMvcAutoConfiguration {
@Bean
@ConditionalOnMissingBean(ProxyExchange.class)
public RestClientProxyExchange restClientProxyExchange(RestClient.Builder restClientBuilder) {
return new RestClientProxyExchange(restClientBuilder.build());
public RestClientProxyExchange restClientProxyExchange(RestClient.Builder restClientBuilder,
GatewayMvcProperties properties) {
return new RestClientProxyExchange(restClientBuilder.build(), properties);
}
@Bean
@@ -163,6 +165,14 @@ public class GatewayServerMvcAutoConfiguration {
return new RemoveContentLengthRequestHeadersFilter();
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = GatewayMvcProperties.PREFIX,
name = "remove-http2-status-response-headers-filter.enabled", matchIfMissing = true)
public RemoveHttp2StatusResponseHeadersFilter removeHttp2StatusResponseHeadersFilter() {
return new RemoveHttp2StatusResponseHeadersFilter();
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = GatewayMvcProperties.PREFIX,

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2013-2023 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.common;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties;
import org.springframework.cloud.gateway.server.mvc.handler.ProxyExchange;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
public abstract class AbstractProxyExchange implements ProxyExchange {
private final GatewayMvcProperties properties;
protected AbstractProxyExchange(GatewayMvcProperties properties) {
this.properties = properties;
}
protected int copyResponseBody(ClientHttpResponse clientResponse, InputStream inputStream,
OutputStream outputStream) throws IOException {
Assert.notNull(clientResponse, "No ClientResponse specified");
Assert.notNull(inputStream, "No InputStream specified");
Assert.notNull(outputStream, "No OutputStream specified");
int transferredBytes;
if (properties.getStreamingMediaTypes().contains(clientResponse.getHeaders().getContentType())) {
transferredBytes = copyResponseBodyWithFlushing(inputStream, outputStream);
}
else {
transferredBytes = StreamUtils.copy(inputStream, outputStream);
}
return transferredBytes;
}
private int copyResponseBodyWithFlushing(InputStream inputStream, OutputStream outputStream) throws IOException {
int readBytes;
var totalReadBytes = 0;
var buffer = new byte[properties.getStreamingBufferSize()];
while ((readBytes = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, readBytes);
outputStream.flush();
if (totalReadBytes < Integer.MAX_VALUE) {
try {
totalReadBytes = Math.addExact(totalReadBytes, readBytes);
}
catch (ArithmeticException e) {
totalReadBytes = Integer.MAX_VALUE;
}
}
}
outputStream.flush();
return totalReadBytes;
}
}

View File

@@ -16,6 +16,8 @@
package org.springframework.cloud.gateway.server.mvc.common;
import java.util.Locale;
import org.springframework.core.style.ToStringCreator;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
@@ -36,7 +38,7 @@ public class HttpStatusHolder {
public static HttpStatusHolder valueOf(String status) {
HttpStatusCode httpStatus;
try {
httpStatus = HttpStatus.valueOf(status.toUpperCase());
httpStatus = HttpStatus.valueOf(status.toUpperCase(Locale.ROOT));
}
catch (IllegalArgumentException e) {
httpStatus = null;

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2012-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.common;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.util.StringUtils;
public class MultipartEnvironmentPostProcessor implements EnvironmentPostProcessor {
/* for testing */ static final String MULTIPART_ENABLED_PROPERTY = "spring.servlet.multipart.enabled";
/* for testing */ static final String MULTIPART_PROPERTY_SOURCE_NAME = "gatewayServerWebmvcMultipartPropertySource";
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
String multipartEnabled = environment.getProperty(MULTIPART_ENABLED_PROPERTY);
if (!StringUtils.hasText(multipartEnabled)) {
// no user set property, set it to false.
MapPropertySource propertySource = new MapPropertySource(MULTIPART_PROPERTY_SOURCE_NAME,
Map.of(MULTIPART_ENABLED_PROPERTY, Boolean.FALSE));
environment.getPropertySources().addFirst(propertySource);
}
}
}

View File

@@ -25,6 +25,7 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -72,6 +73,11 @@ public abstract class MvcUtils {
*/
public static final String GATEWAY_ATTRIBUTES_ATTR = qualify("gatewayAttributes");
/**
* Gateway original request URL attribute name.
*/
public static final String GATEWAY_ORIGINAL_REQUEST_URL_ATTR = qualify("gatewayOriginalRequestUrl");
/**
* Gateway request URL attribute name.
*/
@@ -116,6 +122,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");
@@ -242,6 +256,13 @@ public abstract class MvcUtils {
request.servletRequest().setAttribute(GATEWAY_REQUEST_URL_ATTR, url);
}
@SuppressWarnings("unchecked")
public static void addOriginalRequestUrl(ServerRequest request, URI url) {
LinkedHashSet<URI> urls = (LinkedHashSet<URI>) request.attributes()
.computeIfAbsent(GATEWAY_ORIGINAL_REQUEST_URL_ATTR, s -> new LinkedHashSet<>());
urls.add(url);
}
private record ByteArrayInputMessage(ServerRequest request, ByteArrayInputStream body) implements HttpInputMessage {
@Override

View File

@@ -18,6 +18,7 @@ package org.springframework.cloud.gateway.server.mvc.config;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
@@ -26,6 +27,7 @@ import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.style.ToStringCreator;
import org.springframework.http.MediaType;
@ConfigurationProperties(GatewayMvcProperties.PREFIX)
public class GatewayMvcProperties {
@@ -51,6 +53,18 @@ public class GatewayMvcProperties {
private HttpClient httpClient = new HttpClient();
/**
* Mime-types that are streaming.
*/
private List<MediaType> streamingMediaTypes = Arrays.asList(MediaType.TEXT_EVENT_STREAM,
new MediaType("application", "stream+json"), new MediaType("application", "grpc"),
new MediaType("application", "grpc+protobuf"), new MediaType("application", "grpc+json"));
/**
* Buffer size for streaming media mime-types.
*/
private int streamingBufferSize = 16384;
public List<RouteProperties> getRoutes() {
return routes;
}
@@ -71,11 +85,29 @@ public class GatewayMvcProperties {
return httpClient;
}
public List<MediaType> getStreamingMediaTypes() {
return streamingMediaTypes;
}
public void setStreamingMediaTypes(List<MediaType> streamingMediaTypes) {
this.streamingMediaTypes = streamingMediaTypes;
}
public int getStreamingBufferSize() {
return streamingBufferSize;
}
public void setStreamingBufferSize(int streamingBufferSize) {
this.streamingBufferSize = streamingBufferSize;
}
@Override
public String toString() {
return new ToStringCreator(this).append("httpClient", httpClient)
.append("routes", routes)
.append("routesMap", routesMap)
.append("streamingMediaTypes", streamingMediaTypes)
.append("streamingBufferSize", streamingBufferSize)
.toString();
}

View File

@@ -28,6 +28,8 @@ import org.springframework.cloud.gateway.server.mvc.invoke.OperationParameter;
import org.springframework.cloud.gateway.server.mvc.invoke.OperationParameters;
import org.springframework.cloud.gateway.server.mvc.invoke.reflect.DefaultOperationMethod;
import org.springframework.cloud.gateway.server.mvc.invoke.reflect.OperationMethod;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
@@ -52,8 +54,9 @@ public class NormalizedOperationMethod implements OperationMethod {
}
public boolean isConfigurable() {
Configurable annotation = delegate.getMethod().getAnnotation(Configurable.class);
return annotation != null && delegate.getParameters().getParameterCount() == 1;
MergedAnnotation<Configurable> configurable = MergedAnnotations.from(delegate.getMethod())
.get(Configurable.class);
return configurable.isPresent() && delegate.getParameters().getParameterCount() == 1;
}
@Override
@@ -72,8 +75,10 @@ public class NormalizedOperationMethod implements OperationMethod {
private Map<String, Object> normalizeArgs(Map<String, Object> operationArgs) {
if (hasGeneratedKey(operationArgs)) {
Shortcut shortcut = getMethod().getAnnotation(Shortcut.class);
if (shortcut != null) {
MergedAnnotation<Shortcut> shortcutMergedAnnotation = MergedAnnotations.from(delegate.getMethod())
.get(Shortcut.class);
if (shortcutMergedAnnotation.isPresent()) {
Shortcut shortcut = shortcutMergedAnnotation.synthesize();
String[] fieldOrder = getFieldOrder(shortcut);
return switch (shortcut.type()) {
case DEFAULT -> {

View File

@@ -16,10 +16,14 @@
package org.springframework.cloud.gateway.server.mvc.config;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
@@ -161,13 +165,13 @@ public class RouterFunctionHolderFactory {
String scheme = routeProperties.getUri().getScheme();
Map<String, Object> handlerArgs = new HashMap<>();
Optional<NormalizedOperationMethod> handlerOperationMethod = findOperation(handlerOperations,
scheme.toLowerCase(), handlerArgs);
scheme.toLowerCase(Locale.ROOT), handlerArgs);
if (handlerOperationMethod.isEmpty()) {
// single RouteProperties param
handlerArgs.clear();
String routePropsKey = StringUtils.uncapitalize(RouteProperties.class.getSimpleName());
handlerArgs.put(routePropsKey, routeProperties);
handlerOperationMethod = findOperation(handlerOperations, scheme.toLowerCase(), handlerArgs);
handlerOperationMethod = findOperation(handlerOperations, scheme.toLowerCase(Locale.ROOT), handlerArgs);
if (handlerOperationMethod.isEmpty()) {
throw new IllegalStateException("Unable to find HandlerFunction for scheme: " + scheme);
}
@@ -175,12 +179,15 @@ public class RouterFunctionHolderFactory {
NormalizedOperationMethod normalizedOpMethod = handlerOperationMethod.get();
Object response = invokeOperation(normalizedOpMethod, normalizedOpMethod.getNormalizedArgs());
HandlerFunction<ServerResponse> handlerFunction = null;
// filters added by HandlerDiscoverer need to go last, so save them
List<HandlerFilterFunction<ServerResponse, ServerResponse>> handlerFilterFunctionFilters = new ArrayList<>();
if (response instanceof HandlerFunction<?>) {
handlerFunction = (HandlerFunction<ServerResponse>) response;
}
else if (response instanceof HandlerDiscoverer.Result result) {
handlerFunction = result.getHandlerFunction();
result.getFilters().forEach(builder::filter);
handlerFilterFunctionFilters.addAll(result.getFilters());
}
if (handlerFunction == null) {
throw new IllegalStateException(
@@ -218,6 +225,9 @@ public class RouterFunctionHolderFactory {
translate(filterOperations, filterProperties.getName(), args, HandlerFilterFunction.class, builder::filter);
});
// HandlerDiscoverer filters need higher priority, so put them last
handlerFilterFunctionFilters.forEach(builder::filter);
builder.withAttribute(MvcUtils.GATEWAY_ROUTE_ID_ATTR, routeId);
return builder.build();
@@ -233,6 +243,11 @@ public class RouterFunctionHolderFactory {
if (handlerFilterFunction != null) {
operationHandler.accept(handlerFilterFunction);
}
if (log.isDebugEnabled()) {
log.debug(LogMessage.format("Yaml Properties matched Operations name: %s, args: %s, params: %s",
normalizedName, opMethod.getNormalizedArgs().toString(),
Arrays.toString(opMethod.getParameters().stream().toArray())));
}
}
else {
throw new IllegalArgumentException(String.format("Unable to find operation %s for %s with args %s",
@@ -244,6 +259,7 @@ public class RouterFunctionHolderFactory {
String operationName, Map<String, Object> operationArgs) {
return operations.getOrDefault(operationName, Collections.emptyList())
.stream()
.sorted(Comparator.comparing(OperationMethod::isConfigurable))
.map(operationMethod -> new NormalizedOperationMethod(operationMethod, operationArgs))
.filter(opeMethod -> matchOperation(opeMethod, operationArgs))
.findFirst();
@@ -272,7 +288,7 @@ public class RouterFunctionHolderFactory {
Map<String, Object> args = new HashMap<>();
if (operationMethod.isConfigurable()) {
OperationParameter operationParameter = operationMethod.getParameters().get(0);
Object config = bindConfigurable(operationMethod, args, operationParameter);
Object config = bindConfigurable(operationMethod, operationArgs, operationParameter);
args.put(operationParameter.getName(), config);
}
else {

View File

@@ -45,6 +45,7 @@ import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriTemplate;
import org.springframework.web.util.UriUtils;
import static org.springframework.cloud.gateway.server.mvc.common.MvcUtils.CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR;
import static org.springframework.util.CollectionUtils.unmodifiableMultiValueMap;
@@ -188,12 +189,14 @@ public abstract class BeforeFilterFunctions {
final UriTemplate uriTemplate = new UriTemplate(prefix);
return request -> {
MvcUtils.addOriginalRequestUrl(request, request.uri());
Map<String, Object> uriVariables = MvcUtils.getUriTemplateVariables(request);
URI uri = uriTemplate.expand(uriVariables);
String newPath = uri.getRawPath() + request.uri().getRawPath();
URI prefixedUri = UriComponentsBuilder.fromUri(request.uri()).replacePath(newPath).build().toUri();
MvcUtils.setRequestUrl(request, prefixedUri);
return ServerRequest.from(request).uri(prefixedUri).build();
};
}
@@ -214,10 +217,12 @@ public abstract class BeforeFilterFunctions {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>(request.params());
queryParams.remove(name);
MultiValueMap<String, String> encodedQueryParams = UriUtils.encodeQueryParams(queryParams);
// remove from uri
URI newUri = UriComponentsBuilder.fromUri(request.uri())
.replaceQueryParams(unmodifiableMultiValueMap(queryParams))
.build()
.replaceQueryParams(unmodifiableMultiValueMap(encodedQueryParams))
.build(true)
.toUri();
// remove resolved params from request
@@ -323,7 +328,7 @@ public abstract class BeforeFilterFunctions {
String normalizedReplacement = replacement.replace("$\\", "$");
Pattern pattern = Pattern.compile(regexp);
return request -> {
// TODO: original request url
MvcUtils.addOriginalRequestUrl(request, request.uri());
String path = request.uri().getPath();
String newPath = pattern.matcher(path).replaceAll(normalizedReplacement);
@@ -331,8 +336,7 @@ public abstract class BeforeFilterFunctions {
ServerRequest modified = ServerRequest.from(request).uri(rewrittenUri).build();
// TODO: can this be restored at some point?
// MvcUtils.setRequestUrl(modified, modified.uri());
MvcUtils.setRequestUrl(request, rewrittenUri);
return modified;
};
}
@@ -348,12 +352,13 @@ public abstract class BeforeFilterFunctions {
UriTemplate uriTemplate = new UriTemplate(path);
return request -> {
MvcUtils.addOriginalRequestUrl(request, request.uri());
Map<String, Object> uriVariables = MvcUtils.getUriTemplateVariables(request);
URI uri = uriTemplate.expand(uriVariables);
String newPath = uri.getRawPath();
URI prefixedUri = UriComponentsBuilder.fromUri(request.uri()).replacePath(newPath).build().toUri();
return ServerRequest.from(request).uri(prefixedUri).build();
URI newUri = UriComponentsBuilder.fromUri(request.uri()).replacePath(uri.getRawPath()).build(true).toUri();
MvcUtils.setRequestUrl(request, newUri);
return ServerRequest.from(request).uri(newUri).build();
};
}
@@ -384,6 +389,7 @@ public abstract class BeforeFilterFunctions {
public static Function<ServerRequest, ServerRequest> stripPrefix(int parts) {
return request -> {
MvcUtils.addOriginalRequestUrl(request, request.uri());
// TODO: gateway url attributes
String path = request.uri().getRawPath();
// TODO: begin duplicate code from StripPrefixGatewayFilterFactory
@@ -407,8 +413,10 @@ public abstract class BeforeFilterFunctions {
URI prefixedUri = UriComponentsBuilder.fromUri(request.uri())
.replacePath(newPath.toString())
.build()
.build(true)
.toUri();
MvcUtils.setRequestUrl(request, prefixedUri);
return ServerRequest.from(request).uri(prefixedUri).build();
};
}

View File

@@ -68,7 +68,7 @@ public abstract class CircuitBreakerFilterFunctions {
return circuitBreaker(config);
}
@Shortcut
@Shortcut("id")
@Configurable
public static HandlerFilterFunction<ServerResponse, ServerResponse> circuitBreaker(CircuitBreakerConfig config) {
Set<HttpStatusCode> failureStatuses = config.getStatusCodes()

View File

@@ -66,6 +66,7 @@ public interface FilterFunctions {
return ofResponseProcessor(AfterFilterFunctions.addResponseHeader(name, values));
}
@Shortcut
static HandlerFilterFunction<ServerResponse, ServerResponse> dedupeResponseHeader(String name) {
return ofResponseProcessor(AfterFilterFunctions.dedupeResponseHeader(name));
}

View File

@@ -93,7 +93,7 @@ public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestH
HttpHeaders updated = new HttpHeaders();
// copy all headers except Forwarded
for (Map.Entry<String, List<String>> entry : original.entrySet()) {
for (Map.Entry<String, List<String>> entry : original.headerSet()) {
if (!entry.getKey().equalsIgnoreCase(FORWARDED_HEADER)) {
updated.addAll(entry.getKey(), entry.getValue());
}

View File

@@ -63,6 +63,8 @@ public abstract class LoadBalancerFilterFunctions {
public static HandlerFilterFunction<ServerResponse, ServerResponse> lb(String serviceId,
BiFunction<ServiceInstance, URI, URI> reconstructUriFunction) {
return (request, next) -> {
MvcUtils.addOriginalRequestUrl(request, request.uri());
LoadBalancerClientFactory clientFactory = getApplicationContext(request)
.getBean(LoadBalancerClientFactory.class);
Set<LoadBalancerLifecycle> supportedLifecycleProcessors = LoadBalancerLifecycleValidator

View File

@@ -19,6 +19,7 @@ package org.springframework.cloud.gateway.server.mvc.filter;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -54,8 +55,8 @@ public class RemoveHopByHopRequestHeadersFilter implements RequestHttpHeadersFil
static HttpHeaders filter(HttpHeaders input, Set<String> headersToRemove) {
HttpHeaders filtered = new HttpHeaders();
for (Map.Entry<String, List<String>> entry : input.entrySet()) {
if (!headersToRemove.contains(entry.getKey().toLowerCase())) {
for (Map.Entry<String, List<String>> entry : input.headerSet()) {
if (!headersToRemove.contains(entry.getKey().toLowerCase(Locale.ROOT))) {
filtered.addAll(entry.getKey(), entry.getValue());
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2013-2023 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 org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.web.servlet.function.ServerResponse;
// jdk http client add the :status pseudo-header to the response.
// Copying this header to an upstream HTTP/2 connection will cause a protocol error.
public class RemoveHttp2StatusResponseHeadersFilter implements HttpHeadersFilter.ResponseHttpHeadersFilter, Ordered {
@Override
public int getOrder() {
return 1000;
}
@Override
public HttpHeaders apply(HttpHeaders input, ServerResponse serverResponse) {
if (input.containsKey(":status")) {
HttpHeaders filtered = new HttpHeaders();
filtered.addAll(input);
filtered.remove(":status");
return filtered;
}
return input;
}
}

View File

@@ -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;
@@ -48,6 +49,7 @@ public abstract class RetryFilterFunctions {
private RetryFilterFunctions() {
}
@Shortcut
public static HandlerFilterFunction<ServerResponse, ServerResponse> retry(int retries) {
return retry(config -> config.setRetries(retries));
}
@@ -58,7 +60,7 @@ public abstract class RetryFilterFunctions {
return retry(config);
}
@Shortcut
@Shortcut({ "retries", "series", "methods" })
@Configurable
public static HandlerFilterFunction<ServerResponse, ServerResponse> retry(RetryConfig config) {
RetryTemplateBuilder retryTemplateBuilder = RetryTemplate.builder();
@@ -70,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)
@@ -120,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
@@ -175,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 {

View File

@@ -25,6 +25,7 @@ import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.DeprecatedConfigurationProperty;
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.cloud.gateway.server.mvc.common.MvcUtils;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.util.ObjectUtils;
@@ -374,7 +375,7 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request
HttpHeaders original = input;
HttpHeaders updated = new HttpHeaders();
for (Map.Entry<String, List<String>> entry : original.entrySet()) {
for (Map.Entry<String, List<String>> entry : original.headerSet()) {
updated.addAll(entry.getKey(), entry.getValue());
}
@@ -397,18 +398,15 @@ public class XForwardedRequestHeadersFilter implements HttpHeadersFilter.Request
// - see XForwardedHeadersFilterTests, so first get uris, then extract paths
// and remove one from another if it's the ending part.
LinkedHashSet<URI> originalUris = null; // TODO:
// exchange.getAttribute(GATEWAY_ORIGINAL_REQUEST_URL_ATTR);
URI requestUri = null; // TODO:
// exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);
LinkedHashSet<URI> originalUris = MvcUtils.getAttribute(request,
MvcUtils.GATEWAY_ORIGINAL_REQUEST_URL_ATTR);
URI requestUri = MvcUtils.getAttribute(request, MvcUtils.GATEWAY_REQUEST_URL_ATTR);
if (originalUris != null && requestUri != null) {
originalUris.forEach(originalUri -> {
if (originalUri != null && originalUri.getPath() != null) {
String prefix = originalUri.getPath();
// strip trailing slashes before checking if request path is end
// of original path
String originalUriPath = stripTrailingSlash(originalUri);

View File

@@ -20,18 +20,28 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import org.springframework.cloud.gateway.server.mvc.common.AbstractProxyExchange;
import org.springframework.cloud.gateway.server.mvc.common.MvcUtils;
import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.StreamUtils;
import org.springframework.web.servlet.function.ServerResponse;
public class ClientHttpRequestFactoryProxyExchange implements ProxyExchange {
public class ClientHttpRequestFactoryProxyExchange extends AbstractProxyExchange {
private final ClientHttpRequestFactory requestFactory;
@Deprecated
public ClientHttpRequestFactoryProxyExchange(ClientHttpRequestFactory requestFactory) {
super(new GatewayMvcProperties());
this.requestFactory = requestFactory;
}
public ClientHttpRequestFactoryProxyExchange(ClientHttpRequestFactory requestFactory,
GatewayMvcProperties properties) {
super(properties);
this.requestFactory = requestFactory;
}
@@ -54,7 +64,8 @@ public class ClientHttpRequestFactoryProxyExchange implements ProxyExchange {
InputStream inputStream = MvcUtils.getAttribute(request.getServerRequest(),
MvcUtils.CLIENT_RESPONSE_INPUT_STREAM_ATTR);
// copy body from request to clientHttpRequest
StreamUtils.copy(inputStream, httpServletResponse.getOutputStream());
ClientHttpRequestFactoryProxyExchange.this.copyResponseBody(clientHttpResponse, inputStream,
httpServletResponse.getOutputStream());
}
return null;
});

View File

@@ -123,9 +123,9 @@ public class ProxyExchangeHandlerFunction
private <REQUEST_OR_RESPONSE> HttpHeaders filterHeaders(List<?> filters, HttpHeaders original,
REQUEST_OR_RESPONSE requestOrResponse) {
HttpHeaders filtered = original;
for (var filter : filters) {
for (Object filter : filters) {
@SuppressWarnings("unchecked")
var typed = ((HttpHeadersFilter<REQUEST_OR_RESPONSE>) filter);
HttpHeadersFilter<REQUEST_OR_RESPONSE> typed = ((HttpHeadersFilter<REQUEST_OR_RESPONSE>) filter);
filtered = typed.apply(filtered, requestOrResponse);
}
return filtered;

View File

@@ -19,35 +19,56 @@ package org.springframework.cloud.gateway.server.mvc.handler;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import org.springframework.cloud.gateway.server.mvc.common.AbstractProxyExchange;
import org.springframework.cloud.gateway.server.mvc.common.MvcUtils;
import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.servlet.function.ServerResponse;
public class RestClientProxyExchange implements ProxyExchange {
public class RestClientProxyExchange extends AbstractProxyExchange {
private final RestClient restClient;
@Deprecated
public RestClientProxyExchange(RestClient restClient) {
super(new GatewayMvcProperties());
this.restClient = restClient;
}
public RestClientProxyExchange(RestClient restClient, GatewayMvcProperties properties) {
super(properties);
this.restClient = restClient;
}
@Override
public ServerResponse exchange(Request request) {
return restClient.method(request.getMethod())
RestClient.RequestBodySpec requestSpec = restClient.method(request.getMethod())
.uri(request.getUri())
.headers(httpHeaders -> httpHeaders.putAll(request.getHeaders()))
.body(outputStream -> copyBody(request, outputStream))
.exchange((clientRequest, clientResponse) -> doExchange(request, clientResponse), false);
.headers(httpHeaders -> httpHeaders.putAll(request.getHeaders()));
if (isBodyPresent(request)) {
requestSpec.body(outputStream -> copyBody(request, outputStream));
}
return requestSpec.exchange((clientRequest, clientResponse) -> doExchange(request, clientResponse), false);
}
private static boolean isBodyPresent(Request request) {
try {
return !request.getServerRequest().servletRequest().getInputStream().isFinished();
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static int copyBody(Request request, OutputStream outputStream) throws IOException {
return StreamUtils.copy(request.getServerRequest().servletRequest().getInputStream(), outputStream);
}
private static ServerResponse doExchange(Request request, ClientHttpResponse clientResponse) throws IOException {
private ServerResponse doExchange(Request request, ClientHttpResponse clientResponse) throws IOException {
InputStream body = clientResponse.getBody();
// put the body input stream in a request attribute so filters can read it.
MvcUtils.putAttribute(request.getServerRequest(), MvcUtils.CLIENT_RESPONSE_INPUT_STREAM_ATTR, body);
@@ -59,7 +80,8 @@ public class RestClientProxyExchange implements ProxyExchange {
InputStream inputStream = MvcUtils.getAttribute(request.getServerRequest(),
MvcUtils.CLIENT_RESPONSE_INPUT_STREAM_ATTR);
// copy body from request to clientHttpRequest
StreamUtils.copy(inputStream, httpServletResponse.getOutputStream());
RestClientProxyExchange.this.copyResponseBody(clientResponse, inputStream,
httpServletResponse.getOutputStream());
}
return null;
});

View File

@@ -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);

View File

@@ -1,3 +1,20 @@
#
# Copyright 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.
#
#
org.springframework.cloud.gateway.server.mvc.filter.FilterSupplier=\
org.springframework.cloud.gateway.server.mvc.filter.Bucket4jFilterFunctions.FilterSupplier,\
org.springframework.cloud.gateway.server.mvc.filter.CircuitBreakerFilterFunctions.FilterSupplier,\
@@ -12,3 +29,6 @@ org.springframework.cloud.gateway.server.mvc.handler.HandlerSupplier=\
org.springframework.cloud.gateway.server.mvc.predicate.PredicateSupplier=\
org.springframework.cloud.gateway.server.mvc.predicate.MvcPredicateSupplier,\
org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.PredicateSupplier
org.springframework.boot.env.EnvironmentPostProcessor=\
org.springframework.cloud.gateway.server.mvc.common.MultipartEnvironmentPostProcessor

View File

@@ -28,6 +28,7 @@ import org.springframework.cloud.gateway.server.mvc.filter.ForwardedRequestHeade
import org.springframework.cloud.gateway.server.mvc.filter.RemoveContentLengthRequestHeadersFilter;
import org.springframework.cloud.gateway.server.mvc.filter.RemoveHopByHopRequestHeadersFilter;
import org.springframework.cloud.gateway.server.mvc.filter.RemoveHopByHopResponseHeadersFilter;
import org.springframework.cloud.gateway.server.mvc.filter.RemoveHttp2StatusResponseHeadersFilter;
import org.springframework.cloud.gateway.server.mvc.filter.TransferEncodingNormalizationRequestHeadersFilter;
import org.springframework.cloud.gateway.server.mvc.filter.WeightCalculatorFilter;
import org.springframework.cloud.gateway.server.mvc.filter.XForwardedRequestHeadersFilter;
@@ -46,6 +47,7 @@ public class GatewayServerMvcAutoConfigurationTests {
"spring.cloud.gateway.mvc.remove-content-length-request-headers-filter.enabled=false",
"spring.cloud.gateway.mvc.remove-hop-by-hop-request-headers-filter.enabled=false",
"spring.cloud.gateway.mvc.remove-hop-by-hop-response-headers-filter.enabled=false",
"spring.cloud.gateway.mvc.remove-http2-status-response-headers-filter.enabled=false",
"spring.cloud.gateway.mvc.transfer-encoding-normalization-request-headers-filter.enabled=false",
"spring.cloud.gateway.mvc.weight-calculator-filter.enabled=false",
"spring.cloud.gateway.mvc.x-forwarded-request-headers-filter.enabled=false")
@@ -55,6 +57,7 @@ public class GatewayServerMvcAutoConfigurationTests {
assertThat(context).doesNotHaveBean(RemoveContentLengthRequestHeadersFilter.class);
assertThat(context).doesNotHaveBean(RemoveHopByHopRequestHeadersFilter.class);
assertThat(context).doesNotHaveBean(RemoveHopByHopResponseHeadersFilter.class);
assertThat(context).doesNotHaveBean(RemoveHttp2StatusResponseHeadersFilter.class);
assertThat(context).doesNotHaveBean(TransferEncodingNormalizationRequestHeadersFilter.class);
assertThat(context).doesNotHaveBean(WeightCalculatorFilter.class);
assertThat(context).doesNotHaveBean(XForwardedRequestHeadersFilter.class);

View File

@@ -22,11 +22,11 @@ import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
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;
@@ -39,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;
@@ -78,10 +76,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;
@@ -116,7 +112,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;
@@ -125,7 +120,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;
@@ -241,6 +235,7 @@ public class ServerMvcIntegrationTests {
public void stripPrefixWorks() {
restClient.get()
.uri("/long/path/to/get")
.header("Host", "www.stripprefix.org")
.exchange()
.expectStatus()
.isOk()
@@ -248,6 +243,13 @@ public class ServerMvcIntegrationTests {
.consumeWith(res -> {
Map<String, Object> map = res.getResponseBody();
Map<String, Object> headers = getMap(map, "headers");
assertThat(headers).containsKeys(XForwardedRequestHeadersFilter.X_FORWARDED_PREFIX_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_HOST_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_PORT_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_PROTO_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_FOR_HEADER);
assertThat(headers).containsEntry(XForwardedRequestHeadersFilter.X_FORWARDED_PREFIX_HEADER,
"/long/path/to");
assertThat(headers).containsEntry("X-Test", "stripPrefix");
});
}
@@ -266,10 +268,40 @@ public class ServerMvcIntegrationTests {
Map<String, Object> map = res.getResponseBody();
assertThat(map).containsEntry("data", "hello");
Map<String, Object> headers = getMap(map, "headers");
assertThat(headers).containsKeys(XForwardedRequestHeadersFilter.X_FORWARDED_PREFIX_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_HOST_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_PORT_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_PROTO_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_FOR_HEADER);
assertThat(headers).containsEntry(XForwardedRequestHeadersFilter.X_FORWARDED_PREFIX_HEADER,
"/long/path/to");
assertThat(headers).containsEntry("X-Test", "stripPrefixPost");
});
}
@Test
public void stripPrefixLbWorks() {
restClient.get()
.uri("/long/path/to/get")
.header("Host", "www.stripprefixlb.org")
.exchange()
.expectStatus()
.isOk()
.expectBody(Map.class)
.consumeWith(res -> {
Map<String, Object> map = res.getResponseBody();
Map<String, Object> headers = getMap(map, "headers");
assertThat(headers).containsKeys(XForwardedRequestHeadersFilter.X_FORWARDED_PREFIX_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_HOST_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_PORT_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_PROTO_HEADER,
XForwardedRequestHeadersFilter.X_FORWARDED_FOR_HEADER);
assertThat(headers).containsEntry(XForwardedRequestHeadersFilter.X_FORWARDED_PREFIX_HEADER,
"/long/path/to");
assertThat(headers).containsEntry("X-Test", "stripPrefix");
});
}
@Test
public void setStatusGatewayRouterFunctionWorks() {
restClient.get()
@@ -392,20 +424,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();
@@ -483,7 +501,7 @@ public class ServerMvcIntegrationTests {
@Test
public void rewritePathPostLocalWorks() {
restClient.post()
.uri("/baz/post")
.uri("/baz/localpost")
.bodyValue("hello")
.header("Host", "www.rewritepathpostlocal.org")
.exchange()
@@ -635,8 +653,21 @@ public class ServerMvcIntegrationTests {
private void assertMultipartData(Map responseBody) {
Map<String, Object> files = (Map<String, Object>) responseBody.get("files");
assertThat(files).containsKey("imgpart");
String file = (String) files.get("imgpart");
assertThat(file).startsWith("data:").contains(";base64,");
Object imgpart = files.get("imgpart");
if (imgpart instanceof List l) {
String file = (String) l.get(0);
assertThat(isPNG(file.getBytes()));
}
else {
String file = (String) imgpart;
assertThat(file).startsWith("data:").contains(";base64,");
}
}
private static boolean isPNG(byte[] bytes) {
byte[] pngSignature = { (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
byte[] header = Arrays.copyOf(bytes, pngSignature.length);
return Arrays.equals(pngSignature, header);
}
@Test
@@ -990,11 +1021,6 @@ public class ServerMvcIntegrationTests {
return new TestHandler();
}
@Bean
RetryController retryController() {
return new RetryController();
}
@Bean
EventController eventController() {
return new EventController();
@@ -1070,8 +1096,8 @@ public class ServerMvcIntegrationTests {
// @formatter:off
return route("testsetpath")
.route(POST("/mycustompath{extra}").and(host("**.setpathpost.org")), http())
.filter(new HttpbinUriResolver())
.filter(setPath("/{extra}"))
.filter(new HttpbinUriResolver())
.build();
// @formatter:on
}
@@ -1079,11 +1105,12 @@ public class ServerMvcIntegrationTests {
@Bean
public RouterFunction<ServerResponse> gatewayRouterFunctionsStripPrefix() {
// @formatter:off
return route(GET("/long/path/to/get"), http())
.filter(new HttpbinUriResolver())
return route("teststripprefix")
.route(GET("/long/path/to/get").and(host("**.stripprefix.org")), http())
.filter(stripPrefix(3))
.filter(addRequestHeader("X-Test", "stripPrefix"))
.withAttribute(MvcUtils.GATEWAY_ROUTE_ID_ATTR, "teststripprefix");
.filter(new HttpbinUriResolver())
.build();
// @formatter:on
}
@@ -1092,9 +1119,21 @@ public class ServerMvcIntegrationTests {
// @formatter:off
return route("teststripprefixpost")
.route(POST("/long/path/to/post").and(host("**.stripprefixpost.org")), http())
.filter(new HttpbinUriResolver())
.filter(stripPrefix(3))
.filter(addRequestHeader("X-Test", "stripPrefixPost"))
.filter(new HttpbinUriResolver())
.build();
// @formatter:on
}
@Bean
public RouterFunction<ServerResponse> gatewayRouterFunctionsStripPrefixLb() {
// @formatter:off
return route("teststripprefix")
.route(GET("/long/path/to/get").and(host("**.stripprefixlb.org")), http())
.filter(stripPrefix(3))
.filter(addRequestHeader("X-Test", "stripPrefix"))
.filter(lb("httpbin"))
.build();
// @formatter:on
}
@@ -1179,19 +1218,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
@@ -1278,8 +1304,7 @@ public class ServerMvcIntegrationTests {
// @formatter:off
return route("testform")
.POST("/post", host("**.testform.org"), http())
.before(new LocalServerPortUriResolver())
.filter(prefixPath("/test"))
.filter(new HttpbinUriResolver())
.filter(addRequestHeader("X-Test", "form"))
.build();
// @formatter:on
@@ -1443,8 +1468,8 @@ public class ServerMvcIntegrationTests {
return route("requestheadertorequesturi")
.route(cloudFoundryRouteService().and(host("**.requestheadertorequesturi.org")), http())
//.before(new HttpbinUriResolver()) NO URI RESOLVER!
.before(requestHeaderToRequestUri("X-CF-Forwarded-Url"))
.filter(setPath("/hello"))
.before(requestHeaderToRequestUri("X-CF-Forwarded-Url"))
.build();
// @formatter:on
}
@@ -1533,12 +1558,12 @@ public class ServerMvcIntegrationTests {
return route("testmodifyrequestbodystring")
.POST("/post", host("**.modifyrequestbodystring.org"), http())
.before(new HttpbinUriResolver())
.before(modifyRequestBody(String.class, String.class, null, (request, s) -> s.toUpperCase() + s.toUpperCase()))
.before(modifyRequestBody(String.class, String.class, null, (request, s) -> s.toUpperCase(Locale.ROOT) + s.toUpperCase(Locale.ROOT)))
.build().and(
route("testmodifyrequestbodyobject")
.POST("/post", host("**.modifyrequestbodyobject.org"), http())
.before(new HttpbinUriResolver())
.before(modifyRequestBody(String.class, Hello.class, MediaType.APPLICATION_JSON_VALUE, (request, s) -> new Hello(s.toUpperCase())))
.before(modifyRequestBody(String.class, Hello.class, MediaType.APPLICATION_JSON_VALUE, (request, s) -> new Hello(s.toUpperCase(Locale.ROOT))))
.build());
// @formatter:on
}
@@ -1676,37 +1701,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

View File

@@ -16,6 +16,7 @@
package org.springframework.cloud.gateway.server.mvc;
import java.util.Locale;
import java.util.Map;
import org.junit.jupiter.api.Test;
@@ -79,7 +80,7 @@ public class VanillaRouterFunctionTests {
// @formatter:off
return RouterFunctions.route()
.POST("/anything/routerfunctionsroute", host("**.routerfunctionsroute.org"), http())
.before(modifyRequestBody(String.class, String.class, null, (request, s) -> s.toUpperCase()))
.before(modifyRequestBody(String.class, String.class, null, (request, s) -> s.toUpperCase(Locale.ROOT)))
.before(new HttpbinUriResolver())
.build();
// @formatter:on

View File

@@ -0,0 +1,99 @@
/*
* 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.server.mvc.common;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties;
import org.springframework.http.MediaType;
import org.springframework.mock.http.client.MockClientHttpResponse;
import org.springframework.web.servlet.function.ServerResponse;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* @author Jens Mallien
*/
public class AbstractProxyExchangeTests {
@Test
public void copyResponseBodyForJson() throws IOException {
MockClientHttpResponse mockResponse = new MockClientHttpResponse(new byte[0], 200);
mockResponse.getHeaders().setContentType(MediaType.APPLICATION_JSON);
InputStream inputStream = mock(InputStream.class);
when(inputStream.transferTo(any())).thenReturn(3L);
OutputStream outputStream = mock(OutputStream.class);
int result = new TestProxyExchange().copyResponseBody(mockResponse, inputStream, outputStream);
assertThat(result).isEqualTo(3);
verify(outputStream, times(1)).flush();
}
@Test
public void copyResponseBodyForTextEventStream() throws IOException {
MockClientHttpResponse mockResponse = new MockClientHttpResponse(new byte[0], 200);
mockResponse.getHeaders().setContentType(MediaType.TEXT_EVENT_STREAM);
InputStream inputStream = mock(InputStream.class);
when(inputStream.read(any())).thenReturn(1).thenReturn(1).thenReturn(1).thenReturn(-1);
OutputStream outputStream = mock(OutputStream.class);
int result = new TestProxyExchange().copyResponseBody(mockResponse, inputStream, outputStream);
assertThat(result).isEqualTo(3);
verify(outputStream, times(4)).flush();
}
@Test
public void copyResponseBodyWithoutContentType() throws IOException {
MockClientHttpResponse mockResponse = new MockClientHttpResponse(new byte[0], 200);
InputStream inputStream = mock(InputStream.class);
when(inputStream.transferTo(any())).thenReturn(3L);
OutputStream outputStream = mock(OutputStream.class);
int result = new TestProxyExchange().copyResponseBody(mockResponse, inputStream, outputStream);
assertThat(result).isEqualTo(3);
verify(outputStream, times(1)).flush();
}
class TestProxyExchange extends AbstractProxyExchange {
protected TestProxyExchange() {
super(new GatewayMvcProperties());
}
@Override
public ServerResponse exchange(Request request) {
return ServerResponse.ok().build();
}
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.common;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.cloud.gateway.server.mvc.common.MultipartEnvironmentPostProcessor.MULTIPART_ENABLED_PROPERTY;
import static org.springframework.cloud.gateway.server.mvc.common.MultipartEnvironmentPostProcessor.MULTIPART_PROPERTY_SOURCE_NAME;
public class MultipartEnvironmentPostProcessorTests {
@Test
void multipartDisabledByDefault() {
MockEnvironment environment = new MockEnvironment();
MultipartEnvironmentPostProcessor processor = new MultipartEnvironmentPostProcessor();
processor.postProcessEnvironment(environment, null);
assertThat(environment.getPropertySources().contains(MULTIPART_PROPERTY_SOURCE_NAME)).isTrue();
Boolean multipartEnabled = environment.getProperty(MULTIPART_ENABLED_PROPERTY, Boolean.class);
assertThat(multipartEnabled).isFalse();
}
@Test
void multipartEnabledByUser() {
MockEnvironment environment = new MockEnvironment();
environment.setProperty(MULTIPART_ENABLED_PROPERTY, "true");
MultipartEnvironmentPostProcessor processor = new MultipartEnvironmentPostProcessor();
processor.postProcessEnvironment(environment, null);
assertThat(environment.getPropertySources().contains(MULTIPART_PROPERTY_SOURCE_NAME)).isFalse();
Boolean multipartEnabled = environment.getProperty(MULTIPART_ENABLED_PROPERTY, Boolean.class);
assertThat(multipartEnabled).isTrue();
}
}

View File

@@ -122,7 +122,7 @@ public class GatewayMvcPropertiesBeanDefinitionRegistrarTests {
predicate.accept(new AbstractRequestPredicatesVisitor() {
@Override
public void path(String pattern) {
assertThat(pattern).isEqualTo("/anything/listRoute3");
assertThat(pattern).isEqualTo("/extra/anything/listRoute3");
}
@Override
@@ -181,7 +181,7 @@ public class GatewayMvcPropertiesBeanDefinitionRegistrarTests {
@SuppressWarnings("unchecked")
public void lbRouteWorks() {
restClient.get()
.uri("/anything/listRoute3")
.uri("/extra/anything/listRoute3")
.header("MyHeaderName", "MyHeaderVal")
.exchange()
.expectStatus()

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2013-2024 the original author or authors.
* 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.
@@ -27,12 +27,163 @@ import org.springframework.web.servlet.function.ServerRequest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author raccoonback
* @author Jens Mallien
*/
public class BeforeFilterFunctionsTests {
class BeforeFilterFunctionsTests {
@Test
public void rewritePath() {
void setPath() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/legacy/path")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.setPath("/new/path").apply(request);
assertThat(result.uri().toString()).hasToString("http://localhost/new/path");
}
@Test
void setEncodedPath() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/legacy/path")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.setPath("/new/é").apply(request);
assertThat(result.uri().toString()).hasToString("http://localhost/new/%C3%A9");
}
@Test
void setPathWithParameters() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/legacy/path")
.queryParam("foo", "bar")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.setPath("/new/path").apply(request);
assertThat(result.uri().toString()).hasToString("http://localhost/new/path?foo=bar");
}
@Test
void setPathWithEncodedParameters() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/legacy/path")
.queryParam("foo[]", "bar[]")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.setPath("/new/path").apply(request);
assertThat(result.uri().toString()).hasToString("http://localhost/new/path?foo%5B%5D=bar%5B%5D");
}
@Test
void removeRequestParameter() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/path")
.queryParam("foo", "bar")
.queryParam("baz", "qux")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.removeRequestParameter("foo").apply(request);
assertThat(result.param("foo")).isEmpty();
assertThat(result.param("baz")).isPresent().hasValue("qux");
assertThat(result.uri().toString()).hasToString("http://localhost/path?baz=qux");
}
@Test
void removeEncodedRequestParameter() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/path")
.queryParam("foo[]", "bar")
.queryParam("baz", "qux")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.removeRequestParameter("foo[]").apply(request);
assertThat(result.param("foo[]")).isEmpty();
assertThat(result.param("baz")).isPresent().hasValue("qux");
assertThat(result.uri().toString()).hasToString("http://localhost/path?baz=qux");
}
@Test
void removeRequestParameterWithEncodedRemainParameters() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/path")
.queryParam("foo", "bar")
.queryParam("baz[]", "qux[]")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.removeRequestParameter("foo").apply(request);
assertThat(result.param("foo")).isEmpty();
assertThat(result.param("baz[]")).isPresent().hasValue("qux[]");
assertThat(result.uri().toString()).hasToString("http://localhost/path?baz%5B%5D=qux%5B%5D");
}
@Test
void removeRequestParameterWithEncodedPath() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/é")
.queryParam("foo", "bar")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.removeRequestParameter("foo").apply(request);
assertThat(result.param("foo")).isEmpty();
assertThat(result.uri().toString()).hasToString("http://localhost/%C3%A9");
}
@Test
void stripPrefix() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/depth1/depth2/depth3")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.stripPrefix(2).apply(request);
assertThat(result.uri().toString()).hasToString("http://localhost/depth3");
}
@Test
void stripPrefixWithEncodedPath() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/depth1/depth2/depth3/é")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.stripPrefix(2).apply(request);
assertThat(result.uri().toString()).hasToString("http://localhost/depth3/%C3%A9");
}
@Test
void stripPrefixWithEncodedParameters() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/depth1/depth2/depth3")
.queryParam("baz[]", "qux[]")
.buildRequest(null);
ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList());
ServerRequest result = BeforeFilterFunctions.stripPrefix(2).apply(request);
assertThat(result.param("baz[]")).isPresent().hasValue("qux[]");
assertThat(result.uri().toString()).hasToString("http://localhost/depth3?baz%5B%5D=qux%5B%5D");
}
@Test
void rewritePath() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get")
.buildRequest(null);
@@ -44,7 +195,7 @@ public class BeforeFilterFunctionsTests {
}
@Test
public void rewritePathWithSpace() {
void rewritePathWithSpace() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get/path/with spaces")
.buildRequest(null);
@@ -56,7 +207,7 @@ public class BeforeFilterFunctionsTests {
}
@Test
public void rewritePathWithEnDash() {
void rewritePathWithEnDash() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get/path/withendashes")
.buildRequest(null);
@@ -68,7 +219,7 @@ public class BeforeFilterFunctionsTests {
}
@Test
public void rewritePathWithEnDashAndSpace() {
void rewritePathWithEnDashAndSpace() {
MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get/path/withendashes and spaces")
.buildRequest(null);

View File

@@ -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());
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -16,7 +16,9 @@
package org.springframework.cloud.gateway.server.mvc.test;
import java.lang.reflect.UndeclaredThrowableException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.function.Function;
import org.springframework.cloud.gateway.server.mvc.common.MvcUtils;
@@ -36,7 +38,15 @@ public class HttpbinUriResolver
String host = context.getEnvironment().getProperty("httpbin.host");
Assert.hasText(host, "httpbin.host is not set, did you initialize HttpbinTestcontainers?");
Assert.notNull(port, "httpbin.port is not set, did you initialize HttpbinTestcontainers?");
return URI.create(String.format("http://%s:%d", host, port));
URI original = request.uri();
try {
return new URI("http", original.getUserInfo(), host, port, original.getPath(), original.getQuery(),
original.getFragment());
}
catch (URISyntaxException e) {
throw new UndeclaredThrowableException(e);
}
}
@Override

View File

@@ -16,27 +16,21 @@
package org.springframework.cloud.gateway.server.mvc.test;
import java.io.IOException;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MultiValueMap;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ServerWebExchange;
@RestController
@@ -58,35 +52,7 @@ public class TestController {
return result;
}
@PostMapping(value = "/post", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> postFormData(HttpServletRequest request,
@RequestParam MultiValueMap<String, MultipartFile> parts) throws ServletException, IOException {
HashMap<String, Object> ret = new HashMap<>();
ret.put("headers", getHeaders(request));
HashMap<String, Object> files = new HashMap<>();
ret.put("files", files);
parts.values().stream().flatMap(List::stream).forEach(part -> {
String contentType = part.getContentType();
long contentLength = part.getSize();
// TODO: get part data
files.put(part.getName(), "data:" + contentType + ";base64," + contentLength);
});
return ret;
}
@PostMapping(path = "/post", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> postUrlEncoded(HttpServletRequest request,
@RequestBody(required = false) MultiValueMap form) throws IOException {
HashMap<String, Object> ret = new HashMap<>();
ret.put("headers", getHeaders(request));
ret.put("form", form);
return ret;
}
@PostMapping(path = "/post", produces = MediaType.APPLICATION_JSON_VALUE)
@PostMapping(path = "/localpost", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> post(HttpServletRequest request, @RequestBody(required = false) String body) {
HashMap<String, Object> ret = new HashMap<>();
ret.put("headers", getHeaders(request));

View File

@@ -264,7 +264,7 @@ public class ExchangeResult {
}
private String formatHeaders(HttpHeaders headers, String delimiter) {
return headers.entrySet()
return headers.headerSet()
.stream()
.map(entry -> entry.getKey() + ": " + entry.getValue())
.collect(Collectors.joining(delimiter));

View File

@@ -34,12 +34,16 @@ spring.cloud.gateway.mvc:
- HttpbinUriResolver=
- TokenRelay
- AddRequestHeader=X-Test,listRoute2
- Retry=3,SERVER_ERROR
- id: listRoute3
uri: lb://httpbin
predicates:
- Path=/anything/listRoute3
- Path=/extra/anything/listRoute3
- Header=MyHeaderName,MyHeader.*
filters:
- name: StripPrefix
args:
parts: 1
- name: AddRequestHeader
args:
name: X-Test

View File

@@ -7,7 +7,7 @@
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway</artifactId>
<version>4.1.6-SNAPSHOT</version>
<version>4.1.7-SNAPSHOT</version>
<relativePath>..</relativePath> <!-- lookup parent from repository -->
</parent>
<artifactId>spring-cloud-gateway-server</artifactId>
@@ -235,7 +235,7 @@
<!-- Based on instructions here - https://kotlinlang.org/docs/reference/using-maven.html -->
<artifactId>kotlin-maven-plugin</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>1.9.23</version>
<version>1.9.25</version>
<configuration>
<jvmTarget>17</jvmTarget>
</configuration>

View File

@@ -16,9 +16,11 @@
package org.springframework.cloud.gateway.config;
import java.io.IOException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.util.List;
import java.util.Set;
import java.util.function.Supplier;
@@ -354,10 +356,12 @@ public class GatewayAutoConfiguration {
@ConditionalOnMissingBean(GrpcSslConfigurer.class)
@ConditionalOnClass(name = "io.grpc.Channel")
public GrpcSslConfigurer grpcSslConfigurer(HttpClientProperties properties)
throws KeyStoreException, NoSuchAlgorithmException {
throws KeyStoreException, NoSuchAlgorithmException, CertificateException, IOException {
TrustManagerFactory trustManagerFactory = TrustManagerFactory
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(KeyStore.getInstance(KeyStore.getDefaultType()));
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null);
trustManagerFactory.init(keyStore);
return new GrpcSslConfigurer(properties.getSsl());
}

View File

@@ -169,7 +169,7 @@ public class HttpClientProperties {
public static class Pool {
/** Type of pool for HttpClient to use, defaults to ELASTIC. */
/** Type of pool for HttpClient to use (elastic, fixed or disabled). */
private PoolType type = PoolType.ELASTIC;
/** The channel pool map name, defaults to proxy. */
@@ -302,7 +302,9 @@ public class HttpClientProperties {
public static class Proxy {
/** proxyType for proxy configuration of Netty HttpClient. */
/**
* proxyType for proxy configuration of Netty HttpClient (http, socks4 or socks5).
*/
private ProxyProvider.Proxy type = ProxyProvider.Proxy.HTTP;
/** Hostname for proxy configuration of Netty HttpClient. */

View File

@@ -19,6 +19,7 @@ package org.springframework.cloud.gateway.discovery;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Predicate;
@@ -170,7 +171,7 @@ public class DiscoveryClientRouteDefinitionLocator implements RouteDefinitionLoc
@Override
public String getServiceId() {
if (properties.isLowerCaseServiceId()) {
return delegate.getServiceId().toLowerCase();
return delegate.getServiceId().toLowerCase(Locale.ROOT);
}
return delegate.getServiceId();
}

View File

@@ -191,7 +191,8 @@ public class NettyRoutingFilter implements GlobalFilter, Ordered {
if (responseTimeout != null) {
responseFlux = responseFlux
.timeout(responseTimeout,
Mono.error(new TimeoutException("Response took longer than timeout: " + responseTimeout)))
Mono.defer(() -> Mono
.error(new TimeoutException("Response took longer than timeout: " + responseTimeout))))
.onErrorMap(TimeoutException.class,
th -> new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, th.getMessage(), th));
}

View File

@@ -23,6 +23,7 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.SignalType;
import reactor.netty.Connection;
import org.springframework.core.Ordered;
@@ -98,8 +99,12 @@ public class NettyWriteResponseFilter implements GlobalFilter, Ordered {
return (isStreamingMediaType(contentType)
? response.writeAndFlushWith(body.map(Flux::just))
: response.writeWith(body));
})).doOnCancel(() -> cleanup(exchange))
.doOnError(throwable -> cleanup(exchange));
}))
.doFinally(signalType -> {
if (signalType == SignalType.CANCEL || signalType == SignalType.ON_ERROR) {
cleanup(exchange);
}
});
// @formatter:on
}
@@ -116,12 +121,12 @@ public class NettyWriteResponseFilter implements GlobalFilter, Ordered {
byteBuf.release();
return buffer;
}
throw new IllegalArgumentException("Unkown DataBufferFactory type " + bufferFactory.getClass());
throw new IllegalArgumentException("Unknown DataBufferFactory type " + bufferFactory.getClass());
}
private void cleanup(ServerWebExchange exchange) {
Connection connection = exchange.getAttribute(CLIENT_RESPONSE_CONN_ATTR);
if (connection != null && connection.channel().isActive()) {
if (connection != null) {
connection.dispose();
}
}

View File

@@ -16,37 +16,17 @@
package org.springframework.cloud.gateway.filter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Mono;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.PooledDataBuffer;
import org.springframework.web.server.ServerWebExchange;
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR;
public class RemoveCachedBodyFilter implements GlobalFilter, Ordered {
private static final Log log = LogFactory.getLog(RemoveCachedBodyFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return chain.filter(exchange).doFinally(s -> {
Object attribute = exchange.getAttributes().remove(CACHED_REQUEST_BODY_ATTR);
if (attribute != null && attribute instanceof PooledDataBuffer) {
PooledDataBuffer dataBuffer = (PooledDataBuffer) attribute;
if (dataBuffer.isAllocated()) {
if (log.isTraceEnabled()) {
log.trace("releasing cached body in exchange attribute");
}
// ensure proper release
while (!dataBuffer.release()) {
// release() counts down until zero, will never be infinite loop
}
}
}
});
return chain.filter(exchange).doFinally(s -> ServerWebExchangeUtils.clearCachedRequestBody(exchange));
}
@Override

View File

@@ -21,6 +21,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.apache.commons.logging.Log;
@@ -79,7 +80,7 @@ public class WebsocketRoutingFilter implements GlobalFilter, Ordered {
/* for testing */
static String convertHttpToWs(String scheme) {
scheme = scheme.toLowerCase();
scheme = scheme.toLowerCase(Locale.ROOT);
return "http".equals(scheme) ? "ws" : "https".equals(scheme) ? "wss" : scheme;
}
@@ -142,8 +143,8 @@ public class WebsocketRoutingFilter implements GlobalFilter, Ordered {
headersFilters.add((headers, exchange) -> {
HttpHeaders filtered = new HttpHeaders();
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
if (!entry.getKey().toLowerCase().startsWith("sec-websocket")) {
for (Map.Entry<String, List<String>> entry : headers.headerSet()) {
if (!entry.getKey().toLowerCase(Locale.ROOT).startsWith("sec-websocket")) {
filtered.addAll(entry.getKey(), entry.getValue());
}
}
@@ -157,7 +158,7 @@ public class WebsocketRoutingFilter implements GlobalFilter, Ordered {
static void changeSchemeIfIsWebSocketUpgrade(ServerWebExchange exchange) {
// Check the Upgrade
URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR);
String scheme = requestUrl.getScheme().toLowerCase();
String scheme = requestUrl.getScheme().toLowerCase(Locale.ROOT);
String upgrade = exchange.getRequest().getHeaders().getUpgrade();
// change the scheme if the socket client send a "http" or "https"
if ("WebSocket".equalsIgnoreCase(upgrade) && ("http".equals(scheme) || "https".equals(scheme))) {

View File

@@ -16,6 +16,7 @@
package org.springframework.cloud.gateway.filter.factory;
import org.springframework.cloud.gateway.event.EnableBodyCachingEvent;
import org.springframework.cloud.gateway.support.AbstractConfigurable;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
@@ -43,6 +44,13 @@ public abstract class AbstractGatewayFilterFactory<C> extends AbstractConfigurab
return this.publisher;
}
protected void enableBodyCaching(String routeId) {
if (routeId != null && getPublisher() != null) {
// send an event to enable caching
getPublisher().publishEvent(new EnableBodyCachingEvent(this, routeId));
}
}
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
this.publisher = publisher;

View File

@@ -29,6 +29,7 @@ import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator;
import static org.springframework.util.CollectionUtils.unmodifiableMultiValueMap;
@@ -57,14 +58,19 @@ public class RemoveRequestParameterGatewayFilterFactory
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>(request.getQueryParams());
queryParams.remove(config.getName());
URI newUri = UriComponentsBuilder.fromUri(request.getURI())
.replaceQueryParams(unmodifiableMultiValueMap(queryParams))
.build()
.toUri();
try {
MultiValueMap<String, String> encodedQueryParams = UriUtils.encodeQueryParams(queryParams);
URI newUri = UriComponentsBuilder.fromUri(request.getURI())
.replaceQueryParams(unmodifiableMultiValueMap(encodedQueryParams))
.build(true)
.toUri();
ServerHttpRequest updatedRequest = exchange.getRequest().mutate().uri(newUri).build();
return chain.filter(exchange.mutate().request(updatedRequest).build());
ServerHttpRequest updatedRequest = exchange.getRequest().mutate().uri(newUri).build();
return chain.filter(exchange.mutate().request(updatedRequest).build());
}
catch (IllegalArgumentException ex) {
throw new IllegalStateException("Invalid URI query: \"" + queryParams + "\"");
}
}
@Override

View File

@@ -69,7 +69,7 @@ public class RequestHeaderSizeGatewayFilterFactory
HttpHeaders headers = request.getHeaders();
HashMap<String, Long> longHeaders = new HashMap<>();
for (Map.Entry<String, List<String>> headerEntry : headers.entrySet()) {
for (Map.Entry<String, List<String>> headerEntry : headers.headerSet()) {
long headerSizeInBytes = 0L;
headerSizeInBytes += headerEntry.getKey().getBytes().length;
List<String> values = headerEntry.getValue();

View File

@@ -35,7 +35,6 @@ import reactor.retry.RepeatContext;
import reactor.retry.Retry;
import reactor.retry.RetryContext;
import org.springframework.cloud.gateway.event.EnableBodyCachingEvent;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.support.HasRouteId;
@@ -229,10 +228,7 @@ public class RetryGatewayFilterFactory extends AbstractGatewayFilterFactory<Retr
}
public GatewayFilter apply(String routeId, Repeat<ServerWebExchange> repeat, Retry<ServerWebExchange> retry) {
if (routeId != null && getPublisher() != null) {
// send an event to enable caching
getPublisher().publishEvent(new EnableBodyCachingEvent(this, routeId));
}
enableBodyCaching(routeId);
return (exchange, chain) -> {
trace("Entering retry-filter");

View File

@@ -26,10 +26,14 @@ import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator;
import static org.springframework.util.CollectionUtils.unmodifiableMultiValueMap;
/**
* @author Fredrich Ombico
@@ -59,14 +63,25 @@ public class RewriteRequestParameterGatewayFilterFactory
ServerHttpRequest req = exchange.getRequest();
UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(req.getURI());
if (req.getQueryParams().containsKey(config.getName())) {
uriComponentsBuilder.replaceQueryParam(config.getName(), config.getReplacement());
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>(req.getQueryParams());
if (queryParams.containsKey(config.getName())) {
queryParams.remove(config.getName());
queryParams.add(config.getName(), config.getReplacement());
}
URI uri = uriComponentsBuilder.build().toUri();
ServerHttpRequest request = req.mutate().uri(uri).build();
try {
MultiValueMap<String, String> encodedQueryParams = UriUtils.encodeQueryParams(queryParams);
URI uri = uriComponentsBuilder.replaceQueryParams(unmodifiableMultiValueMap(encodedQueryParams))
.build(true)
.toUri();
return chain.filter(exchange.mutate().request(request).build());
ServerHttpRequest request = req.mutate().uri(uri).build();
return chain.filter(exchange.mutate().request(request).build());
}
catch (IllegalArgumentException ex) {
throw new IllegalStateException("Invalid URI query: \"" + queryParams + "\"");
}
}
@Override

View File

@@ -17,6 +17,7 @@
package org.springframework.cloud.gateway.filter.factory;
import java.util.List;
import java.util.Locale;
import reactor.core.publisher.Mono;
@@ -136,7 +137,7 @@ public class SecureHeadersGatewayFilterFactory
}
private boolean isEnabled(List<String> disabledHeaders, String header) {
return !disabledHeaders.contains(header.toLowerCase());
return !disabledHeaders.contains(header.toLowerCase(Locale.ROOT));
}
public static class Config {

View File

@@ -89,6 +89,9 @@ public abstract class SpringCloudCircuitBreakerFilterFactory
@Override
public GatewayFilter apply(Config config) {
if (config.getFallbackUri() != null) {
enableBodyCaching(config.getRouteId());
}
ReactiveCircuitBreaker cb = reactiveCircuitBreakerFactory.create(config.getId());
Set<HttpStatus> statuses = config.getStatusCodes()
.stream()

View File

@@ -34,6 +34,7 @@ import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCache
import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheProperties.RequestOptions;
import org.springframework.cloud.gateway.filter.factory.cache.keygenerator.CacheKeyGenerator;
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.AfterCacheExchangeMutator;
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.RemoveHeadersAfterCacheExchangeMutator;
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator;
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetMaxAgeHeaderAfterCacheExchangeMutator;
import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetResponseHeadersAfterCacheExchangeMutator;
@@ -80,6 +81,7 @@ public class ResponseCacheManager {
this.ignoreNoCacheUpdate = isSkipNoCacheUpdateActive(requestOptions);
this.afterCacheExchangeMutators = List.of(new SetResponseHeadersAfterCacheExchangeMutator(),
new SetStatusCodeAfterCacheExchangeMutator(),
new RemoveHeadersAfterCacheExchangeMutator(HttpHeaders.PRAGMA, HttpHeaders.EXPIRES),
new SetMaxAgeHeaderAfterCacheExchangeMutator(configuredTimeToLive, Clock.systemDefaultZone(),
ignoreNoCacheUpdate),
new SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator());

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2013-2020 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.filter.factory.cache.postprocessor;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse;
import org.springframework.web.server.ServerWebExchange;
/**
* Removes one or more HTTP headers from response. Assumes it is run after
* {@link SetResponseHeadersAfterCacheExchangeMutator}.
*
* @author Abel Salgado Romero
*/
public class RemoveHeadersAfterCacheExchangeMutator implements AfterCacheExchangeMutator {
private static final Log LOGGER = LogFactory.getLog(RemoveHeadersAfterCacheExchangeMutator.class);
private final String[] httpHeader;
public RemoveHeadersAfterCacheExchangeMutator(String... httpHeaders) {
this.httpHeader = httpHeaders;
}
@Override
public void accept(ServerWebExchange exchange, CachedResponse cachedResponse) {
for (String header : httpHeader) {
var previousValue = exchange.getResponse().getHeaders().remove(header);
if (previousValue != null) {
LOGGER.debug("HTTP Header value found in response, removing HTTP header " + header);
}
}
}
}

View File

@@ -96,7 +96,7 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered {
HttpHeaders updated = new HttpHeaders();
// copy all headers except Forwarded
for (Map.Entry<String, List<String>> entry : original.entrySet()) {
for (Map.Entry<String, List<String>> entry : original.headerSet()) {
if (!entry.getKey().equalsIgnoreCase(FORWARDED_HEADER)) {
updated.addAll(entry.getKey(), entry.getValue());
}

View File

@@ -33,7 +33,7 @@ public class GRPCRequestHeadersFilter implements HttpHeadersFilter, Ordered {
public HttpHeaders filter(HttpHeaders headers, ServerWebExchange exchange) {
HttpHeaders updated = new HttpHeaders();
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
for (Map.Entry<String, List<String>> entry : headers.headerSet()) {
updated.addAll(entry.getKey(), entry.getValue());
}

View File

@@ -19,6 +19,7 @@ package org.springframework.cloud.gateway.filter.headers;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@@ -73,8 +74,8 @@ public class RemoveHopByHopHeadersFilter implements HttpHeadersFilter, Ordered {
Set<String> headersToRemove = new HashSet<>(headers);
headersToRemove.addAll(connectionOptions);
for (Map.Entry<String, List<String>> entry : originalHeaders.entrySet()) {
if (!headersToRemove.contains(entry.getKey().toLowerCase())) {
for (Map.Entry<String, List<String>> entry : originalHeaders.headerSet()) {
if (!headersToRemove.contains(entry.getKey().toLowerCase(Locale.ROOT))) {
filtered.addAll(entry.getKey(), entry.getValue());
}
}

View File

@@ -202,7 +202,7 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered {
HttpHeaders original = input;
HttpHeaders updated = new HttpHeaders();
for (Map.Entry<String, List<String>> entry : original.entrySet()) {
for (Map.Entry<String, List<String>> entry : original.headerSet()) {
updated.addAll(entry.getKey(), entry.getValue());
}

View File

@@ -24,6 +24,7 @@ import org.springframework.cloud.gateway.config.GatewayProperties;
import org.springframework.cloud.gateway.config.GlobalCorsProperties;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.env.Environment;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.reactive.handler.AbstractHandlerMapping;
@@ -99,6 +100,7 @@ public class RoutePredicateHandlerMapping extends AbstractHandlerMapping {
})
.switchIfEmpty(Mono.empty().then(Mono.fromRunnable(() -> {
exchange.getAttributes().remove(GATEWAY_PREDICATE_ROUTE_ATTR);
ServerWebExchangeUtils.clearCachedRequestBody(exchange);
if (logger.isTraceEnabled()) {
logger.trace("No RouteDefinition found for [" + getExchangeDesc(exchange) + "]");
}

View File

@@ -17,7 +17,6 @@
package org.springframework.cloud.gateway.handler.predicate;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Pattern;
@@ -59,9 +58,7 @@ public class HeaderRoutePredicateFactory extends AbstractRoutePredicateFactory<H
return new GatewayPredicate() {
@Override
public boolean test(ServerWebExchange exchange) {
List<String> values = exchange.getRequest()
.getHeaders()
.getOrDefault(config.header, Collections.emptyList());
List<String> values = exchange.getRequest().getHeaders().getValuesAsList(config.header);
if (values.isEmpty()) {
return false;
}

View File

@@ -16,6 +16,7 @@
package org.springframework.cloud.gateway.support;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -79,7 +80,7 @@ public final class NameUtils {
matcher.appendReplacement(stringBuffer, matcher.group(1));
}
}
return stringBuffer.toString().toLowerCase();
return stringBuffer.toString().toLowerCase(Locale.ROOT);
}
private static String removeGarbage(String s) {

View File

@@ -20,6 +20,7 @@ import java.net.URI;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
@@ -40,6 +41,7 @@ import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBuffer;
import org.springframework.core.io.buffer.NettyDataBuffer;
import org.springframework.core.io.buffer.PooledDataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.AbstractServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpRequest;
@@ -267,7 +269,7 @@ public final class ServerWebExchangeUtils {
}
catch (NumberFormatException e) {
// try the enum string
httpStatus = HttpStatus.valueOf(statusString.toUpperCase());
httpStatus = HttpStatus.valueOf(statusString.toUpperCase(Locale.ROOT));
}
return httpStatus;
}
@@ -379,6 +381,27 @@ public final class ServerWebExchangeUtils {
.flatMap(function);
}
/**
* clear the request body in a ServerWebExchange attribute. The attribute is
* {@link #CACHED_REQUEST_BODY_ATTR}.
* @param exchange the available ServerWebExchange.
*/
public static void clearCachedRequestBody(ServerWebExchange exchange) {
Object attribute = exchange.getAttributes().remove(CACHED_REQUEST_BODY_ATTR);
if (attribute != null && attribute instanceof PooledDataBuffer) {
PooledDataBuffer dataBuffer = (PooledDataBuffer) attribute;
if (dataBuffer.isAllocated()) {
if (log.isTraceEnabled()) {
log.trace("releasing cached body in exchange attribute");
}
// ensure proper release
while (!dataBuffer.release()) {
// release() counts down until zero, will never be infinite loop
}
}
}
}
private static ServerHttpRequest decorate(ServerWebExchange exchange, DataBuffer dataBuffer,
boolean cacheDecoratedRequest) {
if (dataBuffer.readableByteCount() > 0) {

View File

@@ -48,9 +48,9 @@ import static org.springframework.cloud.gateway.handler.predicate.RoutePredicate
"spring.cloud.gateway.discovery.locator.lower-case-service-id=true"
/*
* "spring.cloud.gateway.discovery.locator.predicates[0].name=Path",
* "spring.cloud.gateway.discovery.locator.predicates[0].args[pattern]='/'+serviceId.toLowerCase()+'/**'",
* "spring.cloud.gateway.discovery.locator.predicates[0].args[pattern]='/'+serviceId.toLowerCase(Locale.ROOT)+'/**'",
* "spring.cloud.gateway.discovery.locator.filters[0].name=RewritePath",
* "spring.cloud.gateway.discovery.locator.filters[0].args[regexp]='/' + serviceId.toLowerCase() + '/(?<remaining>.*)'"
* "spring.cloud.gateway.discovery.locator.filters[0].args[regexp]='/' + serviceId.toLowerCase(Locale.ROOT) + '/(?<remaining>.*)'"
* ,
* "spring.cloud.gateway.discovery.locator.filters[0].args[replacement]='/$\\\\{remaining}'",
*/

View File

@@ -37,7 +37,7 @@ import static org.mockito.Mockito.when;
/**
* @author Thirunavukkarasu Ravichandran
*/
public class RemoveRequestParameterGatewayFilterFactoryTests {
class RemoveRequestParameterGatewayFilterFactoryTests {
private ServerWebExchange exchange;
@@ -46,7 +46,7 @@ public class RemoveRequestParameterGatewayFilterFactoryTests {
private ArgumentCaptor<ServerWebExchange> captor;
@BeforeEach
public void setUp() {
void setUp() {
filterChain = mock(GatewayFilterChain.class);
captor = ArgumentCaptor.forClass(ServerWebExchange.class);
when(filterChain.filter(captor.capture())).thenReturn(Mono.empty());
@@ -54,7 +54,7 @@ public class RemoveRequestParameterGatewayFilterFactoryTests {
}
@Test
public void removeRequestParameterFilterWorks() {
void removeRequestParameterFilterWorks() {
MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost")
.queryParam("foo", singletonList("bar"))
.build();
@@ -70,7 +70,7 @@ public class RemoveRequestParameterGatewayFilterFactoryTests {
}
@Test
public void removeRequestParameterFilterWorksWhenParamIsNotPresentInRequest() {
void removeRequestParameterFilterWorksWhenParamIsNotPresentInRequest() {
MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost").build();
exchange = MockServerWebExchange.from(request);
NameConfig config = new NameConfig();
@@ -84,7 +84,7 @@ public class RemoveRequestParameterGatewayFilterFactoryTests {
}
@Test
public void removeRequestParameterFilterShouldOnlyRemoveSpecifiedParam() {
void removeRequestParameterFilterShouldOnlyRemoveSpecifiedParam() {
MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost")
.queryParam("foo", "bar")
.queryParam("abc", "xyz")
@@ -102,7 +102,7 @@ public class RemoveRequestParameterGatewayFilterFactoryTests {
}
@Test
public void removeRequestParameterFilterShouldHandleRemainingParamsWhichRequiringEncoding() {
void removeRequestParameterFilterShouldHandleRemainingParamsWhichRequiringEncoding() {
MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost")
.queryParam("foo", "bar")
.queryParam("aaa", "abc xyz")
@@ -123,4 +123,40 @@ public class RemoveRequestParameterGatewayFilterFactoryTests {
assertThat(actualRequest.getQueryParams()).containsEntry("ccc", singletonList(",xyz"));
}
@Test
void removeRequestParameterFilterShouldHandleEncodedParameterName() {
MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost")
.queryParam("foo", "bar")
.queryParam("baz[]", "qux")
.build();
exchange = MockServerWebExchange.from(request);
NameConfig config = new NameConfig();
config.setName("baz[]");
GatewayFilter filter = new RemoveRequestParameterGatewayFilterFactory().apply(config);
filter.filter(exchange, filterChain);
ServerHttpRequest actualRequest = captor.getValue().getRequest();
assertThat(actualRequest.getQueryParams()).doesNotContainKey("baz[]");
assertThat(actualRequest.getQueryParams()).containsEntry("foo", singletonList("bar"));
}
@Test
void removeRequestParameterFilterShouldMaintainEncodedParameters() {
MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost")
.queryParam("foo", "bar")
.queryParam("baz[]", "qux")
.build();
exchange = MockServerWebExchange.from(request);
NameConfig config = new NameConfig();
config.setName("foo");
GatewayFilter filter = new RemoveRequestParameterGatewayFilterFactory().apply(config);
filter.filter(exchange, filterChain);
ServerHttpRequest actualRequest = captor.getValue().getRequest();
assertThat(actualRequest.getQueryParams()).doesNotContainKey("foo");
assertThat(actualRequest.getQueryParams()).containsEntry("baz[]", singletonList("qux"));
}
}

View File

@@ -71,11 +71,23 @@ class RewriteRequestParameterGatewayFilterFactoryTests {
}
@Test
void rewriteRequestParameterFilterWorksWithSpecialCharacters() {
void rewriteRequestParameterFilterWithSpecialCharactersInParameterValue() {
testRewriteRequestParameterFilter("campaign", "black friday~(1.A-B_C!)", "campaign=old&color=green",
Map.of("campaign", List.of("black friday~(1.A-B_C!)"), "color", List.of("green")));
}
@Test
void rewriteRequestParameterFilterWithSpecialCharactersInParameterName() {
testRewriteRequestParameterFilter("campaign[]", "red", "campaign%5B%5D=blue&color=green",
Map.of("campaign[]", List.of("red"), "color", List.of("green")));
}
@Test
void rewriteRequestParameterFilterKeepsOtherParamsEncoded() {
testRewriteRequestParameterFilter("color", "white", "campaign%5B%5D=blue&color=green",
Map.of("campaign[]", List.of("blue"), "color", List.of("white")));
}
private void testRewriteRequestParameterFilter(String name, String replacement, String query,
Map<String, List<String>> expectedQueryParams) {
GatewayFilter filter = new RewriteRequestParameterGatewayFilterFactory()

View File

@@ -21,7 +21,9 @@ import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable;
import org.springframework.cloud.gateway.test.BaseWebClientTests;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.BodyInserters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;
/**
@@ -154,11 +156,11 @@ public abstract class SpringCloudCircuitBreakerFilterFactoryTests extends BaseWe
.is5xxServerError()
.expectBody()
.jsonPath("$.status")
.isEqualTo(504)
.value(status -> assertThat(HttpStatus.valueOf((Integer) status).is5xxServerError()).isTrue())
.jsonPath("$.message")
.isNotEmpty()
.jsonPath("$.error")
.isEqualTo("Gateway Timeout");
.isNotEmpty();
}
@Test
@@ -242,4 +244,11 @@ public abstract class SpringCloudCircuitBreakerFilterFactoryTests extends BaseWe
.valueEquals(ROUTE_ID_HEADER, "circuitbreaker_resume_without_error");
}
@Test
public void filterPostFallback() {
testClient.post().uri("/post").body(BodyInserters.fromValue("hello"))
.header("Host", "www.circuitbreakerfallbackpost.org").exchange().expectStatus()
.isOk().expectBody().json("{\"body\":\"hello\"}");
}
}

View File

@@ -38,6 +38,8 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -68,6 +70,11 @@ public class SpringCloudCircuitBreakerTestConfig {
return Collections.singletonMap("from", "circuitbreakerfallbackcontroller");
}
@PostMapping("/circuitbreakerPostFallbackController")
public Map<String, String> postFallbackController(@RequestBody String body) {
return Collections.singletonMap("body", body);
}
@GetMapping("/circuitbreakerUriFallbackController/**")
public Map<String, String> uriFallbackcontroller(ServerWebExchange exchange, @RequestParam("a") String a) {
return Collections.singletonMap("uri", exchange.getRequest().getURI().toString());

View File

@@ -351,6 +351,34 @@ public class LocalResponseCacheGatewayFilterFactoryTests extends BaseWebClientTe
.jsonPath("$.headers." + CUSTOM_HEADER, "2");
}
@Test
void shouldNotReturnPragmaHeaderInNonCachedAndCachedResponses() {
shouldNotReturnHeader(HttpHeaders.PRAGMA);
}
@Test
void shouldNotReturnExpiresHeaderInNonCachedAndCachedResponses() {
shouldNotReturnHeader(HttpHeaders.EXPIRES);
}
private void shouldNotReturnHeader(String header) {
String uri = "/" + UUID.randomUUID() + "/cache/headers";
testClient.get()
.uri(uri)
.header("Host", "www.localresponsecache.org")
.exchange()
.expectHeader()
.doesNotExist(header);
testClient.get()
.uri(uri)
.header("Host", "www.localresponsecache.org")
.exchange()
.expectHeader()
.doesNotExist(header);
}
void assertNonVaryHeaderInContent(String uri, String varyHeader, String varyHeaderValue, String nonVaryHeader,
String nonVaryHeaderValue, String expectedNonVaryResponse) {
testClient.get()

View File

@@ -30,6 +30,7 @@ import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.gateway.test.BaseWebClientTests;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
@@ -123,6 +124,34 @@ public class LocalResponseCacheGlobalFilterTests {
.isEqualTo("1");
}
@Test
void shouldNotReturnPragmaHeaderInNonCachedAndCachedResponses() {
shouldNotReturnHeader(HttpHeaders.PRAGMA);
}
@Test
void shouldNotReturnExpiresHeaderInNonCachedAndCachedResponses() {
shouldNotReturnHeader(HttpHeaders.EXPIRES);
}
private void shouldNotReturnHeader(String header) {
String uri = "/" + UUID.randomUUID() + "/global-cache/headers";
testClient.get()
.uri(uri)
.header("Host", "www.localresponsecache.org")
.exchange()
.expectHeader()
.doesNotExist(header);
testClient.get()
.uri(uri)
.header("Host", "www.localresponsecache.org")
.exchange()
.expectHeader()
.doesNotExist(header);
}
@EnableAutoConfiguration
@SpringBootConfiguration
@Import(DefaultTestConfig.class)

View File

@@ -0,0 +1,110 @@
/*
* Copyright 2013-2020 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.filter.factory.cache.postprocessor;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpResponse;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.web.server.ServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.HttpHeaders.CACHE_CONTROL;
import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
import static org.springframework.http.HttpHeaders.EXPIRES;
import static org.springframework.http.HttpHeaders.PRAGMA;
/**
* @author Abel Salgado Romero
*/
class RemoveHeaderAfterCacheExchangeMutatorTest {
private static final String HTTP_HEADER_TO_REMOVE = "X-To-Remove";
@Test
void onlyHeaderToRemoveFromResponseIsRemoved() {
final ServerWebExchange inputExchange = setupExchange(Map.of(HTTP_HEADER_TO_REMOVE, "A-Value"));
final var mutator = new RemoveHeadersAfterCacheExchangeMutator(HTTP_HEADER_TO_REMOVE);
CachedResponse cachedResponse = new CachedResponse.Builder(HttpStatus.OK).build();
mutator.accept(inputExchange, cachedResponse);
assertThat(inputExchange.getResponse().getHeaders()).doesNotContainKey(HTTP_HEADER_TO_REMOVE)
.containsEntry(CACHE_CONTROL, List.of("max-age=60"))
.containsEntry(CONTENT_TYPE, List.of("application/octet-stream"))
.hasSize(2);
}
@Test
void multipleHeadersToRemoveFromResponseAreRemoved() {
final Map<String, String> headers = Map.of(HTTP_HEADER_TO_REMOVE, "A-Value", PRAGMA, "void", EXPIRES, "0");
final ServerWebExchange inputExchange = setupExchange(headers);
final var mutator = new RemoveHeadersAfterCacheExchangeMutator(HTTP_HEADER_TO_REMOVE, PRAGMA, EXPIRES);
CachedResponse cachedResponse = new CachedResponse.Builder(HttpStatus.OK).build();
mutator.accept(inputExchange, cachedResponse);
assertThat(inputExchange.getResponse().getHeaders()).doesNotContainKey(HTTP_HEADER_TO_REMOVE)
.doesNotContainKey(PRAGMA)
.doesNotContainKey(EXPIRES)
.containsEntry(CACHE_CONTROL, List.of("max-age=60"))
.containsEntry(CONTENT_TYPE, List.of("application/octet-stream"))
.hasSize(2);
}
@Test
void headersAreNotModifiedIfHeaderToRemoveIsEmpty() {
final ServerWebExchange inputExchange = setupExchange(Map.of());
final var mutator = new RemoveHeadersAfterCacheExchangeMutator(HTTP_HEADER_TO_REMOVE);
CachedResponse cachedResponse = new CachedResponse.Builder(HttpStatus.OK).build();
mutator.accept(inputExchange, cachedResponse);
assertThat(inputExchange.getResponse().getHeaders()).containsEntry(CACHE_CONTROL, List.of("max-age=60"))
.containsEntry(CONTENT_TYPE, List.of("application/octet-stream"))
.hasSize(2);
}
private ServerWebExchange setupExchange(Map<String, String> headersToAdd) {
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setCacheControl(CacheControl.maxAge(Duration.ofSeconds(60)));
responseHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headersToAdd.forEach((k, v) -> responseHeaders.set(k, v));
MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this").build();
MockServerWebExchange inputExchange = MockServerWebExchange.from(httpRequest);
MockServerHttpResponse httpResponse = inputExchange.getResponse();
httpResponse.setStatusCode(HttpStatus.OK);
httpResponse.getHeaders().putAll(responseHeaders);
return inputExchange;
}
}

View File

@@ -16,6 +16,8 @@
package org.springframework.cloud.gateway.filter.factory.rewrite;
import java.util.Locale;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
@@ -134,7 +136,7 @@ public class ModifyRequestBodyGatewayFilterFactoryTests extends BaseWebClientTes
if (body == null) {
return Mono.just("modifyrequest");
}
return Mono.just(body.toUpperCase());
return Mono.just(body.toUpperCase(Locale.ROOT));
}))
.uri(uri))
.route("test_modify_request_body_to_large",
@@ -152,7 +154,7 @@ public class ModifyRequestBodyGatewayFilterFactoryTests extends BaseWebClientTes
.filters(f -> f.modifyRequestBody(new ParameterizedTypeReference<String>() {
}, new ParameterizedTypeReference<String>() {
}, (swe, body) -> {
return Mono.just(body.replaceAll(" ", "_").toUpperCase());
return Mono.just(body.replaceAll(" ", "_").toUpperCase(Locale.ROOT));
}))
.uri(uri))
.build();

Some files were not shown because too many files have changed in this diff Show More