diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index b0a3886d..156bba5d 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -1698,6 +1698,52 @@ NOTE: The default implementation of `ReactiveOAuth2AuthorizedClientService` used uses an in-memory data store. You will need to provide your own implementation `ReactiveOAuth2AuthorizedClientService` if you need a more robust solution. + +=== The `CacheRequestBody` `GatewayFilter` Factory +There are certain situation need to read body.Since the request body stream can only be read once, we need to cache the request body. +You can use the `CacheRequestBody` filter to cache request body before it send to the downstream and get body from exchagne attribute. + + +The following listing shows how to cache the request body `GatewayFilter`: + +==== +[source,java] +---- +@Bean +public RouteLocator routes(RouteLocatorBuilder builder) { + return builder.routes() + .route("cache_request_body_route", r -> r.path("/downstream/**") + .filters(f -> f.prefixPath("/httpbin") + .cacheRequestBody(String.class).uri(uri)) + .build(); +} +---- +==== + + +.application.yml +==== +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: cache_request_body_route + uri: lb://downstream + predicates: + - Path=/downstream/** + filters: + - name: CacheRequestBody + args: + bodyClass: java.lang.String +---- +==== + +`CacheRequestBody` will extract request body and conver it to body class (such as `java.lang.String`, defined in the preceding example). then places it in the `ServerWebExchange.getAttributes()` with a key defined in `ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR`. + +NOTE: This filter only works with http request (including https). + === Default Filters To add a filter and apply it to all routes, you can use `spring.cloud.gateway.default-filters`. 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 2ff02781..e51e0fc8 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 @@ -72,6 +72,7 @@ import org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter; import org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddResponseHeaderGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.CacheRequestBodyGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.MapRequestHeaderGatewayFilterFactory; @@ -466,6 +467,12 @@ public class GatewayAutoConfiguration { return new ModifyResponseBodyGatewayFilterFactory(codecConfigurer.getReaders(), bodyDecoders, bodyEncoders); } + @Bean + @ConditionalOnEnabledFilter + public CacheRequestBodyGatewayFilterFactory cacheRequestBodyGatewayFilterFactory() { + return new CacheRequestBodyGatewayFilterFactory(); + } + @Bean @ConditionalOnEnabledFilter public PrefixPathGatewayFilterFactory prefixPathGatewayFilterFactory() { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/CacheRequestBodyGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/CacheRequestBodyGatewayFilterFactory.java new file mode 100644 index 00000000..2f836854 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/CacheRequestBodyGatewayFilterFactory.java @@ -0,0 +1,108 @@ +/* + * 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; + +import java.net.URI; +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; +import org.springframework.http.codec.HttpMessageReader; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.Assert; +import org.springframework.web.reactive.function.server.HandlerStrategies; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.server.ServerWebExchange; + +import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR; + +/** + * @author weizibin + */ +public class CacheRequestBodyGatewayFilterFactory extends + AbstractGatewayFilterFactory { + + private final List> messageReaders; + + public CacheRequestBodyGatewayFilterFactory() { + super(CacheRequestBodyGatewayFilterFactory.Config.class); + this.messageReaders = HandlerStrategies.withDefaults().messageReaders(); + } + + @Override + public GatewayFilter apply(CacheRequestBodyGatewayFilterFactory.Config config) { + return new GatewayFilter() { + @Override + public Mono filter(ServerWebExchange exchange, + GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + URI requestUri = request.getURI(); + String scheme = requestUri.getScheme(); + + // Record only http requests (including https) + if ((!"http".equals(scheme) && !"https".equals(scheme))) { + return chain.filter(exchange); + } + + Object cachedBody = exchange.getAttribute(ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR); + if (cachedBody != null) { + return chain.filter(exchange); + } + + return ServerWebExchangeUtils.cacheRequestBodyAndRequest(exchange, (serverHttpRequest) -> { + final ServerRequest serverRequest = ServerRequest.create(exchange.mutate() + .request(serverHttpRequest).build(), messageReaders); + return serverRequest.bodyToMono((config.getBodyClass())) + .doOnNext(objectValue -> { + exchange.getAttributes() + .put(ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR, objectValue); + }).then(Mono.defer(() -> { + ServerHttpRequest cachedRequest = + exchange.getAttribute(CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR); + Assert.notNull(cachedRequest, "cache request shouldn't be null"); + exchange.getAttributes().remove(CACHED_SERVER_HTTP_REQUEST_DECORATOR_ATTR); + return chain.filter(exchange.mutate().request(cachedRequest).build()); + })); + }); + } + + @Override + public String toString() { + return filterToStringCreator(CacheRequestBodyGatewayFilterFactory.this) + .append("Body class", config.getBodyClass()) + .toString(); + } + }; + } + + public static class Config { + + private Class bodyClass; + + public Class getBodyClass() { + return bodyClass; + } + + public void setBodyClass(Class bodyClass) { + this.bodyClass = bodyClass; + } + } +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java index d99b96e2..cdb52cd5 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java @@ -39,6 +39,7 @@ import org.springframework.cloud.gateway.filter.factory.AbstractChangeRequestUri import org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddResponseHeaderGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.CacheRequestBodyGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory.Strategy; import org.springframework.cloud.gateway.filter.factory.FallbackHeadersGatewayFilterFactory; @@ -252,6 +253,16 @@ public class GatewayFilterSpec extends UriSpec { .apply(c -> c.setRewriteFunction(inClass, outClass, rewriteFunction).setContentType(newContentType))); } + /** + * A filter that can cache the request body. + * @param bodyClass the class to convert the incoming request body to the original request body class + * @return a {@link GatewayFilterSpec} that can be used to apply additional filters + */ + public GatewayFilterSpec cacheRequestBody(Class bodyClass) { + return filter(getBean(CacheRequestBodyGatewayFilterFactory.class) + .apply(c -> c.setBodyClass(bodyClass))); + } + /** * A filter that can be used to modify the request body. * @param configConsumer request spec for response modification diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java index 83b14aa1..eddd89c0 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java @@ -110,6 +110,7 @@ public class DisableBuiltInFiltersTests { "spring.cloud.gateway.filter.request-header-size.enabled=false", "spring.cloud.gateway.filter.circuit-breaker.enabled=false", "spring.cloud.gateway.filter.token-relay.enabled=false", + "spring.cloud.gateway.filter.cache-request-body.enabled=false", "spring.cloud.gateway.filter.fallback-headers.enabled=false" }) @ActiveProfiles("disable-components") public static class DisableAllFiltersByProperty { diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/CacheRequestBodyGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/CacheRequestBodyGatewayFilterFactoryTests.java new file mode 100644 index 00000000..f5ccc781 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/CacheRequestBodyGatewayFilterFactoryTests.java @@ -0,0 +1,167 @@ +/* + * 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; + +import java.util.Map; + +import org.junit.Test; +import org.junit.runner.RunWith; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +public class CacheRequestBodyGatewayFilterFactoryTests + extends BaseWebClientTests { + + private static final String BODY_VALUE = "here is request body"; + private static final String BODY_EMPTY = ""; + private static final String BODY_CACHED_EXISTS = "BODY_CACHED_EXISTS"; + + @Test + public void cacheRequestBodyWorks() { + testClient.post().uri("/post") + .header("Host", "www.cacherequestbody.org").bodyValue(BODY_VALUE) + .exchange() + .expectStatus().isOk().expectBody(Map.class).consumeWith(result -> { + Map response = result.getResponseBody(); + assertThat(response).isNotNull(); + + String responseBody = (String) response.get("data"); + assertThat(responseBody).isEqualTo(BODY_VALUE); + }); + } + + @Test + public void cacheRequestBodyEmpty() { + testClient.post().uri("/post") + .header("Host", "www.cacherequestbodyempty.org") + .exchange() + .expectStatus().isOk().expectBody(Map.class).consumeWith(result -> { + Map response = result.getResponseBody(); + assertThat(response).isNotNull(); + + assertThat(response.get("data")).isNull(); + }); + } + + @Test + public void cacheRequestBodyExists() { + testClient.post().uri("/post") + .header("Host", "www.cacherequestbodyexists.org") + .exchange() + .expectStatus().isOk(); + } + + @Test + public void toStringFormat() { + CacheRequestBodyGatewayFilterFactory.Config config = new CacheRequestBodyGatewayFilterFactory.Config(); + config.setBodyClass(String.class); + GatewayFilter filter = new CacheRequestBodyGatewayFilterFactory().apply(config); + assertThat(filter.toString()).contains("String"); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + @Value("${test.uri}") + String uri; + + @Bean + public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + .route("cache_request_body_java_test", r -> r + .path("/post").and().host("**.cacherequestbody.org") + .filters(f -> f.prefixPath("/httpbin").cacheRequestBody(String.class) + .filter(new AssertCachedRequestBodyGatewayFilter(BODY_VALUE))) + .uri(uri)) + .route("cache_request_body_empty_java_test", r -> r + .path("/post").and().host("**.cacherequestbodyempty.org") + .filters(f -> f.prefixPath("/httpbin").cacheRequestBody(String.class) + .filter(new AssertCachedRequestBodyGatewayFilter(BODY_EMPTY))) + .uri(uri)) + .route("cache_request_body_exists_java_test", r -> r + .path("/post").and().host("**.cacherequestbodyexists.org") + .filters(f -> f.prefixPath("/httpbin") + .filter(new SetExchangeCachedRequestBodyGatewayFilter(BODY_CACHED_EXISTS)) + .cacheRequestBody(String.class) + .filter(new AssertCachedRequestBodyGatewayFilter(BODY_CACHED_EXISTS))) + .uri(uri)) + .build(); + } + + } + + private static class AssertCachedRequestBodyGatewayFilter implements GatewayFilter { + private boolean exceptNullBody; + private String bodyExcepted; + + AssertCachedRequestBodyGatewayFilter(String body) { + this.exceptNullBody = StringUtils.isEmpty(body); + this.bodyExcepted = body; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + String body = exchange.getAttribute(ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR); + if (exceptNullBody) { + assertThat(body).isNull(); + } + else { + assertThat(body).isEqualTo(bodyExcepted); + } + return chain.filter(exchange); + } + } + + private static class SetExchangeCachedRequestBodyGatewayFilter implements GatewayFilter { + private String bodyToSetCache; + + SetExchangeCachedRequestBodyGatewayFilter(String toSet) { + this.bodyToSetCache = toSet; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + exchange.getAttributes().put(ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR, bodyToSetCache); + return chain.filter(exchange); + } + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java index e1e53cc0..3f5b0308 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/test/AdhocTestSuite.java @@ -80,6 +80,7 @@ import static org.junit.Assume.assumeThat; org.springframework.cloud.gateway.filter.factory.RewritePathGatewayFilterFactoryIntegrationTests.class, org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayFilterFactoryTests.class, org.springframework.cloud.gateway.filter.factory.SetResponseHeaderGatewayFilterFactoryTests.class, + org.springframework.cloud.gateway.filter.factory.CacheRequestBodyGatewayFilterFactoryTests.class, org.springframework.cloud.gateway.filter.factory.rewrite.ModifyResponseBodyGatewayFilterFactoryTests.class, org.springframework.cloud.gateway.filter.WeightCalculatorWebFilterTests.class, org.springframework.cloud.gateway.filter.RouteToRequestUrlFilterTests.class, diff --git a/spring-cloud-gateway-server/src/test/resources/application.yml b/spring-cloud-gateway-server/src/test/resources/application.yml index 7574e248..42dfee80 100644 --- a/spring-cloud-gateway-server/src/test/resources/application.yml +++ b/spring-cloud-gateway-server/src/test/resources/application.yml @@ -73,6 +73,15 @@ spring: filters: - AddResponseHeader=X-Request-Foo, Bar + - id: cache_request_body_test + uri: ${test.uri} + predicates: + - Host=**.cacherequestbody.org + filters: + - name: CacheRequestBody + args: + bodyClass: java.lang.String + # ===================================== - id: circuitbreaker_exception_fallback_test uri: ${test.uri}