diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index c8225adadb..38201cf6ae 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -13,6 +13,7 @@ *** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/addrequestheadersifnotpresent-factory.adoc[] *** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/addrequestparameter-factory.adoc[] *** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/addresponseheader-factory.adoc[] +*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/addresponseheadersifnotpresent-factory.adoc[] *** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/circuitbreaker-filter-factory.adoc[] *** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/cacherequestbody-factory.adoc[] *** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/deduperesponseheader-factory.adoc[] diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/addresponseheadersifnotpresent-factory.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/addresponseheadersifnotpresent-factory.adoc new file mode 100644 index 0000000000..18340f2498 --- /dev/null +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/addresponseheadersifnotpresent-factory.adoc @@ -0,0 +1,44 @@ +[[addresponseheadersifnotpresent-gatewayfilter-factory]] += `AddResponseHeadersIfNotPresent` `GatewayFilter` Factory + +The `AddResponseHeadersIfNotPresent` `GatewayFilter` factory takes a collection of `name` and `value` pairs separated by colon. +The following example configures an `AddResponseHeadersIfNotPresent` `GatewayFilter`: + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: add_response_headers_if_not_present_route + uri: https://example.org + filters: + - AddResponseHeadersIfNotPresent=X-Response-Color-1:blue,X-Response-Color-2:green +---- + + +This listing adds 2 headers `X-Response-Color-1:blue` and `X-Response-Color-2:green` to the downstream response's headers for all matching requests. +This is similar to how `AddResponseHeader` works, but unlike `AddResponseHeader` it will do it only if the header is not already there. +Otherwise, the original value in the response is return. + +Additionally, to set a multi-valued header, use the header name multiple times like `AddResponseHeadersIfNotPresent=X-Response-Color-1:blue,X-Response-Color-1:green`. + +`AddResponseHeadersIfNotPresent` also supports URI variables used to match a path or host. +URI variables may be used in the value and are expanded at runtime. +The following example configures an `AddResponseHeadersIfNotPresent` `GatewayFilter` that uses a variable: + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: add_response_headers_if_not_present_route + uri: https://example.org + predicates: + - Path=/red/{segment} + filters: + - AddResponseHeadersIfNotPresent=X-Response-Red:Blue-{segment} +---- \ No newline at end of file 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 5d399d953c..c7084a873a 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 @@ -83,6 +83,7 @@ import org.springframework.cloud.gateway.filter.factory.AddRequestHeadersIfNotPresentGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddResponseHeaderGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.AddResponseHeadersIfNotPresentGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.CacheRequestBodyGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.DedupeResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory; @@ -551,6 +552,12 @@ public AddResponseHeaderGatewayFilterFactory addResponseHeaderGatewayFilterFacto return new AddResponseHeaderGatewayFilterFactory(); } + @Bean + @ConditionalOnEnabledFilter + public AddResponseHeadersIfNotPresentGatewayFilterFactory addResponseHeadersIfNotPresentGatewayFilterFactory() { + return new AddResponseHeadersIfNotPresentGatewayFilterFactory(); + } + @Bean @ConditionalOnEnabledFilter public ModifyRequestBodyGatewayFilterFactory modifyRequestBodyGatewayFilterFactory( diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/AddResponseHeadersIfNotPresentGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/AddResponseHeadersIfNotPresentGatewayFilterFactory.java new file mode 100644 index 0000000000..03733b24f2 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/AddResponseHeadersIfNotPresentGatewayFilterFactory.java @@ -0,0 +1,124 @@ +/* + * 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.filter.factory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +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.cloud.gateway.support.config.KeyValue; +import org.springframework.cloud.gateway.support.config.KeyValueConfig; +import org.springframework.core.style.ToStringCreator; +import org.springframework.http.HttpHeaders; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator; + +/** + * Adds one or more headers to the response’s headers without overriding previous values. + * If the header is are already present, value(s) will not be set. + * + * @author jiangyuan + */ +public class AddResponseHeadersIfNotPresentGatewayFilterFactory extends AbstractGatewayFilterFactory { + + @Override + public GatewayFilter apply(KeyValueConfig config) { + return new GatewayFilter() { + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + return chain.filter(exchange) + .then(Mono.fromRunnable(() -> addResponseHeaderIfNotPresent(exchange, config))); + } + + @Override + public String toString() { + ToStringCreator toStringCreator = filterToStringCreator( + AddResponseHeadersIfNotPresentGatewayFilterFactory.this); + for (KeyValue keyValue : config.getKeyValues()) { + toStringCreator.append(keyValue.getKey(), keyValue.getValue()); + } + return toStringCreator.toString(); + } + }; + } + + private void addResponseHeaderIfNotPresent(ServerWebExchange exchange, KeyValueConfig config) { + if (!exchange.getResponse().isCommitted()) { + Map> aggregatedHeaders = new HashMap<>(); + for (KeyValue keyValue : config.getKeyValues()) { + String key = keyValue.getKey(); + List candidateValue = aggregatedHeaders.get(key); + if (candidateValue == null) { + candidateValue = new ArrayList<>(); + candidateValue.add(keyValue.getValue()); + aggregatedHeaders.put(key, candidateValue); + } + else { + candidateValue.add(keyValue.getValue()); + } + } + + HttpHeaders headers = exchange.getResponse().getHeaders(); + for (Map.Entry> kv : aggregatedHeaders.entrySet()) { + String headerName = kv.getKey(); + + boolean headerIsMissingOrBlank = headers.getOrEmpty(headerName) + .stream() + .allMatch(h -> !StringUtils.hasText(h)); + + if (headerIsMissingOrBlank) { + List replacedValues = kv.getValue() + .stream() + .map(value -> ServerWebExchangeUtils.expand(exchange, value)) + .collect(Collectors.toList()); + headers.addAll(headerName, replacedValues); + } + } + } + } + + @Override + public ShortcutType shortcutType() { + return ShortcutType.GATHER_LIST; + } + + @Override + public List shortcutFieldOrder() { + return Collections.singletonList("keyValues"); + } + + @Override + public KeyValueConfig newConfig() { + return new KeyValueConfig(); + } + + @Override + public Class getConfigClass() { + return KeyValueConfig.class; + } + +} 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 47723b74d8..334f9bfb20 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 @@ -43,6 +43,7 @@ import org.springframework.cloud.gateway.filter.factory.AddRequestHeadersIfNotPresentGatewayFilterFactory.KeyValue; import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddResponseHeaderGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.AddResponseHeadersIfNotPresentGatewayFilterFactory; 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; @@ -225,6 +226,22 @@ public GatewayFilterSpec addResponseHeader(String headerName, String headerValue .apply(c -> c.setName(headerName).setValue(headerValue))); } + /** + * Adds a header to the response returned to the Gateway from the route. + * @param headers the header name(s) and value(s) as + * 'name-1:value-1,name-2:value-2,...' + * @return a {@link GatewayFilterSpec} that can be used to apply additional filters + */ + public GatewayFilterSpec addResponseHeadersIfNotPresent(String... headers) { + return filter(getBean(AddResponseHeadersIfNotPresentGatewayFilterFactory.class).apply(c -> { + org.springframework.cloud.gateway.support.config.KeyValue[] values = Arrays.stream(headers) + .map(header -> header.split(":")) + .map(parts -> new org.springframework.cloud.gateway.support.config.KeyValue(parts[0], parts[1])) + .toArray(size -> new org.springframework.cloud.gateway.support.config.KeyValue[size]); + c.setKeyValues(values); + })); + } + /** * A filter that adds a local cache for storing response body for repeated requests. *

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 2669059d38..b43ddf5d26 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 @@ -81,6 +81,7 @@ public void shouldInjectOnlyEnabledBuiltInFilters() { "spring.cloud.gateway.filter.add-request-headers-if-not-present.enabled=false", "spring.cloud.gateway.filter.add-request-parameter.enabled=false", "spring.cloud.gateway.filter.add-response-header.enabled=false", + "spring.cloud.gateway.filter.add-response-headers-if-not-present.enabled=false", "spring.cloud.gateway.filter.json-to-grpc.enabled=false", "spring.cloud.gateway.filter.modify-request-body.enabled=false", "spring.cloud.gateway.filter.local-response-cache.enabled=false", diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/AddResponseHeadersIfNotPresentGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/AddResponseHeadersIfNotPresentGatewayFilterFactoryTests.java new file mode 100644 index 0000000000..556f2c9ad4 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/AddResponseHeadersIfNotPresentGatewayFilterFactoryTests.java @@ -0,0 +1,195 @@ +/* + * 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.filter.factory; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +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.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.cloud.gateway.support.config.KeyValue; +import org.springframework.cloud.gateway.support.config.KeyValueConfig; +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.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +@ActiveProfiles(profiles = "response-headers-if-not-present-web-filter") +public class AddResponseHeadersIfNotPresentGatewayFilterFactoryTests extends BaseWebClientTests { + + private static final String TEST_HEADER_KEY1 = "X-Response-Example"; + + private static final String TEST_HEADER_KEY2 = "X-Response-Second-Example"; + + private static final String TEST_HEADER_VALUE_A = "ValueA"; + + private static final String TEST_HEADER_VALUE_C = "ValueC"; + + @Test + public void responseHeadersPresent() { + Map body = new HashMap<>(); + final String headerValue1 = "ResponseHeadersPresent-Value1"; + body.put(TEST_HEADER_KEY1, headerValue1); + + testClient.patch() + .uri("/headers") + .header("Host", "www.addresponseheadersifnotpresenttest.org") + .bodyValue(body) + .exchange() + .expectHeader() + .valueEquals(TEST_HEADER_KEY1, headerValue1); + } + + @Test + public void responseHeadersNotPresentAndFilterWorks() { + testClient.patch() + .uri("/headers") + .header("Host", "www.addresponseheadersifnotpresenttest.org") + .bodyValue(new HashMap<>()) + .exchange() + .expectHeader() + .valueEquals(TEST_HEADER_KEY1, TEST_HEADER_VALUE_A); + } + + @Test + public void responseMultipleHeadersPresent() { + Map body = new HashMap<>(); + final String headerValue1 = "ResponseMultipleHeadersPresent-Value1"; + final String headerValue2 = "ResponseMultipleHeadersPresent-Value2"; + body.put(TEST_HEADER_KEY1, headerValue1); + body.put(TEST_HEADER_KEY2, headerValue2); + + testClient.patch() + .uri("/headers") + .header("Host", "www.addmultipleresponseheadersifnotpresenttest.org") + .bodyValue(body) + .exchange() + .expectHeader() + .valueEquals(TEST_HEADER_KEY1, headerValue1) + .expectHeader() + .valueEquals(TEST_HEADER_KEY2, headerValue2); + } + + @Test + public void responseMultipleHeadersNotPresentAndFilterWorks() { + testClient.patch() + .uri("/headers") + .header("Host", "www.addmultipleresponseheadersifnotpresenttest.org") + .bodyValue(new HashMap<>()) + .exchange() + .expectHeader() + .valueEquals(TEST_HEADER_KEY1, TEST_HEADER_VALUE_A) + .expectHeader() + .valueEquals(TEST_HEADER_KEY2, TEST_HEADER_VALUE_C); + } + + @Test + public void responseMultipleHeadersPartialPresent() { + Map body = new HashMap<>(); + final String headerValue2 = "ResponseMultipleHeadersPartialPresent-Value2"; + body.put(TEST_HEADER_KEY2, headerValue2); + + testClient.patch() + .uri("/headers") + .header("Host", "www.addmultipleresponseheadersifnotpresenttest.org") + .bodyValue(body) + .exchange() + .expectHeader() + .valueEquals(TEST_HEADER_KEY1, TEST_HEADER_VALUE_A) + .expectHeader() + .valueEquals(TEST_HEADER_KEY2, headerValue2); + } + + @Test + public void responseHeadersNotPresentAndFilterWorksJavaDsl() { + testClient.patch() + .uri("/headers") + .header("Host", "www.addresponseheadersifnotpresentjavadsl.org") + .bodyValue(new HashMap<>()) + .exchange() + .expectHeader() + .valueEquals("X-Response-Java-Example", "Value-www"); + } + + @Test + public void responseMultipleHeadersNotPresentAndFilterWorksJavaDsl() { + testClient.patch() + .uri("/headers") + .header("Host", "www.addmultipleresponseheadersifnotpresentjavadsl.org") + .bodyValue(new HashMap<>()) + .exchange() + .expectHeader() + .valueEquals("X-Response-Java-Example", "Value-www", "ValueX", "ValueY", "ValueZ"); + } + + @Test + public void toStringFormat() { + KeyValueConfig keyValueConfig = new KeyValueConfig(); + keyValueConfig.setKeyValues(new KeyValue[] { new KeyValue("X-Response-Key1", "Value1"), + new KeyValue("X-Response-Key2", "Value2") }); + GatewayFilter filter = new AddResponseHeadersIfNotPresentGatewayFilterFactory().apply(keyValueConfig); + assertThat(filter.toString()).startsWith("[AddResponseHeadersIfNotPresent") + .contains("X-Response-Key1 = 'Value1'") + .contains("X-Response-Key2 = 'Value2'") + .endsWith("]"); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + @Value("${test.uri}") + String uri; + + @Bean + public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + .route("add_response_headers_if_not_present_java_test", + r -> r.path("/headers") + .and() + .host("{sub}.addresponseheadersifnotpresentjavadsl.org") + .filters(f -> f.addResponseHeadersIfNotPresent("X-Response-Java-Example:Value-{sub}")) + .uri(uri)) + .route("add_multiple_response_headers_if_not_present_java_test", + r -> r.path("/headers") + .and() + .host("{sub}.addmultipleresponseheadersifnotpresentjavadsl.org") + .filters(f -> f.addResponseHeadersIfNotPresent("X-Response-Java-Example:Value-{sub}", + "X-Response-Java-Example:ValueX", "X-Response-Java-Example:ValueY", + "X-Response-Java-Example:ValueZ")) + .uri(uri)) + .build(); + + } + + } + +} diff --git a/spring-cloud-gateway-server/src/test/resources/application-response-headers-if-not-present-web-filter.yml b/spring-cloud-gateway-server/src/test/resources/application-response-headers-if-not-present-web-filter.yml new file mode 100644 index 0000000000..c806c833ec --- /dev/null +++ b/spring-cloud-gateway-server/src/test/resources/application-response-headers-if-not-present-web-filter.yml @@ -0,0 +1,18 @@ +spring: + cloud: + gateway: + routes: + - id: add_response_headers_if_not_present_test + uri: ${test.uri} + predicates: + - Path=/headers + - Host=**.addresponseheadersifnotpresenttest.org + filters: + - AddResponseHeadersIfNotPresent=X-Response-Example:ValueA + - id: add_multiple_response_headers_if_not_present_test + uri: ${test.uri} + predicates: + - Path=/headers + - Host=**.addmultipleresponseheadersifnotpresenttest.org + filters: + - AddResponseHeadersIfNotPresent=X-Response-Example:ValueA,X-Response-Second-Example:ValueC \ No newline at end of file