diff --git a/docs/src/main/asciidoc/ghpages.sh b/docs/src/main/asciidoc/ghpages.sh index 57c5da3a..55e76be1 100755 --- a/docs/src/main/asciidoc/ghpages.sh +++ b/docs/src/main/asciidoc/ghpages.sh @@ -40,7 +40,7 @@ function check_if_anything_to_sync() { function retrieve_current_branch() { # Code getting the name of the current branch. For master we want to publish as we did until now - # http://stackoverflow.com/questions/1593051/how-to-programmatically-determine-the-current-checked-out-git-branch + # https://stackoverflow.com/questions/1593051/how-to-programmatically-determine-the-current-checked-out-git-branch # If there is a branch already passed will reuse it - otherwise will try to find it CURRENT_BRANCH=${BRANCH} if [[ -z "${CURRENT_BRANCH}" ]] ; then @@ -147,7 +147,7 @@ function copy_docs_for_current_version() { COMMIT_CHANGES="yes" else echo -e "Current branch is [${CURRENT_BRANCH}]" - # http://stackoverflow.com/questions/29300806/a-bash-script-to-check-if-a-string-is-present-in-a-comma-separated-list-of-strin + # https://stackoverflow.com/questions/29300806/a-bash-script-to-check-if-a-string-is-present-in-a-comma-separated-list-of-strin if [[ ",${WHITELISTED_BRANCHES_VALUE}," = *",${CURRENT_BRANCH},"* ]] ; then mkdir -p ${ROOT_FOLDER}/${CURRENT_BRANCH} echo -e "Branch [${CURRENT_BRANCH}] is whitelisted! Will copy the current docs to the [${CURRENT_BRANCH}] folder" diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index a520afaa..2be60a2c 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/mvnw b/mvnw index a69491ac..675cb6b0 100755 --- a/mvnw +++ b/mvnw @@ -8,7 +8,7 @@ # "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 +# 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 diff --git a/mvnw.cmd b/mvnw.cmd index 2b934e89..4b98b78c 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -7,7 +7,7 @@ @REM "License"); you may not use this file except in compliance @REM with the License. You may obtain a copy of the License at @REM -@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM https://www.apache.org/licenses/LICENSE-2.0 @REM @REM Unless required by applicable law or agreed to in writing, @REM software distributed under the License is distributed on an diff --git a/pom.xml b/pom.xml index fd5f7c88..64610ae9 100644 --- a/pom.xml +++ b/pom.xml @@ -32,7 +32,7 @@ Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 + https://www.apache.org/licenses/LICENSE-2.0 @@ -41,7 +41,7 @@ Spencer Gibb sgibb at pivotal.io Pivotal Software, Inc. - http://www.spring.io + https://www.spring.io Project lead 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 40e18765..e0de7e85 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..f9c547ed --- /dev/null +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactory.java @@ -0,0 +1,149 @@ +/* + * 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 org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.http.HttpHeaders; +import reactor.core.publisher.Mono; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/* +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..1ecfd931 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,17 @@ 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..c96f08f8 --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryTests.java @@ -0,0 +1,55 @@ +/* + * 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..3d2c291e --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/DedupeResponseHeaderGatewayFilterFactoryUnitTests.java @@ -0,0 +1,113 @@ +/* + * 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 org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.http.HttpHeaders; + +import java.util.ArrayList; +import java.util.Arrays; + +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()); + } +} \ No newline at end of file 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..705dc8d3 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: