diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java index 53485b2d..62e98fa4 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java @@ -20,10 +20,12 @@ import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties; import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledGlobalFilter; +import org.springframework.cloud.gateway.filter.LoadBalancerServiceInstanceCookieFilter; import org.springframework.cloud.gateway.filter.ReactiveLoadBalancerClientFilter; import org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration; import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; @@ -52,4 +54,15 @@ public class GatewayReactiveLoadBalancerClientAutoConfiguration { return new ReactiveLoadBalancerClientFilter(clientFactory, properties, loadBalancerProperties); } + @Bean + @ConditionalOnBean(ReactiveLoadBalancerClientFilter.class) + @ConditionalOnProperty(value = "spring.cloud.loadbalancer.sticky-session.add-service-instance-cookie", + havingValue = "true") + @ConditionalOnMissingBean + @ConditionalOnEnabledGlobalFilter + public LoadBalancerServiceInstanceCookieFilter loadBalancerServiceInstanceCookieFilter( + LoadBalancerProperties loadBalancerProperties) { + return new LoadBalancerServiceInstanceCookieFilter(loadBalancerProperties); + } + } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/LoadBalancerServiceInstanceCookieFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/LoadBalancerServiceInstanceCookieFilter.java new file mode 100644 index 00000000..9581a9d2 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/LoadBalancerServiceInstanceCookieFilter.java @@ -0,0 +1,77 @@ +/* + * Copyright 2013-2021 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; + +import java.util.ArrayList; +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties; +import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.core.Ordered; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +import static org.springframework.cloud.gateway.filter.ReactiveLoadBalancerClientFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_LOADBALANCER_RESPONSE_ATTR; + +/** + * A {@link GlobalFilter} that allows passing the {@code} instanceId) of the + * {@link ServiceInstance} selected by the {@link ReactiveLoadBalancerClientFilter} in a + * cookie. + * + * @author Olga Maciaszek-Sharma + * @since 3.0.2 + */ +public class LoadBalancerServiceInstanceCookieFilter implements GlobalFilter, Ordered { + + private final LoadBalancerProperties loadBalancerProperties; + + public LoadBalancerServiceInstanceCookieFilter(LoadBalancerProperties loadBalancerProperties) { + this.loadBalancerProperties = loadBalancerProperties; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + Response serviceInstanceResponse = exchange.getAttribute(GATEWAY_LOADBALANCER_RESPONSE_ATTR); + if (serviceInstanceResponse == null || !serviceInstanceResponse.hasServer()) { + return chain.filter(exchange); + } + String instanceIdCookieName = loadBalancerProperties.getStickySession().getInstanceIdCookieName(); + if (!StringUtils.hasText(instanceIdCookieName)) { + return chain.filter(exchange); + } + ServerWebExchange newExchange = exchange.mutate().request(exchange.getRequest().mutate().headers((headers) -> { + List cookieHeaders = new ArrayList<>(headers.getOrEmpty(HttpHeaders.COOKIE)); + String serviceInstanceCookie = new HttpCookie(instanceIdCookieName, + serviceInstanceResponse.getServer().getInstanceId()).toString(); + cookieHeaders.add(serviceInstanceCookie); + headers.put(HttpHeaders.COOKIE, cookieHeaders); + }).build()).build(); + return chain.filter(newExchange); + } + + @Override + public int getOrder() { + return LOAD_BALANCER_CLIENT_FILTER_ORDER + 1; + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInGlobalFiltersTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInGlobalFiltersTests.java index 447ce5bc..0db80831 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInGlobalFiltersTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInGlobalFiltersTests.java @@ -88,6 +88,7 @@ public class DisableBuiltInGlobalFiltersTests { "spring.cloud.gateway.global-filter.netty-routing.enabled=false", "spring.cloud.gateway.global-filter.reactive-load-balancer-client.enabled=false", "spring.cloud.gateway.global-filter.load-balancer-client.enabled=false", + "spring.cloud.gateway.global-filter.load-balancer-service-instance-cookie.enabled=false", "spring.cloud.gateway.metrics.enabled=false" }) @ActiveProfiles("disable-components") public static class DisableAllGlobalFiltersByProperty { diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/LoadBalancerServiceInstanceCookieFilterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/LoadBalancerServiceInstanceCookieFilterTests.java new file mode 100644 index 00000000..9cfad225 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/LoadBalancerServiceInstanceCookieFilterTests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2013-2021 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; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.client.DefaultServiceInstance; +import org.springframework.cloud.client.loadbalancer.DefaultResponse; +import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_LOADBALANCER_RESPONSE_ATTR; + +/** + * Tests for {@link LoadBalancerServiceInstanceCookieFilter}. + * + * @author Olga Maciaszek-Sharma + */ +class LoadBalancerServiceInstanceCookieFilterTests { + + private final LoadBalancerProperties properties = new LoadBalancerProperties(); + + private final GatewayFilterChain chain = mock(GatewayFilterChain.class); + + private final ServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("http://localhost/get").build()); + + private final LoadBalancerServiceInstanceCookieFilter filter = new LoadBalancerServiceInstanceCookieFilter( + properties); + + @Test + void shouldAddServiceInstanceCookieHeader() { + exchange.getAttributes().put(GATEWAY_LOADBALANCER_RESPONSE_ATTR, + new DefaultResponse(new DefaultServiceInstance("test-01", "test", "host", 8080, false))); + + ServerWebExchange filteredExchange = testFilter(exchange); + + assertThat(filteredExchange.getRequest().getHeaders().get(HttpHeaders.COOKIE)).hasSize(1); + assertThat(filteredExchange.getRequest().getHeaders().get(HttpHeaders.COOKIE)) + .containsExactly("sc-lb-instance-id=test-01"); + } + + @Test + void shouldAppendServiceInstanceCookieHeaderIfCookiesPresent() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("http://localhost/get") + .cookie(new HttpCookie("testCookieName", "testCookieValue")).build()); + exchange.getAttributes().put(GATEWAY_LOADBALANCER_RESPONSE_ATTR, + new DefaultResponse(new DefaultServiceInstance("test-01", "test", "host", 8080, false))); + + ServerWebExchange filteredExchange = testFilter(exchange); + + assertThat(filteredExchange.getRequest().getHeaders().get(HttpHeaders.COOKIE)) + .containsExactly("testCookieName=testCookieValue", "sc-lb-instance-id=test-01"); + } + + @Test + void shouldContinueChainWhenNoServiceInstanceResponse() { + ServerWebExchange filteredExchange = testFilter(exchange); + + assertThat(filteredExchange.getRequest().getHeaders()).isEmpty(); + } + + @Test + void shouldContinueChainWhenNullServiceInstanceCookieName() { + exchange.getAttributes().put(GATEWAY_LOADBALANCER_RESPONSE_ATTR, + new DefaultResponse(new DefaultServiceInstance("test-01", "test", "host", 8080, false))); + properties.getStickySession().setInstanceIdCookieName(null); + + ServerWebExchange filteredExchange = testFilter(exchange); + + assertThat(filteredExchange.getRequest().getHeaders()).isEmpty(); + } + + @Test + void shouldContinueChainWhenEmptyServiceInstanceCookieName() { + exchange.getAttributes().put(GATEWAY_LOADBALANCER_RESPONSE_ATTR, + new DefaultResponse(new DefaultServiceInstance("test-01", "test", "host", 8080, false))); + properties.getStickySession().setInstanceIdCookieName(""); + + ServerWebExchange filteredExchange = testFilter(exchange); + + assertThat(filteredExchange.getRequest().getHeaders()).isEmpty(); + } + + private ServerWebExchange testFilter(ServerWebExchange exchange) { + ArgumentCaptor captor = ArgumentCaptor.forClass(ServerWebExchange.class); + when(chain.filter(captor.capture())).thenReturn(Mono.empty()); + + filter.filter(exchange, chain).block(); + verify(chain).filter(any(ServerWebExchange.class)); + verifyNoMoreInteractions(chain); + return captor.getValue(); + } + +}