diff --git a/.github/dco.yml b/.github/dco.yml new file mode 100644 index 00000000..0c4b142e --- /dev/null +++ b/.github/dco.yml @@ -0,0 +1,2 @@ +require: + members: false diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 6aceddb1..d9851135 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -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: diff --git a/README.adoc b/README.adoc index 9a06cafb..d4195c40 100644 --- a/README.adoc +++ b/README.adoc @@ -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 ----- - -true <1> - true - <2> - true - <3> - - - - - <4> - io.spring.javaformat - spring-javaformat-maven-plugin - - <5> - org.apache.maven.plugins - maven-checkstyle-plugin - - - - - - <5> - org.apache.maven.plugins - maven-checkstyle-plugin - - - - ----- -<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 ----- - - - - - - ----- - -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] ----- - - - - org.basepom.maven - duplicate-finder-maven-plugin - - - ----- - -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] ----- - - - - org.basepom.maven - duplicate-finder-maven-plugin - - - org.joda.time.base.BaseDateTime - .*module-info - - - changelog.txt - - - - - - - ----- - +Unresolved directive in - 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] diff --git a/docs/antora.yml b/docs/antora.yml index fad926be..4bfa002b 100644 --- a/docs/antora.yml +++ b/docs/antora.yml @@ -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/ diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index d1e78782..edcb0484 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -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[] diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/loadbalancer.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/loadbalancer.adoc index 58859f9c..2135aaf2 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/loadbalancer.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/loadbalancer.adoc @@ -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`. diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/prefixpath.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/prefixpath.adoc index 2313b6e5..5bc8b5e8 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/prefixpath.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/prefixpath.adoc @@ -33,7 +33,7 @@ class RouteConfiguration { public RouterFunction 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. diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/retry.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/retry.adoc index fa6529fa..96165f0d 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/retry.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/retry.adoc @@ -9,6 +9,7 @@ The `Retry` filter supports the following parameters: * `methods`: The HTTP methods that should be retried, represented by using `org.springframework.http.HttpMethod`. * `series`: The series of status codes to be retried, represented by using `org.springframework.http.HttpStatus.Series`. * `exceptions`: A list of thrown exceptions that should be retried. +* `cacheBody`: A flag to signal if the request body should be cached. If set to `true`, the `adaptCacheBody` filter must be used to send the cached body downstream. //* `backoff`: The configured exponential backoff for the retries. //Retries are performed after a backoff interval of `firstBackoff * (factor ^ n)`, where `n` is the iteration. //If `maxBackoff` is configured, the maximum backoff applied is limited to `maxBackoff`. @@ -20,8 +21,11 @@ The following defaults are configured for `Retry` filter, if enabled: * `series`: 5XX series * `methods`: GET method * `exceptions`: `IOException`, `TimeoutException` and `RetryException` +* `cacheBody`: `false` //* `backoff`: disabled +WARNING: Setting `cacheBody` to `true` causes the gateway to read the whole body into memory. This should be used with caution. + The following listing configures a Retry filter: .application.yml @@ -42,11 +46,14 @@ spring: retries: 3 series: SERVER_ERROR methods: GET,POST + cacheBody: true + - name: AdaptCachedBody ---- .GatewaySampleApplication.java [source,java] ---- +import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.adaptCachedBody; import static org.springframework.cloud.gateway.server.mvc.filter.RetryFilterFunctions.retry; import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; @@ -59,7 +66,8 @@ class RouteConfiguration { public RouterFunction gatewayRouterFunctionsAddReqHeader() { return route("add_request_parameter_route") .route(host("*.retry.com"), http("https://example.org")) - .filter(retry(config -> config.setRetries(3).setSeries(Set.of(HttpStatus.Series.SERVER_ERROR)).setMethods(Set.of(HttpMethod.GET, HttpMethod.POST)))) + .filter(retry(config -> config.setRetries(3).setSeries(Set.of(HttpStatus.Series.SERVER_ERROR)).setMethods(Set.of(HttpMethod.GET, HttpMethod.POST)).setCacheBody(true))) + .filter(adaptCachedBody()) .build(); } } diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/rewritepath.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/rewritepath.adoc index 7247db46..3b50b615 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/rewritepath.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/rewritepath.adoc @@ -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. \ No newline at end of file diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/setpath.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/setpath.adoc index 261345e6..9355a068 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/setpath.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/setpath.adoc @@ -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. \ No newline at end of file diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/stripprefix.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/stripprefix.adoc index ee2d2efa..dc2be798 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/stripprefix.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/filters/stripprefix.adoc @@ -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. \ No newline at end of file diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/writing-custom-predicates-and-filters.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/writing-custom-predicates-and-filters.adoc index aa12f127..fdabf35d 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/writing-custom-predicates-and-filters.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-mvc/writing-custom-predicates-and-filters.adoc @@ -94,7 +94,7 @@ import org.springframework.web.servlet.function.ServerResponse; class SampleHandlerFilterFunctions { public static HandlerFilterFunction 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 instrument(String header) { - return request -> ServerRequest.from(request).header(header, generateId());; + return request -> ServerRequest.from(request).header(header, generateId()).build(); } } ---- diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway/configuration-properties.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway/configuration-properties.adoc index 1aed5173..7d7642d2 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway/configuration-properties.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway/configuration-properties.adoc @@ -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]. diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway/http-client.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway/http-client.adoc new file mode 100644 index 00000000..0f972f90 --- /dev/null +++ b/docs/modules/ROOT/pages/spring-cloud-gateway/http-client.adoc @@ -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. \ No newline at end of file diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway/tls-and-ssl.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway/tls-and-ssl.adoc index 012f1cf3..ab1f0e68 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway/tls-and-ssl.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway/tls-and-ssl.adoc @@ -68,4 +68,3 @@ spring: close-notify-flush-timeout-millis: 3000 close-notify-read-timeout-millis: 0 ---- - diff --git a/docs/modules/ROOT/partials/_configprops.adoc b/docs/modules/ROOT/partials/_configprops.adoc index b1a778c9..81866e90 100644 --- a/docs/modules/ROOT/partials/_configprops.adoc +++ b/docs/modules/ROOT/partials/_configprops.adoc @@ -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. diff --git a/docs/package.json b/docs/package.json index c3570e2f..6c97a4cc 100644 --- a/docs/package.json +++ b/docs/package.json @@ -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" } } diff --git a/docs/pom.xml b/docs/pom.xml index 085bbd1d..0fed8431 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -6,7 +6,7 @@ org.springframework.cloud spring-cloud-gateway - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT spring-cloud-gateway-docs jar @@ -25,20 +25,28 @@ .* ${maven.multiModuleProjectDirectory}/docs/modules/ROOT/partials/ - - - ${project.groupId} - spring-cloud-starter-gateway - - - ${project.groupId} - spring-cloud-starter-gateway-mvc - - src/main/asciidoc + + enable-configuration-properties + + + !disableConfigurationProperties + + + + + ${project.groupId} + spring-cloud-starter-gateway + + + ${project.groupId} + spring-cloud-starter-gateway-mvc + + + docs diff --git a/docs/src/main/asciidoc/README.adoc b/docs/src/main/asciidoc/README.adoc index d5ff430d..c3ef9823 100644 --- a/docs/src/main/asciidoc/README.adoc +++ b/docs/src/main/asciidoc/README.adoc @@ -20,7 +20,7 @@ image::https://codecov.io/gh/spring-cloud/spring-cloud-gateway/branch/main/graph [[building]] = Building -include::https://raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/docs/modules/ROOT/partials/contributing.adoc[] +include::https:///raw.githubusercontent.com/spring-cloud/spring-cloud-build/main/docs/modules/ROOT/partials/building.adoc[] [[contributing]] = Contributing diff --git a/pom.xml b/pom.xml index 008753e5..353ccff7 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.springframework.cloud spring-cloud-gateway - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT pom Spring Cloud Gateway @@ -15,7 +15,7 @@ org.springframework.cloud spring-cloud-build - 4.1.4-SNAPSHOT + 4.1.6-SNAPSHOT @@ -55,8 +55,8 @@ 1.0.8.RELEASE 17 1.9.1 - 3.1.3-SNAPSHOT - 4.1.5-SNAPSHOT + 3.1.5-SNAPSHOT + 4.1.6-SNAPSHOT diff --git a/spring-cloud-gateway-dependencies/pom.xml b/spring-cloud-gateway-dependencies/pom.xml index f82500bf..198369ec 100644 --- a/spring-cloud-gateway-dependencies/pom.xml +++ b/spring-cloud-gateway-dependencies/pom.xml @@ -6,12 +6,12 @@ spring-cloud-dependencies-parent org.springframework.cloud - 4.1.4-SNAPSHOT + 4.1.6-SNAPSHOT spring-cloud-gateway-dependencies - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT pom spring-cloud-gateway-dependencies diff --git a/spring-cloud-gateway-integration-tests/grpc/pom.xml b/spring-cloud-gateway-integration-tests/grpc/pom.xml index d7be74ae..5df23c4d 100644 --- a/spring-cloud-gateway-integration-tests/grpc/pom.xml +++ b/spring-cloud-gateway-integration-tests/grpc/pom.xml @@ -18,7 +18,7 @@ org.springframework.cloud spring-cloud-gateway-integration-tests - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. diff --git a/spring-cloud-gateway-integration-tests/http2/pom.xml b/spring-cloud-gateway-integration-tests/http2/pom.xml index 0b57feb5..f74bfb93 100644 --- a/spring-cloud-gateway-integration-tests/http2/pom.xml +++ b/spring-cloud-gateway-integration-tests/http2/pom.xml @@ -16,7 +16,7 @@ org.springframework.cloud spring-cloud-gateway-integration-tests - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. diff --git a/spring-cloud-gateway-integration-tests/mvc-failure-analyzer/pom.xml b/spring-cloud-gateway-integration-tests/mvc-failure-analyzer/pom.xml index 4b6f30c2..db507feb 100644 --- a/spring-cloud-gateway-integration-tests/mvc-failure-analyzer/pom.xml +++ b/spring-cloud-gateway-integration-tests/mvc-failure-analyzer/pom.xml @@ -16,7 +16,7 @@ org.springframework.cloud spring-cloud-gateway-integration-tests - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. diff --git a/spring-cloud-gateway-integration-tests/pom.xml b/spring-cloud-gateway-integration-tests/pom.xml index f89b909d..121e9019 100644 --- a/spring-cloud-gateway-integration-tests/pom.xml +++ b/spring-cloud-gateway-integration-tests/pom.xml @@ -16,7 +16,7 @@ org.springframework.cloud spring-cloud-gateway - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. diff --git a/spring-cloud-gateway-mvc/pom.xml b/spring-cloud-gateway-mvc/pom.xml index ad39869a..6141755d 100644 --- a/spring-cloud-gateway-mvc/pom.xml +++ b/spring-cloud-gateway-mvc/pom.xml @@ -11,7 +11,7 @@ org.springframework.cloud spring-cloud-gateway - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. diff --git a/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/gateway/mvc/ProxyExchange.java b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/gateway/mvc/ProxyExchange.java index 00cbec88..11eb688c 100644 --- a/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/gateway/mvc/ProxyExchange.java +++ b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/gateway/mvc/ProxyExchange.java @@ -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 { 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 { private Set filterHeaderKeys(Collection headerNames) { final Set 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()); } diff --git a/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/gateway/mvc/config/ProxyExchangeArgumentResolver.java b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/gateway/mvc/config/ProxyExchangeArgumentResolver.java index 9af97ab2..56335607 100644 --- a/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/gateway/mvc/config/ProxyExchangeArgumentResolver.java +++ b/spring-cloud-gateway-mvc/src/main/java/org/springframework/cloud/gateway/mvc/config/ProxyExchangeArgumentResolver.java @@ -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))); } } diff --git a/spring-cloud-gateway-sample/pom.xml b/spring-cloud-gateway-sample/pom.xml index 8cd7a0c9..2f7b6297 100644 --- a/spring-cloud-gateway-sample/pom.xml +++ b/spring-cloud-gateway-sample/pom.xml @@ -16,7 +16,7 @@ org.springframework.cloud spring-cloud-gateway - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. diff --git a/spring-cloud-gateway-sample/src/main/java/org/springframework/cloud/gateway/sample/GatewaySampleApplication.java b/spring-cloud-gateway-sample/src/main/java/org/springframework/cloud/gateway/sample/GatewaySampleApplication.java index 4ebe38ec..7a7a07d5 100644 --- a/spring-cloud-gateway-sample/src/main/java/org/springframework/cloud/gateway/sample/GatewaySampleApplication.java +++ b/spring-cloud-gateway-sample/src/main/java/org/springframework/cloud/gateway/sample/GatewaySampleApplication.java @@ -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) ) diff --git a/spring-cloud-gateway-server-mvc/pom.xml b/spring-cloud-gateway-server-mvc/pom.xml index b04f8739..9ecf0a73 100644 --- a/spring-cloud-gateway-server-mvc/pom.xml +++ b/spring-cloud-gateway-server-mvc/pom.xml @@ -22,7 +22,7 @@ org.springframework.cloud spring-cloud-gateway - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. spring-cloud-gateway-server-mvc diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java index a3e94fac..9baad4ce 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfiguration.java @@ -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, diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/AbstractProxyExchange.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/AbstractProxyExchange.java new file mode 100644 index 00000000..9cc4606b --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/AbstractProxyExchange.java @@ -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; + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/HttpStatusHolder.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/HttpStatusHolder.java index c19f6d47..f5740f4d 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/HttpStatusHolder.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/HttpStatusHolder.java @@ -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; diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MultipartEnvironmentPostProcessor.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MultipartEnvironmentPostProcessor.java new file mode 100644 index 00000000..e06226f9 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MultipartEnvironmentPostProcessor.java @@ -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); + } + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java index 5e635b71..05c073ed 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java @@ -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 urls = (LinkedHashSet) request.attributes() + .computeIfAbsent(GATEWAY_ORIGINAL_REQUEST_URL_ATTR, s -> new LinkedHashSet<>()); + urls.add(url); + } + private record ByteArrayInputMessage(ServerRequest request, ByteArrayInputStream body) implements HttpInputMessage { @Override diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java index 9bea9ddd..ac553b7d 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java @@ -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 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 getRoutes() { return routes; } @@ -71,11 +85,29 @@ public class GatewayMvcProperties { return httpClient; } + public List getStreamingMediaTypes() { + return streamingMediaTypes; + } + + public void setStreamingMediaTypes(List 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(); } diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethod.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethod.java index 9e0b6216..d8f31d4a 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethod.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/NormalizedOperationMethod.java @@ -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 = 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 normalizeArgs(Map operationArgs) { if (hasGeneratedKey(operationArgs)) { - Shortcut shortcut = getMethod().getAnnotation(Shortcut.class); - if (shortcut != null) { + MergedAnnotation 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 -> { diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java index e75b259f..dcebc7e7 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java @@ -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 handlerArgs = new HashMap<>(); Optional 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 handlerFunction = null; + + // filters added by HandlerDiscoverer need to go last, so save them + List> handlerFilterFunctionFilters = new ArrayList<>(); if (response instanceof HandlerFunction) { handlerFunction = (HandlerFunction) 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 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 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 { diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/BeforeFilterFunctions.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/BeforeFilterFunctions.java index c8ac1a42..c96fffdd 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/BeforeFilterFunctions.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/BeforeFilterFunctions.java @@ -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 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 queryParams = new LinkedMultiValueMap<>(request.params()); queryParams.remove(name); + MultiValueMap 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 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 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(); }; } diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctions.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctions.java index 54ae1709..ba522460 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctions.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/CircuitBreakerFilterFunctions.java @@ -68,7 +68,7 @@ public abstract class CircuitBreakerFilterFunctions { return circuitBreaker(config); } - @Shortcut + @Shortcut("id") @Configurable public static HandlerFilterFunction circuitBreaker(CircuitBreakerConfig config) { Set failureStatuses = config.getStatusCodes() diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java index d21c08f5..c7cb910f 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterFunctions.java @@ -66,6 +66,7 @@ public interface FilterFunctions { return ofResponseProcessor(AfterFilterFunctions.addResponseHeader(name, values)); } + @Shortcut static HandlerFilterFunction dedupeResponseHeader(String name) { return ofResponseProcessor(AfterFilterFunctions.dedupeResponseHeader(name)); } diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java index 7924c5ef..e4fedaf9 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ForwardedRequestHeadersFilter.java @@ -93,7 +93,7 @@ public class ForwardedRequestHeadersFilter implements HttpHeadersFilter.RequestH HttpHeaders updated = new HttpHeaders(); // copy all headers except Forwarded - for (Map.Entry> entry : original.entrySet()) { + for (Map.Entry> entry : original.headerSet()) { if (!entry.getKey().equalsIgnoreCase(FORWARDED_HEADER)) { updated.addAll(entry.getKey(), entry.getValue()); } diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/LoadBalancerFilterFunctions.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/LoadBalancerFilterFunctions.java index ab6d47dc..db7b1464 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/LoadBalancerFilterFunctions.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/LoadBalancerFilterFunctions.java @@ -63,6 +63,8 @@ public abstract class LoadBalancerFilterFunctions { public static HandlerFilterFunction lb(String serviceId, BiFunction reconstructUriFunction) { return (request, next) -> { + MvcUtils.addOriginalRequestUrl(request, request.uri()); + LoadBalancerClientFactory clientFactory = getApplicationContext(request) .getBean(LoadBalancerClientFactory.class); Set supportedLifecycleProcessors = LoadBalancerLifecycleValidator diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RemoveHopByHopRequestHeadersFilter.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RemoveHopByHopRequestHeadersFilter.java index 44f21f90..0206a38d 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RemoveHopByHopRequestHeadersFilter.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RemoveHopByHopRequestHeadersFilter.java @@ -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 headersToRemove) { HttpHeaders filtered = new HttpHeaders(); - for (Map.Entry> entry : input.entrySet()) { - if (!headersToRemove.contains(entry.getKey().toLowerCase())) { + for (Map.Entry> entry : input.headerSet()) { + if (!headersToRemove.contains(entry.getKey().toLowerCase(Locale.ROOT))) { filtered.addAll(entry.getKey(), entry.getValue()); } } diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RemoveHttp2StatusResponseHeadersFilter.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RemoveHttp2StatusResponseHeadersFilter.java new file mode 100644 index 00000000..36551378 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RemoveHttp2StatusResponseHeadersFilter.java @@ -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; + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java index ac8358f0..272b1e6c 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java @@ -27,6 +27,7 @@ import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import org.springframework.cloud.gateway.server.mvc.common.Configurable; +import org.springframework.cloud.gateway.server.mvc.common.MvcUtils; import org.springframework.cloud.gateway.server.mvc.common.Shortcut; import org.springframework.core.NestedRuntimeException; import org.springframework.http.HttpMethod; @@ -48,6 +49,7 @@ public abstract class RetryFilterFunctions { private RetryFilterFunctions() { } + @Shortcut public static HandlerFilterFunction 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 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 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 { diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java index 428370b6..9b8b9bcb 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/XForwardedRequestHeadersFilter.java @@ -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> entry : original.entrySet()) { + for (Map.Entry> 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 originalUris = null; // TODO: - // exchange.getAttribute(GATEWAY_ORIGINAL_REQUEST_URL_ATTR); - URI requestUri = null; // TODO: - // exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR); + LinkedHashSet 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); diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ClientHttpRequestFactoryProxyExchange.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ClientHttpRequestFactoryProxyExchange.java index ccda73d4..5c17dd2d 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ClientHttpRequestFactoryProxyExchange.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ClientHttpRequestFactoryProxyExchange.java @@ -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; }); diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunction.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunction.java index 84ce2018..0ea445fc 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunction.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ProxyExchangeHandlerFunction.java @@ -123,9 +123,9 @@ public class ProxyExchangeHandlerFunction private 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) filter); + HttpHeadersFilter typed = ((HttpHeadersFilter) filter); filtered = typed.apply(filtered, requestOrResponse); } return filtered; diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/RestClientProxyExchange.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/RestClientProxyExchange.java index 37493b6b..46e9d032 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/RestClientProxyExchange.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/RestClientProxyExchange.java @@ -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; }); diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java index 5dbb3c7d..68f54b73 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java @@ -374,6 +374,9 @@ public abstract class GatewayRequestPredicates { @Override public boolean test(ServerRequest request) { String host = request.headers().firstHeader(HttpHeaders.HOST); + if (host == null) { + host = ""; + } PathContainer pathContainer = PathContainer.parsePath(host, PathContainer.Options.MESSAGE_ROUTE); PathPattern.PathMatchInfo info = this.pattern.matchAndExtract(pathContainer); traceMatch("Pattern", this.pattern.getPatternString(), host, info != null); diff --git a/spring-cloud-gateway-server-mvc/src/main/resources/META-INF/spring.factories b/spring-cloud-gateway-server-mvc/src/main/resources/META-INF/spring.factories index 972cdb6d..4b1b5077 100644 --- a/spring-cloud-gateway-server-mvc/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-gateway-server-mvc/src/main/resources/META-INF/spring.factories @@ -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 \ No newline at end of file diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfigurationTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfigurationTests.java index e8c139e7..0cdce409 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfigurationTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/GatewayServerMvcAutoConfigurationTests.java @@ -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); diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java index c43fd629..4188d73a 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java @@ -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 map = res.getResponseBody(); Map 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 map = res.getResponseBody(); assertThat(map).containsEntry("data", "hello"); Map 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 map = res.getResponseBody(); + Map 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 files = (Map) 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 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 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 gatewayRouterFunctionsRetry() { - // @formatter:off - return route("testretry") - .route(path("/retry"), http()) - .before(new LocalServerPortUriResolver()) - .filter(retry(3)) - //.filter(retry(config -> config.setRetries(3).setSeries(Set.of(HttpStatus.Series.SERVER_ERROR)).setMethods(Set.of(HttpMethod.GET, HttpMethod.POST)))) - .filter(prefixPath("/do")) - .build(); - // @formatter:on - } - @Bean public RouterFunction gatewayRouterFunctionsRateLimit() { // @formatter:off @@ -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 map = new ConcurrentHashMap<>(); - - @GetMapping("/do/retry") - public ResponseEntity retry(@RequestParam("key") String key, - @RequestParam(name = "count", defaultValue = "3") int count, - @RequestParam(name = "failStatus", required = false) Integer failStatus) { - AtomicInteger num = getCount(key); - int i = num.incrementAndGet(); - log.warn("Retry count: " + i); - String body = String.valueOf(i); - if (i < count) { - HttpStatus httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; - if (failStatus != null) { - httpStatus = HttpStatus.resolve(failStatus); - } - return ResponseEntity.status(httpStatus).header("X-Retry-Count", body).body("temporarily broken"); - } - return ResponseEntity.status(HttpStatus.OK).header("X-Retry-Count", body).body(body); - } - - AtomicInteger getCount(String key) { - return map.computeIfAbsent(key, s -> new AtomicInteger()); - } - - } - protected static class TestHandler implements HandlerFunction { @Override diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/VanillaRouterFunctionTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/VanillaRouterFunctionTests.java index 4959d897..fd96c938 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/VanillaRouterFunctionTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/VanillaRouterFunctionTests.java @@ -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 diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/common/AbstractProxyExchangeTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/common/AbstractProxyExchangeTests.java new file mode 100644 index 00000000..a153c3c8 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/common/AbstractProxyExchangeTests.java @@ -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(); + } + + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/common/MultipartEnvironmentPostProcessorTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/common/MultipartEnvironmentPostProcessorTests.java new file mode 100644 index 00000000..72dd1b3f --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/common/MultipartEnvironmentPostProcessorTests.java @@ -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(); + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcPropertiesBeanDefinitionRegistrarTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcPropertiesBeanDefinitionRegistrarTests.java index f4f7675c..861b1694 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcPropertiesBeanDefinitionRegistrarTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcPropertiesBeanDefinitionRegistrarTests.java @@ -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() diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/BeforeFilterFunctionsTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/BeforeFilterFunctionsTests.java index 889ceeab..309b81bf 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/BeforeFilterFunctionsTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/BeforeFilterFunctionsTests.java @@ -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/with–en–dashes") .buildRequest(null); @@ -68,7 +219,7 @@ public class BeforeFilterFunctionsTests { } @Test - public void rewritePathWithEnDashAndSpace() { + void rewritePathWithEnDashAndSpace() { MockHttpServletRequest servletRequest = MockMvcRequestBuilders.get("http://localhost/get/path/with–en–dashes and spaces") .buildRequest(null); diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java new file mode 100644 index 00000000..edae11e4 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.server.mvc.filter; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.cloud.gateway.server.mvc.test.HttpbinTestcontainers; +import org.springframework.cloud.gateway.server.mvc.test.LocalServerPortUriResolver; +import org.springframework.cloud.gateway.server.mvc.test.TestLoadBalancerConfig; +import org.springframework.cloud.gateway.server.mvc.test.client.TestRestClient; +import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient; +import org.springframework.context.annotation.Bean; +import org.springframework.core.log.LogMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.adaptCachedBody; +import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.prefixPath; +import static org.springframework.cloud.gateway.server.mvc.filter.RetryFilterFunctions.retry; +import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; +import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; + +@SuppressWarnings("unchecked") +@SpringBootTest(properties = {}, webEnvironment = WebEnvironment.RANDOM_PORT) +@ContextConfiguration(initializers = HttpbinTestcontainers.class) +public class RetryFilterFunctionTests { + + @LocalServerPort + int port; + + @Autowired + TestRestClient restClient; + + @Test + public void retryWorks() { + restClient.get().uri("/retry?key=get").exchange().expectStatus().isOk().expectBody(String.class).isEqualTo("3"); + // test for: java.lang.IllegalArgumentException: You have already selected another + // retry policy + restClient.get() + .uri("/retry?key=get2") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("3"); + } + + @Test + public void retryBodyWorks() { + restClient.post() + .uri("/retrybody?key=post") + .bodyValue("thebody") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("3"); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @LoadBalancerClient(name = "httpbin", configuration = TestLoadBalancerConfig.Httpbin.class) + protected static class TestConfiguration { + + @Bean + public RouterFunction gatewayRouterFunctionsRetry() { + // @formatter:off + return route("testretry") + .GET("/retry", http()) + .before(new LocalServerPortUriResolver()) + .filter(retry(3)) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @Bean + public RouterFunction gatewayRouterFunctionsRetryBody() { + // @formatter:off + return route("testretrybody") + .POST("/retrybody", http()) + .before(new LocalServerPortUriResolver()) + .filter(retry(config -> config.setRetries(3).setSeries(Set.of(HttpStatus.Series.SERVER_ERROR)) + .setMethods(Set.of(HttpMethod.GET, HttpMethod.POST)).setCacheBody(true))) + .filter(adaptCachedBody()) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @RestController + protected static class RetryController { + + Log log = LogFactory.getLog(getClass()); + + ConcurrentHashMap map = new ConcurrentHashMap<>(); + + @GetMapping("/do/retry") + public ResponseEntity retry(@RequestParam("key") String key, + @RequestParam(name = "count", defaultValue = "3") int count, + @RequestParam(name = "failStatus", required = false) Integer failStatus) { + AtomicInteger num = getCount(key); + int i = num.incrementAndGet(); + log.warn("Retry count: " + i); + String body = String.valueOf(i); + if (i < count) { + HttpStatus httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + if (failStatus != null) { + httpStatus = HttpStatus.resolve(failStatus); + } + return ResponseEntity.status(httpStatus).header("X-Retry-Count", body).body("temporarily broken"); + } + return ResponseEntity.status(HttpStatus.OK).header("X-Retry-Count", body).body(body); + } + + @PostMapping("/do/retrybody") + public ResponseEntity retryBody(@RequestParam("key") String key, + @RequestParam(name = "count", defaultValue = "3") int count, @RequestBody String requestBody) { + AtomicInteger num = getCount(key); + int i = num.incrementAndGet(); + log.warn(LogMessage.format("Retry count: %s, body: %s", i, requestBody)); + String body = String.valueOf(i); + if (!StringUtils.hasText(requestBody)) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .header("X-Retry-Count", body) + .body("missing body"); + } + if (i < count) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .header("X-Retry-Count", body) + .body("temporarily broken"); + } + return ResponseEntity.status(HttpStatus.OK).header("X-Retry-Count", body).body(body); + } + + AtomicInteger getCount(String key) { + return map.computeIfAbsent(key, s -> new AtomicInteger()); + } + + } + + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicatesTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicatesTests.java new file mode 100644 index 00000000..15876d87 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicatesTests.java @@ -0,0 +1,37 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.server.mvc.predicate; + +import java.util.Collections; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.servlet.function.ServerRequest; + +public class GatewayRequestPredicatesTests { + + @Test + void nullHostPassedToHostPredicate() { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(); + ServerRequest serverRequest = ServerRequest.create(servletRequest, Collections.emptyList()); + boolean result = GatewayRequestPredicates.host("*.myhost.org").test(serverRequest); + Assertions.assertThat(result).isFalse(); + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HttpbinUriResolver.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HttpbinUriResolver.java index c46e1f25..93b6bb51 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HttpbinUriResolver.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/HttpbinUriResolver.java @@ -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 diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestController.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestController.java index d65d50f9..5048c042 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestController.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/TestController.java @@ -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 postFormData(HttpServletRequest request, - @RequestParam MultiValueMap parts) throws ServletException, IOException { - HashMap ret = new HashMap<>(); - ret.put("headers", getHeaders(request)); - HashMap 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 postUrlEncoded(HttpServletRequest request, - @RequestBody(required = false) MultiValueMap form) throws IOException { - HashMap 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 post(HttpServletRequest request, @RequestBody(required = false) String body) { HashMap ret = new HashMap<>(); ret.put("headers", getHeaders(request)); diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/client/ExchangeResult.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/client/ExchangeResult.java index 427aed77..e87ea133 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/client/ExchangeResult.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/test/client/ExchangeResult.java @@ -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)); diff --git a/spring-cloud-gateway-server-mvc/src/test/resources/application-propertiesbeandefinitionregistrartests.yml b/spring-cloud-gateway-server-mvc/src/test/resources/application-propertiesbeandefinitionregistrartests.yml index d27c4c76..a91fe14d 100644 --- a/spring-cloud-gateway-server-mvc/src/test/resources/application-propertiesbeandefinitionregistrartests.yml +++ b/spring-cloud-gateway-server-mvc/src/test/resources/application-propertiesbeandefinitionregistrartests.yml @@ -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 diff --git a/spring-cloud-gateway-server/pom.xml b/spring-cloud-gateway-server/pom.xml index a026b0f6..52a624d7 100644 --- a/spring-cloud-gateway-server/pom.xml +++ b/spring-cloud-gateway-server/pom.xml @@ -7,7 +7,7 @@ org.springframework.cloud spring-cloud-gateway - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. spring-cloud-gateway-server @@ -235,7 +235,7 @@ kotlin-maven-plugin org.jetbrains.kotlin - 1.9.23 + 1.9.25 17 diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index c1906e3f..3a63edaf 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -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()); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/HttpClientProperties.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/HttpClientProperties.java index 85b3b027..8631cf2b 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/HttpClientProperties.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/HttpClientProperties.java @@ -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. */ diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/discovery/DiscoveryClientRouteDefinitionLocator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/discovery/DiscoveryClientRouteDefinitionLocator.java index 70050580..4554f248 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/discovery/DiscoveryClientRouteDefinitionLocator.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/discovery/DiscoveryClientRouteDefinitionLocator.java @@ -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(); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/NettyRoutingFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/NettyRoutingFilter.java index 4d342892..bf4f6ad7 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/NettyRoutingFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/NettyRoutingFilter.java @@ -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)); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/NettyWriteResponseFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/NettyWriteResponseFilter.java index c06c175b..278c428b 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/NettyWriteResponseFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/NettyWriteResponseFilter.java @@ -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(); } } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/RemoveCachedBodyFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/RemoveCachedBodyFilter.java index 5307cb98..e3b15fe8 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/RemoveCachedBodyFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/RemoveCachedBodyFilter.java @@ -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 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 diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/WebsocketRoutingFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/WebsocketRoutingFilter.java index 04136bf7..7a0bddb0 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/WebsocketRoutingFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/WebsocketRoutingFilter.java @@ -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> entry : headers.entrySet()) { - if (!entry.getKey().toLowerCase().startsWith("sec-websocket")) { + for (Map.Entry> 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))) { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/AbstractGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/AbstractGatewayFilterFactory.java index e94a2ede..2413fbda 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/AbstractGatewayFilterFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/AbstractGatewayFilterFactory.java @@ -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 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; diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RemoveRequestParameterGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RemoveRequestParameterGatewayFilterFactory.java index a2e2c92a..9d17f6ef 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RemoveRequestParameterGatewayFilterFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RemoveRequestParameterGatewayFilterFactory.java @@ -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 queryParams = new LinkedMultiValueMap<>(request.getQueryParams()); queryParams.remove(config.getName()); - URI newUri = UriComponentsBuilder.fromUri(request.getURI()) - .replaceQueryParams(unmodifiableMultiValueMap(queryParams)) - .build() - .toUri(); + try { + MultiValueMap 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 diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestHeaderSizeGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestHeaderSizeGatewayFilterFactory.java index 8dcd9413..b6e0d6b6 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestHeaderSizeGatewayFilterFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestHeaderSizeGatewayFilterFactory.java @@ -69,7 +69,7 @@ public class RequestHeaderSizeGatewayFilterFactory HttpHeaders headers = request.getHeaders(); HashMap longHeaders = new HashMap<>(); - for (Map.Entry> headerEntry : headers.entrySet()) { + for (Map.Entry> headerEntry : headers.headerSet()) { long headerSizeInBytes = 0L; headerSizeInBytes += headerEntry.getKey().getBytes().length; List values = headerEntry.getValue(); diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactory.java index d8046e27..814a6ecf 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactory.java @@ -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 repeat, Retry 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"); diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactory.java index ecffdf15..02bccdaf 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactory.java @@ -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 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 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 diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactory.java index 46d7e780..3a079968 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SecureHeadersGatewayFilterFactory.java @@ -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 disabledHeaders, String header) { - return !disabledHeaders.contains(header.toLowerCase()); + return !disabledHeaders.contains(header.toLowerCase(Locale.ROOT)); } public static class Config { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerFilterFactory.java index 0cbc3eca..823eac83 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerFilterFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerFilterFactory.java @@ -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 statuses = config.getStatusCodes() .stream() diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java index 7bbc4b12..1a5471f5 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java @@ -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()); diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/RemoveHeadersAfterCacheExchangeMutator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/RemoveHeadersAfterCacheExchangeMutator.java new file mode 100644 index 00000000..13d9e818 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/RemoveHeadersAfterCacheExchangeMutator.java @@ -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); + } + } + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java index 788d305c..b95cb191 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/ForwardedHeadersFilter.java @@ -96,7 +96,7 @@ public class ForwardedHeadersFilter implements HttpHeadersFilter, Ordered { HttpHeaders updated = new HttpHeaders(); // copy all headers except Forwarded - for (Map.Entry> entry : original.entrySet()) { + for (Map.Entry> entry : original.headerSet()) { if (!entry.getKey().equalsIgnoreCase(FORWARDED_HEADER)) { updated.addAll(entry.getKey(), entry.getValue()); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilter.java index 71489ae9..1c7e9318 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/GRPCRequestHeadersFilter.java @@ -33,7 +33,7 @@ public class GRPCRequestHeadersFilter implements HttpHeadersFilter, Ordered { public HttpHeaders filter(HttpHeaders headers, ServerWebExchange exchange) { HttpHeaders updated = new HttpHeaders(); - for (Map.Entry> entry : headers.entrySet()) { + for (Map.Entry> entry : headers.headerSet()) { updated.addAll(entry.getKey(), entry.getValue()); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/RemoveHopByHopHeadersFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/RemoveHopByHopHeadersFilter.java index 091b7aec..556f1c97 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/RemoveHopByHopHeadersFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/RemoveHopByHopHeadersFilter.java @@ -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 headersToRemove = new HashSet<>(headers); headersToRemove.addAll(connectionOptions); - for (Map.Entry> entry : originalHeaders.entrySet()) { - if (!headersToRemove.contains(entry.getKey().toLowerCase())) { + for (Map.Entry> entry : originalHeaders.headerSet()) { + if (!headersToRemove.contains(entry.getKey().toLowerCase(Locale.ROOT))) { filtered.addAll(entry.getKey(), entry.getValue()); } } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java index 1623349e..2c1e8363 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/headers/XForwardedHeadersFilter.java @@ -202,7 +202,7 @@ public class XForwardedHeadersFilter implements HttpHeadersFilter, Ordered { HttpHeaders original = input; HttpHeaders updated = new HttpHeaders(); - for (Map.Entry> entry : original.entrySet()) { + for (Map.Entry> entry : original.headerSet()) { updated.addAll(entry.getKey(), entry.getValue()); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMapping.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMapping.java index fb12f36b..eb0006a8 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMapping.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/RoutePredicateHandlerMapping.java @@ -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) + "]"); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/HeaderRoutePredicateFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/HeaderRoutePredicateFactory.java index e96e9bc6..97d8c43d 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/HeaderRoutePredicateFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/handler/predicate/HeaderRoutePredicateFactory.java @@ -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 values = exchange.getRequest() - .getHeaders() - .getOrDefault(config.header, Collections.emptyList()); + List values = exchange.getRequest().getHeaders().getValuesAsList(config.header); if (values.isEmpty()) { return false; } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/NameUtils.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/NameUtils.java index fb7498cb..69e0baad 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/NameUtils.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/NameUtils.java @@ -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) { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/ServerWebExchangeUtils.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/ServerWebExchangeUtils.java index 0ce96e64..7585a182 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/ServerWebExchangeUtils.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/ServerWebExchangeUtils.java @@ -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) { diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/discovery/DiscoveryClientRouteDefinitionLocatorTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/discovery/DiscoveryClientRouteDefinitionLocatorTests.java index 5c1f5a66..fc26a822 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/discovery/DiscoveryClientRouteDefinitionLocatorTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/discovery/DiscoveryClientRouteDefinitionLocatorTests.java @@ -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() + '/(?.*)'" + * "spring.cloud.gateway.discovery.locator.filters[0].args[regexp]='/' + serviceId.toLowerCase(Locale.ROOT) + '/(?.*)'" * , * "spring.cloud.gateway.discovery.locator.filters[0].args[replacement]='/$\\\\{remaining}'", */ diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RemoveRequestParameterGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RemoveRequestParameterGatewayFilterFactoryTests.java index a1fdf471..a96aef38 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RemoveRequestParameterGatewayFilterFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RemoveRequestParameterGatewayFilterFactoryTests.java @@ -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 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")); + } + } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactoryTests.java index 9df80c61..f7803861 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactoryTests.java @@ -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> expectedQueryParams) { GatewayFilter filter = new RewriteRequestParameterGatewayFilterFactory() diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerFilterFactoryTests.java index 9368ed5c..30eb9a8b 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerFilterFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerFilterFactoryTests.java @@ -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\"}"); + } + } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerTestConfig.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerTestConfig.java index 1b481ee2..fb31edcc 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerTestConfig.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/SpringCloudCircuitBreakerTestConfig.java @@ -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 postFallbackController(@RequestBody String body) { + return Collections.singletonMap("body", body); + } + @GetMapping("/circuitbreakerUriFallbackController/**") public Map uriFallbackcontroller(ServerWebExchange exchange, @RequestParam("a") String a) { return Collections.singletonMap("uri", exchange.getRequest().getURI().toString()); diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java index 1803ae0b..1325e75e 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java @@ -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() diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGlobalFilterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGlobalFilterTests.java index 54ebd154..f5f4c668 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGlobalFilterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGlobalFilterTests.java @@ -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) diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/RemoveHeaderAfterCacheExchangeMutatorTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/RemoveHeaderAfterCacheExchangeMutatorTest.java new file mode 100644 index 00000000..5217a779 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/RemoveHeaderAfterCacheExchangeMutatorTest.java @@ -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 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 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; + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/rewrite/ModifyRequestBodyGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/rewrite/ModifyRequestBodyGatewayFilterFactoryTests.java index d4440c9b..2eb8b7cc 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/rewrite/ModifyRequestBodyGatewayFilterFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/rewrite/ModifyRequestBodyGatewayFilterFactoryTests.java @@ -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() { }, new ParameterizedTypeReference() { }, (swe, body) -> { - return Mono.just(body.replaceAll(" ", "_").toUpperCase()); + return Mono.just(body.replaceAll(" ", "_").toUpperCase(Locale.ROOT)); })) .uri(uri)) .build(); diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/HttpHeadersFilterMixedTypeTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/HttpHeadersFilterMixedTypeTests.java index 1ee3cb38..cbb9a4d2 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/HttpHeadersFilterMixedTypeTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/HttpHeadersFilterMixedTypeTests.java @@ -59,7 +59,7 @@ public class HttpHeadersFilterMixedTypeTests { @Override public HttpHeaders filter(HttpHeaders headers, ServerWebExchange exchange) { HttpHeaders result = new HttpHeaders(); - headers.entrySet().forEach(entry -> { + headers.headerSet().forEach(entry -> { if (!headerNamesSet.contains(entry.getKey())) { result.put(entry.getKey(), entry.getValue()); } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/HttpHeadersFilterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/HttpHeadersFilterTests.java index 9940d035..9619b71f 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/HttpHeadersFilterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/HttpHeadersFilterTests.java @@ -52,7 +52,7 @@ public class HttpHeadersFilterTests { private HttpHeaders filter(HttpHeaders input, String keyToFilter) { HttpHeaders filtered = new HttpHeaders(); - input.entrySet() + input.headerSet() .stream() .filter(entry -> !entry.getKey().equals(keyToFilter)) .forEach(entry -> filtered.addAll(entry.getKey(), entry.getValue())); diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/RemoveHopByHopHeadersFilterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/RemoveHopByHopHeadersFilterTests.java index 0a5445fa..689821f6 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/RemoveHopByHopHeadersFilterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/headers/RemoveHopByHopHeadersFilterTests.java @@ -19,6 +19,7 @@ package org.springframework.cloud.gateway.filter.headers; import java.util.Arrays; import java.util.HashSet; import java.util.LinkedHashSet; +import java.util.Locale; import java.util.Set; import org.junit.jupiter.api.Test; @@ -50,7 +51,7 @@ public class RemoveHopByHopHeadersFilterTests { public void caseInsensitive() { MockServerHttpRequest.BaseBuilder builder = MockServerHttpRequest.get("http://localhost/get"); - HEADERS_REMOVED_ON_REQUEST.forEach(header -> builder.header(header.toLowerCase(), header + "1")); + HEADERS_REMOVED_ON_REQUEST.forEach(header -> builder.header(header.toLowerCase(Locale.ROOT), header + "1")); testFilter(MockServerWebExchange.from(builder)); } @@ -60,7 +61,7 @@ public class RemoveHopByHopHeadersFilterTests { MockServerHttpRequest.BaseBuilder builder = MockServerHttpRequest.get("http://localhost/get"); HEADERS_REMOVED_ON_REQUEST - .forEach(header -> builder.header(StringUtils.capitalize(header.toLowerCase()), header + "1")); + .forEach(header -> builder.header(StringUtils.capitalize(header.toLowerCase(Locale.ROOT)), header + "1")); LinkedHashSet customHeaders = new LinkedHashSet<>(); HEADERS_REMOVED_ON_REQUEST.forEach(header -> { @@ -78,7 +79,8 @@ public class RemoveHopByHopHeadersFilterTests { String arbitraryConnectionOption = "xyz"; assumeThat(HEADERS_REMOVED_ON_REQUEST).doesNotContain(arbitraryConnectionOption); - builder.header(HttpHeaders.CONNECTION, "upgrade", "keep-alive", arbitraryConnectionOption.toUpperCase()); + builder.header(HttpHeaders.CONNECTION, "upgrade", "keep-alive", + arbitraryConnectionOption.toUpperCase(Locale.ROOT)); builder.header(HttpHeaders.UPGRADE, "WebSocket"); builder.header("Keep-Alive", "timeout=5"); builder.header(arbitraryConnectionOption, ""); diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/HeaderRoutePredicateFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/HeaderRoutePredicateFactoryTests.java index 92be2a3a..6792ed97 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/HeaderRoutePredicateFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/HeaderRoutePredicateFactoryTests.java @@ -92,6 +92,20 @@ public class HeaderRoutePredicateFactoryTests extends BaseWebClientTests { assertThat(predicate.toString()).contains("Header: myheader regexp=myregexp"); } + @Test + public void headerRouteHandlesCommaSeparatedValues() { + testClient.get() + .uri("/get") + .header("X-Example-Header", "value1, value2 ,exact_match,value3") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals(HANDLER_MAPPER_HEADER, RoutePredicateHandlerMapping.class.getSimpleName()) + .expectHeader() + .valueEquals(ROUTE_ID_HEADER, "header_test_comma_separated"); + } + @EnableAutoConfiguration @SpringBootConfiguration @Import(DefaultTestConfig.class) @@ -104,6 +118,10 @@ public class HeaderRoutePredicateFactoryTests extends BaseWebClientTests { RouteLocator queryRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route("header_exists_dsl", r -> r.header("X-Foo").filters(f -> f.prefixPath("/httpbin")).uri(uri)) + .route("header_test_comma_separated", + r -> r.header("X-Example-Header", "exact_match") + .filters(f -> f.prefixPath("/httpbin")) + .uri(uri)) .build(); } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java index d986e0e9..dde2af02 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java @@ -21,7 +21,9 @@ import java.util.function.Predicate; import org.junit.jupiter.api.Test; +import org.springframework.beans.BeansException; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; @@ -33,6 +35,8 @@ 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.security.web.server.WebFilterChainProxy; +import org.springframework.security.web.server.firewall.StrictServerWebExchangeFirewall; import org.springframework.test.annotation.DirtiesContext; import org.springframework.web.server.ServerWebExchange; @@ -92,6 +96,7 @@ public class PathRoutePredicateFactoryTests extends BaseWebClientTests { } @Test + // @Disabled public void pathRouteWorksWithPercent() { testClient.get() .uri("/abc/123%/function") @@ -147,6 +152,22 @@ public class PathRoutePredicateFactoryTests extends BaseWebClientTests { @Value("${test.uri}") String uri; + // TODO: move to bean of StrictServerWebExchangeFirewall + @Bean + public BeanPostProcessor firewallPostProcessor() { + return new BeanPostProcessor() { + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof WebFilterChainProxy webFilterChainProxy) { + StrictServerWebExchangeFirewall firewall = new StrictServerWebExchangeFirewall(); + firewall.setAllowUrlEncodedPercent(true); + webFilterChainProxy.setFirewall(firewall); + } + return bean; + } + }; + } + @Bean public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { return builder.routes() diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicatePathContainerAttrBenchMarkTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicatePathContainerAttrBenchMarkTests.java index e7f47220..41055e5c 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicatePathContainerAttrBenchMarkTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicatePathContainerAttrBenchMarkTests.java @@ -22,7 +22,6 @@ import java.util.List; import java.util.Random; import java.util.function.Predicate; -import org.apache.commons.lang3.RandomStringUtils; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Fork; @@ -47,8 +46,10 @@ public class PathRoutePredicatePathContainerAttrBenchMarkTests { static { predicates = new LinkedList<>(); - PATH_PATTERN_PREFIX = String.format("/%s/%s/", RandomStringUtils.random(20, true, false), - RandomStringUtils.random(10, true, false)); + Random random = new Random(); + String path1 = String.format("%1$" + 20 + "s", random.nextInt()).replace(' ', '0'); + String path2 = String.format("%1$" + 10 + "s", random.nextInt()).replace(' ', '0'); + PATH_PATTERN_PREFIX = String.format("/%s/%s/", path1, path2); for (int i = 0; i < ROUTES_NUM; i++) { PathRoutePredicateFactory.Config config = new PathRoutePredicateFactory.Config() .setPatterns(Collections.singletonList(PATH_PATTERN_PREFIX + i)) diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/CustomBlockHoundIntegrationTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/CustomBlockHoundIntegrationTest.java index 69558c90..308c0b93 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/CustomBlockHoundIntegrationTest.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/CustomBlockHoundIntegrationTest.java @@ -17,6 +17,7 @@ package org.springframework.cloud.gateway.test; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledForJreRange; import org.junit.jupiter.api.condition.JRE; @@ -29,7 +30,9 @@ import reactor.core.scheduler.Schedulers; public class CustomBlockHoundIntegrationTest { @Test - @DisabledForJreRange(min = JRE.JAVA_16) + @DisabledForJreRange(min = JRE.JAVA_18) + // Disable this test for now flaky on GitHub Actions + @Disabled public void shouldThrowErrorForBlockingCallWithCustomBlockHoundIntegration() { Assertions.assertThrows(RuntimeException.class, () -> Mono.fromCallable(() -> { Thread.sleep(1); diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/HttpBinCompatibleController.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/HttpBinCompatibleController.java index dbf7dd24..5872965f 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/HttpBinCompatibleController.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/HttpBinCompatibleController.java @@ -177,7 +177,7 @@ public class HttpBinCompatibleController { public ResponseEntity> responseHeaders(@PathVariable int status, ServerWebExchange exchange) { HttpHeaders httpHeaders = exchange.getRequest() .getHeaders() - .entrySet() + .headerSet() .stream() .filter(entry -> entry.getKey().startsWith("X-Test-")) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, diff --git a/spring-cloud-gateway-server/src/test/resources/application.yml b/spring-cloud-gateway-server/src/test/resources/application.yml index 2b1fde13..a08f82da 100644 --- a/spring-cloud-gateway-server/src/test/resources/application.yml +++ b/spring-cloud-gateway-server/src/test/resources/application.yml @@ -104,6 +104,19 @@ spring: name: fallbackcmd fallbackUri: forward:/circuitbreakerFallbackController + # ===================================== + - id: circuitbreaker_fallback_test_post + uri: ${test.uri} + predicates: + - Host=**.circuitbreakerfallbackpost.org + filters: + - name: CircuitBreaker + args: + name: fallbackcmd + statusCodes: + - 200 + fallbackUri: forward:/circuitbreakerPostFallbackController + # ===================================== - id: circuitbreaker_fallback_test_variables uri: ${test.uri} diff --git a/spring-cloud-gateway-webflux/pom.xml b/spring-cloud-gateway-webflux/pom.xml index f3f384aa..2c529a28 100644 --- a/spring-cloud-gateway-webflux/pom.xml +++ b/spring-cloud-gateway-webflux/pom.xml @@ -11,7 +11,7 @@ org.springframework.cloud spring-cloud-gateway - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. diff --git a/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/ProxyExchange.java b/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/ProxyExchange.java index 67cecf50..47dfb962 100644 --- a/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/ProxyExchange.java +++ b/spring-cloud-gateway-webflux/src/main/java/org/springframework/cloud/gateway/webflux/ProxyExchange.java @@ -22,6 +22,7 @@ import java.net.URISyntaxException; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.Locale; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -221,7 +222,7 @@ public class ProxyExchange { this.excluded.clear(); for (String name : names) { - this.excluded.add(name.toLowerCase()); + this.excluded.add(name.toLowerCase(Locale.ROOT)); } return this; } @@ -407,7 +408,7 @@ public class ProxyExchange { final Set excludedHeaders = this.excluded != null ? this.excluded : Collections.emptySet(); return headers.keySet() .stream() - .filter(header -> !excludedHeaders.contains(header.toLowerCase())) + .filter(header -> !excludedHeaders.contains(header.toLowerCase(Locale.ROOT))) .collect(Collectors.toSet()); } diff --git a/spring-cloud-starter-gateway-mvc/pom.xml b/spring-cloud-starter-gateway-mvc/pom.xml index c682053b..ccfa5c5f 100644 --- a/spring-cloud-starter-gateway-mvc/pom.xml +++ b/spring-cloud-starter-gateway-mvc/pom.xml @@ -6,7 +6,7 @@ org.springframework.cloud spring-cloud-gateway - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. spring-cloud-starter-gateway-mvc diff --git a/spring-cloud-starter-gateway/pom.xml b/spring-cloud-starter-gateway/pom.xml index a60b865b..614e9dd5 100644 --- a/spring-cloud-starter-gateway/pom.xml +++ b/spring-cloud-starter-gateway/pom.xml @@ -6,7 +6,7 @@ org.springframework.cloud spring-cloud-gateway - 4.1.6-SNAPSHOT + 4.1.7-SNAPSHOT .. spring-cloud-starter-gateway