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 233e93e8..ddeaafd2 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 @@ -65,7 +65,7 @@ public class LocalResponseCacheAutoConfiguration { @Qualifier(RESPONSE_CACHE_MANAGER_NAME) CacheManager cacheManager, LocalResponseCacheProperties properties) { return new GlobalLocalResponseCacheGatewayFilter(responseCacheManagerFactory, responseCache(cacheManager), - properties.getTimeToLive()); + properties.getTimeToLive(), properties.getRequest()); } @Bean(name = RESPONSE_CACHE_MANAGER_NAME) @@ -78,7 +78,7 @@ public class LocalResponseCacheAutoConfiguration { public LocalResponseCacheGatewayFilterFactory localResponseCacheGatewayFilterFactory( ResponseCacheManagerFactory responseCacheManagerFactory, LocalResponseCacheProperties properties) { return new LocalResponseCacheGatewayFilterFactory(responseCacheManagerFactory, properties.getTimeToLive(), - properties.getSize()); + properties.getSize(), properties.getRequest()); } @Bean 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 03acc42a..75f74ca3 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 @@ -40,12 +40,19 @@ public class GlobalLocalResponseCacheGatewayFilter implements GlobalFilter, Orde private final ResponseCacheGatewayFilter responseCacheGatewayFilter; + @Deprecated public GlobalLocalResponseCacheGatewayFilter(ResponseCacheManagerFactory cacheManagerFactory, Cache globalCache, Duration configuredTimeToLive) { responseCacheGatewayFilter = new ResponseCacheGatewayFilter( cacheManagerFactory.create(globalCache, configuredTimeToLive)); } + public GlobalLocalResponseCacheGatewayFilter(ResponseCacheManagerFactory cacheManagerFactory, Cache globalCache, + Duration configuredTimeToLive, LocalResponseCacheProperties.RequestOptions requestOptions) { + responseCacheGatewayFilter = new ResponseCacheGatewayFilter( + cacheManagerFactory.create(globalCache, configuredTimeToLive, requestOptions)); + } + @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { if (exchange.getAttributes().get(LOCAL_RESPONSE_CACHE_FILTER_APPLIED) == null) { 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 cdeef696..652de96f 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 @@ -24,17 +24,16 @@ 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.cache.LocalResponseCacheProperties.RequestOptions; import org.springframework.cloud.gateway.support.HasRouteId; import org.springframework.util.unit.DataSize; import org.springframework.validation.annotation.Validated; /** * {@link org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory} of - * {@link ResponseCacheGatewayFilter}. - * - * By default, a global cache (defined as properties in the application) is used. For - * specific route configuration, parameters can be added following - * {@link RouteCacheConfiguration} class. + * {@link ResponseCacheGatewayFilter}. By default, a global cache (defined as properties + * in the application) is used. For specific route configuration, parameters can be added + * following {@link RouteCacheConfiguration} class. * * @author Marta Medio * @author Ignacio Lozano @@ -49,23 +48,27 @@ public class LocalResponseCacheGatewayFilterFactory */ public static final String LOCAL_RESPONSE_CACHE_FILTER_APPLIED = "LocalResponseCacheGatewayFilter-Applied"; - private ResponseCacheManagerFactory cacheManagerFactory; + private final ResponseCacheManagerFactory cacheManagerFactory; - private Duration defaultTimeToLive; + private final Duration defaultTimeToLive; - private DataSize defaultSize; + private final DataSize defaultSize; + private final RequestOptions requestOptions; + + @Deprecated public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, - Duration defaultTimeToLive) { - this(cacheManagerFactory, defaultTimeToLive, null); + Duration defaultTimeToLive, DataSize defaultSize) { + this(cacheManagerFactory, defaultTimeToLive, defaultSize, new RequestOptions()); } public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, - Duration defaultTimeToLive, DataSize defaultSize) { + Duration defaultTimeToLive, DataSize defaultSize, RequestOptions requestOptions) { super(RouteCacheConfiguration.class); this.cacheManagerFactory = cacheManagerFactory; this.defaultTimeToLive = defaultTimeToLive; this.defaultSize = defaultSize; + this.requestOptions = requestOptions; } @Override @@ -74,7 +77,8 @@ public class LocalResponseCacheGatewayFilterFactory 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(), requestOptions)); } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheProperties.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheProperties.java index 96e76da1..33a27b03 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheProperties.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheProperties.java @@ -40,6 +40,8 @@ public class LocalResponseCacheProperties { private Duration timeToLive; + private RequestOptions request = new RequestOptions(); + public DataSize getSize() { return size; } @@ -64,9 +66,57 @@ public class LocalResponseCacheProperties { this.timeToLive = timeToLive; } + public RequestOptions getRequest() { + return request; + } + + public void setRequest(RequestOptions request) { + this.request = request; + } + @Override public String toString() { - return "LocalResponseCacheProperties{" + "timeToLive=" + getTimeToLive() + '\'' + ", size='" + getSize() + '}'; + return "LocalResponseCacheProperties{" + "size=" + size + ", timeToLive=" + timeToLive + ", request=" + request + + '}'; + } + + public static class RequestOptions { + + private NoCacheStrategy noCacheStrategy = NoCacheStrategy.SKIP_UPDATE_CACHE_ENTRY; + + public NoCacheStrategy getNoCacheStrategy() { + return noCacheStrategy; + } + + public void setNoCacheStrategy(NoCacheStrategy noCacheStrategy) { + this.noCacheStrategy = noCacheStrategy; + } + + @Override + public String toString() { + return "RequestOptions{" + "noCacheStrategy=" + noCacheStrategy + '}'; + } + + } + + /** + * When client sends "no-cache" directive in "Cache-Control" header, the response + * should be re-validated from upstream. There are several strategies that indicates + * what to do with the new fresh response. + */ + public enum NoCacheStrategy { + + /** + * Update the cache entry by the fresh response coming from upstream with a new + * time to live. + */ + UPDATE_CACHE_ENTRY, + /** + * Skip the update. The client will receive the fresh response, other clients will + * receive the old entry in cache. + */ + SKIP_UPDATE_CACHE_ENTRY + } } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java new file mode 100644 index 00000000..6de54ac5 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java @@ -0,0 +1,32 @@ +/* + * 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 org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.StringUtils; + +public final class LocalResponseCacheUtils { + + private LocalResponseCacheUtils() { + } + + public static boolean isNoCacheRequest(ServerHttpRequest request) { + String cacheControl = request.getHeaders().getCacheControl(); + return StringUtils.hasText(cacheControl) && cacheControl.matches(".*(\s|,|^)no-cache(\\s|,|$).*"); + } + +} 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 a7769df1..5fec016d 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 @@ -66,7 +66,7 @@ public class ResponseCacheGatewayFilter implements GatewayFilter, Ordered { private Mono filterWithCache(ServerWebExchange exchange, GatewayFilterChain chain) { final String metadataKey = responseCacheManager.resolveMetadataKey(exchange); - Optional cached = responseCacheManager.getFromCache(exchange.getRequest(), metadataKey); + Optional cached = getCachedResponse(exchange, metadataKey); if (cached.isPresent()) { return responseCacheManager.processFromCache(exchange, metadataKey, cached.get()); @@ -77,6 +77,22 @@ public class ResponseCacheGatewayFilter implements GatewayFilter, Ordered { } } + private Optional getCachedResponse(ServerWebExchange exchange, String metadataKey) { + Optional cached; + if (shouldRevalidate(exchange)) { + cached = Optional.empty(); + } + else { + cached = responseCacheManager.getFromCache(exchange.getRequest(), metadataKey); + } + + return cached; + } + + private boolean shouldRevalidate(ServerWebExchange exchange) { + return LocalResponseCacheUtils.isNoCacheRequest(exchange.getRequest()); + } + private class CachingResponseDecorator extends ServerHttpResponseDecorator { private final String metadataKey; @@ -94,7 +110,8 @@ public class ResponseCacheGatewayFilter implements GatewayFilter, Ordered { final ServerHttpResponse response = exchange.getResponse(); Flux decoratedBody; - if (responseCacheManager.isResponseCacheable(response)) { + if (responseCacheManager.isResponseCacheable(response) + && !responseCacheManager.isNoCacheRequestWithoutUpdate(exchange.getRequest())) { decoratedBody = responseCacheManager.processFromUpstream(metadataKey, exchange, Flux.from(body)); } else { 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 4ad1a82d..90d8a42d 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 @@ -30,8 +30,11 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.cache.Cache; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheProperties.NoCacheStrategy; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheProperties.RequestOptions; import org.springframework.cloud.gateway.filter.factory.cache.keygenerator.CacheKeyGenerator; import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.AfterCacheExchangeMutator; +import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator; import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetMaxAgeHeaderAfterCacheExchangeMutator; import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetResponseHeadersAfterCacheExchangeMutator; import org.springframework.cloud.gateway.filter.factory.cache.postprocessor.SetStatusCodeAfterCacheExchangeMutator; @@ -63,12 +66,28 @@ public class ResponseCacheManager { private final Cache cache; + private final boolean ignoreNoCacheUpdate; + + @Deprecated public ResponseCacheManager(CacheKeyGenerator cacheKeyGenerator, Cache cache, Duration configuredTimeToLive) { + this(cacheKeyGenerator, cache, configuredTimeToLive, new RequestOptions()); + } + + public ResponseCacheManager(CacheKeyGenerator cacheKeyGenerator, Cache cache, Duration configuredTimeToLive, + RequestOptions requestOptions) { this.cacheKeyGenerator = cacheKeyGenerator; this.cache = cache; + this.ignoreNoCacheUpdate = isSkipNoCacheUpdateActive(requestOptions); this.afterCacheExchangeMutators = List.of(new SetResponseHeadersAfterCacheExchangeMutator(), new SetStatusCodeAfterCacheExchangeMutator(), - new SetMaxAgeHeaderAfterCacheExchangeMutator(configuredTimeToLive, Clock.systemDefaultZone())); + new SetMaxAgeHeaderAfterCacheExchangeMutator(configuredTimeToLive, Clock.systemDefaultZone(), + ignoreNoCacheUpdate), + new SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator()); + } + + private static boolean isSkipNoCacheUpdateActive(RequestOptions requestOptions) { + return requestOptions != null + && requestOptions.getNoCacheStrategy().equals(NoCacheStrategy.SKIP_UPDATE_CACHE_ENTRY); } private static final List statusesToCache = Arrays.asList(HttpStatus.OK, HttpStatus.PARTIAL_CONTENT, @@ -132,13 +151,8 @@ public class ResponseCacheManager { afterCacheExchangeMutators.forEach(processor -> processor.accept(exchange, cachedResponse)); saveMetadataInCache(metadataKey, new CachedResponseMetadata(cachedResponse.headers().getVary())); - if (HttpStatus.NOT_MODIFIED.equals(response.getStatusCode())) { - return response.writeWith(Mono.empty()); - } - else { - return response.writeWith( - Flux.fromIterable(cachedResponse.body()).map(data -> response.bufferFactory().wrap(data))); - } + return response + .writeWith(Flux.fromIterable(cachedResponse.body()).map(data -> response.bufferFactory().wrap(data))); } private CachedResponseMetadata retrieveMetadata(String metadataKey) { @@ -157,6 +171,10 @@ public class ResponseCacheManager { return isStatusCodeToCache(response) && isCacheControlAllowed(response) && !isVaryWildcard(response); } + boolean isNoCacheRequestWithoutUpdate(ServerHttpRequest request) { + return LocalResponseCacheUtils.isNoCacheRequest(request) && ignoreNoCacheUpdate; + } + private boolean isStatusCodeToCache(ServerHttpResponse response) { return statusesToCache.contains(response.getStatusCode()); } 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 00592068..1030070b 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 @@ -33,8 +33,14 @@ public class ResponseCacheManagerFactory { this.cacheKeyGenerator = cacheKeyGenerator; } + @Deprecated public ResponseCacheManager create(Cache cache, Duration timeToLive) { return new ResponseCacheManager(cacheKeyGenerator, cache, timeToLive); } + public ResponseCacheManager create(Cache cache, Duration timeToLive, + LocalResponseCacheProperties.RequestOptions requestOptions) { + return new ResponseCacheManager(cacheKeyGenerator, cache, timeToLive, requestOptions); + } + } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator.java new file mode 100644 index 00000000..cd72b13a --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator.java @@ -0,0 +1,73 @@ +/* + * 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.postprocessor; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse; +import org.springframework.web.server.ServerWebExchange; + +public class SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator implements AfterCacheExchangeMutator { + + final Pattern MAX_AGE_PATTERN = Pattern.compile("(?:,|^)\\s*max-age=(\\d+)"); + + @Override + public void accept(ServerWebExchange exchange, CachedResponse cachedResponse) { + Optional maxAge = Optional.ofNullable(exchange.getResponse().getHeaders().getCacheControl()) + .map(MAX_AGE_PATTERN::matcher).filter(Matcher::find).map(matcher -> matcher.group(1)) + .map(Integer::parseInt); + + if (maxAge.isPresent()) { + if (maxAge.get() > 0) { + removeNoCacheHeaders(exchange); + } + else { + keepNoCacheHeaders(exchange); + } + } + } + + private void keepNoCacheHeaders(ServerWebExchange exchange) { + // at least it contains 'max-age' so we can append items with commas safely + String cacheControl = exchange.getResponse().getHeaders().getCacheControl(); + StringBuilder newCacheControl = new StringBuilder(cacheControl); + + if (!cacheControl.contains("no-cache")) { + newCacheControl.append(",no-cache"); + } + + if (!cacheControl.contains("must-revalidate")) { + newCacheControl.append(",must-revalidate"); + } + exchange.getResponse().getHeaders().setCacheControl(newCacheControl.toString()); + } + + private void removeNoCacheHeaders(ServerWebExchange exchange) { + String cacheControl = exchange.getResponse().getHeaders().getCacheControl(); + List cacheControlValues = Arrays.asList(cacheControl.split("\\s*,\\s*")); + + String newCacheControl = cacheControlValues.stream() + .filter(s -> !s.matches("must-revalidate|no-cache|no-store")).collect(Collectors.joining(",")); + exchange.getResponse().getHeaders().setCacheControl(newCacheControl); + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutator.java index 7b9b5dc0..2bb55319 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutator.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutator.java @@ -25,7 +25,9 @@ import java.util.List; import java.util.stream.Collectors; import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheUtils; import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.web.server.ServerWebExchange; @@ -45,25 +47,33 @@ public class SetMaxAgeHeaderAfterCacheExchangeMutator implements AfterCacheExcha private final Clock clock; - public SetMaxAgeHeaderAfterCacheExchangeMutator(Duration configuredTimeToLive, Clock clock) { + private final boolean ignoreNoCacheUpdate; + + public SetMaxAgeHeaderAfterCacheExchangeMutator(Duration configuredTimeToLive, Clock clock, + boolean ignoreNoCacheUpdate) { this.configuredTimeToLive = configuredTimeToLive; this.clock = clock; + this.ignoreNoCacheUpdate = ignoreNoCacheUpdate; } @Override public void accept(ServerWebExchange exchange, CachedResponse cachedResponse) { ServerHttpResponse response = exchange.getResponse(); - long calculatedMaxAgeInSeconds = calculateMaxAgeInSeconds(cachedResponse, configuredTimeToLive); + long calculatedMaxAgeInSeconds = calculateMaxAgeInSeconds(exchange.getRequest(), cachedResponse, + configuredTimeToLive); rewriteCacheControlMaxAge(response.getHeaders(), calculatedMaxAgeInSeconds); } - private long calculateMaxAgeInSeconds(CachedResponse cachedResponse, Duration configuredTimeToLive) { + private long calculateMaxAgeInSeconds(ServerHttpRequest request, CachedResponse cachedResponse, + Duration configuredTimeToLive) { + boolean noCache = LocalResponseCacheUtils.isNoCacheRequest(request); long maxAge; - if (configuredTimeToLive.getSeconds() == -1) { - maxAge = -1; + if (noCache && ignoreNoCacheUpdate || configuredTimeToLive.getSeconds() < 0) { + maxAge = 0; } else { - maxAge = Math.max(0, configuredTimeToLive.minus(getElapsedTimeInSeconds(cachedResponse)).getSeconds()); + long calculatedMaxAge = configuredTimeToLive.minus(getElapsedTimeInSeconds(cachedResponse)).getSeconds(); + maxAge = Math.max(0, calculatedMaxAge); } return maxAge; @@ -77,10 +87,10 @@ public class SetMaxAgeHeaderAfterCacheExchangeMutator implements AfterCacheExcha boolean isMaxAgePresent = headers.getCacheControl() != null && headers.getCacheControl().contains(MAX_AGE_PREFIX); + List newCacheControlDirectives = new ArrayList<>(); if (isMaxAgePresent) { List cacheControlHeaders = headers.get(HttpHeaders.CACHE_CONTROL); cacheControlHeaders = cacheControlHeaders == null ? Collections.emptyList() : cacheControlHeaders; - List replacedCacheControlHeaders = new ArrayList<>(); for (String value : cacheControlHeaders) { if (value.contains(MAX_AGE_PREFIX)) { if (seconds == -1) { @@ -92,11 +102,17 @@ public class SetMaxAgeHeaderAfterCacheExchangeMutator implements AfterCacheExcha value = value.replaceFirst("\\bmax-age=\\d+\\b", MAX_AGE_PREFIX + seconds); } } - replacedCacheControlHeaders.add(value); + newCacheControlDirectives.add(value); } - headers.remove(HttpHeaders.CACHE_CONTROL); - headers.addAll(HttpHeaders.CACHE_CONTROL, replacedCacheControlHeaders); } + else { + List cacheControlHeaders = headers.get(HttpHeaders.CACHE_CONTROL); + newCacheControlDirectives = cacheControlHeaders == null ? new ArrayList<>() + : new ArrayList<>(cacheControlHeaders); + newCacheControlDirectives.add("max-age=" + seconds); + } + headers.remove(HttpHeaders.CACHE_CONTROL); + headers.addAll(HttpHeaders.CACHE_CONTROL, newCacheControlDirectives); } } diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutator.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutator.java index 41b2c817..ff724617 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutator.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutator.java @@ -17,38 +17,21 @@ package org.springframework.cloud.gateway.filter.factory.cache.postprocessor; import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpResponse; -import org.springframework.util.CollectionUtils; import org.springframework.web.server.ServerWebExchange; /** - * It sets HTTP Status Code depending {@literal no-cache} - * {@link HttpHeaders#CACHE_CONTROL} header. + * It sets HTTP Status Code. * * @author Marta Medio * @author Ignacio Lozano */ public class SetStatusCodeAfterCacheExchangeMutator implements AfterCacheExchangeMutator { - private static final String NO_CACHE_VALUE = "no-cache"; - @Override public void accept(ServerWebExchange exchange, CachedResponse cachedResponse) { - HttpHeaders requestHeaders = exchange.getRequest().getHeaders(); ServerHttpResponse response = exchange.getResponse(); - - if (!CollectionUtils.isEmpty(cachedResponse.body()) && isRequestNoCache(requestHeaders)) { - response.setStatusCode(HttpStatus.NOT_MODIFIED); - } - else { - response.setStatusCode(cachedResponse.statusCode()); - } - } - - private boolean isRequestNoCache(HttpHeaders requestHeaders) { - return requestHeaders.getCacheControl() != null && requestHeaders.getCacheControl().contains(NO_CACHE_VALUE); + response.setStatusCode(cachedResponse.statusCode()); } } 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 90105d6b..753c5efa 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 @@ -45,13 +45,13 @@ { "name": "spring.cloud.gateway.filter.local-response-cache.size", "type": "org.springframework.util.unit.DataSize", - "description": "Maximum size of the cache to evict entries for this route (in KB, MB and GB).", - "defaultValue": "5m" + "description": "Maximum size of the cache to evict entries for this route (in KB, MB and GB)." }, { "name": "spring.cloud.gateway.filter.local-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)." + "description": "Time to expire a cache entry (expressed in s for seconds, m for minutes, and h for hours).", + "defaultValue": "5m" }, { "name": "spring.cloud.gateway.filter.dedupe-response-header.enabled", diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java index eb538345..a161ec54 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactoryTests.java @@ -17,6 +17,7 @@ package org.springframework.cloud.gateway.filter.factory.cache; import java.time.Duration; +import java.time.Instant; import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -44,6 +45,7 @@ import org.springframework.util.StringUtils; import org.springframework.util.unit.DataSize; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.data.Offset.offset; /** * @author Ignacio Lozano @@ -53,12 +55,23 @@ import static org.assertj.core.api.Assertions.assertThat; @ActiveProfiles(profiles = "local-cache-filter") public class LocalResponseCacheGatewayFilterFactoryTests extends BaseWebClientTests { - private static final String CUSTOM_HEADER = "X-Custom-Date"; + private static final String CUSTOM_HEADER = "X-Custom-Header"; + + private static 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; + } @Nested @SpringBootTest(properties = { "spring.cloud.gateway.filter.local-response-cache.enabled=true" }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) - public class LocalResponseCacheUsingFilterParams extends BaseWebClientTests { + public class UsingFilterParams extends BaseWebClientTests { @Test void shouldNotCacheResponseWhenGetRequestHasBody() { @@ -101,17 +114,18 @@ public class LocalResponseCacheGatewayFilterFactoryTests extends BaseWebClientTe } @Test - void shouldCacheAndReturnNotModifiedStatusWhenCacheControlIsNoCache() { + void shouldNotIncludeMustRevalidateNoStoreAndNoCacheDirectivesWhenMaxAgeIsPositive() { String uri = "/" + UUID.randomUUID() + "/cache/headers"; - testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "1").exchange() - .expectBody().jsonPath("$.headers." + CUSTOM_HEADER); + var response = testClient.get().uri(uri).header("Host", "www.localresponsecache.org").exchange() + .expectBody().returnResult(); + var maxAge = response.getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(LocalResponseCacheGatewayFilterFactoryTests::parseMaxAge).filter(Objects::nonNull).findAny() + .orElse(null); - testClient.get().uri(uri).header("Host", "www.localresponsecache.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(); + assertThat(maxAge).isGreaterThan(0L); + assertThat(response.getResponseHeaders().get(HttpHeaders.CACHE_CONTROL)).doesNotContain("no-store", + "must-revalidate", "no-cache"); } @Test @@ -163,11 +177,13 @@ public class LocalResponseCacheGatewayFilterFactoryTests extends BaseWebClientTe String uri = "/" + UUID.randomUUID() + "/cache/headers"; Long maxAgeRequest1 = testClient.get().uri(uri).header("Host", "www.localresponsecache.org").exchange() .expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() - .map(this::parseMaxAge).filter(Objects::nonNull).findAny().orElse(null); + .map(LocalResponseCacheGatewayFilterFactoryTests::parseMaxAge).filter(Objects::nonNull).findAny() + .orElse(null); Thread.sleep(2000); Long maxAgeRequest2 = testClient.get().uri(uri).header("Host", "www.localresponsecache.org").exchange() .expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() - .map(this::parseMaxAge).filter(Objects::nonNull).findAny().orElse(null); + .map(LocalResponseCacheGatewayFilterFactoryTests::parseMaxAge).filter(Objects::nonNull).findAny() + .orElse(null); assertThat(maxAgeRequest2).isLessThan(maxAgeRequest1); } @@ -213,17 +229,6 @@ public class LocalResponseCacheGatewayFilterFactoryTests extends BaseWebClientTe .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.localresponsecache.org").header("X-Request-Vary", varyHeader) @@ -275,34 +280,185 @@ public class LocalResponseCacheGatewayFilterFactoryTests extends BaseWebClientTe properties = { "spring.cloud.gateway.filter.local-response-cache.enabled=true", "spring.cloud.gateway.filter.local-response-cache.timeToLive=20s" }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) - public class LocalResponseCacheUsingDefaultProperties extends BaseWebClientTests { + public class UsingPropertiesAsDefault extends BaseWebClientTests { @Test void shouldApplyMaxAgeFromPropertiesWhenFilterHasNoParams() throws InterruptedException { String uri = "/" + UUID.randomUUID() + "/cache/headers"; Long maxAgeRequest1 = testClient.get().uri(uri).header("Host", "www.localresponsecache.org").exchange() .expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() - .map(this::parseMaxAge).filter(Objects::nonNull).findAny().orElse(null); + .map(LocalResponseCacheGatewayFilterFactoryTests::parseMaxAge).filter(Objects::nonNull).findAny() + .orElse(null); assertThat(maxAgeRequest1).isLessThanOrEqualTo(20L); Thread.sleep(2000); Long maxAgeRequest2 = testClient.get().uri(uri).header("Host", "www.localresponsecache.org").exchange() .expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() - .map(this::parseMaxAge).filter(Objects::nonNull).findAny().orElse(null); + .map(LocalResponseCacheGatewayFilterFactoryTests::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)); - } + @Test + void shouldNotCacheWhenPrivateDirectiveIsInRequest() { + testClient = testClient.mutate().responseTimeout(Duration.ofHours(1)).build(); + + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + + testClient.get().uri(uri).header("Host", "www.localresponsecache.org") + .header(HttpHeaders.CACHE_CONTROL, CacheControl.noStore().getHeaderValue()) + .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"); + + testClient.get().uri(uri).header("Host", "www.localresponsecache.org").header(CUSTOM_HEADER, "3") // second + // request + // cached + // "2" + // -> + // "3" + // will + // be + // ignored + .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("local_response_cache_java_test", + r -> r.path("/{namespace}/cache/**").and().host("{sub}.localresponsecache.org") + .filters(f -> f.stripPrefix(2).prefixPath("/httpbin").localResponseCache(null, null)) + .uri(uri)) + .build(); } - return null; + + } + + } + + @Nested + @SpringBootTest(properties = { "spring.cloud.gateway.filter.local-response-cache.enabled=true", + "spring.cloud.gateway.filter.local-response-cache.time-to-live=2m", + "spring.cloud.gateway.filter.local-response-cache.request.no-cache-strategy=skip-update-cache-entry" }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + public class DirectiveNoCacheSkippingUpdate extends BaseWebClientTests { + + @Test + void shouldNotCacheWhenCacheControlAsksToValidateWithNotCache_refreshCacheWhenDirectiveNoCache() + throws InterruptedException { + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + + // 1. Store in cache - max-age ~= 2m AND NOT + // (must-revalidate,no-cache,no-store) + final Instant when1stRequest = Instant.now(); + var firstResponse = testClient.get().uri(uri).header("Host", "www.localresponsecache.org") + .header(CUSTOM_HEADER, "1").exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER) + .isEqualTo("1").returnResult(); + var maxAge1st = firstResponse.getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(LocalResponseCacheGatewayFilterFactoryTests::parseMaxAge).filter(Objects::nonNull).findAny() + .orElse(null); + assertThat(maxAge1st).isCloseTo(Duration.ofMinutes(2).toSeconds(), offset(10L)); + assertThat(firstResponse.getResponseHeaders().getCacheControl()).doesNotContain("must-revalidate", + "no-cache", "no-store"); + + // 2. "no-cache" should return max-age=0 & must-revalidate,no-cache,no-store + var secondResponse = testClient.get().uri(uri).header("Host", "www.localresponsecache.org") + .header(CUSTOM_HEADER, "2") + // Cache-Control asks to not use the cached content + .header(HttpHeaders.CACHE_CONTROL, CacheControl.noCache().getHeaderValue()).exchange().expectBody() + .jsonPath("$.headers." + CUSTOM_HEADER).isEqualTo("2").returnResult(); + var maxAge2nd = secondResponse.getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(LocalResponseCacheGatewayFilterFactoryTests::parseMaxAge).filter(Objects::nonNull).findAny() + .orElse(null); + assertThat(maxAge2nd).isZero(); + + // 3. After 2s, max-age = (when1stRequest) - 1s - offset_delay + var waitDuration = Duration.ofSeconds(1); + Thread.sleep(waitDuration.toMillis()); // Wait 2s to check max-age renewed + final Instant when3rdRequest = Instant.now(); + var thirdResponseCached = testClient.get().uri(uri).header("Host", "www.localresponsecache.org") + .header(CUSTOM_HEADER, "3").exchange().expectBody().jsonPath("$.headers." + CUSTOM_HEADER) + .isEqualTo("1").returnResult(); + var maxAge3rd = thirdResponseCached.getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(LocalResponseCacheGatewayFilterFactoryTests::parseMaxAge).filter(Objects::nonNull).findAny() + .orElse(null); + assertThat(maxAge3rd).isCloseTo( + Duration.ofMinutes(2).minus(Duration.between(when1stRequest, when3rdRequest)).getSeconds(), + offset(10L)); + assertThat(maxAge3rd).isNotZero(); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + @Value("${test.uri}") + String uri; + + @Bean + public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { + return builder.routes().route("local_response_cache_java_test", + r -> r.path("/{namespace}/cache/**").and().host("{sub}.localresponsecache.org") + .filters(f -> f.stripPrefix(2).prefixPath("/httpbin").localResponseCache(null, null)) + .uri(uri)) + .build(); + } + + } + + } + + @Nested + @SpringBootTest( + properties = { "spring.cloud.gateway.filter.local-response-cache.enabled=true", + "spring.cloud.gateway.filter.local-response-cache.time-to-live=2m", + "spring.cloud.gateway.filter.local-response-cache.request.no-cache-strategy=update-cache-entry" }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) + public class DirectiveNoCacheWithUpdate extends BaseWebClientTests { + + @Test + void oldMaxAgeWhenNoCacheRequest() throws InterruptedException { + testClient = testClient.mutate().responseTimeout(Duration.ofHours(1)).build(); + + String uri = "/" + UUID.randomUUID() + "/cache/headers"; + // First request -> cache miss + final Instant when1stRequest = Instant.now(); + Long maxAgeRequest1 = testClient.get().uri(uri).header("Host", "www.localresponsecache.org").exchange() + .expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(LocalResponseCacheGatewayFilterFactoryTests::parseMaxAge).filter(Objects::nonNull).findAny() + .orElse(null); + assertThat(maxAgeRequest1).isCloseTo(Duration.ofMinutes(2).toSeconds(), offset(10L)); + + // Second request + no-cache -> skip cache and ignore update + Thread.sleep(1000); + final Duration between1stAnd2ndRequest = Duration.between(when1stRequest, Instant.now()); + Long maxAgeRequest2 = testClient.get().uri(uri).header("Host", "www.localresponsecache.org") + .header(HttpHeaders.CACHE_CONTROL, CacheControl.noCache().getHeaderValue()).exchange().expectBody() + .returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(LocalResponseCacheGatewayFilterFactoryTests::parseMaxAge).filter(Objects::nonNull).findAny() + .orElse(null); + assertThat(maxAgeRequest2).isCloseTo(Duration.ofMinutes(2).minus(between1stAnd2ndRequest).getSeconds(), + offset(10L)); + + // Third request -> cache hit -> entry (and max-age) is updated + Thread.sleep(1000); + Long maxAgeRequest3 = testClient.get().uri(uri).header("Host", "www.localresponsecache.org").exchange() + .expectBody().returnResult().getResponseHeaders().get(HttpHeaders.CACHE_CONTROL).stream() + .map(LocalResponseCacheGatewayFilterFactoryTests::parseMaxAge).filter(Objects::nonNull).findAny() + .orElse(null); + assertThat(maxAgeRequest3).isCloseTo(Duration.ofMinutes(2).toSeconds(), offset(10L)); } @EnableAutoConfiguration diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtilsTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtilsTests.java new file mode 100644 index 00000000..711672b8 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtilsTests.java @@ -0,0 +1,50 @@ +/* + * 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 org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +class LocalResponseCacheUtilsTests { + + @ParameterizedTest + @ValueSource(strings = { "", "no-store", "no-store, wrong-no-cache", "s-no-cache" }) + void shouldNotIdentifyRequestAsNoCacheRequest(String cacheControl) { + MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this") + .header("Cache-Control", cacheControl).build(); + + boolean result = LocalResponseCacheUtils.isNoCacheRequest(httpRequest); + + assertThat(result).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = { "no-cache", "s-no-cache, no-cache", "private,no-cache", " no-cache", "no-cache " }) + void shouldIdentifyRequestAsNoCacheRequest(String cacheControl) { + MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this") + .header("Cache-Control", cacheControl).build(); + + boolean result = LocalResponseCacheUtils.isNoCacheRequest(httpRequest); + + assertThat(result).isTrue(); + } + +} 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/ResponseCacheGatewayFilterTest.java index 64616cc8..cb2506dd 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/ResponseCacheGatewayFilterTest.java @@ -30,7 +30,7 @@ import static org.assertj.core.api.Assertions.assertThat; */ class ResponseCacheGatewayFilterTest { - ResponseCacheManager cacheManagerToTest = new ResponseCacheManager(null, null, null); + ResponseCacheManager cacheManagerToTest = new ResponseCacheManager(null, null, null, null); @Test void requestShouldBeCacheable() { diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetCacheDirectivesByMaxAgeAfterCacheExchangeMutatorTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetCacheDirectivesByMaxAgeAfterCacheExchangeMutatorTests.java new file mode 100644 index 00000000..2911a213 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetCacheDirectivesByMaxAgeAfterCacheExchangeMutatorTests.java @@ -0,0 +1,89 @@ +/* + * 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.postprocessor; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.mock.web.server.MockServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +class SetCacheDirectivesByMaxAgeAfterCacheExchangeMutatorTests { + + private MockServerWebExchange inputExchange; + + private SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator toTest; + + @BeforeEach + void setUp() { + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setCacheControl("max-age=1234"); + MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this").build(); + + inputExchange = MockServerWebExchange.from(httpRequest); + MockServerHttpResponse httpResponse = inputExchange.getResponse(); + httpResponse.setStatusCode(HttpStatus.OK); + httpResponse.getHeaders().putAll(responseHeaders); + + toTest = new SetCacheDirectivesByMaxAgeAfterCacheExchangeMutator(); + } + + @ParameterizedTest + @ValueSource(strings = { "", "ETag=1234-123", "s-max-age=20" }) + void doesntModifyCacheControlWhenNoMaxAge(String cacheControlValue) { + inputExchange.getResponse().getHeaders().setCacheControl(cacheControlValue); + + toTest.accept(inputExchange, null); + + assertThat(inputExchange.getResponse().getHeaders().getCacheControl()).isEqualTo(cacheControlValue); + } + + @ParameterizedTest + @ValueSource( + strings = { "max-age=0", "ETag=1234-123,max-age=0", "s-max-age=20,max-age=0", "ETag=with-spaces, max-age=0", + "ETag=with-spaces, max-age=0,Expires=123123123", " max-age=0, ETag=with-spaces" }) + void directivesNoCacheAreAddedWhenMaxAgeIsZero(String cacheControlValue) { + inputExchange.getResponse().getHeaders().setCacheControl(cacheControlValue); + + toTest.accept(inputExchange, null); + + assertThat(inputExchange.getResponse().getHeaders().getCacheControl()).doesNotContainPattern(",\\s*,") + .contains("max-age=0").contains("must-revalidate").contains("no-cache"); + } + + @ParameterizedTest + @ValueSource(strings = { "max-age=10,must-revalidate", "must-revalidate,ETag=1234-123,max-age=10", + "must-revalidate,s-max-age=0,max-age=10", " max-age=10, must-revalidate,ETag=with-spaces", + "ETag=with-spaces,must-revalidate, max-age=10,Expires=123123123", + "ETag=with-spaces,must-revalidate, max-age=10", "max-age=10,no-store" }) + void directivesNoCacheAreRemovedWhenMaxAgePositive(String cacheControlValue) { + inputExchange.getResponse().getHeaders().setCacheControl(cacheControlValue); + + toTest.accept(inputExchange, null); + + assertThat(inputExchange.getResponse().getHeaders().getCacheControl()).contains("max-age=10") + .doesNotContainPattern(",\\s*,").doesNotContain("no-store").doesNotContain("must-revalidate") + .doesNotContain("no-cache"); + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutatorTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutatorTest.java index b2adcd64..fab93d26 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutatorTest.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetMaxAgeHeaderAfterCacheExchangeMutatorTest.java @@ -34,6 +34,7 @@ import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.http.server.reactive.MockServerHttpResponse; import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -67,16 +68,29 @@ class SetMaxAgeHeaderAfterCacheExchangeMutatorTest { } @Test - void maxAgeIsNotAdded_whenMaxAgeIsNotPresent() { + void maxAgeIsTimeToLive_whenMaxAgeIsNotPresent() { inputExchange.getResponse().getHeaders().setCacheControl((String) null); Duration timeToLive = Duration.ofSeconds(30); CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build(); SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive, - clock); + clock, false); toTest.accept(inputExchange, inputCachedResponse); - assertThat(parseMaxAge(inputExchange.getResponse())).isEmpty(); + assertThat(parseMaxAge(inputExchange.getResponse()).get()).isEqualTo(timeToLive.getSeconds()); + } + + @Test + void maxAgeIsZero_whenTimeToLiveIsNegative() { + inputExchange.getResponse().getHeaders().setCacheControl((String) null); + + Duration timeToLive = Duration.ofSeconds(-1); + CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build(); + + SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive, + clock, false); + toTest.accept(inputExchange, inputCachedResponse); + assertThat(parseMaxAge(inputExchange.getResponse()).get()).isEqualTo(0L); } @Test @@ -85,12 +99,12 @@ class SetMaxAgeHeaderAfterCacheExchangeMutatorTest { CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build(); SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive, - clock); + clock, false); toTest.accept(inputExchange, inputCachedResponse); Optional firstMaxAgeSeconds = parseMaxAge(inputExchange.getResponse()); SetMaxAgeHeaderAfterCacheExchangeMutator toTestSecondsLater = new SetMaxAgeHeaderAfterCacheExchangeMutator( - timeToLive, clockSecondsLater); + timeToLive, clockSecondsLater, false); toTestSecondsLater.accept(inputExchange, inputCachedResponse); Optional secondMaxAgeSeconds = parseMaxAge(inputExchange.getResponse()); @@ -106,12 +120,12 @@ class SetMaxAgeHeaderAfterCacheExchangeMutatorTest { CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build(); SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive, - clock); + clock, false); toTest.accept(inputExchange, inputCachedResponse); Optional firstMaxAgeSeconds = parseMaxAge(inputExchange.getResponse()); SetMaxAgeHeaderAfterCacheExchangeMutator toTestSecondsLater = new SetMaxAgeHeaderAfterCacheExchangeMutator( - timeToLive, clockSecondsLater); + timeToLive, clockSecondsLater, false); toTestSecondsLater.accept(inputExchange, inputCachedResponse); Optional secondMaxAgeSeconds = parseMaxAge(inputExchange.getResponse()); @@ -126,11 +140,11 @@ class SetMaxAgeHeaderAfterCacheExchangeMutatorTest { CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build(); SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive, - clock); + clock, false); toTest.accept(inputExchange, inputCachedResponse); - String[] cacheControlValues = Optional.ofNullable(inputExchange.getResponse().getHeaders().getCacheControl()) - .map(s -> s.split("\\s*,\\s*")).orElse(null); + String[] cacheControlValues = StringUtils + .tokenizeToStringArray(inputExchange.getResponse().getHeaders().getCacheControl(), ","); assertThat(cacheControlValues).contains("max-stale=12", "min-stale=1"); } @@ -141,7 +155,7 @@ class SetMaxAgeHeaderAfterCacheExchangeMutatorTest { CachedResponse inputCachedResponse = CachedResponse.create(HttpStatus.OK).timestamp(clock.instant()).build(); SetMaxAgeHeaderAfterCacheExchangeMutator toTest = new SetMaxAgeHeaderAfterCacheExchangeMutator(timeToLive, - clock); + clock, false); toTest.accept(inputExchange, inputCachedResponse); List cacheControlValues = inputExchange.getResponse().getHeaders().get("X-Custom-Header"); @@ -149,18 +163,22 @@ class SetMaxAgeHeaderAfterCacheExchangeMutatorTest { } private Optional parseMaxAge(ServerHttpResponse response) { - return parseMaxAge(response.getHeaders().getCacheControl()); + return parseMaxAge(response.getHeaders().get("Cache-Control")); } - private Optional parseMaxAge(String cacheControlValue) { - if (StringUtils.hasText(cacheControlValue)) { - Pattern maxAgePattern = Pattern.compile("\\bmax-age=(\\d+)\\b"); - Matcher matcher = maxAgePattern.matcher(cacheControlValue); - if (matcher.find()) { - return Optional.of(Long.parseLong(matcher.group(1))); - } + private Optional parseMaxAge(List cacheControlValues) { + if (CollectionUtils.isEmpty(cacheControlValues)) { + return Optional.empty(); } - return Optional.empty(); + + final Pattern maxAgePattern = Pattern.compile("\\bmax-age=(\\d+)\\b"); + return cacheControlValues.stream().map(cacheControlDirective -> { + Matcher matcher = maxAgePattern.matcher(cacheControlDirective); + if (matcher.find()) { + return Long.parseLong(matcher.group(1)); + } + return null; + }).filter(maxAge -> maxAge != null).findFirst(); } } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutatorTest.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutatorTest.java index 9ad1e802..9ff03362 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutatorTest.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/cache/postprocessor/SetStatusCodeAfterCacheExchangeMutatorTest.java @@ -17,7 +17,8 @@ package org.springframework.cloud.gateway.filter.factory.cache.postprocessor; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.cloud.gateway.filter.factory.cache.CachedResponse; import org.springframework.http.HttpHeaders; @@ -47,42 +48,18 @@ class SetStatusCodeAfterCacheExchangeMutatorTest { httpResponse.getHeaders().putAll(responseHeaders); } - @Test - void statusCodeIs304_whenCacheHitsAndNoCacheHeaderIsPresent() { - CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK).body("some-data").build(); - MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this") - .header("Cache-Control", "no-cache").build(); + @ParameterizedTest + @ValueSource(ints = { 200, 400, 404, 500 }) + void statusCodeIsSetFromCachedResponse(int statusCode) { + CachedResponse cachedResponse = CachedResponse.create(HttpStatus.valueOf(statusCode)).body("some-data").build(); + MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this").build(); inputExchange = MockServerWebExchange.from(httpRequest); SetStatusCodeAfterCacheExchangeMutator toTest = new SetStatusCodeAfterCacheExchangeMutator(); toTest.accept(inputExchange, cachedResponse); - assertThat(inputExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.NOT_MODIFIED); - } - - @Test - void statusCodeIs200_whenCacheHitsAndNoCacheHeaderIsNotPresent() { - CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK).body("some-data").build(); - - SetStatusCodeAfterCacheExchangeMutator toTest = new SetStatusCodeAfterCacheExchangeMutator(); - toTest.accept(inputExchange, cachedResponse); - - assertThat(inputExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); - } - - @Test - void statusCodeIs200_whenNoCacheHitsAndEvenNoCacheHeaderIsPresent() { - CachedResponse cachedResponse = CachedResponse.create(HttpStatus.OK).build(); - MockServerHttpRequest httpRequest = MockServerHttpRequest.get("https://this") - .header("Cache-Control", "no-cache").build(); - - inputExchange = MockServerWebExchange.from(httpRequest); - - SetStatusCodeAfterCacheExchangeMutator toTest = new SetStatusCodeAfterCacheExchangeMutator(); - toTest.accept(inputExchange, cachedResponse); - - assertThat(inputExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(inputExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.valueOf(statusCode)); } }