diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index fccee2e7..ef59a964 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -1263,6 +1263,45 @@ but the `ServiceInstance` indicates it is not secure, then the downstream reques route in the Gateway configuration, the prefix will be stripped and the resulting scheme from the route URL will override the `ServiceInstance` configuration. +WARNING: `LoadBalancerClientFilter` uses a blocking Ribbon `LoadBalancerClient` under the hood. +We suggest you use <>. +You can switch to using it by adding `org.springframework.cloud:spring-cloud-loadbalancer` dependency to your project +and setting the value of the `spring.cloud.loadbalancer.ribbon.enabled` to `false`. + +[[reactive-loadbalancer-client-filter]] +=== ReactiveLoadBalancerClientFilter + +The `ReactiveLoadBalancerClientFilter` looks for a URI in the exchange attribute +`ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR`. If the url has a `lb` scheme (ie `lb://myservice`), +it will use the Spring Cloud `ReactorLoadBalancer` to resolve the name (`myservice` in the previous example) +to an actual host and port and replace the URI in the same attribute. The unmodified +original url is appended to the list in the `ServerWebExchangeUtils.GATEWAY_ORIGINAL_REQUEST_URL_ATTR` attribute. +The filter will also look in the `ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR` attribute to see if it equals +`lb` and then the same rules apply. + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: myRoute + uri: lb://service + predicates: + - Path=/service/** +---- + +NOTE: By default when a service instance cannot be found by the `ReactorLoadBalancer`, a `503` will be returned. +You can configure the Gateway to return a `404` by setting `spring.cloud.gateway.loadbalancer.use404=true`. + +NOTE: The `isSecure` value of the `ServiceInstance` returned from the `ReactiveLoadBalancerClientFilter` will override +the scheme specified in the request made to the Gateway. For example, if the request comes into the Gateway over `HTTPS` +but the `ServiceInstance` indicates it is not secure, then the downstream request will be made over +`HTTP`. The opposite situation can also apply. However if `GATEWAY_SCHEME_PREFIX_ATTR` is specified for the +route in the Gateway configuration, the prefix will be stripped and the resulting scheme from the +route URL will override the `ServiceInstance` configuration. + === Netty Routing Filter The Netty Routing Filter runs if the url located in the `ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR` exchange attribute has a `http` or `https` scheme. It uses the Netty `HttpClient` to make the downstream proxy request. The response is put in the `ServerWebExchangeUtils.CLIENT_RESPONSE_ATTR` exchange attribute for use in a later filter. (There is an experimental `WebClientHttpRoutingFilter` that performs the same function, but does not require netty) diff --git a/spring-cloud-gateway-core/pom.xml b/spring-cloud-gateway-core/pom.xml index b6166707..8c957862 100644 --- a/spring-cloud-gateway-core/pom.xml +++ b/spring-cloud-gateway-core/pom.xml @@ -48,6 +48,11 @@ spring-cloud-starter-netflix-hystrix true + + org.springframework.cloud + spring-cloud-loadbalancer + true + org.springframework.boot spring-boot-devtools diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayLoadBalancerClientAutoConfiguration.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayLoadBalancerClientAutoConfiguration.java index 82306808..5e74a372 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayLoadBalancerClientAutoConfiguration.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayLoadBalancerClientAutoConfiguration.java @@ -23,13 +23,17 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.client.loadbalancer.LoadBalancerClient; import org.springframework.cloud.gateway.filter.LoadBalancerClientFilter; +import org.springframework.cloud.gateway.filter.ReactiveLoadBalancerClientFilter; import org.springframework.cloud.netflix.ribbon.RibbonAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.DispatcherHandler; /** + * AutoConfiguration for {@link LoadBalancerClientFilter}. + * * @author Spencer Gibb + * @author Olga Maciaszek-Sharma */ @Configuration @ConditionalOnClass({ LoadBalancerClient.class, RibbonAutoConfiguration.class, @@ -38,11 +42,10 @@ import org.springframework.web.reactive.DispatcherHandler; @EnableConfigurationProperties(LoadBalancerProperties.class) public class GatewayLoadBalancerClientAutoConfiguration { - // GlobalFilter beans - @Bean @ConditionalOnBean(LoadBalancerClient.class) - @ConditionalOnMissingBean(LoadBalancerClientFilter.class) + @ConditionalOnMissingBean({ LoadBalancerClientFilter.class, + ReactiveLoadBalancerClientFilter.class }) public LoadBalancerClientFilter loadBalancerClientFilter(LoadBalancerClient client, LoadBalancerProperties properties) { return new LoadBalancerClientFilter(client, properties); diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java new file mode 100644 index 00000000..07bab84c --- /dev/null +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java @@ -0,0 +1,78 @@ +/* + * 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 + * + * 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.config; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +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.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.gateway.filter.ReactiveLoadBalancerClientFilter; +import org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.DispatcherHandler; + +/** + * AutoConfiguration for {@link ReactiveLoadBalancerClientFilter}. + * + * @author Spencer Gibb + * @author Olga Maciaszek-Sharma + */ +@Configuration +@ConditionalOnClass({ ReactiveLoadBalancer.class, LoadBalancerAutoConfiguration.class, + DispatcherHandler.class }) +@AutoConfigureBefore(GatewayLoadBalancerClientAutoConfiguration.class) +@AutoConfigureAfter(LoadBalancerAutoConfiguration.class) +@EnableConfigurationProperties(LoadBalancerProperties.class) +public class GatewayReactiveLoadBalancerClientAutoConfiguration { + + @Bean + @ConditionalOnBean(LoadBalancerClientFactory.class) + @ConditionalOnMissingBean(ReactiveLoadBalancerClientFilter.class) + @Conditional(OnNoRibbonDefaultCondition.class) + public ReactiveLoadBalancerClientFilter gatewayLoadBalancerClientFilter( + LoadBalancerClientFactory clientFactory, LoadBalancerProperties properties) { + return new ReactiveLoadBalancerClientFilter(clientFactory, properties); + } + + private static final class OnNoRibbonDefaultCondition extends AnyNestedCondition { + + private OnNoRibbonDefaultCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(value = "spring.cloud.loadbalancer.ribbon.enabled", havingValue = "false") + static class RibbonNotEnabled { + + } + + @ConditionalOnMissingClass("org.springframework.cloud.netflix.ribbon.RibbonLoadBalancerClient") + static class RibbonLoadBalancerNotPresent { + + } + + } + +} diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/LoadBalancerClientFilter.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/LoadBalancerClientFilter.java index da728256..8cfa5931 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/LoadBalancerClientFilter.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/LoadBalancerClientFilter.java @@ -17,7 +17,6 @@ package org.springframework.cloud.gateway.filter; import java.net.URI; -import java.util.Map; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -26,6 +25,7 @@ import reactor.core.publisher.Mono; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.loadbalancer.LoadBalancerClient; import org.springframework.cloud.gateway.config.LoadBalancerProperties; +import org.springframework.cloud.gateway.support.DelegatingServiceInstance; import org.springframework.cloud.gateway.support.NotFoundException; import org.springframework.core.Ordered; import org.springframework.web.server.ServerWebExchange; @@ -74,7 +74,9 @@ public class LoadBalancerClientFilter implements GlobalFilter, Ordered { // preserve the original url addOriginalRequestUrl(exchange, url); - log.trace("LoadBalancerClientFilter url before: " + url); + if (log.isTraceEnabled()) { + log.trace("LoadBalancerClientFilter url before: " + url); + } final ServiceInstance instance = choose(exchange); @@ -95,7 +97,10 @@ public class LoadBalancerClientFilter implements GlobalFilter, Ordered { URI requestUrl = loadBalancer.reconstructURI( new DelegatingServiceInstance(instance, overrideScheme), uri); - log.trace("LoadBalancerClientFilter url chosen: " + requestUrl); + if (log.isTraceEnabled()) { + log.trace("LoadBalancerClientFilter url chosen: " + requestUrl); + } + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, requestUrl); return chain.filter(exchange); } @@ -105,56 +110,4 @@ public class LoadBalancerClientFilter implements GlobalFilter, Ordered { ((URI) exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR)).getHost()); } - class DelegatingServiceInstance implements ServiceInstance { - - final ServiceInstance delegate; - - private String overrideScheme; - - DelegatingServiceInstance(ServiceInstance delegate, String overrideScheme) { - this.delegate = delegate; - this.overrideScheme = overrideScheme; - } - - @Override - public String getServiceId() { - return delegate.getServiceId(); - } - - @Override - public String getHost() { - return delegate.getHost(); - } - - @Override - public int getPort() { - return delegate.getPort(); - } - - @Override - public boolean isSecure() { - return delegate.isSecure(); - } - - @Override - public URI getUri() { - return delegate.getUri(); - } - - @Override - public Map getMetadata() { - return delegate.getMetadata(); - } - - @Override - public String getScheme() { - String scheme = delegate.getScheme(); - if (scheme != null) { - return scheme; - } - return this.overrideScheme; - } - - } - } diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ReactiveLoadBalancerClientFilter.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ReactiveLoadBalancerClientFilter.java new file mode 100644 index 00000000..7b462653 --- /dev/null +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ReactiveLoadBalancerClientFilter.java @@ -0,0 +1,132 @@ +/* + * 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 + * + * 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.net.URI; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.client.loadbalancer.reactive.Request; +import org.springframework.cloud.client.loadbalancer.reactive.Response; +import org.springframework.cloud.gateway.config.LoadBalancerProperties; +import org.springframework.cloud.gateway.support.DelegatingServiceInstance; +import org.springframework.cloud.gateway.support.NotFoundException; +import org.springframework.cloud.loadbalancer.core.ReactorLoadBalancer; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.core.Ordered; +import org.springframework.web.server.ServerWebExchange; + +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.addOriginalRequestUrl; + +/** + * A {@link GlobalFilter} implementation that routes requests using reactive Spring Cloud + * LoadBalancer. + * + * @author Spencer Gibb + * @author Tim Ysewyn + * @author Olga Maciaszek-Sharma + */ +public class ReactiveLoadBalancerClientFilter implements GlobalFilter, Ordered { + + private static final Log log = LogFactory + .getLog(ReactiveLoadBalancerClientFilter.class); + + private static final int LOAD_BALANCER_CLIENT_FILTER_ORDER = 10150; + + private final LoadBalancerClientFactory clientFactory; + + private LoadBalancerProperties properties; + + public ReactiveLoadBalancerClientFilter(LoadBalancerClientFactory clientFactory, + LoadBalancerProperties properties) { + this.clientFactory = clientFactory; + this.properties = properties; + } + + @Override + public int getOrder() { + return LOAD_BALANCER_CLIENT_FILTER_ORDER; + } + + @Override + @SuppressWarnings("Duplicates") + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + URI url = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR); + String schemePrefix = exchange.getAttribute(GATEWAY_SCHEME_PREFIX_ATTR); + if (url == null + || (!"lb".equals(url.getScheme()) && !"lb".equals(schemePrefix))) { + return chain.filter(exchange); + } + // preserve the original url + addOriginalRequestUrl(exchange, url); + + if (log.isTraceEnabled()) { + log.trace(ReactiveLoadBalancerClientFilter.class.getSimpleName() + + " url before: " + url); + } + + return choose(exchange).doOnNext(response -> { + + if (!response.hasServer()) { + throw NotFoundException.create(properties.isUse404(), + "Unable to find instance for " + url.getHost()); + } + + URI uri = exchange.getRequest().getURI(); + + // if the `lb:` mechanism was used, use `` as the default, + // if the loadbalancer doesn't provide one. + String overrideScheme = null; + if (schemePrefix != null) { + overrideScheme = url.getScheme(); + } + + DelegatingServiceInstance serviceInstance = new DelegatingServiceInstance( + response.getServer(), overrideScheme); + + URI requestUrl = LoadBalancerUriTools.reconstructURI(serviceInstance, uri); + + if (log.isTraceEnabled()) { + log.trace("LoadBalancerClientFilter url chosen: " + requestUrl); + } + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, requestUrl); + }).then(chain.filter(exchange)); + } + + private Mono> choose(ServerWebExchange exchange) { + URI uri = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR); + ReactorLoadBalancer loadBalancer = this.clientFactory + .getInstance(uri.getHost(), ReactorLoadBalancer.class, + ServiceInstance.class); + if (loadBalancer == null) { + throw new NotFoundException("No loadbalancer available for " + uri.getHost()); + } + return loadBalancer.choose(createRequest()); + } + + private Request createRequest() { + return ReactiveLoadBalancer.REQUEST; + } + +} diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/support/DelegatingServiceInstance.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/support/DelegatingServiceInstance.java new file mode 100644 index 00000000..6fb84f57 --- /dev/null +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/support/DelegatingServiceInstance.java @@ -0,0 +1,83 @@ +/* + * 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 + * + * 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.support; + +import java.net.URI; +import java.util.Map; + +import org.springframework.cloud.client.ServiceInstance; + +/** + * A {@link ServiceInstance} implementation that uses a delegate instance under the hood. + * + * @author Spencer Gibb + */ +public class DelegatingServiceInstance implements ServiceInstance { + + final ServiceInstance delegate; + + private String overrideScheme; + + public DelegatingServiceInstance(ServiceInstance delegate, String overrideScheme) { + this.delegate = delegate; + this.overrideScheme = overrideScheme; + } + + @Override + public String getServiceId() { + return delegate.getServiceId(); + } + + @Override + public String getHost() { + return delegate.getHost(); + } + + @Override + public int getPort() { + return delegate.getPort(); + } + + @Override + public boolean isSecure() { + // TODO: move to map + if ("https".equals(this.overrideScheme) || "wss".equals(this.overrideScheme)) { + return true; + } + return delegate.isSecure(); + } + + @Override + public URI getUri() { + return delegate.getUri(); + } + + @Override + public Map getMetadata() { + return delegate.getMetadata(); + } + + @Override + public String getScheme() { + String scheme = delegate.getScheme(); + if (scheme != null) { + return scheme; + } + return this.overrideScheme; + } + +} diff --git a/spring-cloud-gateway-core/src/main/resources/META-INF/spring.factories b/spring-cloud-gateway-core/src/main/resources/META-INF/spring.factories index 4765e647..eca8b698 100644 --- a/spring-cloud-gateway-core/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-gateway-core/src/main/resources/META-INF/spring.factories @@ -7,7 +7,8 @@ org.springframework.cloud.gateway.config.GatewayNoLoadBalancerClientAutoConfigur org.springframework.cloud.gateway.config.GatewayMetricsAutoConfiguration,\ org.springframework.cloud.gateway.config.GatewayRedisAutoConfiguration,\ org.springframework.cloud.gateway.discovery.GatewayDiscoveryClientAutoConfiguration,\ -org.springframework.cloud.gateway.config.SimpleUrlHandlerMappingGlobalCorsAutoConfiguration +org.springframework.cloud.gateway.config.SimpleUrlHandlerMappingGlobalCorsAutoConfiguration,\ +org.springframework.cloud.gateway.config.GatewayReactiveLoadBalancerClientAutoConfiguration org.springframework.boot.env.EnvironmentPostProcessor=\ org.springframework.cloud.gateway.config.GatewayEnvironmentPostProcessor \ No newline at end of file diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ReactiveLoadBalancerClientFilterTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ReactiveLoadBalancerClientFilterTests.java new file mode 100644 index 00000000..538ed85a --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ReactiveLoadBalancerClientFilterTests.java @@ -0,0 +1,292 @@ +/* + * 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 + * + * 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.net.URI; +import java.util.LinkedHashSet; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.client.DefaultServiceInstance; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.gateway.config.LoadBalancerProperties; +import org.springframework.cloud.gateway.support.NotFoundException; +import org.springframework.cloud.loadbalancer.core.ReactorLoadBalancer; +import org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.cloud.loadbalancer.support.ServiceInstanceSuppliers; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ORIGINAL_REQUEST_URL_ATTR; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR; + +/** + * Tests for {@link ReactiveLoadBalancerClientFilter}. + * + * @author Spencer Gibb + * @author Tim Ysewyn + * @author Olga Maciaszek-Sharma + */ +@SuppressWarnings("UnassignedFluxMonoInstance") +@RunWith(MockitoJUnitRunner.class) +public class ReactiveLoadBalancerClientFilterTests { + + private ServerWebExchange exchange; + + private LoadBalancerProperties properties; + + @Mock + private GatewayFilterChain chain; + + @Mock + private LoadBalancerClientFactory clientFactory; + + @InjectMocks + private ReactiveLoadBalancerClientFilter filter; + + @Before + public void setup() { + properties = new LoadBalancerProperties(); + exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/mypath").build()); + } + + @Test + public void shouldNotFilterWhenGatewayRequestUrlIsMissing() { + filter.filter(exchange, chain); + + verify(chain).filter(exchange); + verifyNoMoreInteractions(chain); + verifyZeroInteractions(clientFactory); + } + + @Test + public void shouldNotFilterWhenGatewayRequestUrlSchemeIsNotLb() { + URI uri = UriComponentsBuilder.fromUriString("http://myservice").build().toUri(); + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, uri); + + filter.filter(exchange, chain); + + verify(chain).filter(exchange); + verifyNoMoreInteractions(chain); + verifyZeroInteractions(clientFactory); + } + + @Test(expected = NotFoundException.class) + public void shouldThrowExceptionWhenNoServiceInstanceIsFound() { + URI uri = UriComponentsBuilder.fromUriString("lb://myservice").build().toUri(); + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, uri); + + filter.filter(exchange, chain).block(); + } + + @SuppressWarnings("unchecked") + @Test + public void shouldFilter() { + URI url = UriComponentsBuilder.fromUriString("lb://myservice").build().toUri(); + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, url); + + ServiceInstance serviceInstance = new DefaultServiceInstance("myservice", + "localhost", 8080, true); + + when(clientFactory.getInstance("myservice", ReactorLoadBalancer.class, + ServiceInstance.class)).thenReturn(new RoundRobinLoadBalancer("myservice", + ServiceInstanceSuppliers.toProvider("myservice", serviceInstance), + -1)); + + when(chain.filter(exchange)).thenReturn(Mono.empty()); + + filter.filter(exchange, chain).block(); + + assertThat((LinkedHashSet) exchange + .getAttribute(GATEWAY_ORIGINAL_REQUEST_URL_ATTR)).contains(url); + + verify(clientFactory).getInstance("myservice", ReactorLoadBalancer.class, + ServiceInstance.class); + + verifyNoMoreInteractions(clientFactory); + + assertThat((URI) exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR)) + .isEqualTo(URI.create("https://localhost:8080/mypath")); + + verify(chain).filter(exchange); + verifyNoMoreInteractions(chain); + } + + @Test + public void happyPath() { + MockServerHttpRequest request = MockServerHttpRequest + .get("http://localhost/get?a=b").build(); + + URI lbUri = URI.create("lb://service1?a=b"); + ServerWebExchange webExchange = testFilter(request, lbUri); + URI uri = webExchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); + assertThat(uri).hasScheme("http").hasHost("service1-host1").hasParameter("a", + "b"); + } + + @Test + public void noQueryParams() { + MockServerHttpRequest request = MockServerHttpRequest.get("http://localhost/get") + .build(); + + ServerWebExchange webExchange = testFilter(request, URI.create("lb://service1")); + URI uri = webExchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); + assertThat(uri).hasScheme("http").hasHost("service1-host1"); + } + + @Test + public void encodedParameters() { + URI url = UriComponentsBuilder.fromUriString("http://localhost/get?a=b&c=d[]") + .buildAndExpand().encode().toUri(); + + MockServerHttpRequest request = MockServerHttpRequest.method(HttpMethod.GET, url) + .build(); + + URI lbUrl = UriComponentsBuilder.fromUriString("lb://service1?a=b&c=d[]") + .buildAndExpand().encode().toUri(); + + // prove that it is encoded + assertThat(lbUrl.getRawQuery()).isEqualTo("a=b&c=d%5B%5D"); + + assertThat(lbUrl).hasParameter("c", "d[]"); + + ServerWebExchange webExchange = testFilter(request, lbUrl); + URI uri = webExchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); + assertThat(uri).hasScheme("http").hasHost("service1-host1").hasParameter("a", "b") + .hasParameter("c", "d[]"); + + // prove that it is not double encoded + assertThat(uri.getRawQuery()).isEqualTo("a=b&c=d%5B%5D"); + } + + @Test + public void unencodedParameters() { + URI url = URI.create("http://localhost/get?a=b&c=d[]"); + + MockServerHttpRequest request = MockServerHttpRequest.method(HttpMethod.GET, url) + .build(); + + URI lbUrl = URI.create("lb://service1?a=b&c=d[]"); + + // prove that it is unencoded + assertThat(lbUrl.getRawQuery()).isEqualTo("a=b&c=d[]"); + + ServerWebExchange webExchange = testFilter(request, lbUrl); + + URI uri = webExchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); + assertThat(uri).hasScheme("http").hasHost("service1-host1").hasParameter("a", "b") + .hasParameter("c", "d[]"); + + // prove that it is NOT encoded + assertThat(uri.getRawQuery()).isEqualTo("a=b&c=d[]"); + } + + @Test + public void happyPathWithAttributeRatherThanScheme() { + MockServerHttpRequest request = MockServerHttpRequest + .get("ws://localhost/get?a=b").build(); + + URI lbUri = URI.create("ws://service1?a=b"); + + exchange = MockServerWebExchange.from(request); + exchange.getAttributes().put(GATEWAY_SCHEME_PREFIX_ATTR, "lb"); + + ServerWebExchange webExchange = testFilter(exchange, lbUri); + URI uri = webExchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); + assertThat(uri).hasScheme("ws").hasHost("service1-host1").hasParameter("a", "b"); + } + + @Test + public void shouldNotFilterWhenGatewaySchemePrefixAttrIsNotLb() { + URI uri = UriComponentsBuilder.fromUriString("http://myservice").build().toUri(); + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, uri); + exchange.getAttributes().put(GATEWAY_SCHEME_PREFIX_ATTR, "xx"); + + filter.filter(exchange, chain); + + verify(chain).filter(exchange); + verifyNoMoreInteractions(chain); + verifyZeroInteractions(clientFactory); + } + + @Test + public void shouldThrow4O4ExceptionWhenNoServiceInstanceIsFound() { + URI uri = UriComponentsBuilder.fromUriString("lb://service1").build().toUri(); + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, uri); + when(clientFactory.getInstance("service1", ReactorLoadBalancer.class, + ServiceInstance.class)) + .thenReturn(new RoundRobinLoadBalancer("service1", + ServiceInstanceSuppliers.toProvider("service1"), -1)); + properties.setUse404(true); + ReactiveLoadBalancerClientFilter filter = new ReactiveLoadBalancerClientFilter( + clientFactory, properties); + when(chain.filter(exchange)).thenReturn(Mono.empty()); + try { + filter.filter(exchange, chain).block(); + } + catch (NotFoundException exception) { + assertThat(exception.getStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + private ServerWebExchange testFilter(MockServerHttpRequest request, URI uri) { + return testFilter(MockServerWebExchange.from(request), uri); + } + + private ServerWebExchange testFilter(ServerWebExchange exchange, URI uri) { + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, uri); + + ArgumentCaptor captor = ArgumentCaptor + .forClass(ServerWebExchange.class); + when(chain.filter(captor.capture())).thenReturn(Mono.empty()); + + when(clientFactory.getInstance("service1", ReactorLoadBalancer.class, + ServiceInstance.class)) + .thenReturn(new RoundRobinLoadBalancer("service1", + ServiceInstanceSuppliers.toProvider("service1", + new DefaultServiceInstance("service1_1", + "service1", "service1-host1", 8081, + false)), + -1)); + + ReactiveLoadBalancerClientFilter filter = new ReactiveLoadBalancerClientFilter( + clientFactory, properties); + filter.filter(exchange, chain).block(); + + return captor.getValue(); + } + +}