diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index b061ce2fb8..def5c93107 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -1100,6 +1100,49 @@ NOTE: To enable this feature, add `com.github.ben-manes.caffeine:caffeine` and ` WARNING: If your project creates custom `CacheManager` beans, it will either need to be marked with `@Primary` or injected using `@Qualifier`. +[[redis-cache-response-filter]] +=== The `RedisResponseCache` `GatewayFilter` Factory + +This filter allows caching the response body and headers following the same rules as <> is also available as feature. + +The following listing shows how to add redis response cache `GatewayFilter`: + +==== +[source,java] +---- +@Bean +public RouteLocator routes(RouteLocatorBuilder builder) { + return builder.routes() + .route("rewrite_response_upper", r -> r.host("*.rewriteresponseupper.org") + .filters(f -> f.prefixPath("/httpbin") + .redisResponseCache(Duration.ofMinutes(30)) + ).uri(uri)) + .build(); +} +---- + +or this + +.application.yaml +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: resource + uri: http://localhost:9000 + predicates: + - Path=/resource + filters: + - RedisResponseCache=30m +---- +==== + +NOTE: To enable this feature, add `spring-boot-starter-data-redis-reactive` and `spring-boot-starter-cache` as project dependencies. + === The `MapRequestHeader` `GatewayFilter` Factory @@ -2199,6 +2242,30 @@ NOTE: To enable this feature, add `com.github.ben-manes.caffeine:caffeine` and ` WARNING: If your project creates custom `CacheManager` beans, it will either need to be marked with `@Primary` or injected using `@Qualifier`. +[[redis-cache-response-global-filter]] +=== The Redis Response Cache Filter + +The `RedisResponseCache` runs if associated properties are enabled: + +* `spring.cloud.gateway.global-filter.redis-response-cache.enabled`: Activates the global cache for all routes +* `spring.cloud.gateway.filter.redis-response-cache.enabled`: Activates the associated filter to use at route level + +This feature enables a cache using Redis for all responses that meet the criteria as spelled out in <> allows to use this functionality at route level. + +NOTE: To enable this feature, add `spring-boot-starter-data-redis-reactive` and `spring-boot-starter-cache` as project dependencies. + === Forward Routing Filter The `ForwardRoutingFilter` looks for a URI in the exchange attribute `ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR`. diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/ConfigurableHintsRegistrationProcessor.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/ConfigurableHintsRegistrationProcessor.java index a2d525c178..00fa595d49 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/ConfigurableHintsRegistrationProcessor.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/ConfigurableHintsRegistrationProcessor.java @@ -39,6 +39,7 @@ import org.springframework.cloud.gateway.filter.factory.SpringCloudCircuitBreakerResilience4JFilterFactory; import org.springframework.cloud.gateway.filter.factory.TokenRelayGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.cache.RedisResponseCacheGatewayFilterFactory; import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter; import org.springframework.cloud.gateway.support.Configurable; import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; @@ -77,7 +78,9 @@ class ConfigurableHintsRegistrationProcessor implements BeanFactoryInitializatio FallbackHeadersGatewayFilterFactory.class, circuitBreakerConditionalClasses, LocalResponseCacheGatewayFilterFactory.class, Set.of("com.github.benmanes.caffeine.cache.Weigher", "com.github.benmanes.caffeine.cache.Caffeine", - "org.springframework.cache.caffeine.CaffeineCacheManager")); + "org.springframework.cache.caffeine.CaffeineCacheManager"), + RedisResponseCacheGatewayFilterFactory.class, Set.of("org.springframework.data.redis.cache.RedisCache", + "org.springframework.data.redis.connection.RedisConnectionFactory")); @Override public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java index 233e93e897..06f81797c4 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java @@ -26,6 +26,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.AllNestedConditions; 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.cache.Cache; @@ -82,6 +83,7 @@ public LocalResponseCacheGatewayFilterFactory localResponseCacheGatewayFilterFac } @Bean + @ConditionalOnMissingBean public ResponseCacheManagerFactory responseCacheManagerFactory(CacheKeyGenerator cacheKeyGenerator) { return new ResponseCacheManagerFactory(cacheKeyGenerator); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/RedisResponseCacheAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/RedisResponseCacheAutoConfiguration.java new file mode 100644 index 0000000000..c624c29f4f --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/RedisResponseCacheAutoConfiguration.java @@ -0,0 +1,114 @@ +/* + * Copyright 2013-2020 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.condition.AllNestedConditions; +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.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledFilter; +import org.springframework.cloud.gateway.filter.factory.cache.GlobalRedisResponseCacheGatewayFilter; +import org.springframework.cloud.gateway.filter.factory.cache.RedisResponseCacheGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.cache.RedisResponseCacheProperties; +import org.springframework.cloud.gateway.filter.factory.cache.ResponseCacheManagerFactory; +import org.springframework.cloud.gateway.filter.factory.cache.keygenerator.CacheKeyGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCache; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties({ RedisResponseCacheProperties.class }) +@ConditionalOnClass({ RedisCache.class, RedisConnectionFactory.class }) +@ConditionalOnEnabledFilter(RedisResponseCacheGatewayFilterFactory.class) +public class RedisResponseCacheAutoConfiguration { + + private static final String RESPONSE_CACHE_NAME = "response-cache"; + + @Bean + @Conditional(OnGlobalRedisResponseCacheCondition.class) + public GlobalRedisResponseCacheGatewayFilter globalRedisResponseCacheGatewayFilter( + ResponseCacheManagerFactory responseCacheManagerFactory, RedisResponseCacheProperties properties, + RedisConnectionFactory redisConnectionFactory) { + return new GlobalRedisResponseCacheGatewayFilter(responseCacheManagerFactory, + responseCache(createGatewayCacheManager(properties, redisConnectionFactory)), + properties.getTimeToLive()); + } + + @Bean + public RedisResponseCacheGatewayFilterFactory redisResponseCacheGatewayFilterFactory( + ResponseCacheManagerFactory responseCacheManagerFactory, RedisResponseCacheProperties properties, + RedisConnectionFactory redisConnectionFactory) { + return new RedisResponseCacheGatewayFilterFactory(responseCacheManagerFactory, properties.getTimeToLive(), + redisConnectionFactory); + } + + @Bean + @ConditionalOnMissingBean + public ResponseCacheManagerFactory responseCacheManagerFactory(CacheKeyGenerator redisResponseCacheKeyGenerator) { + return new ResponseCacheManagerFactory(redisResponseCacheKeyGenerator); + } + + @Bean + public CacheKeyGenerator redisResponseCacheKeyGenerator() { + return new CacheKeyGenerator(); + } + + public static RedisCacheManager createGatewayCacheManager(RedisResponseCacheProperties cacheProperties, + RedisConnectionFactory redisConnectionFactory) { + RedisCacheConfiguration redisCacheConfigurationWithTtl = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(cacheProperties.getTimeToLive()); + + return RedisCacheManager.builder(redisConnectionFactory).cacheDefaults(redisCacheConfigurationWithTtl).build(); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + Cache responseCache(CacheManager cacheManager) { + return cacheManager.getCache(RESPONSE_CACHE_NAME); + } + + public static class OnGlobalRedisResponseCacheCondition extends AllNestedConditions { + + OnGlobalRedisResponseCacheCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(value = "spring.cloud.gateway.enabled", havingValue = "true", matchIfMissing = true) + static class OnGatewayPropertyEnabled { + + } + + @ConditionalOnProperty(value = "spring.cloud.gateway.filter.redis-response-cache.enabled", havingValue = "true") + static class OnRedisResponseCachePropertyEnabled { + + } + + @ConditionalOnProperty(name = "spring.cloud.gateway.global-filter.redis-response-cache.enabled", + havingValue = "true", matchIfMissing = true) + static class OnGlobalRedisResponseCachePropertyEnabled { + + } + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponse.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponse.java index fc7a78a1e2..a660323070 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponse.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/CachedResponse.java @@ -42,7 +42,7 @@ * @author Marta Medio * @author Ignacio Lozano */ -public final class CachedResponse implements Serializable { +public class CachedResponse implements Serializable { private HttpStatusCode statusCode; @@ -52,7 +52,7 @@ public final class CachedResponse implements Serializable { private Date timestamp; - private CachedResponse(HttpStatusCode statusCode, HttpHeaders headers, List body, Date timestamp) { + public CachedResponse(HttpStatusCode statusCode, HttpHeaders headers, List body, Date timestamp) { this.statusCode = statusCode; this.headers = headers; this.body = body; @@ -104,7 +104,7 @@ byte[] bodyAsByteArray() throws IOException { return bodyStream.toByteArray(); } - String bodyAsString() throws IOException { + public String bodyAsString() throws IOException { InputStream byteStream = new ByteArrayInputStream(bodyAsByteArray()); if (headers.getOrEmpty(HttpHeaders.CONTENT_ENCODING).contains("gzip")) { byteStream = new GZIPInputStream(byteStream); diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalAbstractResponseCacheGatewayFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalAbstractResponseCacheGatewayFilter.java new file mode 100644 index 0000000000..9c3e5241e7 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalAbstractResponseCacheGatewayFilter.java @@ -0,0 +1,62 @@ +/* + * Copyright 2013-2020 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.cache; + +import java.time.Duration; + +import reactor.core.publisher.Mono; + +import org.springframework.cache.Cache; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter; +import org.springframework.core.Ordered; +import org.springframework.web.server.ServerWebExchange; + +/** + * Base class providing global response caching. + */ +public abstract class GlobalAbstractResponseCacheGatewayFilter implements GlobalFilter, Ordered { + + protected final ResponseCacheGatewayFilter responseCacheGatewayFilter; + + protected GlobalAbstractResponseCacheGatewayFilter(ResponseCacheManagerFactory cacheManagerFactory, + Cache globalCache, Duration configuredTimeToLive, String filterAppliedAttribute) { + responseCacheGatewayFilter = new ResponseCacheGatewayFilter( + cacheManagerFactory.create(globalCache, configuredTimeToLive), filterAppliedAttribute); + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (exchange.getAttributes().get(getFilterAppliedAttribute()) == null) { + return responseCacheGatewayFilter.filter(exchange, chain); + } + return chain.filter(exchange); + } + + @Override + public int getOrder() { + return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 2; + } + + /** + * @return an exchange attribute name we can use to detect if this type of caching + * filter has already been applied + */ + abstract public String getFilterAppliedAttribute(); + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalLocalResponseCacheGatewayFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalLocalResponseCacheGatewayFilter.java index 03acc42a47..3cf08778e0 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalLocalResponseCacheGatewayFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalLocalResponseCacheGatewayFilter.java @@ -18,16 +18,7 @@ import java.time.Duration; -import reactor.core.publisher.Mono; - import org.springframework.cache.Cache; -import org.springframework.cloud.gateway.filter.GatewayFilterChain; -import org.springframework.cloud.gateway.filter.GlobalFilter; -import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter; -import org.springframework.core.Ordered; -import org.springframework.web.server.ServerWebExchange; - -import static org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory.LOCAL_RESPONSE_CACHE_FILTER_APPLIED; /** * Caches responses for routes that don't have the @@ -36,27 +27,17 @@ * @author Ignacio Lozano * @author Marta Medio */ -public class GlobalLocalResponseCacheGatewayFilter implements GlobalFilter, Ordered { - - private final ResponseCacheGatewayFilter responseCacheGatewayFilter; +public class GlobalLocalResponseCacheGatewayFilter extends GlobalAbstractResponseCacheGatewayFilter { public GlobalLocalResponseCacheGatewayFilter(ResponseCacheManagerFactory cacheManagerFactory, Cache globalCache, Duration configuredTimeToLive) { - responseCacheGatewayFilter = new ResponseCacheGatewayFilter( - cacheManagerFactory.create(globalCache, configuredTimeToLive)); - } - - @Override - public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - if (exchange.getAttributes().get(LOCAL_RESPONSE_CACHE_FILTER_APPLIED) == null) { - return responseCacheGatewayFilter.filter(exchange, chain); - } - return chain.filter(exchange); + super(cacheManagerFactory, globalCache, configuredTimeToLive, + LocalResponseCacheGatewayFilterFactory.LOCAL_RESPONSE_CACHE_FILTER_APPLIED); } @Override - public int getOrder() { - return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 2; + public String getFilterAppliedAttribute() { + return LocalResponseCacheGatewayFilterFactory.LOCAL_RESPONSE_CACHE_FILTER_APPLIED; } } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalRedisResponseCacheGatewayFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalRedisResponseCacheGatewayFilter.java new file mode 100644 index 0000000000..e279483fa9 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/GlobalRedisResponseCacheGatewayFilter.java @@ -0,0 +1,43 @@ +/* + * Copyright 2013-2020 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.cache; + +import java.time.Duration; + +import org.springframework.cache.Cache; + +/** + * Caches responses for routes that don't have the + * {@link ResponseCacheGatewayFilterFactory} configured. + * + * @author Ignacio Lozano + * @author Marta Medio + */ +public class GlobalRedisResponseCacheGatewayFilter extends GlobalAbstractResponseCacheGatewayFilter { + + public GlobalRedisResponseCacheGatewayFilter(ResponseCacheManagerFactory cacheManagerFactory, Cache globalCache, + Duration configuredTimeToLive) { + super(cacheManagerFactory, globalCache, configuredTimeToLive, + RedisResponseCacheGatewayFilterFactory.REDIS_RESPONSE_CACHE_FILTER_APPLIED); + } + + @Override + public String getFilterAppliedAttribute() { + return RedisResponseCacheGatewayFilterFactory.REDIS_RESPONSE_CACHE_FILTER_APPLIED; + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java index cdeef6967a..4e263bdf9d 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java @@ -23,7 +23,7 @@ import org.springframework.cache.Cache; import org.springframework.cloud.gateway.config.LocalResponseCacheAutoConfiguration; import org.springframework.cloud.gateway.filter.GatewayFilter; -import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory; import org.springframework.cloud.gateway.support.HasRouteId; import org.springframework.util.unit.DataSize; import org.springframework.validation.annotation.Validated; @@ -41,7 +41,8 @@ */ @ConditionalOnProperty(value = "spring.cloud.gateway.filter.local-response-cache.enabled", havingValue = "true") public class LocalResponseCacheGatewayFilterFactory - extends AbstractGatewayFilterFactory { + extends ResponseCacheGatewayFilterFactory + implements GatewayFilterFactory { /** * Exchange attribute name to track if the request has been already process by cache @@ -49,10 +50,6 @@ public class LocalResponseCacheGatewayFilterFactory */ public static final String LOCAL_RESPONSE_CACHE_FILTER_APPLIED = "LocalResponseCacheGatewayFilter-Applied"; - private ResponseCacheManagerFactory cacheManagerFactory; - - private Duration defaultTimeToLive; - private DataSize defaultSize; public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, @@ -74,8 +71,8 @@ public GatewayFilter apply(RouteCacheConfiguration config) { Cache routeCache = LocalResponseCacheAutoConfiguration.createGatewayCacheManager(cacheProperties) .getCache(config.getRouteId() + "-cache"); - return new ResponseCacheGatewayFilter(cacheManagerFactory.create(routeCache, cacheProperties.getTimeToLive())); - + return new ResponseCacheGatewayFilter(cacheManagerFactory.create(routeCache, cacheProperties.getTimeToLive()), + LOCAL_RESPONSE_CACHE_FILTER_APPLIED); } private LocalResponseCacheProperties mapRouteCacheConfig(RouteCacheConfiguration config) { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheGatewayFilterFactory.java new file mode 100644 index 0000000000..f33b9618c0 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheGatewayFilterFactory.java @@ -0,0 +1,105 @@ +/* + * Copyright 2023-2023 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.cache; + +import java.time.Duration; +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.Cache; +import org.springframework.cloud.gateway.config.RedisResponseCacheAutoConfiguration; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory; +import org.springframework.cloud.gateway.support.HasRouteId; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.validation.annotation.Validated; + +@ConditionalOnProperty(value = "spring.cloud.gateway.filter.redis-response-cache.enabled", havingValue = "true") +public class RedisResponseCacheGatewayFilterFactory + extends ResponseCacheGatewayFilterFactory + implements GatewayFilterFactory { + + /** + * Exchange attribute name to track if the request has been already process by cache + * at route filter level. + */ + public static final String REDIS_RESPONSE_CACHE_FILTER_APPLIED = "RedisResponseCacheGatewayFilter-Applied"; + + private RedisConnectionFactory redisConnectionFactory; + + public RedisResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, + Duration defaultTimeToLive, RedisConnectionFactory redisConnectionFactory) { + super(RouteCacheConfiguration.class); + this.cacheManagerFactory = cacheManagerFactory; + this.defaultTimeToLive = defaultTimeToLive; + this.redisConnectionFactory = redisConnectionFactory; + } + + @Override + public List shortcutFieldOrder() { + return List.of("timeToLive"); + } + + @Override + public GatewayFilter apply(RouteCacheConfiguration config) { + RedisResponseCacheProperties cacheProperties = mapRouteCacheConfig(config); + + Cache routeCache = RedisResponseCacheAutoConfiguration + .createGatewayCacheManager(cacheProperties, redisConnectionFactory) + .getCache(config.getRouteId() + "-cache"); + return new ResponseCacheGatewayFilter(cacheManagerFactory.create(routeCache, cacheProperties.getTimeToLive()), + REDIS_RESPONSE_CACHE_FILTER_APPLIED); + } + + private RedisResponseCacheProperties mapRouteCacheConfig(RouteCacheConfiguration config) { + Duration timeToLive = config.getTimeToLive() != null ? config.getTimeToLive() : defaultTimeToLive; + + RedisResponseCacheProperties responseCacheProperties = new RedisResponseCacheProperties(); + responseCacheProperties.setTimeToLive(timeToLive); + + return responseCacheProperties; + } + + @Validated + public static class RouteCacheConfiguration implements HasRouteId { + + private Duration timeToLive; + + private String routeId; + + public Duration getTimeToLive() { + return timeToLive; + } + + public RouteCacheConfiguration setTimeToLive(Duration timeToLive) { + this.timeToLive = timeToLive; + return this; + } + + @Override + public void setRouteId(String routeId) { + this.routeId = routeId; + } + + @Override + public String getRouteId() { + return this.routeId; + } + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheProperties.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheProperties.java new file mode 100644 index 0000000000..5d9373d572 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheProperties.java @@ -0,0 +1,58 @@ +/* + * Copyright 2013-2022 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.cache; + +import java.time.Duration; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = RedisResponseCacheProperties.PREFIX) +public class RedisResponseCacheProperties { + + static final String PREFIX = "spring.cloud.gateway.filter.redis-response-cache"; + + private static final Log LOGGER = LogFactory.getLog(RedisResponseCacheProperties.class); + + private static final Duration DEFAULT_CACHE_TTL_SECONDS = Duration.ofMinutes(5); + + private Duration timeToLive; + + public Duration getTimeToLive() { + if (timeToLive == null) { + LOGGER.debug(String.format( + "No TTL configuration found. Default TTL will be applied for cache entries: %s seconds", + DEFAULT_CACHE_TTL_SECONDS)); + return DEFAULT_CACHE_TTL_SECONDS; + } + else { + return timeToLive; + } + } + + public void setTimeToLive(Duration timeToLive) { + this.timeToLive = timeToLive; + } + + @Override + public String toString() { + return "RedisResponseCacheProperties{" + "timeToLive=" + getTimeToLive() + "\'}"; + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilter.java index a7769df1ee..d19db1c14d 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilter.java @@ -31,8 +31,6 @@ import org.springframework.http.server.reactive.ServerHttpResponseDecorator; import org.springframework.web.server.ServerWebExchange; -import static org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory.LOCAL_RESPONSE_CACHE_FILTER_APPLIED; - /** * {@literal LocalResponseCache} Gateway Filter that stores HTTP Responses in a cache, so * latency and upstream overhead is reduced. @@ -44,14 +42,22 @@ public class ResponseCacheGatewayFilter implements GatewayFilter, Ordered { private final ResponseCacheManager responseCacheManager; + private final String filterAppliedAttribute; + + //TODO delete this constructor at next major release public ResponseCacheGatewayFilter(ResponseCacheManager responseCacheManager) { + this(responseCacheManager, null); + } + + public ResponseCacheGatewayFilter(ResponseCacheManager responseCacheManager, String filterAppliedAttribute) { this.responseCacheManager = responseCacheManager; + this.filterAppliedAttribute = filterAppliedAttribute; } @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { if (responseCacheManager.isRequestCacheable(exchange.getRequest())) { - exchange.getAttributes().put(LOCAL_RESPONSE_CACHE_FILTER_APPLIED, true); + exchange.getAttributes().put(getFilterAppliedAttribute(), true); return filterWithCache(exchange, chain); } else { @@ -66,7 +72,7 @@ public int getOrder() { private Mono filterWithCache(ServerWebExchange exchange, GatewayFilterChain chain) { final String metadataKey = responseCacheManager.resolveMetadataKey(exchange); - Optional cached = responseCacheManager.getFromCache(exchange.getRequest(), metadataKey); + Optional cached = responseCacheManager.getFromCache(exchange, metadataKey); if (cached.isPresent()) { return responseCacheManager.processFromCache(exchange, metadataKey, cached.get()); @@ -77,6 +83,17 @@ private Mono filterWithCache(ServerWebExchange exchange, GatewayFilterChai } } + private String getFilterAppliedAttribute() { + if (filterAppliedAttribute != null) { + return filterAppliedAttribute; + } + else { + // for backwards compatibility + //TODO delete this backwards compatible stuff in a future major release in favor of always explicitly setting filterAppliedAttribute at construction + return LocalResponseCacheGatewayFilterFactory.LOCAL_RESPONSE_CACHE_FILTER_APPLIED; + } + } + private class CachingResponseDecorator extends ServerHttpResponseDecorator { private final String metadataKey; diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilterFactory.java new file mode 100644 index 0000000000..81c21a801c --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilterFactory.java @@ -0,0 +1,36 @@ +/* + * Copyright 2013-2020 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.cache; + +import java.time.Duration; + +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; + +/** + * Base class for of response caching implementations. + */ +public abstract class ResponseCacheGatewayFilterFactory extends AbstractGatewayFilterFactory { + + protected ResponseCacheManagerFactory cacheManagerFactory; + + protected Duration defaultTimeToLive; + + protected ResponseCacheGatewayFilterFactory(Class configClass) { + super(configClass); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java index 4ad1a82d2b..93d9a9f993 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManager.java @@ -82,6 +82,18 @@ public Optional getFromCache(ServerHttpRequest request, String m return getFromCache(key); } + /** + * This method operates on a {@link ServerWebExchange} to facilitate overrides/functionality extensions that may + * need to use more data than just a {@link ServerHttpRequest} provides. + * + * @param exchange + * @param metadataKey + * @return + */ + public Optional getFromCache(ServerWebExchange exchange, String metadataKey) { + return getFromCache(exchange.getRequest(), metadataKey); + } + public Flux processFromUpstream(String metadataKey, ServerWebExchange exchange, Flux body) { final ServerHttpResponse response = exchange.getResponse(); final CachedResponseMetadata metadata = new CachedResponseMetadata(response.getHeaders().getVary()); @@ -106,7 +118,7 @@ public Flux processFromUpstream(String metadataKey, ServerWebExchang }); } - private Optional getFromCache(String key) { + protected Optional getFromCache(String key) { CachedResponse cachedResponse; try { cachedResponse = cache.get(key, CachedResponse.class); @@ -153,26 +165,26 @@ private CachedResponseMetadata retrieveMetadata(String metadataKey) { return metadata; } - boolean isResponseCacheable(ServerHttpResponse response) { + protected boolean isResponseCacheable(ServerHttpResponse response) { return isStatusCodeToCache(response) && isCacheControlAllowed(response) && !isVaryWildcard(response); } - private boolean isStatusCodeToCache(ServerHttpResponse response) { + protected boolean isStatusCodeToCache(ServerHttpResponse response) { return statusesToCache.contains(response.getStatusCode()); } - boolean isRequestCacheable(ServerHttpRequest request) { + protected boolean isRequestCacheable(ServerHttpRequest request) { return HttpMethod.GET.equals(request.getMethod()) && !hasRequestBody(request) && isCacheControlAllowed(request); } - private boolean isVaryWildcard(ServerHttpResponse response) { + protected boolean isVaryWildcard(ServerHttpResponse response) { HttpHeaders headers = response.getHeaders(); List varyValues = headers.getOrEmpty(HttpHeaders.VARY); return varyValues.stream().anyMatch(VARY_WILDCARD::equals); } - private boolean isCacheControlAllowed(HttpMessage request) { + protected boolean isCacheControlAllowed(HttpMessage request) { HttpHeaders headers = request.getHeaders(); List cacheControlHeader = headers.getOrEmpty(HttpHeaders.CACHE_CONTROL); diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerFactory.java index 0059206848..6933d4819b 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerFactory.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerFactory.java @@ -27,7 +27,7 @@ */ public class ResponseCacheManagerFactory { - private final CacheKeyGenerator cacheKeyGenerator; + protected final CacheKeyGenerator cacheKeyGenerator; public ResponseCacheManagerFactory(CacheKeyGenerator cacheKeyGenerator) { this.cacheKeyGenerator = cacheKeyGenerator; diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java index d9c2a6af95..46b0bf45a1 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java @@ -76,6 +76,7 @@ import org.springframework.cloud.gateway.filter.factory.StripPrefixGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.TokenRelayGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.cache.RedisResponseCacheGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyRequestBodyGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyResponseBodyGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.rewrite.RewriteFunction; @@ -117,11 +118,25 @@ public GatewayFilterSpec filter(GatewayFilter gatewayFilter) { * @return a {@link GatewayFilterSpec} that can be used to apply additional filters */ public GatewayFilterSpec filter(GatewayFilter gatewayFilter, int order) { + return filter(gatewayFilter, order, false); + } + + /** + * Applies the filter to the route. + * @param gatewayFilter the filter to apply + * @param order the order to apply the filter + * @param forceOrder if true, then force the order even if the supplied + * {@link GatewayFilter} implements {@link Ordered} + * @return a {@link GatewayFilterSpec} that can be used to apply additional filters + */ + public GatewayFilterSpec filter(GatewayFilter gatewayFilter, int order, boolean forceOrder) { if (gatewayFilter instanceof Ordered) { - this.routeBuilder.filter(gatewayFilter); - log.warn("GatewayFilter already implements ordered " + gatewayFilter.getClass() - + "ignoring order parameter: " + order); - return this; + if (!forceOrder) { + this.routeBuilder.filter(gatewayFilter); + log.warn("GatewayFilter already implements ordered " + gatewayFilter.getClass() + + "ignoring order parameter: " + order); + return this; + } } this.routeBuilder.filter(new OrderedGatewayFilter(gatewayFilter, order)); return this; @@ -223,6 +238,19 @@ public GatewayFilterSpec localResponseCache(Duration timeToLive, DataSize size) .apply(c -> c.setTimeToLive(timeToLive).setSize(size))); } + /** + * A filter that adds a redis cache for storing response body for repeated requests. + *

+ * If `timeToLive` is null, a global cache is used configured by the global + * configuration + * {@link org.springframework.cloud.gateway.filter.factory.cache.RedisResponseCacheProperties}. + * @param timeToLive time an entry is kept in cache. Default: 5 minutes + * @return a {@link GatewayFilterSpec} that can be used to apply additional filters + */ + public GatewayFilterSpec redisResponseCache(Duration timeToLive) { + return filter(getBean(RedisResponseCacheGatewayFilterFactory.class).apply(c -> c.setTimeToLive(timeToLive))); + } + /** * A filter that removes duplication on a response header before it is returned to the * client by the Gateway. diff --git a/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 90105d6b74..25a78cfec8 100644 --- a/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -42,6 +42,17 @@ "description": "Enables the local-response-cache filter.", "defaultValue": "false" }, + { + "name": "spring.cloud.gateway.filter.redis-response-cache.enabled", + "type": "java.lang.Boolean", + "description": "Enables the redis-response-cache filter.", + "defaultValue": "false" + }, + { + "name": "spring.cloud.gateway.filter.redis-response-cache.time-to-live", + "type": "java.time.Duration", + "description": "Time to expire a cache entry (expressed in s for seconds, m for minutes, and h for hours)." + }, { "name": "spring.cloud.gateway.filter.local-response-cache.size", "type": "org.springframework.util.unit.DataSize", @@ -287,6 +298,12 @@ "description": "Enables the local-response-cache filter for all routes, it allows to add a specific configuration at route level using LocalResponseCache filter.", "defaultValue": "true" }, + { + "name": "spring.cloud.gateway.global-filter.redis-response-cache.enabled", + "type": "java.lang.Boolean", + "description": "Enables the redis-response-cache filter for all routes, it allows to add a specific configuration at route level using RedisResponseCache filter.", + "defaultValue": "true" + }, { "name": "spring.cloud.gateway.predicate.after.enabled", "type": "java.lang.Boolean", diff --git a/spring-cloud-gateway-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-gateway-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 2c486c7cd5..4061e82bb7 100644 --- a/spring-cloud-gateway-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-gateway-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -8,4 +8,5 @@ org.springframework.cloud.gateway.discovery.GatewayDiscoveryClientAutoConfigurat org.springframework.cloud.gateway.config.SimpleUrlHandlerMappingGlobalCorsAutoConfiguration org.springframework.cloud.gateway.config.GatewayReactiveLoadBalancerClientAutoConfiguration org.springframework.cloud.gateway.config.GatewayReactiveOAuth2AutoConfiguration -org.springframework.cloud.gateway.config.LocalResponseCacheAutoConfiguration \ No newline at end of file +org.springframework.cloud.gateway.config.LocalResponseCacheAutoConfiguration +org.springframework.cloud.gateway.config.RedisResponseCacheAutoConfiguration \ No newline at end of file diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java index 7892ab6ece..ec43dbcd65 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java @@ -83,6 +83,7 @@ public void shouldInjectOnlyEnabledBuiltInFilters() { "spring.cloud.gateway.filter.json-to-grpc.enabled=false", "spring.cloud.gateway.filter.modify-request-body.enabled=false", "spring.cloud.gateway.filter.local-response-cache.enabled=false", + "spring.cloud.gateway.filter.redis-response-cache.enabled=false", "spring.cloud.gateway.filter.dedupe-response-header.enabled=false", "spring.cloud.gateway.filter.modify-response-body.enabled=false", "spring.cloud.gateway.filter.prefix-path.enabled=false", diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheGatewayFilterFactoryTests.java new file mode 100644 index 0000000000..2457e91bc1 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheGatewayFilterFactoryTests.java @@ -0,0 +1,362 @@ +/* + * Copyright 2023-2023 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.cache; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.util.StringUtils; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Jesse Estum + */ +@DirtiesContext +@ActiveProfiles(profiles = "redis-cache-filter") +public class RedisResponseCacheGatewayFilterFactoryTests extends BaseWebClientTests { + + private static final String CUSTOM_HEADER = "X-Custom-Date"; + + @Nested + @SpringBootTest(properties = { "spring.cloud.gateway.filter.redis-response-cache.enabled=true" }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + @Testcontainers + public class RedisResponseCacheUsingFilterParams extends BaseWebClientTests { + + @Container + public static GenericContainer redis = new GenericContainer<>("redis:5.0.14-alpine").withExposedPorts(6379); + + @BeforeAll + public static void startRedisContainer() { + redis.start(); + } + + @DynamicPropertySource + static void containerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", redis::getFirstMappedPort); + } + + @Test + void shouldNotCacheResponseWhenGetRequestHasBody() { + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + + testClient.method(HttpMethod.GET).uri(uri).header("Host", "www.redisresponsecache.org") + .header(CUSTOM_HEADER, "1").bodyValue("whatever").exchange().expectBody() + .jsonPath("$.headers." + CUSTOM_HEADER); + + testClient.method(HttpMethod.GET).uri(uri).header("Host", "www.redisresponsecache.org") + .bodyValue("whatever").header(CUSTOM_HEADER, "2").exchange().expectBody() + .jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2"); + } + + @Test + void shouldNotCacheResponseWhenPostRequestHasBody() { + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + + testClient.method(HttpMethod.POST).uri(uri).header("Host", "www.redisresponsecache.org") + .header(CUSTOM_HEADER, "1").bodyValue("whatever").exchange().expectBody() + .jsonPath("$.headers." + CUSTOM_HEADER); + + testClient.method(HttpMethod.POST).uri(uri).header("Host", "www.redisresponsecache.org") + .bodyValue("whatever").header(CUSTOM_HEADER, "2").exchange().expectBody() + .jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2"); + } + + @Test + void shouldNotCacheWhenCacheControlAsksToDoNotCache() { + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "1").exchange() + .expectBody().jsonPath("$.headers." + CUSTOM_HEADER); + + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "2") + // Cache-Control asks to not use the cached content and not store the + // response + .header(HttpHeaders.CACHE_CONTROL, CacheControl.noStore().getHeaderValue()).exchange().expectBody() + .jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2"); + } + + @Test + void shouldCacheAndReturnNotModifiedStatusWhenCacheControlIsNoCache() { + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "1").exchange() + .expectBody().jsonPath("$.headers." + CUSTOM_HEADER); + + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "2") + // Cache-Control asks to not return cached content because it is + // HttpHeaders.NotModified + .header(HttpHeaders.CACHE_CONTROL, CacheControl.noCache().getHeaderValue()).exchange() + .expectStatus().isNotModified().expectBody().isEmpty(); + } + + @Test + void shouldCacheResponseWhenOnlyNonVaryHeaderIsDifferent() { + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "1").exchange() + .expectBody().jsonPath("$.headers." + CUSTOM_HEADER) + .value(customHeaderFromReq1 -> testClient.get().uri(uri) + .header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "2").exchange() + .expectBody().jsonPath("$.headers." + CUSTOM_HEADER, customHeaderFromReq1)); + } + + @Test + void shouldNotCacheResponseWhenVaryHeaderIsDifferent() { + String varyHeader = HttpHeaders.ORIGIN; + String sameUri = "/" + UUID.randomUUID() + "/cache/vary-on-header"; + String firstNonVary = "1"; + String secondNonVary = "2"; + assertNonVaryHeaderInContent(sameUri, varyHeader, "origin-1", CUSTOM_HEADER, firstNonVary, firstNonVary); + assertNonVaryHeaderInContent(sameUri, varyHeader, "origin-1", CUSTOM_HEADER, secondNonVary, firstNonVary); + assertNonVaryHeaderInContent(sameUri, varyHeader, "origin-2", CUSTOM_HEADER, secondNonVary, secondNonVary); + } + + @Test + void shouldNotCacheResponseWhenResponseVaryIsWildcard() { + String uri = "/" + UUID.randomUUID() + "/cache/vary-on-header"; + // Vary: * + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "1") + .header("X-Request-Vary", "*").exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER, "1"); + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "2") + .header("X-Request-Vary", "*").exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER, "2"); + } + + @Test + void shouldNotCacheResponseWhenPathIsDifferent() { + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + String uri2 = "/" + UUID.randomUUID() + "/cache/headers"; + + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "1").exchange() + .expectBody().jsonPath("$.headers." + CUSTOM_HEADER); + + testClient.get().uri(uri2).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "2") + .exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2"); + } + + @Test + void shouldDecreaseCacheControlMaxAgeTimeWhenResponseIsFromCache() throws InterruptedException { + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + Long maxAgeRequest1 = testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").exchange() + .expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(this::parseMaxAge).filter(Objects::nonNull).findAny().orElse(null); + Thread.sleep(2000); + Long maxAgeRequest2 = testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").exchange() + .expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(this::parseMaxAge).filter(Objects::nonNull).findAny().orElse(null); + + assertThat(maxAgeRequest2).isLessThan(maxAgeRequest1); + } + + @Test + void shouldNotCacheResponseWhenTimeToLiveIsReached() { + String uri = "/" + UUID.randomUUID() + "/ephemeral-cache/headers"; + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "1").exchange() + .expectBody().jsonPath("$.headers." + CUSTOM_HEADER).value(customHeaderFromReq1 -> { + try { + Thread.sleep(100); // Min time to have entry expired + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org") + .header(CUSTOM_HEADER, "2").exchange().expectBody() + .jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2"); + } + catch (InterruptedException e) { + throw new RuntimeException(e); + } + }); + } + +// @Test +// void shouldNotCacheWhenLocalResponseCacheSizeIsReached() { +// String uri = "/" + UUID.randomUUID() + "/one-byte-cache/headers"; +// +// testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "1").exchange() +// .expectBody().jsonPath("$.headers." + CUSTOM_HEADER); +// +// testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header(CUSTOM_HEADER, "2").exchange() +// .expectBody().jsonPath("$.headers." + CUSTOM_HEADER, "2"); +// } + + @Test + void shouldNotCacheWhenAuthorizationHeaderIsDifferent() { + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org") + .header(HttpHeaders.AUTHORIZATION, "1").header(CUSTOM_HEADER, "1").exchange().expectBody() + .jsonPath("$.headers." + CUSTOM_HEADER); + + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org") + .header(HttpHeaders.AUTHORIZATION, "2").header(CUSTOM_HEADER, "2").exchange().expectBody() + .jsonPath("$.headers." + CUSTOM_HEADER, "2"); + } + + private Long parseMaxAge(String cacheControlValue) { + if (StringUtils.hasText(cacheControlValue)) { + Pattern maxAgePattern = Pattern.compile("\\bmax-age=(\\d+)\\b"); + Matcher matcher = maxAgePattern.matcher(cacheControlValue); + if (matcher.find()) { + return Long.parseLong(matcher.group(1)); + } + } + return null; + } + + void assertNonVaryHeaderInContent(String uri, String varyHeader, String varyHeaderValue, String nonVaryHeader, + String nonVaryHeaderValue, String expectedNonVaryResponse) { + testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").header("X-Request-Vary", varyHeader) + .header(varyHeader, varyHeaderValue).header(nonVaryHeader, nonVaryHeaderValue).exchange() + .expectBody(Map.class).consumeWith(response -> { + assertThat(response.getResponseHeaders()).hasEntrySatisfying("Vary", + o -> assertThat(o).contains(varyHeader)); + assertThat((Map) response.getResponseBody().get("headers")).containsEntry(nonVaryHeader, + expectedNonVaryResponse); + }); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + @Value("${test.uri}") + String uri; + + @Bean + public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + .route("redis_response_cache_java_test", + r -> r.path("/{namespace}/cache/**").and().host("{sub}.redisresponsecache.org") + .filters(f -> f.stripPrefix(2).prefixPath("/httpbin") + .redisResponseCache(Duration.ofMinutes(2))) + .uri(uri)) + .route("100_millisec_ephemeral_prefix_redis_response_cache_java_test", + r -> r.path("/{namespace}/ephemeral-cache/**").and() + .host("{sub}.redisresponsecache.org") + .filters(f -> f.stripPrefix(2).prefixPath("/httpbin") + .redisResponseCache(Duration.ofMillis(100))) + .uri(uri)) + .route("min_sized_prefix_redis_response_cache_java_test", + r -> r.path("/{namespace}/one-byte-cache/**").and().host("{sub}.redisresponsecache.org") + .filters(f -> f.stripPrefix(2).prefixPath("/httpbin").redisResponseCache(null)) + .uri(uri)) + .build(); + } + + } + + } + + @Nested + @SpringBootTest( + properties = { "spring.cloud.gateway.filter.redis-response-cache.enabled=true", + "spring.cloud.gateway.filter.redis-response-cache.timeToLive=20s" }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + @Testcontainers + public class RedisResponseCacheUsingDefaultProperties extends BaseWebClientTests { + + @Container + public static GenericContainer redis = new GenericContainer<>("redis:5.0.14-alpine").withExposedPorts(6379); + + @BeforeAll + public static void startRedisContainer() { + redis.start(); + } + + @DynamicPropertySource + static void containerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", redis::getFirstMappedPort); + } + + @Test + void shouldApplyMaxAgeFromPropertiesWhenFilterHasNoParams() throws InterruptedException { + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + Long maxAgeRequest1 = testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").exchange() + .expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(this::parseMaxAge).filter(Objects::nonNull).findAny().orElse(null); + assertThat(maxAgeRequest1).isLessThanOrEqualTo(20L); + + Thread.sleep(2000); + + Long maxAgeRequest2 = testClient.get().uri(uri).header("Host", "www.redisresponsecache.org").exchange() + .expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(this::parseMaxAge).filter(Objects::nonNull).findAny().orElse(null); + + assertThat(maxAgeRequest2).isLessThan(maxAgeRequest1); + } + + private Long parseMaxAge(String cacheControlValue) { + if (StringUtils.hasText(cacheControlValue)) { + Pattern maxAgePattern = Pattern.compile("\\bmax-age=(\\d+)\\b"); + Matcher matcher = maxAgePattern.matcher(cacheControlValue); + if (matcher.find()) { + return Long.parseLong(matcher.group(1)); + } + } + return null; + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + @Value("${test.uri}") + String uri; + + @Bean + public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { + return builder.routes().route("redis_response_cache_java_test", + r -> r.path("/{namespace}/cache/**").and().host("{sub}.redisresponsecache.org") + .filters(f -> f.stripPrefix(2).prefixPath("/httpbin").redisResponseCache(null)) + .uri(uri)) + .build(); + } + + } + + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheGlobalFilterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheGlobalFilterTests.java new file mode 100644 index 0000000000..702bf95c13 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/RedisResponseCacheGlobalFilterTests.java @@ -0,0 +1,143 @@ +/* + * Copyright 2023-2023 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.cache; + +import java.util.UUID; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * @author Jesse Estum + */ +@DirtiesContext +@ActiveProfiles(profiles = "redis-cache-filter") +public class RedisResponseCacheGlobalFilterTests { + + private static final String CUSTOM_HEADER = "X-Custom-Date"; + + @Nested + @SpringBootTest( + properties = { "spring.cloud.gateway.filter.redis-response-cache.enabled=true", + "spring.cloud.gateway.global-filter.redis-response-cache.enabled=false" }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + public class GlobalCacheNotEnabled extends BaseWebClientTests { + + @Test + void shouldNotCacheResponseWhenGlobalIsNotEnabled() { + String uri = "/" + UUID.randomUUID() + "/global-cache-deactivated/headers"; + + testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1").exchange() + .expectBody().jsonPath("$.headers." + CUSTOM_HEADER); + + testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "2").exchange() + .expectBody().jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2"); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + @Value("${test.uri}") + String uri; + + @Bean + public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + .route("global_local_response_cache_deactivated_java_test", + r -> r.path("/{namespace}/global-cache-deactivated/**").and() + .host("{sub}.localresponsecache.org") + .filters(f -> f.stripPrefix(2).prefixPath("/httpbin")).uri(uri)) + .build(); + } + + } + + } + + @Nested + @SpringBootTest(properties = { "spring.cloud.gateway.filter.redis-response-cache.enabled=true" }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + @Testcontainers + public class GlobalCacheEnabled extends BaseWebClientTests { + + @Container + public static GenericContainer redis = new GenericContainer<>("redis:5.0.14-alpine").withExposedPorts(6379); + + @BeforeAll + public static void startRedisContainer() { + redis.start(); + } + + @DynamicPropertySource + static void containerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", redis::getFirstMappedPort); + } + + @Test + void shouldGlobalCacheResponseWhenRouteDoesNotHaveFilter() { + String uri = "/" + UUID.randomUUID() + "/global-cache/headers"; + + testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1").exchange() + .expectBody().jsonPath("$.headers." + CUSTOM_HEADER); + + testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "2").exchange() + .expectBody().jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("1"); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + @Value("${test.uri}") + String uri; + + @Bean + public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + .route("global_local_response_cache_java_test", + r -> r.path("/{namespace}/global-cache/**").and().host("{sub}.localresponsecache.org") + .filters(f -> f.stripPrefix(2).prefixPath("/httpbin")).uri(uri)) + .build(); + } + + } + + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilterTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerTest.java similarity index 98% rename from spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilterTest.java rename to spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerTest.java index 64616cc8c4..52a93d804d 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheGatewayFilterTest.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/ResponseCacheManagerTest.java @@ -28,7 +28,7 @@ /** * @author Ignacio Lozano */ -class ResponseCacheGatewayFilterTest { +class ResponseCacheManagerTest { ResponseCacheManager cacheManagerToTest = new ResponseCacheManager(null, null, null); diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java index bb4a987c22..1f0d16e4fd 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/support/NameUtilsTests.java @@ -33,6 +33,7 @@ import org.springframework.cloud.gateway.filter.factory.JsonToGrpcGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.MapRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.cache.RedisResponseCacheGatewayFilterFactory; import org.springframework.cloud.gateway.handler.predicate.AfterRoutePredicateFactory; import org.springframework.cloud.gateway.handler.predicate.CloudFoundryRouteServiceRoutePredicateFactory; import org.springframework.cloud.gateway.handler.predicate.ReadBodyRoutePredicateFactory; @@ -91,13 +92,14 @@ void shouldNormalizeFiltersNamesAsProperties() { List>> predicates = Arrays.asList( AddRequestHeaderGatewayFilterFactory.class, DedupeResponseHeaderGatewayFilterFactory.class, FallbackHeadersGatewayFilterFactory.class, MapRequestHeaderGatewayFilterFactory.class, - JsonToGrpcGatewayFilterFactory.class, LocalResponseCacheGatewayFilterFactory.class); + JsonToGrpcGatewayFilterFactory.class, LocalResponseCacheGatewayFilterFactory.class, + RedisResponseCacheGatewayFilterFactory.class); List resultNames = predicates.stream().map(NameUtils::normalizeFilterFactoryNameAsProperty) .collect(Collectors.toList()); List expectedNames = Arrays.asList("add-request-header", "dedupe-response-header", "fallback-headers", - "map-request-header", "json-to-grpc", "local-response-cache"); + "map-request-header", "json-to-grpc", "local-response-cache", "redis-response-cache"); assertThat(resultNames).isEqualTo(expectedNames); }