diff --git a/docs/pom.xml b/docs/pom.xml index b1239878..6ba42ac6 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -15,7 +15,7 @@ spring-cloud-gateway ${basedir}/.. - 1.0.x + 1.0.x,2.1.x diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index 5e3f8376..12914c65 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -369,7 +369,7 @@ spring: cloud: gateway: routes: - - id: add_request_header_route + - id: add_response_header_route uri: http://example.org filters: - AddResponseHeader=X-Response-Foo, Bar @@ -377,6 +377,26 @@ spring: This will add `X-Response-Foo:Bar` header to the downstream response's headers for all matching requests. +=== DedupeResponseHeader GatewayFilter Factory +The DedupeResponseHeader GatewayFilter Factory takes a `name` parameter and an optional `strategy` parameter. `name` can contain a list of header names, space separated. + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: dedupe_response_header_route + uri: http://example.org + filters: + - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin +---- + +This will remove duplicate values of `Access-Control-Allow-Credentials` and `Access-Control-Allow-Origin` response headers in cases when both the gateway CORS logic and the downstream add them. + +The DedupeResponseHeader filter also accepts an optional `strategy` parameter. The accepted values are `RETAIN_FIRST` (default), `RETAIN_LAST`, and `RETAIN_UNIQUE`. + [[hystrix]] === Hystrix GatewayFilter Factory https://github.com/Netflix/Hystrix[Hystrix] is a library from Netflix that implements the https://martinfowler.com/bliki/CircuitBreaker.html[circuit breaker pattern]. diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index be1ffd7f..7ad519ee 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -56,6 +56,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.DedupeResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.FallbackHeadersGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.HystrixGatewayFilterFactory; @@ -395,6 +396,11 @@ public class GatewayAutoConfiguration { return new ModifyRequestBodyGatewayFilterFactory(codecConfigurer); } + @Bean + public DedupeResponseHeaderGatewayFilterFactory dedupeResponseHeaderGatewayFilterFactory() { + return new DedupeResponseHeaderGatewayFilterFactory(); + } + @Bean public ModifyResponseBodyGatewayFilterFactory modifyResponseBodyGatewayFilterFactory( ServerCodecConfigurer codecConfigurer) { diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactory.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactory.java new file mode 100644 index 00000000..b7da35cd --- /dev/null +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactory.java @@ -0,0 +1,154 @@ +/* + * Copyright 2013-2019 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 + * + * http://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.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.http.HttpHeaders; + +/* +Use case: Both your legacy backend and your API gateway add CORS header values. So, your consumer ends up with + Access-Control-Allow-Credentials: true, true + Access-Control-Allow-Origin: https://musk.mars, https://musk.mars +(The one from the gateway will be the first of the two.) To fix, add + DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin + +Configuration parameters: +- name + String representing response header names, space separated. Required. +- strategy + RETAIN_FIRST - Default. Retain the first value only. + RETAIN_LAST - Retain the last value only. + RETAIN_UNIQUE - Retain all unique values in the order of their first encounter. + +Example 1 + default-filters: + - DedupeResponseHeader=Access-Control-Allow-Credentials + +Response header Access-Control-Allow-Credentials: true, false +Modified response header Access-Control-Allow-Credentials: true + +Example 2 + default-filters: + - DedupeResponseHeader=Access-Control-Allow-Credentials, RETAIN_LAST + +Response header Access-Control-Allow-Credentials: true, false +Modified response header Access-Control-Allow-Credentials: false + +Example 3 + default-filters: + - DedupeResponseHeader=Access-Control-Allow-Credentials, RETAIN_UNIQUE + +Response header Access-Control-Allow-Credentials: true, true +Modified response header Access-Control-Allow-Credentials: true + */ + +/** + * @author Vitaliy Pavlyuk + */ +public class DedupeResponseHeaderGatewayFilterFactory extends + AbstractGatewayFilterFactory { + + private static final String STRATEGY_KEY = "strategy"; + + public DedupeResponseHeaderGatewayFilterFactory() { + super(Config.class); + } + + @Override + public List shortcutFieldOrder() { + return Arrays.asList(NAME_KEY, STRATEGY_KEY); + } + + @Override + public GatewayFilter apply(Config config) { + return (exchange, chain) -> chain.filter(exchange).then(Mono.fromRunnable(() -> { + dedupe(exchange.getResponse().getHeaders(), config); + })); + } + + public enum Strategy { + + /** + * Default: Retain the first value only. + */ + RETAIN_FIRST, + + /** + * Retain the last value only. + */ + RETAIN_LAST, + + /** + * Retain all unique values in the order of their first encounter. + */ + RETAIN_UNIQUE + + } + + void dedupe(HttpHeaders headers, Config config) { + String names = config.getName(); + Strategy strategy = config.getStrategy(); + if (headers == null || names == null || strategy == null) { + return; + } + for (String name : names.split(" ")) { + dedupe(headers, name.trim(), strategy); + } + } + + private void dedupe(HttpHeaders headers, String name, Strategy strategy) { + List values = headers.get(name); + if (values == null || values.size() <= 1) { + return; + } + switch (strategy) { + case RETAIN_FIRST: + headers.set(name, values.get(0)); + break; + case RETAIN_LAST: + headers.set(name, values.get(values.size() - 1)); + break; + case RETAIN_UNIQUE: + headers.put(name, values.stream().distinct().collect(Collectors.toList())); + break; + default: + break; + } + } + + public static class Config extends AbstractGatewayFilterFactory.NameConfig { + + private Strategy strategy = Strategy.RETAIN_FIRST; + + public Strategy getStrategy() { + return strategy; + } + + public Config setStrategy(Strategy strategy) { + this.strategy = strategy; + return this; + } + + } + +} diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactory.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactory.java index 6228306d..b8ab5ec1 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactory.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactory.java @@ -88,7 +88,7 @@ public class PathRoutePredicateFactory }); } return exchange -> { - PathContainer path = parsePath(exchange.getRequest().getURI().getPath()); + PathContainer path = parsePath(exchange.getRequest().getURI().getRawPath()); Optional optionalPathPattern = pathPatterns.stream() .filter(pattern -> pattern.matches(path)).findFirst(); diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java index fa722d18..3a688763 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java @@ -39,6 +39,8 @@ 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.DedupeResponseHeaderGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory.Strategy; import org.springframework.cloud.gateway.filter.factory.FallbackHeadersGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.HystrixGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.PrefixPathGatewayFilterFactory; @@ -177,6 +179,18 @@ public class GatewayFilterSpec extends UriSpec { .apply(c -> c.setName(headerName).setValue(headerValue))); } + /** + * A filter that removes duplication on a response header before it is returned to the + * client by the Gateway. + * @param headerName the header name(s), space separated + * @param strategy RETAIN_FIRST, RETAIN_LAST, or RETAIN_UNIQUE + * @return a {@link GatewayFilterSpec} that can be used to apply additional filters + */ + public GatewayFilterSpec dedupeResponseHeader(String headerName, String strategy) { + return filter(getBean(DedupeResponseHeaderGatewayFilterFactory.class).apply( + c -> c.setStrategy(Strategy.valueOf(strategy)).setName(headerName))); + } + /** * Wraps the route in a Hystrix command. Depends on @{code * org.springframework.cloud::spring-cloud-starter-netflix-hystrix} being on the diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryTests.java new file mode 100644 index 00000000..ad9d6ee7 --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2013-2018 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 + * + * http://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 org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +public class DedupeResponseHeaderGatewayFilterFactoryTests extends BaseWebClientTests { + + @Test + public void dedupeResponseHeaderFilterWorks() { + testClient.get().uri("/headers").header("Host", "www.deduperesponseheader.org") + .exchange().expectStatus().isOk().expectHeader() + .valueEquals("Access-Control-Allow-Credentials", "true").expectHeader() + .valueEquals("Access-Control-Allow-Origin", "https://musk.mars") + .expectHeader().valueEquals("Scout-Cookie", "S'mores").expectHeader() + .valueEquals("Next-Week-Lottery-Numbers", "4", "2", "42"); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + } + +} diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryUnitTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryUnitTests.java new file mode 100644 index 00000000..2e9e8c67 --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryUnitTests.java @@ -0,0 +1,123 @@ +/* + * Copyright 2013-2019 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 + * + * http://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.ArrayList; +import java.util.Arrays; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import org.springframework.http.HttpHeaders; + +public class DedupeResponseHeaderGatewayFilterFactoryUnitTests { + + private static final String NAME_1 = HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN; + + private static final String NAME_2 = HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS; + + private HttpHeaders headers; + + private DedupeResponseHeaderGatewayFilterFactory.Config config; + + private DedupeResponseHeaderGatewayFilterFactory filter; + + @Before + public void setUp() { + headers = Mockito.mock(HttpHeaders.class); + config = new DedupeResponseHeaderGatewayFilterFactory.Config(); + filter = new DedupeResponseHeaderGatewayFilterFactory(); + } + + @Test + public void dedupNullName() { + filter.dedupe(headers, config); + Mockito.verify(headers, Mockito.never()).get(Mockito.anyString()); + Mockito.verify(headers, Mockito.never()).set(Mockito.anyString(), + Mockito.anyString()); + } + + @Test + public void dedupNullValues() { + config.setName(NAME_1); + Mockito.when(headers.get(NAME_1)).thenReturn(null); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers, Mockito.never()).set(Mockito.anyString(), + Mockito.anyString()); + } + + @Test + public void dedupEmptyValues() { + config.setName(NAME_1); + Mockito.when(headers.get(NAME_1)).thenReturn(new ArrayList<>()); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers, Mockito.never()).set(Mockito.anyString(), + Mockito.anyString()); + } + + @Test + public void dedupSingleValue() { + config.setName(NAME_1); + Mockito.when(headers.get(NAME_1)).thenReturn(Arrays.asList("1")); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers, Mockito.never()).set(Mockito.anyString(), + Mockito.anyString()); + } + + @Test + public void dedupMultipleValuesRetainFirst() { + config.setName(NAME_1 + " " + NAME_2); + Mockito.when(headers.get(NAME_1)).thenReturn(Arrays.asList("2", "3", "3", "4")); + Mockito.when(headers.get(NAME_2)).thenReturn(Arrays.asList("true", "false")); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers).set(NAME_1, "2"); + Mockito.verify(headers).get(NAME_2); + Mockito.verify(headers).set(NAME_2, "true"); + Mockito.verify(headers, Mockito.times(2)).set(Mockito.anyString(), + Mockito.anyString()); + } + + @Test + public void dedupMultipleValuesRetainLast() { + config.setName(NAME_1); + config.setStrategy(DedupeResponseHeaderGatewayFilterFactory.Strategy.RETAIN_LAST); + Mockito.when(headers.get(NAME_1)).thenReturn(Arrays.asList("2", "3", "3", "4")); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers).set(NAME_1, "4"); + Mockito.verify(headers).set(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void dedupMultipleValuesRetainUnique() { + config.setName(NAME_1); + config.setStrategy( + DedupeResponseHeaderGatewayFilterFactory.Strategy.RETAIN_UNIQUE); + Mockito.when(headers.get(NAME_1)).thenReturn(Arrays.asList("2", "3", "3", "4")); + filter.dedupe(headers, config); + Mockito.verify(headers).get(NAME_1); + Mockito.verify(headers).put(Mockito.eq(NAME_1), + Mockito.eq(Arrays.asList("2", "3", "4"))); + Mockito.verify(headers).put(Mockito.anyString(), Mockito.anyList()); + } + +} diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java index 4e58c53c..3c299e83 100644 --- a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/handler/predicate/PathRoutePredicateFactoryTests.java @@ -83,6 +83,16 @@ public class PathRoutePredicateFactoryTests extends BaseWebClientTests { expectPathRoute("/anything/multidsl3", "www.pathmultidsl.org", "path_multi_dsl"); } + @Test + public void pathRouteWorksWithPercent() { + testClient.get().uri("/abc/123%/function") + .header(HttpHeaders.HOST, "www.path.org").exchange().expectStatus().isOk() + .expectHeader() + .valueEquals(HANDLER_MAPPER_HEADER, + RoutePredicateHandlerMapping.class.getSimpleName()) + .expectHeader().valueEquals(ROUTE_ID_HEADER, "path_test"); + } + @EnableAutoConfiguration @SpringBootConfiguration @Import(DefaultTestConfig.class) diff --git a/spring-cloud-gateway-core/src/test/resources/application.yml b/spring-cloud-gateway-core/src/test/resources/application.yml index 8b293395..c362dcf0 100644 --- a/spring-cloud-gateway-core/src/test/resources/application.yml +++ b/spring-cloud-gateway-core/src/test/resources/application.yml @@ -69,6 +69,27 @@ spring: - AddResponseHeader=X-Request-Foo, Bar # ===================================== + - id: dedupe_response_header_test + uri: ${test.uri} + predicates: + - Host=**.deduperesponseheader.org + - Path=/headers + filters: + - AddResponseHeader=Access-Control-Allow-Credentials, true + - AddResponseHeader=Access-Control-Allow-Credentials, false + - AddResponseHeader=Access-Control-Allow-Origin, https://musk.mars + - AddResponseHeader=Access-Control-Allow-Origin, * + - AddResponseHeader=Scout-Cookie, Thin Mints + - AddResponseHeader=Scout-Cookie, S'mores + - AddResponseHeader=Next-Week-Lottery-Numbers, 4 + - AddResponseHeader=Next-Week-Lottery-Numbers, 2 + - AddResponseHeader=Next-Week-Lottery-Numbers, 2 + - AddResponseHeader=Next-Week-Lottery-Numbers, 42 + - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin, RETAIN_FIRST + - DedupeResponseHeader=Scout-Cookie, RETAIN_LAST + - DedupeResponseHeader=Next-Week-Lottery-Numbers, RETAIN_UNIQUE + + # ===================================== - id: rewrite_response_header_test uri: ${test.uri} predicates: