diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index c6a7d00ba..b7ff37eb8 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -47,6 +47,7 @@ *** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/requestsize-factory.adoc[] *** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/setrequesthostheader-factory.adoc[] *** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/tokenrelay-factory.adoc[] +*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/websessionsticky-factory.adoc[] *** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/default-filters.adoc[] ** xref:spring-cloud-gateway-server-webflux/global-filters.adoc[] ** xref:spring-cloud-gateway-server-webflux/httpheadersfilters.adoc[] diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/websessionsticky-factory.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/websessionsticky-factory.adoc new file mode 100644 index 000000000..69f844196 --- /dev/null +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/websessionsticky-factory.adoc @@ -0,0 +1,57 @@ +[[websessionsticky-factory]] += `WebSessionSticky` `GatewayFilter` Factory + +The `WebSessionSticky` filter enables per-route, server-side sticky load balancing using +the gateway's own `WebSession` as the affinity store. When this filter is applied to a +route, every client session is pinned to a specific backend instance for the duration of +that session. If the pinned instance is deregistered or fails a health check, the +gateway transparently selects a new instance and updates the session affinity — the +client sees no error. + +This filter is the per-route, composable alternative to enabling sticky routing via the +`sticky://` URI scheme in the +xref:spring-cloud-gateway-server-webflux/global-filters.adoc#web-session-sticky-load-balancer-filter[WebSessionStickyLoadBalancerFilter]. +Use this factory when you want `lb://` to remain the URI scheme and need to mix sticky +and non-sticky routes to the same service. + +NOTE: This filter requires `WebSessionStickyLoadBalancerFilter` to be enabled: +`spring.cloud.gateway.global-filter.web-session-sticky-load-balancer.enabled=true` + +The following listing shows how to add the `WebSessionSticky` `GatewayFilter`: + +.application.yaml +[source,yaml] +---- +spring: + cloud: + gateway: + server: + webflux: + global-filter: + web-session-sticky-load-balancer: + enabled: true + routes: + - id: legacy-service-route + uri: lb://legacy-service + predicates: + - Path=/legacy/** + filters: + - WebSessionSticky + - id: stateless-route + uri: lb://legacy-service + predicates: + - Path=/api/** +---- + +In this example, requests matching `/legacy/**` are pinned to a single instance of +`legacy-service` for each client session. Requests matching `/api/**` use standard +round-robin load balancing. + +NOTE: Session affinity is stored server-side in the gateway `WebSession`. Unlike the +cookie-based `RequestBasedStickySessionServiceInstanceListSupplier`, clients do not need +to store or echo any instance-ID cookie. This is particularly useful for legacy backends +that cannot achieve distributed session state. + +NOTE: If you run multiple gateway instances, ensure that the session store is shared +(e.g. Spring Session backed by Redis) so that a client reaching a different gateway +replica retains its affinity. diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc index 5ee63d466..30fb969ab 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc @@ -153,6 +153,94 @@ However, if `GATEWAY_SCHEME_PREFIX_ATTR` is specified for the route in the Gatew TIP: Gateway supports all the LoadBalancer features. You can read more about them in the https://docs.spring.io/spring-cloud-commons/docs/current/reference/html/#spring-cloud-loadbalancer[Spring Cloud Commons documentation]. +[[web-session-sticky-load-balancer-filter]] +== `WebSessionStickyLoadBalancerFilter` + +The `WebSessionStickyLoadBalancerFilter` provides server-side, WebSession-based sticky routing as an opt-in complement to the standard `ReactiveLoadBalancerClientFilter`. +A route opts in by using `sticky://` as its URI scheme instead of `lb://`. + +Unlike the cookie-based approach provided by `LoadBalancerServiceInstanceCookieFilter`, this filter stores the `serviceId -> instanceId` affinity map server-side in the gateway's own `WebSession`. +Clients do not need to send or manage any cookie. + +When a request arrives on a sticky route, the filter: + +1. Looks up an existing `serviceId -> instanceId` affinity in the session. +2. If a pinned instance is found *and* that instance is still registered, routes directly to it. +3. If no affinity exists, or the pinned instance has been deregistered (e.g. after a health-check failure), selects a new instance via the configured delegate balancer (round-robin by default) and records the new affinity in the session. + +This means a failed instance is transparently replaced — the client sees no error and subsequent requests are pinned to the new instance. + +=== Enabling the filter + +The filter is disabled by default. +Enable it with: + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + server: + webflux: + global-filter: + web-session-sticky-load-balancer-filter: + enabled: true +---- + +=== Route configuration + +Use `sticky://` as the URI scheme on any route that should use session affinity: + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + server: + webflux: + routes: + - id: sticky-api-route + uri: sticky://my-service + predicates: + - Path=/api/** + - id: regular-route + uri: lb://my-service + predicates: + - Path=/public/** +---- + +In the example above, `/api/**` requests are pinned to a single instance of `my-service` for the life of the session, while `/public/**` requests are distributed across instances using the normal round-robin balancer. + +NOTE: The `WebSessionStickyLoadBalancerFilter` handles only `sticky://` URIs. +Routes using the standard `lb://` scheme continue to be handled by `ReactiveLoadBalancerClientFilter` and are unaffected. + +NOTE: Session affinity depends on the gateway maintaining a `WebSession` for each client. +Ensure that session persistence (e.g. Spring Session with Redis) is configured if you run multiple gateway instances; otherwise, a client that reaches a different gateway replica will lose its affinity until a new instance is selected. + +=== Comparison with cookie-based sticky sessions + +|=== +| Feature | `LoadBalancerServiceInstanceCookieFilter` | `WebSessionStickyLoadBalancerFilter` + +| Affinity storage +| Client-side HTTP cookie (`sc-lb-instance-id`) +| Server-side `WebSession` attribute + +| Per-route opt-in +| No — configured per service via `LoadBalancerProperties` +| Yes — use `sticky://` on individual routes + +| Client transparency +| Client must echo the cookie on each request +| Fully transparent; no client change required + +| Graceful re-pin on instance failure +| No — cookie retains stale instance id +| Yes — stale affinity is replaced automatically +|=== + [[routetorequesturl-filter]] == `RouteToRequestUrl` Filter diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java index 306b5bc60..1e11e8359 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayReactiveLoadBalancerClientAutoConfiguration.java @@ -22,9 +22,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledFilter; 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.gateway.filter.WebSessionStickyLoadBalancerFilter; +import org.springframework.cloud.gateway.filter.factory.WebSessionStickyGatewayFilterFactory; import org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration; import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; import org.springframework.context.annotation.Bean; @@ -36,6 +39,7 @@ * * @author Spencer Gibb * @author Olga Maciaszek-Sharma + * @author Beteab Gebru */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass({ ReactiveLoadBalancer.class, LoadBalancerAutoConfiguration.class, DispatcherHandler.class }) @@ -61,4 +65,21 @@ public LoadBalancerServiceInstanceCookieFilter loadBalancerServiceInstanceCookie return new LoadBalancerServiceInstanceCookieFilter(loadBalancerClientFactory); } + @Bean + @ConditionalOnBean(LoadBalancerClientFactory.class) + @ConditionalOnMissingBean(WebSessionStickyLoadBalancerFilter.class) + @ConditionalOnEnabledGlobalFilter(WebSessionStickyLoadBalancerFilter.class) + public WebSessionStickyLoadBalancerFilter webSessionStickyLoadBalancerFilter( + LoadBalancerClientFactory clientFactory) { + return new WebSessionStickyLoadBalancerFilter(clientFactory); + } + + @Bean + @ConditionalOnBean(LoadBalancerClientFactory.class) + @ConditionalOnMissingBean(WebSessionStickyGatewayFilterFactory.class) + @ConditionalOnEnabledFilter(WebSessionStickyGatewayFilterFactory.class) + public WebSessionStickyGatewayFilterFactory webSessionStickyGatewayFilterFactory() { + return new WebSessionStickyGatewayFilterFactory(); + } + } diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancer.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancer.java new file mode 100644 index 000000000..194de39c4 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancer.java @@ -0,0 +1,184 @@ +/* + * Copyright 2013-present 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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.DefaultResponse; +import org.springframework.cloud.client.loadbalancer.EmptyResponse; +import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.cloud.loadbalancer.core.NoopServiceInstanceListSupplier; +import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; +import org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer; +import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link ReactorServiceInstanceLoadBalancer} that implements server-side, + * WebSession-based sticky routing. When the exchange attribute + * {@link WebSessionStickyLoadBalancerFilter#IS_STICKY_ATTRIBUTE} is {@code true}, the + * balancer reads a {@code serviceId -> instanceId} affinity map from the current + * {@link org.springframework.web.server.WebSession} and re-uses the previously selected + * instance if it is still available. If no affinity is recorded, or the recorded instance + * has been deregistered, a fresh instance is selected via the delegate (round-robin by + * default) and the new affinity is written back to the session. + * + *

+ * When the {@code IS_STICKY_ATTRIBUTE} is absent or {@code false}, or when the request + * context is not a {@link ServerWebExchange} (e.g. an internal {@code WebClient} call), + * this balancer transparently delegates to the round-robin balancer so that non-sticky + * routes are unaffected. + * + * @author Beteab Gebru + * @since 5.0.4 + * @see WebSessionStickyLoadBalancerFilter + */ +public class WebSessionStickyLoadBalancer implements ReactorServiceInstanceLoadBalancer { + + /** + * Logger for this class. + */ + private static final Log LOG = LogFactory.getLog(WebSessionStickyLoadBalancer.class); + + /** + * Session attribute key under which the {@code serviceId -> instanceId} affinity map + * is stored. + */ + public static final String STICKY_MAP_SESSION_ATTR = "spring.cloud.gateway.sticky.service-map"; + + private final String serviceId; + + private final ReactorServiceInstanceLoadBalancer delegate; + + private final ObjectProvider serviceInstanceListSupplierProvider; + + /** + * Creates a {@code WebSessionStickyLoadBalancer} backed by a + * {@link RoundRobinLoadBalancer} delegate. + * @param supplierProvider provider of available service instances + * @param serviceId the service identifier used to key session affinity + */ + public WebSessionStickyLoadBalancer(ObjectProvider supplierProvider, + String serviceId) { + this(supplierProvider, serviceId, new RoundRobinLoadBalancer(supplierProvider, serviceId)); + } + + /** + * Creates a {@code WebSessionStickyLoadBalancer} with a custom delegate. + * @param supplierProvider provider of available service instances + * @param serviceId the service identifier used to key session affinity + * @param delegate the fallback balancer used when stickiness does not apply + */ + public WebSessionStickyLoadBalancer(ObjectProvider supplierProvider, String serviceId, + ReactorServiceInstanceLoadBalancer delegate) { + this.serviceId = serviceId; + this.serviceInstanceListSupplierProvider = supplierProvider; + this.delegate = delegate; + } + + @Override + @SuppressWarnings("rawtypes") + public Mono> choose(final Request request) { + ServiceInstanceListSupplier supplier = serviceInstanceListSupplierProvider + .getIfAvailable(NoopServiceInstanceListSupplier::new); + Object context = request.getContext(); + + // If the context is not a ServerWebExchange we have no session to pin + // against — this happens for internal WebClient calls that originate + // within the gateway itself rather than from a routed inbound request. + if (!(context instanceof ServerWebExchange)) { + return delegate.choose(request); + } + + ServerWebExchange exchange = (ServerWebExchange) context; + return supplier.get().next().flatMap(instances -> getInstanceResponse(instances, exchange, request)); + } + + @SuppressWarnings("rawtypes") + private Mono> getInstanceResponse(final List instances, + final ServerWebExchange exchange, final Request request) { + if (instances.isEmpty()) { + LOG.warn("No servers available for service: " + this.serviceId); + return Mono.just(new EmptyResponse()); + } + + Boolean isSticky = exchange.getAttributeOrDefault(WebSessionStickyLoadBalancerFilter.IS_STICKY_ATTRIBUTE, + Boolean.FALSE); + if (!isSticky) { + return delegate.choose(request); + } + + return serviceInstanceFromSession(exchange, instances).flatMap(instance -> { + Response response = new DefaultResponse(instance); + return writeSessionAffinity(exchange, response); + }) + .switchIfEmpty(Mono + .defer(() -> delegate.choose(request).flatMap(response -> writeSessionAffinity(exchange, response)))); + } + + private Mono serviceInstanceFromSession(final ServerWebExchange exchange, + final List instances) { + return exchange.getSession().flatMap(session -> { + @SuppressWarnings("unchecked") + Map stickyMap = (Map) session.getAttribute(STICKY_MAP_SESSION_ATTR); + if (stickyMap == null || !stickyMap.containsKey(serviceId)) { + LOG.debug("No existing session affinity for service '" + serviceId + "', selecting new instance"); + return Mono.empty(); + } + String pinnedInstanceId = stickyMap.get(serviceId); + Optional match = instances.stream() + .filter(i -> Objects.equals(i.getInstanceId(), pinnedInstanceId)) + .findFirst(); + if (!match.isPresent()) { + LOG.debug("Pinned instance '" + pinnedInstanceId + "' for service '" + serviceId + + "' is no longer available, selecting new instance"); + return Mono.empty(); + } + LOG.debug("Reusing pinned instance '" + pinnedInstanceId + "' for service '" + serviceId + "'"); + return Mono.just(match.get()); + }); + } + + @SuppressWarnings("unchecked") + private Mono> writeSessionAffinity(final ServerWebExchange exchange, + final Response response) { + if (!response.hasServer()) { + return Mono.just(response); + } + return exchange.getSession().map(session -> { + Map stickyMap = (Map) session.getAttribute(STICKY_MAP_SESSION_ATTR); + if (stickyMap == null) { + stickyMap = new HashMap<>(); + session.getAttributes().put(STICKY_MAP_SESSION_ATTR, stickyMap); + } + stickyMap.put(serviceId, response.getServer().getInstanceId()); + return response; + }); + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerFilter.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerFilter.java new file mode 100644 index 000000000..e19455a6a --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerFilter.java @@ -0,0 +1,180 @@ +/* + * Copyright 2013-present 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.DefaultRequest; +import org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools; +import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.cloud.gateway.support.DelegatingServiceInstance; +import org.springframework.cloud.gateway.support.NotFoundException; +import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; +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} that provides WebSession-based sticky load balancing as an + * opt-in complement to the standard {@link ReactiveLoadBalancerClientFilter}. + * + *

+ * Routes opt in to sticky routing by using {@code sticky://} as the URI scheme (e.g. + * {@code sticky://myservice}). Routes using the normal {@code lb://} scheme are handled + * by {@link ReactiveLoadBalancerClientFilter} and are not affected by this filter. + * + *

+ * This filter sets the exchange attribute {@link #IS_STICKY_ATTRIBUTE} to {@code true} + * for sticky routes, then delegates instance selection to a + * {@link WebSessionStickyLoadBalancer}. The load balancer uses the gateway's own + * {@link org.springframework.web.server.WebSession} to pin a client to a specific service + * instance. If the previously pinned instance is no longer available, the balancer picks + * a new instance and updates the session affinity transparently. + * + *

+ * This filter runs at order + * {@link ReactiveLoadBalancerClientFilter#LOAD_BALANCER_CLIENT_FILTER_ORDER}. Enable it + * with:

{@code
+ * spring.cloud.gateway.global-filter.web-session-sticky-load-balancer.enabled=true
+ * }
+ * + * @author Beteab Gebru + * @since 5.0.4 + * @see WebSessionStickyLoadBalancer + */ +public class WebSessionStickyLoadBalancerFilter implements GlobalFilter, Ordered { + + /** + * Logger for this class. + */ + private static final Log LOG = LogFactory.getLog(WebSessionStickyLoadBalancerFilter.class); + + /** + * Filter order — matches + * {@link ReactiveLoadBalancerClientFilter#LOAD_BALANCER_CLIENT_FILTER_ORDER}. + */ + public static final int LOAD_BALANCER_CLIENT_FILTER_ORDER = ReactiveLoadBalancerClientFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER; + + /** + * URI scheme that triggers WebSession-based sticky routing. + */ + public static final String STICKY_SCHEME = "sticky"; + + /** + * Exchange attribute set to {@code true} when the route uses the + * {@link #STICKY_SCHEME} scheme. Consulted by {@link WebSessionStickyLoadBalancer} to + * decide whether to apply session affinity. + */ + public static final String IS_STICKY_ATTRIBUTE = "spring.cloud.gateway.sticky.is-sticky"; + + private final LoadBalancerClientFactory clientFactory; + + /** + * Creates a new {@code WebSessionStickyLoadBalancerFilter}. + * @param clientFactory the factory used to resolve per-service load balancers + */ + public WebSessionStickyLoadBalancerFilter(final LoadBalancerClientFactory clientFactory) { + this.clientFactory = clientFactory; + } + + @Override + public int getOrder() { + return LOAD_BALANCER_CLIENT_FILTER_ORDER; + } + + @Override + public Mono filter(final ServerWebExchange exchange, final GatewayFilterChain chain) { + URI url = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR); + String schemePrefix = exchange.getAttribute(GATEWAY_SCHEME_PREFIX_ATTR); + + if (url == null || (!isStickyScheme(url.getScheme()) && !isStickyScheme(schemePrefix))) { + return chain.filter(exchange); + } + + boolean isSticky = isStickyScheme(url.getScheme()) || isStickyScheme(schemePrefix); + exchange.getAttributes().put(IS_STICKY_ATTRIBUTE, isSticky); + + addOriginalRequestUrl(exchange, url); + + if (LOG.isTraceEnabled()) { + LOG.trace(WebSessionStickyLoadBalancerFilter.class.getSimpleName() + " url before: " + url + ", sticky=" + + isSticky); + } + + return choose(exchange).doOnNext(response -> { + if (!response.hasServer()) { + throw NotFoundException.create(false, "Unable to find instance for " + url.getHost()); + } + + URI uri = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR); + String overrideScheme = null; + if (schemePrefix != null) { + overrideScheme = url.getScheme(); + } + + DelegatingServiceInstance serviceInstance = new DelegatingServiceInstance(response.getServer(), + overrideScheme); + URI requestUrl = reconstructURI(serviceInstance, uri); + + if (LOG.isTraceEnabled()) { + LOG.trace(WebSessionStickyLoadBalancerFilter.class.getSimpleName() + " url chosen: " + requestUrl); + } + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, requestUrl); + }).then(chain.filter(exchange)); + } + + private static boolean isStickyScheme(final String scheme) { + return STICKY_SCHEME.equals(scheme); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private Mono> choose(final ServerWebExchange exchange) { + URI uri = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR); + ReactorServiceInstanceLoadBalancer loadBalancer = this.clientFactory.getInstance(uri.getHost(), + ReactorServiceInstanceLoadBalancer.class); + if (loadBalancer == null) { + throw new NotFoundException("No loadbalancer available for " + uri.getHost()); + } + return loadBalancer.choose(createRequest(exchange)); + } + + private static Request createRequest(final ServerWebExchange exchange) { + return new DefaultRequest<>(exchange); + } + + /** + * Reconstructs the target URI using the chosen {@link ServiceInstance}. Protected to + * allow overriding in subclasses or tests. + * @param serviceInstance the chosen instance + * @param original the original request URI + * @return the rewritten URI pointing at the chosen instance + */ + protected URI reconstructURI(final ServiceInstance serviceInstance, final URI original) { + return LoadBalancerUriTools.reconstructURI(serviceInstance, original); + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/WebSessionStickyGatewayFilterFactory.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/WebSessionStickyGatewayFilterFactory.java new file mode 100644 index 000000000..a08105400 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/WebSessionStickyGatewayFilterFactory.java @@ -0,0 +1,109 @@ +/* + * Copyright 2013-present 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 reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.WebSessionStickyLoadBalancerFilter; +import org.springframework.web.server.ServerWebExchange; + +import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator; + +/** + * A {@link GatewayFilterFactory} that enables WebSession-based sticky load balancing on a + * per-route basis without requiring a custom URI scheme. + * + *

+ * When applied to a route, this filter sets the + * {@link WebSessionStickyLoadBalancerFilter#IS_STICKY_ATTRIBUTE} exchange attribute to + * {@code true} before the load-balancer filter runs. The + * {@link org.springframework.cloud.gateway.filter.WebSessionStickyLoadBalancer} reads + * this attribute to apply session affinity: the first request from a client selects an + * instance via the delegate (round-robin by default) and records that selection in the + * gateway {@link org.springframework.web.server.WebSession}; all subsequent requests from + * that client session are routed to the same instance. If the instance is deregistered or + * fails a health check, a fresh instance is chosen transparently and the session affinity + * is updated. + * + *

+ * Unlike the {@code sticky://} scheme approach, this factory works with the standard + * {@code lb://} URI scheme and composes naturally with other {@link GatewayFilterFactory} + * filters on the same route. It requires {@link WebSessionStickyLoadBalancerFilter} to be + * enabled: + * + *

{@code
+ * spring.cloud.gateway.global-filter.web-session-sticky-load-balancer.enabled=true
+ * }
+ * + * Example route configuration: + * + *
{@code
+ * spring:
+ *   cloud:
+ *     gateway:
+ *       server:
+ *         webflux:
+ *           routes:
+ *             - id: legacy-service-route
+ *               uri: lb://legacy-service
+ *               predicates:
+ *                 - Path=/legacy/**
+ *               filters:
+ *                 - WebSessionSticky
+ *             - id: stateless-service-route
+ *               uri: lb://my-service
+ *               predicates:
+ *                 - Path=/api/**
+ * }
+ * + * In the example above, {@code /legacy/**} requests are pinned to a single instance of + * {@code legacy-service} for the life of each client session, while {@code /api/**} + * requests are distributed via normal round-robin. + * + * @author Beteab Gebru + * @since 5.0.4 + * @see WebSessionStickyLoadBalancerFilter + * @see org.springframework.cloud.gateway.filter.WebSessionStickyLoadBalancer + */ +public class WebSessionStickyGatewayFilterFactory extends AbstractGatewayFilterFactory { + + /** + * Creates a new {@code WebSessionStickyGatewayFilterFactory}. + */ + public WebSessionStickyGatewayFilterFactory() { + super(Object.class); + } + + @Override + public GatewayFilter apply(final Object config) { + return new GatewayFilter() { + @Override + public Mono filter(final ServerWebExchange exchange, final GatewayFilterChain chain) { + exchange.getAttributes().put(WebSessionStickyLoadBalancerFilter.IS_STICKY_ATTRIBUTE, Boolean.TRUE); + return chain.filter(exchange); + } + + @Override + public String toString() { + return filterToStringCreator(WebSessionStickyGatewayFilterFactory.this).toString(); + } + }; + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerFilterTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerFilterTests.java new file mode 100644 index 000000000..f443df097 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerFilterTests.java @@ -0,0 +1,221 @@ +/* + * Copyright 2013-present 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.jupiter.api.BeforeEach; +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.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.DefaultResponse; +import org.springframework.cloud.client.loadbalancer.EmptyResponse; +import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.cloud.gateway.support.NotFoundException; +import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +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.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +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 WebSessionStickyLoadBalancerFilter}. + * + * @author Beteab Gebru + */ +@SuppressWarnings({ "unchecked", "rawtypes" }) +class WebSessionStickyLoadBalancerFilterTests { + + private GatewayFilterChain chain; + + private LoadBalancerClientFactory clientFactory; + + private WebSessionStickyLoadBalancerFilter filter; + + private ServerWebExchange exchange; + + @BeforeEach + void setUp() { + chain = mock(GatewayFilterChain.class); + clientFactory = mock(LoadBalancerClientFactory.class); + filter = new WebSessionStickyLoadBalancerFilter(clientFactory); + exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path").build()); + when(chain.filter(any())).thenReturn(Mono.empty()); + } + + // ----------------------------------------------------------------------- + // Pass-through cases → non-sticky / non-lb URIs + // ----------------------------------------------------------------------- + + @Test + void shouldPassThroughWhenGatewayRequestUrlAttrIsMissing() { + filter.filter(exchange, chain).block(); + + verify(chain).filter(exchange); + verifyNoInteractions(clientFactory); + } + + @Test + void shouldPassThroughWhenSchemeIsNotSticky() { + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, URI.create("lb://my-service")); + + filter.filter(exchange, chain).block(); + + verify(chain).filter(exchange); + verifyNoInteractions(clientFactory); + } + + @Test + void shouldPassThroughWhenSchemeIsHttp() { + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, URI.create("http://my-service")); + + filter.filter(exchange, chain).block(); + + verify(chain).filter(exchange); + verifyNoInteractions(clientFactory); + } + + @Test + void shouldPassThroughWhenSchemePrefixAttrIsNotSticky() { + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, URI.create("http://my-service")); + exchange.getAttributes().put(GATEWAY_SCHEME_PREFIX_ATTR, "lb"); + + filter.filter(exchange, chain).block(); + + verify(chain).filter(exchange); + verifyNoInteractions(clientFactory); + } + + // ----------------------------------------------------------------------- + // IS_STICKY_ATTRIBUTE set correctly + // ----------------------------------------------------------------------- + + @Test + void shouldSetIsStickyTrueForStickyScheme() { + ServiceInstance instance = new DefaultServiceInstance("s1", "my-service", "host1", 8080, false); + mockLoadBalancer("my-service", new DefaultResponse(instance)); + + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, URI.create("sticky://my-service")); + + filter.filter(exchange, chain).block(); + + assertThat((Boolean) exchange.getAttribute(WebSessionStickyLoadBalancerFilter.IS_STICKY_ATTRIBUTE)).isTrue(); + } + + @Test + void shouldSetIsStickyTrueForStickySchemePrefix() { + ServiceInstance instance = new DefaultServiceInstance("s1", "my-service", "host1", 8080, false); + mockLoadBalancer("my-service", new DefaultResponse(instance)); + + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, URI.create("http://my-service")); + exchange.getAttributes().put(GATEWAY_SCHEME_PREFIX_ATTR, "sticky"); + + filter.filter(exchange, chain).block(); + + assertThat((Boolean) exchange.getAttribute(WebSessionStickyLoadBalancerFilter.IS_STICKY_ATTRIBUTE)).isTrue(); + } + + // ----------------------------------------------------------------------- + // URI rewrite on happy path + // ----------------------------------------------------------------------- + + @Test + void shouldRewriteRequestUriToChosenInstance() { + ServiceInstance instance = new DefaultServiceInstance("s1", "my-service", "host1", 8080, false); + mockLoadBalancer("my-service", new DefaultResponse(instance)); + + URI stickyUri = URI.create("sticky://my-service/path"); + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, stickyUri); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ServerWebExchange.class); + when(chain.filter(captor.capture())).thenReturn(Mono.empty()); + + filter.filter(exchange, chain).block(); + + URI rewritten = captor.getValue().getAttribute(GATEWAY_REQUEST_URL_ATTR); + assertThat(rewritten).isNotNull(); + assertThat(rewritten.getHost()).isEqualTo("host1"); + assertThat(rewritten.getPort()).isEqualTo(8080); + + // Original URL should be preserved + assertThat((LinkedHashSet) exchange.getAttribute(GATEWAY_ORIGINAL_REQUEST_URL_ATTR)).contains(stickyUri); + } + + // ----------------------------------------------------------------------- + // NotFoundException when no loadbalancer configured + // ----------------------------------------------------------------------- + + @Test + void shouldThrowNotFoundExceptionWhenNoLoadBalancerAvailable() { + when(clientFactory.getInstance(eq("my-service"), eq(ReactorServiceInstanceLoadBalancer.class))) + .thenReturn(null); + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, URI.create("sticky://my-service")); + + assertThatExceptionOfType(NotFoundException.class).isThrownBy(() -> filter.filter(exchange, chain).block()) + .withMessageContaining("No loadbalancer available for my-service"); + } + + // ----------------------------------------------------------------------- + // NotFoundException when loadbalancer returns no server + // ----------------------------------------------------------------------- + + @Test + void shouldThrowNotFoundExceptionWhenNoInstanceFound() { + mockLoadBalancer("my-service", new EmptyResponse()); + exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, URI.create("sticky://my-service")); + + assertThatExceptionOfType(NotFoundException.class).isThrownBy(() -> filter.filter(exchange, chain).block()) + .withMessageContaining("Unable to find instance for my-service"); + } + + // ----------------------------------------------------------------------- + // Filter order + // ----------------------------------------------------------------------- + + @Test + void filterOrderShouldMatchReactiveLoadBalancerClientFilterOrder() { + assertThat(filter.getOrder()).isEqualTo(ReactiveLoadBalancerClientFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER); + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private void mockLoadBalancer(String serviceId, Response response) { + ReactorServiceInstanceLoadBalancer lb = mock(ReactorServiceInstanceLoadBalancer.class); + when(lb.choose(any(Request.class))).thenReturn(Mono.just(response)); + when(clientFactory.getInstance(eq(serviceId), eq(ReactorServiceInstanceLoadBalancer.class))).thenReturn(lb); + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerTests.java new file mode 100644 index 000000000..2c119cce2 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/WebSessionStickyLoadBalancerTests.java @@ -0,0 +1,237 @@ +/* + * Copyright 2013-present 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.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.cloud.client.DefaultServiceInstance; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.DefaultRequest; +import org.springframework.cloud.client.loadbalancer.DefaultResponse; +import org.springframework.cloud.client.loadbalancer.EmptyResponse; +import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.RequestDataContext; +import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; +import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link WebSessionStickyLoadBalancer}. + * + * @author Beteab Gebru + */ +class WebSessionStickyLoadBalancerTests { + + private static final String SERVICE_ID = "test-service"; + + private static final ServiceInstance INSTANCE_1 = new DefaultServiceInstance("instance-1", SERVICE_ID, "host1", + 8080, false); + + private static final ServiceInstance INSTANCE_2 = new DefaultServiceInstance("instance-2", SERVICE_ID, "host2", + 8081, false); + + private ReactorServiceInstanceLoadBalancer mockDelegate; + + private ServiceInstanceListSupplier mockSupplier; + + @SuppressWarnings("unchecked") + private ObjectProvider mockProvider; + + private WebSessionStickyLoadBalancer balancer; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + mockDelegate = mock(ReactorServiceInstanceLoadBalancer.class); + mockSupplier = mock(ServiceInstanceListSupplier.class); + mockProvider = mock(ObjectProvider.class); + when(mockProvider.getIfAvailable(any())).thenReturn(mockSupplier); + when(mockSupplier.get()).thenReturn(Flux.just(Arrays.asList(INSTANCE_1, INSTANCE_2))); + + balancer = new WebSessionStickyLoadBalancer(mockProvider, SERVICE_ID, mockDelegate); + } + + // ----------------------------------------------------------------------- + // Non-ServerWebExchange context → delegate immediately + // ----------------------------------------------------------------------- + + @Test + void shouldDelegateWhenContextIsNotServerWebExchange() { + Request request = new DefaultRequest<>(mock(RequestDataContext.class)); + Response delegateResponse = new DefaultResponse(INSTANCE_1); + when(mockDelegate.choose(request)).thenReturn(Mono.just(delegateResponse)); + + StepVerifier.create(balancer.choose(request)) + .assertNext(r -> assertThat(r.getServer()).isEqualTo(INSTANCE_1)) + .verifyComplete(); + + verify(mockDelegate).choose(request); + } + + // ----------------------------------------------------------------------- + // IS_STICKY_ATTRIBUTE = false → delegate + // ----------------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + void shouldDelegateWhenExchangeIsNotSticky() { + ServerWebExchange exchange = stickyExchange(false); + Request request = new DefaultRequest<>(exchange); + Response delegateResponse = new DefaultResponse(INSTANCE_2); + when(mockDelegate.choose(any())).thenReturn(Mono.just(delegateResponse)); + + StepVerifier.create(balancer.choose(request)) + .assertNext(r -> assertThat(r.getServer()).isEqualTo(INSTANCE_2)) + .verifyComplete(); + + verify(mockDelegate).choose(any()); + } + + // ----------------------------------------------------------------------- + // IS_STICKY_ATTRIBUTE = true, no prior session affinity → delegate + pin + // ----------------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + void shouldPickFreshInstanceAndPinWhenNoAffinityExists() { + ServerWebExchange exchange = stickyExchange(true); + Request request = new DefaultRequest<>(exchange); + when(mockDelegate.choose(any())).thenReturn(Mono.just(new DefaultResponse(INSTANCE_1))); + + StepVerifier.create(balancer.choose(request)) + .assertNext(r -> assertThat(r.getServer().getInstanceId()).isEqualTo("instance-1")) + .verifyComplete(); + + // Session should now contain the pinned instanceId + exchange.getSession().map(session -> { + @SuppressWarnings("unchecked") + Map map = (Map) session + .getAttribute(WebSessionStickyLoadBalancer.STICKY_MAP_SESSION_ATTR); + assertThat(map).containsEntry(SERVICE_ID, "instance-1"); + return session; + }).block(); + } + + // ----------------------------------------------------------------------- + // IS_STICKY_ATTRIBUTE = true, valid affinity in session → reuse pinned instance + // ----------------------------------------------------------------------- + + @Test + void shouldReusePinnedInstanceWhenAffinityExistsAndInstanceIsAvailable() { + ServerWebExchange exchange = stickyExchange(true); + // Pre-seed the session with a pinned instance + seedSessionAffinity(exchange, "instance-2"); + + Request request = new DefaultRequest<>(exchange); + + StepVerifier.create(balancer.choose(request)) + .assertNext(r -> assertThat(r.getServer().getInstanceId()).isEqualTo("instance-2")) + .verifyComplete(); + + // Delegate should NOT have been consulted + verify(mockDelegate, never()).choose(any()); + } + + // ----------------------------------------------------------------------- + // IS_STICKY_ATTRIBUTE = true, pinned instance gone → re-pick via delegate + repin + // ----------------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + void shouldRepickAndRepinWhenPinnedInstanceIsGone() { + // Only INSTANCE_1 is "alive" now — supplier returns just that one + when(mockSupplier.get()).thenReturn(Flux.just(Collections.singletonList(INSTANCE_1))); + + ServerWebExchange exchange = stickyExchange(true); + // Session was pinned to instance-2, which is no longer in the list + seedSessionAffinity(exchange, "instance-2"); + + Request request = new DefaultRequest<>(exchange); + when(mockDelegate.choose(any())).thenReturn(Mono.just(new DefaultResponse(INSTANCE_1))); + + StepVerifier.create(balancer.choose(request)) + .assertNext(r -> assertThat(r.getServer().getInstanceId()).isEqualTo("instance-1")) + .verifyComplete(); + + verify(mockDelegate).choose(any()); + + // Affinity should now be updated to the new instance + exchange.getSession().map(session -> { + @SuppressWarnings("unchecked") + Map map = (Map) session + .getAttribute(WebSessionStickyLoadBalancer.STICKY_MAP_SESSION_ATTR); + assertThat(map).containsEntry(SERVICE_ID, "instance-1"); + return session; + }).block(); + } + + // ----------------------------------------------------------------------- + // Empty instance list → EmptyResponse + // ----------------------------------------------------------------------- + + @Test + void shouldReturnEmptyResponseWhenNoInstancesAvailable() { + when(mockSupplier.get()).thenReturn(Flux.just(Collections.emptyList())); + ServerWebExchange exchange = stickyExchange(true); + Request request = new DefaultRequest<>(exchange); + + StepVerifier.create(balancer.choose(request)) + .assertNext(r -> assertThat(r).isInstanceOf(EmptyResponse.class)) + .verifyComplete(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private static ServerWebExchange stickyExchange(boolean isSticky) { + ServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("http://localhost/test").build()); + exchange.getAttributes().put(WebSessionStickyLoadBalancerFilter.IS_STICKY_ATTRIBUTE, isSticky); + return exchange; + } + + private static void seedSessionAffinity(ServerWebExchange exchange, String instanceId) { + exchange.getSession().map(session -> { + Map map = new HashMap<>(); + map.put(SERVICE_ID, instanceId); + session.getAttributes().put(WebSessionStickyLoadBalancer.STICKY_MAP_SESSION_ATTR, map); + return session; + }).block(); + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/WebSessionStickyGatewayFilterFactoryTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/WebSessionStickyGatewayFilterFactoryTests.java new file mode 100644 index 000000000..37a7c2107 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/WebSessionStickyGatewayFilterFactoryTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2013-present 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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.WebSessionStickyLoadBalancerFilter; +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.when; + +/** + * Tests for {@link WebSessionStickyGatewayFilterFactory}. + * + * @author Beteab Gebru + */ +class WebSessionStickyGatewayFilterFactoryTests { + + private WebSessionStickyGatewayFilterFactory factory; + + private GatewayFilterChain chain; + + private ServerWebExchange exchange; + + @BeforeEach + void setUp() { + factory = new WebSessionStickyGatewayFilterFactory(); + chain = mock(GatewayFilterChain.class); + exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test").build()); + when(chain.filter(any())).thenReturn(Mono.empty()); + } + + @Test + void shouldSetIsStickyAttributeToTrue() { + GatewayFilter filter = factory.apply((Object) null); + + filter.filter(exchange, chain).block(); + + assertThat((Boolean) exchange.getAttribute(WebSessionStickyLoadBalancerFilter.IS_STICKY_ATTRIBUTE)).isTrue(); + } + + @Test + void shouldContinueFilterChain() { + GatewayFilter filter = factory.apply((Object) null); + + filter.filter(exchange, chain).block(); + + verify(chain).filter(exchange); + } + + @Test + void shouldNotSetAttributeWhenNotApplied() { + Object attribute = exchange.getAttribute(WebSessionStickyLoadBalancerFilter.IS_STICKY_ATTRIBUTE); + assertThat(attribute).isNull(); + } + + @Test + void toStringShouldIncludeFilterName() { + GatewayFilter filter = factory.apply((Object) null); + + String description = filter.toString(); + assertThat(description).contains("WebSessionSticky"); + } + +}