Dedupe response header filter (#866)
Filter that de-duplicates response filter values. Comes handy when e.g. both the gateway CORS logic and a downstream add Access-Control-Allow-Credentials and Access-Control-Allow-Origin headers. See https://github.com/spring-cloud/spring-cloud-gateway/issues/728 for additional context.
This commit is contained in:
@@ -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 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].
|
||||
The Hystrix GatewayFilter allows you to introduce circuit breakers to your gateway routes, protecting your services from cascading failures and allowing you to provide fallback responses in the event of downstream failures.
|
||||
|
||||
@@ -58,6 +58,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.GatewayFilterFactory;
|
||||
import org.springframework.cloud.gateway.filter.factory.HystrixGatewayFilterFactory;
|
||||
import org.springframework.cloud.gateway.filter.factory.PrefixPathGatewayFilterFactory;
|
||||
@@ -502,6 +503,11 @@ public class GatewayAutoConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DedupeResponseHeaderGatewayFilterFactory dedupeResponseHeaderGatewayFilterFactory() {
|
||||
return new DedupeResponseHeaderGatewayFilterFactory();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ModifyRequestBodyGatewayFilterFactory modifyRequestBodyGatewayFilterFactory(ServerCodecConfigurer codecConfigurer) {
|
||||
return new ModifyRequestBodyGatewayFilterFactory(codecConfigurer);
|
||||
|
||||
@@ -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<DedupeResponseHeaderGatewayFilterFactory.Config> {
|
||||
|
||||
private static final String STRATEGY_KEY = "strategy";
|
||||
|
||||
public DedupeResponseHeaderGatewayFilterFactory() {
|
||||
super(Config.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.HystrixGatewayFilterFactory;
|
||||
import org.springframework.cloud.gateway.filter.factory.PrefixPathGatewayFilterFactory;
|
||||
import org.springframework.cloud.gateway.filter.factory.PreserveHostHeaderGatewayFilterFactory;
|
||||
@@ -173,6 +175,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 classpath,
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import org.junit.runners.model.Statement;
|
||||
|
||||
import org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayFilterFactoryTests;
|
||||
import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactoryTests;
|
||||
import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactoryTests;
|
||||
import org.springframework.cloud.gateway.filter.factory.HystrixGatewayFilterFactoryTests;
|
||||
import org.springframework.cloud.gateway.filter.factory.PreserveHostHeaderGatewayFilterFactoryTests;
|
||||
import org.springframework.cloud.gateway.filter.factory.RedirectToGatewayFilterFactoryTests;
|
||||
@@ -90,6 +91,7 @@ import static org.junit.Assume.assumeThat;
|
||||
SetPathGatewayFilterFactoryIntegrationTests.class,
|
||||
AddRequestParameterGatewayFilterFactoryTests.class,
|
||||
SetResponseHeaderGatewayFilterFactoryTests.class,
|
||||
DedupeResponseHeaderGatewayFilterFactoryTests.class,
|
||||
RewriteResponseHeaderGatewayFilterFactoryTests.class,
|
||||
PrincipalNameKeyResolverIntegrationTests.class,
|
||||
RedisRateLimiterTests.class,
|
||||
|
||||
@@ -66,6 +66,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:
|
||||
|
||||
Reference in New Issue
Block a user