diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 32c975909..0e54b50d9 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -82,7 +82,7 @@ *** xref:spring-cloud-gateway-server-webmvc/filters/deduperesponseheader.adoc[] *** xref:spring-cloud-gateway-server-webmvc/filters/fallback-headers.adoc[] *** xref:spring-cloud-gateway-server-webmvc/filters/loadbalancer.adoc[] -//*** xref:spring-cloud-gateway-server-mvc/filters/local-cache-response-filter.adoc[] +*** xref:spring-cloud-gateway-server-webmvc/filters/local-cache-response-filter.adoc[] *** xref:spring-cloud-gateway-server-webmvc/filters/maprequestheader.adoc[] *** xref:spring-cloud-gateway-server-webmvc/filters/modifyrequestbody.adoc[] *** xref:spring-cloud-gateway-server-webmvc/filters/modifyresponsebody.adoc[] diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/local-cache-response-filter.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/local-cache-response-filter.adoc index 0929e94a1..2f946d437 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/local-cache-response-filter.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/local-cache-response-filter.adoc @@ -6,50 +6,58 @@ This filter allows caching the response body and headers to follow these rules: * It can only cache bodiless GET requests. * It caches the response only for one of the following status codes: HTTP 200 (OK), HTTP 206 (Partial Content), or HTTP 301 (Moved Permanently). * Response data is not cached if `Cache-Control` header does not allow it (`no-store` present in the request or `no-store` or `private` present in the response). -* If the response is already cached and a new request is performed with no-cache value in `Cache-Control` header, it returns a bodiless response with 304 (Not Modified). +* Response data is not cached if the response `Vary` header is `*`. +* Response data is not cached if the response has a streaming media type (see `spring.cloud.gateway.server.webmvc.streaming-media-types`). +* If a new request is performed with `no-cache` value in `Cache-Control` header, the request is forwarded to the upstream and the existing cache entry is kept. -This filter configures the local response cache per route and is available only if the `spring.cloud.gateway.filter.local-response-cache.enabled` property is enabled. And a xref:spring-cloud-gateway-server-webflux/global-filters.adoc#local-cache-response-global-filter[local response cache configured globally] is also available as feature. +This filter configures the local response cache per route. It accepts the first parameter to override the time to expire a cache entry (expressed in `s` for seconds, `m` for minutes, and `h` for hours) and a second parameter to set the maximum size of the cache to evict entries for this route (`KB`, `MB`, or `GB`). +Both parameters are optional: if the time to live is not set, it defaults to 5 minutes; if the maximum size is not set, the cache is not size-bounded. -The following listing shows how to add local response cache filter: +The following listing shows how to add the local response cache filter: -[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") - .localResponseCache(Duration.ofMinutes(30), "500MB") - ).uri(uri)) - .build(); -} ----- - -or this - -.application.yaml +.application.yml [source,yaml] ---- spring: cloud: gateway: - routes: - - id: resource - uri: http://localhost:9000 - predicates: - - Path=/resource - filters: - - LocalResponseCache=30m,500MB + server: + webmvc: + routes: + - id: resource + uri: http://localhost:9000 + predicates: + - Path=/resource + filters: + - LocalResponseCache=30m,500MB +---- + +.GatewaySampleApplication.java +[source,java] +---- +import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.uri; +import static org.springframework.cloud.gateway.server.mvc.filter.ResponseCacheFilterFunctions.localResponseCache; +import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; +import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; + +@Configuration +class RouteConfiguration { + + @Bean + public RouterFunction gatewayRouterFunctionsCache() { + return route("resource") + .GET("/resource", http()) + .before(uri("http://localhost:9000")) + .filter(localResponseCache(Duration.ofMinutes(30), DataSize.parse("500MB"))) + .build(); + } +} ---- NOTE: This filter also automatically calculates the `max-age` value in the HTTP `Cache-Control` header. -Only if `max-age` is present on the original response is the value rewritten with the number of seconds set in the `timeToLive` configuration parameter. +If `max-age` is not present on the original response, the value is set to the number of seconds set in the `timeToLive` configuration parameter. In consecutive calls, this value is recalculated with the number of seconds left until the response expires. -NOTE: To enable this feature, add `com.github.ben-manes.caffeine:caffeine` and `spring-boot-starter-cache` as project dependencies. - -WARNING: If your project creates custom `CacheManager` beans, it will either need to be marked with `@Primary` or injected using `@Qualifier`. - - +NOTE: To enable this feature, add `com.github.ben-manes.caffeine:caffeine` as a project dependency. diff --git a/spring-cloud-gateway-server-webmvc/pom.xml b/spring-cloud-gateway-server-webmvc/pom.xml index c3119f258..7bca83c2c 100644 --- a/spring-cloud-gateway-server-webmvc/pom.xml +++ b/spring-cloud-gateway-server-webmvc/pom.xml @@ -92,6 +92,11 @@ true + + com.github.ben-manes.caffeine + caffeine + true + com.bucket4j bucket4j_jdk17-core @@ -163,11 +168,6 @@ bucket4j_jdk17-caffeine test - - com.github.ben-manes.caffeine - caffeine - test - org.testcontainers testcontainers-junit-jupiter diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterAutoConfiguration.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterAutoConfiguration.java index 4a5809874..f0065bfdd 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterAutoConfiguration.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterAutoConfiguration.java @@ -20,6 +20,8 @@ import java.util.Objects; import java.util.function.Function; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Weigher; import io.github.bucket4j.BucketConfiguration; import org.springframework.beans.factory.BeanFactory; @@ -113,4 +115,15 @@ public TokenRelayFilterFunctions.FilterSupplier tokenRelayFilterFunctionsSupplie } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ Weigher.class, Caffeine.class }) + static class ResponseCacheFilterConfiguration { + + @Bean + public ResponseCacheFilterFunctions.FilterSupplier responseCacheFilterFunctionsSupplier() { + return new ResponseCacheFilterFunctions.FilterSupplier(); + } + + } + } diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheFilterFunctions.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheFilterFunctions.java new file mode 100644 index 000000000..9967b67eb --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheFilterFunctions.java @@ -0,0 +1,709 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.server.mvc.filter; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Weigher; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.cloud.gateway.server.mvc.common.MvcUtils; +import org.springframework.cloud.gateway.server.mvc.common.Shortcut; +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; +import org.springframework.cloud.gateway.server.mvc.handler.GatewayServerResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; +import org.springframework.util.unit.DataSize; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.function.AsyncServerResponse; +import org.springframework.web.servlet.function.HandlerFilterFunction; +import org.springframework.web.servlet.function.HandlerFunction; +import org.springframework.web.servlet.function.ServerRequest; +import org.springframework.web.servlet.function.ServerResponse; + +/** + * {@link HandlerFilterFunction HandlerFilterFunctions} that cache HTTP responses, so + * latency and upstream overhead are reduced. Mirrors the semantics of the WebFlux + * {@literal LocalResponseCache} filter. + * + * @author Ingo Griebsch + * @author Nikita Kibitkin + */ +public abstract class ResponseCacheFilterFunctions { + + private static final Duration DEFAULT_TIME_TO_LIVE = Duration.ofMinutes(5); + + private ResponseCacheFilterFunctions() { + } + + @Shortcut + public static HandlerFilterFunction localResponseCache() { + return localResponseCache(null, null); + } + + @Shortcut({ "timeToLive" }) + public static HandlerFilterFunction localResponseCache( + @Nullable Duration timeToLive) { + return localResponseCache(timeToLive, null); + } + + @Shortcut({ "timeToLive", "size" }) + public static HandlerFilterFunction localResponseCache( + @Nullable Duration timeToLive, @Nullable DataSize size) { + Duration ttl = timeToLive != null ? timeToLive : DEFAULT_TIME_TO_LIVE; + return new ResponseCacheFilter( + new ResponseCacheManager(new CacheKeyGenerator(), createCache(ttl, size), ttl, Clock.systemUTC())); + } + + private static Cache createCache(Duration timeToLive, @Nullable DataSize size) { + Caffeine caffeine = Caffeine.newBuilder().expireAfterWrite(timeToLive); + if (size != null) { + return caffeine.maximumWeight(size.toBytes()).weigher(new CachedResponseWeigher()).build(); + } + return caffeine.build(); + } + + /** + * A {@link HandlerFilterFunction} that serves responses from the local cache when + * possible and populates the cache from the upstream response otherwise. + */ + static class ResponseCacheFilter implements HandlerFilterFunction { + + private final ResponseCacheManager responseCacheManager; + + private volatile @Nullable List streamingMediaTypes; + + ResponseCacheFilter(ResponseCacheManager responseCacheManager) { + this.responseCacheManager = responseCacheManager; + } + + @Override + public ServerResponse filter(ServerRequest request, HandlerFunction next) throws Exception { + if (!responseCacheManager.isRequestCacheable(request)) { + return next.handle(request); + } + if (responseCacheManager.isNoCacheRequest(request)) { + // no-cache: revalidate against the upstream, skip the cache entry update + return next.handle(request); + } + String metadataKey = responseCacheManager.resolveMetadataKey(request); + Optional cachedResponse = responseCacheManager.getFromCache(request, metadataKey); + if (cachedResponse.isPresent()) { + return responseCacheManager.processFromCache(metadataKey, cachedResponse.get()); + } + ServerResponse response = next.handle(request); + if (response instanceof AsyncServerResponse) { + // an async response completes later and cannot be inspected or + // captured here + return response; + } + if (isStreamingResponse(request, response)) { + // a streaming body must not be buffered into the cache + return response; + } + return responseCacheManager.processFromUpstream(request, metadataKey, response); + } + + private boolean isStreamingResponse(ServerRequest request, ServerResponse response) { + MediaType contentType = response.headers().getContentType(); + if (contentType == null) { + return false; + } + return streamingMediaTypes(request).stream().anyMatch(contentType::isCompatibleWith); + } + + private List streamingMediaTypes(ServerRequest request) { + List mediaTypes = this.streamingMediaTypes; + if (mediaTypes == null) { + mediaTypes = MvcUtils.getApplicationContext(request) + .getBeanProvider(GatewayMvcProperties.class) + .getIfAvailable(GatewayMvcProperties::new) + .getStreamingMediaTypes(); + this.streamingMediaTypes = mediaTypes; + } + return mediaTypes; + } + + } + + /** + * Caches responses and their metadata and applies the cache related response header + * mutations on both the cached and the upstream exchange path. + */ + static class ResponseCacheManager { + + private static final Log LOGGER = LogFactory.getLog(ResponseCacheManager.class); + + private static final List FORBIDDEN_CACHE_CONTROL_VALUES = List.of("private", "no-store"); + + private static final List STATUSES_TO_CACHE = List.of(HttpStatus.OK, HttpStatus.PARTIAL_CONTENT, + HttpStatus.MOVED_PERMANENTLY); + + private static final String VARY_WILDCARD = "*"; + + private static final Pattern NO_CACHE_PATTERN = Pattern.compile(".*(\\s|,|^)no-cache(\\s|,|$).*"); + + private static final String MAX_AGE_PREFIX = "max-age="; + + private final CacheKeyGenerator cacheKeyGenerator; + + private final Cache cache; + + private final Duration timeToLive; + + private final Clock clock; + + ResponseCacheManager(CacheKeyGenerator cacheKeyGenerator, Cache cache, Duration timeToLive, + Clock clock) { + this.cacheKeyGenerator = cacheKeyGenerator; + this.cache = cache; + this.timeToLive = timeToLive; + this.clock = clock; + } + + boolean isRequestCacheable(ServerRequest request) { + return HttpMethod.GET.equals(request.method()) && !hasRequestBody(request) + && isCacheControlAllowed(request.headers().asHttpHeaders()); + } + + boolean isNoCacheRequest(ServerRequest request) { + String cacheControl = request.headers().asHttpHeaders().getCacheControl(); + return cacheControl != null && NO_CACHE_PATTERN.matcher(cacheControl).matches(); + } + + String resolveMetadataKey(ServerRequest request) { + return cacheKeyGenerator.generateMetadataKey(request); + } + + Optional getFromCache(ServerRequest request, String metadataKey) { + List varyOnHeaders = getIfPresent(metadataKey) instanceof CachedResponseMetadata metadata + ? metadata.varyOnHeaders() : Collections.emptyList(); + String key = cacheKeyGenerator.generateKey(request, varyOnHeaders); + return getIfPresent(key) instanceof CachedResponse cachedResponse ? Optional.of(cachedResponse) + : Optional.empty(); + } + + ServerResponse processFromUpstream(ServerRequest request, String metadataKey, ServerResponse response) { + if (!isResponseCacheable(response)) { + return response; + } + CachedResponseMetadata metadata = new CachedResponseMetadata(response.headers().getVary()); + String key = cacheKeyGenerator.generateKey(request, metadata.varyOnHeaders()); + try { + applyAfterCacheMutations(response.headers(), clock.instant()); + } + catch (UnsupportedOperationException ex) { + // the response implementation exposes read-only headers (e.g. a + // replacement built with ServerResponse.ok()); the entry is still + // cached and cache hits receive the mutations + } + // the body cannot be read here without breaking the deferred proxy write, + // so capture the bytes while they are written and fill the cache afterwards + return new CachingServerResponse(response, this, metadataKey, metadata, key); + } + + void cacheCapturedResponse(String metadataKey, CachedResponseMetadata metadata, String key, + HttpStatusCode statusCode, HttpHeaders headers, byte[] body) { + if (!STATUSES_TO_CACHE.contains(statusCode)) { + // the status was mutated after the response was wrapped (e.g. by an + // outer SetStatus filter) + return; + } + if (headers.getContentLength() > -1 && headers.getContentLength() != body.length) { + // the framing header does not match the written body, so something + // outside the filter altered the exchange; do not cache the anomaly + return; + } + putInCache(metadataKey, metadata); + putInCache(key, new CachedResponse(statusCode, headers, body, clock.instant())); + } + + ServerResponse processFromCache(String metadataKey, CachedResponse cachedResponse) { + putInCache(metadataKey, new CachedResponseMetadata(cachedResponse.headers().getVary())); + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.addAll(cachedResponse.headers()); + applyAfterCacheMutations(responseHeaders, cachedResponse.timestamp()); + return GatewayServerResponse.status(cachedResponse.statusCode()) + .headers(headers -> headers.addAll(responseHeaders)) + .build((servletRequest, servletResponse) -> { + servletResponse.getOutputStream().write(cachedResponse.body()); + return null; + }); + } + + private boolean isResponseCacheable(ServerResponse response) { + return STATUSES_TO_CACHE.contains(response.statusCode()) && isCacheControlAllowed(response.headers()) + && !isVaryWildcard(response.headers()); + } + + private void applyAfterCacheMutations(HttpHeaders headers, Instant cachedAt) { + headers.remove(HttpHeaders.PRAGMA); + headers.remove(HttpHeaders.EXPIRES); + long maxAgeInSeconds = calculateMaxAgeInSeconds(cachedAt); + rewriteCacheControlMaxAge(headers, maxAgeInSeconds); + reconcileCacheControlDirectives(headers, maxAgeInSeconds); + } + + private long calculateMaxAgeInSeconds(Instant cachedAt) { + if (timeToLive.getSeconds() < 0) { + return 0; + } + Duration elapsed = Duration.between(cachedAt, clock.instant()); + return Math.max(0, timeToLive.minus(elapsed).getSeconds()); + } + + private static void rewriteCacheControlMaxAge(HttpHeaders headers, long maxAgeInSeconds) { + List cacheControlValues = headers.getOrEmpty(HttpHeaders.CACHE_CONTROL); + List newCacheControlValues = new ArrayList<>(); + boolean maxAgePresent = cacheControlValues.stream().anyMatch(value -> value.contains(MAX_AGE_PREFIX)); + if (maxAgePresent) { + for (String value : cacheControlValues) { + if (value.contains(MAX_AGE_PREFIX)) { + value = value.replaceFirst("\\bmax-age=\\d+\\b", MAX_AGE_PREFIX + maxAgeInSeconds); + } + newCacheControlValues.add(value); + } + } + else { + newCacheControlValues.addAll(cacheControlValues); + newCacheControlValues.add(MAX_AGE_PREFIX + maxAgeInSeconds); + } + headers.remove(HttpHeaders.CACHE_CONTROL); + headers.addAll(HttpHeaders.CACHE_CONTROL, newCacheControlValues); + } + + private static void reconcileCacheControlDirectives(HttpHeaders headers, long maxAgeInSeconds) { + String cacheControl = headers.getCacheControl(); + if (cacheControl == null) { + return; + } + if (maxAgeInSeconds > 0) { + headers.setCacheControl(Arrays.stream(cacheControl.split("\\s*,\\s*")) + .filter(directive -> !directive.matches("must-revalidate|no-cache|no-store")) + .collect(Collectors.joining(","))); + } + else { + // 'max-age' is present, so appending directives with commas is safe + StringBuilder newCacheControl = new StringBuilder(cacheControl); + if (!cacheControl.contains("no-cache")) { + newCacheControl.append(",no-cache"); + } + if (!cacheControl.contains("must-revalidate")) { + newCacheControl.append(",must-revalidate"); + } + headers.setCacheControl(newCacheControl.toString()); + } + } + + private static boolean isCacheControlAllowed(HttpHeaders headers) { + return headers.getOrEmpty(HttpHeaders.CACHE_CONTROL) + .stream() + .noneMatch(FORBIDDEN_CACHE_CONTROL_VALUES::contains); + } + + private static boolean isVaryWildcard(HttpHeaders headers) { + return headers.getOrEmpty(HttpHeaders.VARY).stream().anyMatch(VARY_WILDCARD::equals); + } + + private static boolean hasRequestBody(ServerRequest request) { + return request.headers().asHttpHeaders().getContentLength() > 0; + } + + private @Nullable Object getIfPresent(String key) { + try { + return cache.getIfPresent(key); + } + catch (RuntimeException e) { + LOGGER.error("Error reading from cache. Data will not come from cache.", e); + return null; + } + } + + private void putInCache(String key, Object value) { + try { + cache.put(key, value); + } + catch (RuntimeException e) { + LOGGER.error("Error writing into cache. Data will not be cached.", e); + } + } + + } + + /** + * Creates cache keys based on a {@link ServerRequest} and the headers the cached + * response varies on. + */ + static class CacheKeyGenerator { + + private static final String KEY_SEPARATOR = ";"; + + private static final String METADATA_KEY_PREFIX = "META_"; + + private final ThreadLocal messageDigest = ThreadLocal.withInitial(() -> { + try { + return MessageDigest.getInstance("MD5"); + } + catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Error creating CacheKeyGenerator", e); + } + }); + + String generateMetadataKey(ServerRequest request) { + return METADATA_KEY_PREFIX + generateKey(request, Collections.emptyList()); + } + + String generateKey(ServerRequest request, List varyOnHeaders) { + byte[] digest = messageDigest.get().digest(generateRawKey(request, varyOnHeaders)); + return Base64.getEncoder().encodeToString(digest); + } + + private byte[] generateRawKey(ServerRequest request, List varyOnHeaders) { + StringBuilder rawKey = new StringBuilder(); + rawKey.append(request.uri()).append(KEY_SEPARATOR); + rawKey.append(headerKeyValue(request, HttpHeaders.AUTHORIZATION, KEY_SEPARATOR)).append(KEY_SEPARATOR); + rawKey.append(cookiesKeyValue(request)).append(KEY_SEPARATOR); + varyOnHeaders.stream() + .sorted() + .forEach(header -> rawKey.append(headerKeyValue(request, header, ",")).append(KEY_SEPARATOR)); + return rawKey.toString().getBytes(StandardCharsets.UTF_8); + } + + private static String headerKeyValue(ServerRequest request, String header, String valueSeparator) { + List values = request.headers().asHttpHeaders().get(header); + if (values == null) { + return ""; + } + return header + "=" + values.stream().sorted().collect(Collectors.joining(valueSeparator)); + } + + private static String cookiesKeyValue(ServerRequest request) { + MultiValueMap cookies = request.cookies(); + if (CollectionUtils.isEmpty(cookies)) { + return ""; + } + return cookies.values() + .stream() + .flatMap(Collection::stream) + .map(cookie -> cookie.getName() + "=" + cookie.getValue()) + .sorted() + .collect(Collectors.joining(KEY_SEPARATOR)); + } + + } + + /** + * A cached HTTP response. + * + * @param statusCode the status code of the cached response + * @param headers the headers of the cached response, as received from the upstream + * @param body the body of the cached response + * @param timestamp the moment the response was cached + */ + record CachedResponse(HttpStatusCode statusCode, HttpHeaders headers, byte[] body, Instant timestamp) { + + } + + /** + * The metadata of a cached HTTP response. + * + * @param varyOnHeaders the request headers the cached response varies on + */ + record CachedResponseMetadata(List varyOnHeaders) { + + } + + /** + * A {@link ServerResponse} decorator that captures the bytes the delegate writes and + * fills the cache once the delegate has been written successfully. Capturing at write + * time keeps the regular (deferred) proxy write path intact and guarantees that the + * cached body always belongs to the response that produced it. + */ + private static final class CachingServerResponse implements GatewayServerResponse { + + private final ServerResponse delegate; + + private final ResponseCacheManager responseCacheManager; + + private final String metadataKey; + + private final CachedResponseMetadata metadata; + + private final String key; + + CachingServerResponse(ServerResponse delegate, ResponseCacheManager responseCacheManager, String metadataKey, + CachedResponseMetadata metadata, String key) { + this.delegate = delegate; + this.responseCacheManager = responseCacheManager; + this.metadataKey = metadataKey; + this.metadata = metadata; + this.key = key; + } + + @Override + public HttpStatusCode statusCode() { + return delegate.statusCode(); + } + + @Override + public void setStatusCode(HttpStatusCode statusCode) { + // matches the SetStatus filter semantics: status mutation is only + // supported for gateway-produced responses + if (delegate instanceof GatewayServerResponse gatewayServerResponse) { + gatewayServerResponse.setStatusCode(statusCode); + } + } + + @Override + public HttpHeaders headers() { + return delegate.headers(); + } + + @Override + public MultiValueMap cookies() { + return delegate.cookies(); + } + + @Override + public @Nullable ModelAndView writeTo(HttpServletRequest request, HttpServletResponse response, Context context) + throws ServletException, IOException { + BodyCapturingResponseWrapper capturingResponse = new BodyCapturingResponseWrapper(response); + ModelAndView modelAndView; + try { + modelAndView = delegate.writeTo(request, capturingResponse, context); + } + finally { + // the capturing writer buffers; flush so the client receives the tail + // even when the write turns out not to be cacheable + capturingResponse.flushWriter(); + } + if (isCompletedCacheableWrite(request, capturingResponse, modelAndView)) { + responseCacheManager.cacheCapturedResponse(metadataKey, metadata, key, delegate.statusCode(), + headersToCache(capturingResponse), capturingResponse.getCapturedBody()); + } + else { + capturingResponse.stopCapturing(); + } + return modelAndView; + } + + private boolean isCompletedCacheableWrite(HttpServletRequest request, BodyCapturingResponseWrapper response, + @Nullable ModelAndView modelAndView) { + if (modelAndView != null || request.isAsyncStarted()) { + // the body is produced outside this call (view rendering or async + // dispatch), so nothing was captured + return false; + } + if (response.getStatus() != delegate.statusCode().value()) { + // the delegate answered differently than advertised, e.g. 304 Not + // Modified to a conditional request, so the captured body is not the + // full response + return false; + } + if (response.hasCaptureErrors()) { + // a swallowed writer error means the captured body may be truncated + return false; + } + return true; + } + + private HttpHeaders headersToCache(BodyCapturingResponseWrapper response) { + HttpHeaders headers = new HttpHeaders(); + headers.addAll(delegate.headers()); + // some headers only exist on the servlet response, e.g. the Content-Type a + // message converter chose while writing a replacement response + if (headers.getContentType() == null && response.getContentType() != null) { + headers.set(HttpHeaders.CONTENT_TYPE, response.getContentType()); + } + return headers; + } + + } + + /** + * A {@link HttpServletResponseWrapper} that copies everything written to the response + * body into a buffer while passing it through to the underlying response. + */ + private static final class BodyCapturingResponseWrapper extends HttpServletResponseWrapper { + + private final ByteArrayOutputStream capturedBody = new ByteArrayOutputStream(); + + private volatile boolean capturing = true; + + private @Nullable ServletOutputStream outputStream; + + private @Nullable PrintWriter writer; + + BodyCapturingResponseWrapper(HttpServletResponse response) { + super(response); + } + + byte[] getCapturedBody() { + flushWriter(); + return capturedBody.toByteArray(); + } + + boolean hasCaptureErrors() { + return writer != null && writer.checkError(); + } + + void flushWriter() { + if (writer != null) { + writer.flush(); + } + } + + void stopCapturing() { + // async writes (e.g. a locally produced SSE stream) may continue through + // this wrapper long after the routing call returned; without this the + // buffer would grow for the lifetime of the connection + capturing = false; + capturedBody.reset(); + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + if (outputStream == null) { + outputStream = new BodyCapturingOutputStream(super.getOutputStream()); + } + return outputStream; + } + + @Override + public PrintWriter getWriter() throws IOException { + if (writer == null) { + String characterEncoding = getCharacterEncoding(); + Charset charset = characterEncoding != null ? Charset.forName(characterEncoding) + : StandardCharsets.ISO_8859_1; + writer = new PrintWriter(new OutputStreamWriter(getOutputStream(), charset)); + } + return writer; + } + + /** + * A {@link ServletOutputStream} that tees everything written to it into the + * capture buffer while capturing is active. + */ + private final class BodyCapturingOutputStream extends ServletOutputStream { + + private final ServletOutputStream delegate; + + BodyCapturingOutputStream(ServletOutputStream delegate) { + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + delegate.write(b); + if (capturing) { + capturedBody.write(b); + } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + delegate.write(b, off, len); + if (capturing) { + capturedBody.write(b, off, len); + } + } + + @Override + public void flush() throws IOException { + delegate.flush(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + @Override + public boolean isReady() { + return delegate.isReady(); + } + + @Override + public void setWriteListener(WriteListener writeListener) { + delegate.setWriteListener(writeListener); + } + + } + + } + + /** + * Weighs cache entries by the size of the cached response body. + */ + private static final class CachedResponseWeigher implements Weigher { + + @Override + public int weigh(String key, Object value) { + if (value instanceof CachedResponse cachedResponse) { + long contentLength = cachedResponse.headers().getContentLength(); + return (int) Math.min(Integer.MAX_VALUE, + contentLength > -1 ? contentLength : cachedResponse.body().length); + } + return 0; + } + + } + + public static class FilterSupplier extends SimpleFilterSupplier { + + public FilterSupplier() { + super(ResponseCacheFilterFunctions.class); + } + + } + +} diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheFilterFunctionsTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheFilterFunctionsTests.java new file mode 100644 index 000000000..ed4d4edb0 --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheFilterFunctionsTests.java @@ -0,0 +1,606 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.server.mvc.filter; + +import java.time.Duration; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties; +import org.springframework.cloud.gateway.server.mvc.test.LocalServerPortUriResolver; +import org.springframework.cloud.gateway.server.mvc.test.PermitAllSecurityConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.removeRequestHeader; +import static org.springframework.cloud.gateway.server.mvc.filter.BodyFilterFunctions.modifyResponseBody; +import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.prefixPath; +import static org.springframework.cloud.gateway.server.mvc.filter.ResponseCacheFilterFunctions.localResponseCache; +import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; +import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; + +/** + * @author Ingo Griebsch + * @author Nikita Kibitkin + */ +@SpringBootTest( + properties = { GatewayMvcProperties.PREFIX + ".function.enabled=false", + GatewayMvcProperties.PREFIX + ".routes[0].id=testcachedprops", + GatewayMvcProperties.PREFIX + ".routes[0].uri=no://op", + GatewayMvcProperties.PREFIX + ".routes[0].predicates[0]=Path=/props/cached", + GatewayMvcProperties.PREFIX + ".routes[0].filters[0]=LocalServerPortUriResolver=", + GatewayMvcProperties.PREFIX + ".routes[0].filters[1]=LocalResponseCache=30s,2MB", + GatewayMvcProperties.PREFIX + ".routes[0].filters[2]=SetPath=/do/cached", + GatewayMvcProperties.PREFIX + ".routes[1].id=testcachedprops0", + GatewayMvcProperties.PREFIX + ".routes[1].uri=no://op", + GatewayMvcProperties.PREFIX + ".routes[1].predicates[0]=Path=/props/cached0", + GatewayMvcProperties.PREFIX + ".routes[1].filters[0]=LocalServerPortUriResolver=", + GatewayMvcProperties.PREFIX + ".routes[1].filters[1]=LocalResponseCache", + GatewayMvcProperties.PREFIX + ".routes[1].filters[2]=SetPath=/do/cached", + GatewayMvcProperties.PREFIX + ".routes[2].id=testcachedprops1", + GatewayMvcProperties.PREFIX + ".routes[2].uri=no://op", + GatewayMvcProperties.PREFIX + ".routes[2].predicates[0]=Path=/props/cached1", + GatewayMvcProperties.PREFIX + ".routes[2].filters[0]=LocalServerPortUriResolver=", + GatewayMvcProperties.PREFIX + ".routes[2].filters[1]=LocalResponseCache=90s", + GatewayMvcProperties.PREFIX + ".routes[2].filters[2]=SetPath=/do/cached" }, + webEnvironment = WebEnvironment.RANDOM_PORT) +public class ResponseCacheFilterFunctionsTests { + + @Autowired + RestTestClient restClient; + + @Autowired + TestConfiguration.CacheController cacheController; + + @Test + public void responseIsServedFromCache() { + restClient.get() + .uri("/cached?key=cache-works") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/cached?key=cache-works") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueMatches(HttpHeaders.CACHE_CONTROL, "max-age=\\d+") + .expectHeader() + .doesNotExist(HttpHeaders.PRAGMA) + .expectHeader() + .doesNotExist(HttpHeaders.EXPIRES) + .expectBody(String.class) + .isEqualTo("1"); + } + + @Test + public void noCacheRequestRevalidatesAndKeepsCacheEntry() { + restClient.get() + .uri("/cached?key=no-cache") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/cached?key=no-cache") + .header(HttpHeaders.CACHE_CONTROL, "no-cache") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("2"); + restClient.get() + .uri("/cached?key=no-cache") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + } + + @Test + public void requestWithNoStoreIsNotCached() { + restClient.get() + .uri("/cached?key=no-store") + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/cached?key=no-store") + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("2"); + } + + @Test + public void postRequestIsNotCached() { + restClient.post() + .uri("/cachedpost?key=post") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.post() + .uri("/cachedpost?key=post") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("2"); + } + + @Test + public void privateResponseIsNotCached() { + restClient.get() + .uri("/private?key=private") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/private?key=private") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("2"); + } + + @Test + public void varyWildcardResponseIsNotCached() { + restClient.get() + .uri("/varystar?key=varystar") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/varystar?key=varystar") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("2"); + } + + @Test + public void varyHeaderProducesSeparateCacheEntries() { + restClient.get() + .uri("/vary?key=vary") + .header("X-Custom", "one") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/vary?key=vary") + .header("X-Custom", "two") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("2"); + restClient.get() + .uri("/vary?key=vary") + .header("X-Custom", "one") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + } + + @Test + public void nonVaryHeaderDoesNotProduceSeparateCacheEntries() { + restClient.get() + .uri("/cached?key=non-vary") + .header("X-Custom", "one") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/cached?key=non-vary") + .header("X-Custom", "two") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + } + + @Test + public void propertiesDefinedRouteIsCached() { + restClient.get() + .uri("/props/cached?key=props") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/props/cached?key=props") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + } + + @Test + public void propertiesDefinedRouteWithoutArgsIsCached() { + restClient.get() + .uri("/props/cached0?key=props0") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/props/cached0?key=props0") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + } + + @Test + public void propertiesDefinedRouteWithOnlyTimeToLiveIsCached() { + restClient.get() + .uri("/props/cached1?key=props1") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/props/cached1?key=props1") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + } + + @Test + public void modifiedResponseBodyIsCachedAndServedConsistently() { + restClient.get() + .uri("/modified?key=modify") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1-modified"); + restClient.get() + .uri("/modified?key=modify") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1-modified"); + } + + @Test + public void streamingResponseIsNotCached() { + restClient.get() + .uri("/stream?key=stream") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("data: 1\n\n"); + restClient.get() + .uri("/stream?key=stream") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("data: 2\n\n"); + } + + @Test + public void replacedResponseIsNeverMixedWithUpstreamBody() { + // a filter between the cache and the proxy replaces the failing upstream + // response (e.g. a circuit breaker fallback); the cache must never pair the + // replacement status with the discarded upstream body + restClient.get() + .uri("/fallback?key=fallback") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("fallback"); + restClient.get() + .uri("/fallback?key=fallback") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueMatches(HttpHeaders.CONTENT_TYPE, "text/plain.*") + .expectBody(String.class) + .isEqualTo("fallback"); + // the second request was served from the cache: the upstream was hit once + assertThat(cacheController.map.get("fallback").get()).isEqualTo(1); + } + + @Test + public void conditionalRequestDoesNotPoisonCache() { + // the upstream ignores conditional headers (they are stripped before + // proxying) and always replies 200 with an ETag; the gateway itself answers + // 304 to the matching conditional request and must not cache the bodiless + // write under the advertised 200 + restClient.get() + .uri("/etag?key=etag") + .header(HttpHeaders.IF_NONE_MATCH, "\"v1\"") + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.NOT_MODIFIED); + restClient.get().uri("/etag?key=etag").exchange().expectStatus().isOk().expectBody(String.class).isEqualTo("2"); + restClient.get().uri("/etag?key=etag").exchange().expectStatus().isOk().expectBody(String.class).isEqualTo("2"); + } + + @Test + public void conditionalRequestIsAnsweredFromCacheWithNotModified() { + // once the response is cached, a matching conditional request revalidates + // against the cached ETag: the gateway answers 304 and keeps the entry + restClient.get() + .uri("/etag?key=etag-hit") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + restClient.get() + .uri("/etag?key=etag-hit") + .header(HttpHeaders.IF_NONE_MATCH, "\"v1\"") + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.NOT_MODIFIED); + restClient.get() + .uri("/etag?key=etag-hit") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("1"); + // both cache hits: the upstream was hit only by the priming request + assertThat(cacheController.map.get("etag-hit").get()).isEqualTo(1); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(PermitAllSecurityConfiguration.class) + protected static class TestConfiguration { + + @Bean + public RouterFunction gatewayRouterFunctionsCached() { + // @formatter:off + return route("testcached") + .GET("/cached", http()) + .before(new LocalServerPortUriResolver()) + .filter(localResponseCache(Duration.ofSeconds(30), null)) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @Bean + public RouterFunction gatewayRouterFunctionsCachedPost() { + // @formatter:off + return route("testcachedpost") + .POST("/cachedpost", http()) + .before(new LocalServerPortUriResolver()) + .filter(localResponseCache(Duration.ofSeconds(30), null)) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @Bean + public RouterFunction gatewayRouterFunctionsCachedPrivate() { + // @formatter:off + return route("testcachedprivate") + .GET("/private", http()) + .before(new LocalServerPortUriResolver()) + .filter(localResponseCache(Duration.ofSeconds(30), null)) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @Bean + public RouterFunction gatewayRouterFunctionsCachedVaryStar() { + // @formatter:off + return route("testcachedvarystar") + .GET("/varystar", http()) + .before(new LocalServerPortUriResolver()) + .filter(localResponseCache(Duration.ofSeconds(30), null)) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @Bean + public RouterFunction gatewayRouterFunctionsCachedVary() { + // @formatter:off + return route("testcachedvary") + .GET("/vary", http()) + .before(new LocalServerPortUriResolver()) + .filter(localResponseCache(Duration.ofSeconds(30), null)) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @Bean + public RouterFunction gatewayRouterFunctionsCachedModify() { + // @formatter:off + return route("testcachedmodify") + .GET("/modified", http()) + .before(new LocalServerPortUriResolver()) + .filter(localResponseCache(Duration.ofSeconds(30), null)) + .filter(prefixPath("/do")) + .after(modifyResponseBody(String.class, String.class, null, + (request, response, body) -> body + "-modified")) + .build(); + // @formatter:on + } + + @Bean + public RouterFunction gatewayRouterFunctionsCachedStream() { + // @formatter:off + return route("testcachedstream") + .GET("/stream", http()) + .before(new LocalServerPortUriResolver()) + .filter(localResponseCache(Duration.ofSeconds(30), null)) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @Bean + public RouterFunction gatewayRouterFunctionsCachedEtag() { + // @formatter:off + return route("testcachedetag") + .GET("/etag", http()) + .before(new LocalServerPortUriResolver()) + .before(removeRequestHeader(HttpHeaders.IF_NONE_MATCH)) + .filter(localResponseCache(Duration.ofSeconds(30), null)) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @Bean + public RouterFunction gatewayRouterFunctionsCachedFallback() { + // @formatter:off + return route("testcachedfallback") + .GET("/fallback", http()) + .before(new LocalServerPortUriResolver()) + .filter(localResponseCache(Duration.ofSeconds(30), null)) + .filter((request, next) -> { + ServerResponse response = next.handle(request); + if (response.statusCode().is5xxServerError()) { + return ServerResponse.ok().body("fallback"); + } + return response; + }) + .filter(prefixPath("/do")) + .build(); + // @formatter:on + } + + @RestController + protected static class CacheController { + + ConcurrentHashMap map = new ConcurrentHashMap<>(); + + @GetMapping("/do/cached") + public ResponseEntity cached(@RequestParam("key") String key) { + return ResponseEntity.ok() + .header(HttpHeaders.PRAGMA, "no-cache") + .header(HttpHeaders.EXPIRES, "0") + .body(next(key)); + } + + @PostMapping("/do/cachedpost") + public ResponseEntity cachedPost(@RequestParam("key") String key) { + return ResponseEntity.ok(next(key)); + } + + @GetMapping("/do/private") + public ResponseEntity cachedPrivate(@RequestParam("key") String key) { + return ResponseEntity.ok().header(HttpHeaders.CACHE_CONTROL, "private").body(next(key)); + } + + @GetMapping("/do/varystar") + public ResponseEntity varyStar(@RequestParam("key") String key) { + return ResponseEntity.ok().header(HttpHeaders.VARY, "*").body(next(key)); + } + + @GetMapping("/do/vary") + public ResponseEntity vary(@RequestParam("key") String key, + @RequestHeader(name = "X-Custom", required = false) String custom) { + assertThat(custom).isNotNull(); + return ResponseEntity.ok().header(HttpHeaders.VARY, "X-Custom").body(next(key)); + } + + @GetMapping("/do/modified") + public ResponseEntity modified(@RequestParam("key") String key) { + return ResponseEntity.ok(next(key)); + } + + @GetMapping("/do/stream") + public ResponseEntity stream(@RequestParam("key") String key) { + return ResponseEntity.ok().contentType(MediaType.TEXT_EVENT_STREAM).body("data: " + next(key) + "\n\n"); + } + + @GetMapping("/do/etag") + public ResponseEntity etag(@RequestParam("key") String key) { + return ResponseEntity.ok().eTag("\"v1\"").body(next(key)); + } + + @GetMapping("/do/fallback") + public ResponseEntity fallback(@RequestParam("key") String key) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(next(key)); + } + + private String next(String key) { + return String.valueOf(map.computeIfAbsent(key, s -> new AtomicInteger()).incrementAndGet()); + } + + } + + } + +} diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheManagerTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheManagerTests.java new file mode 100644 index 000000000..c8999844a --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheManagerTests.java @@ -0,0 +1,426 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.server.mvc.filter; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import com.github.benmanes.caffeine.cache.Caffeine; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.cloud.gateway.server.mvc.filter.ResponseCacheFilterFunctions.CacheKeyGenerator; +import org.springframework.cloud.gateway.server.mvc.filter.ResponseCacheFilterFunctions.CachedResponse; +import org.springframework.cloud.gateway.server.mvc.filter.ResponseCacheFilterFunctions.CachedResponseMetadata; +import org.springframework.cloud.gateway.server.mvc.filter.ResponseCacheFilterFunctions.ResponseCacheManager; +import org.springframework.cloud.gateway.server.mvc.handler.GatewayServerResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.servlet.function.ServerRequest; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ingo Griebsch + * @author Nikita Kibitkin + */ +public class ResponseCacheManagerTests { + + private static final Duration TIME_TO_LIVE = Duration.ofMinutes(5); + + private final CacheKeyGenerator cacheKeyGenerator = new CacheKeyGenerator(); + + @Test + public void requestIsCacheableWhenBodilessGet() { + ResponseCacheManager manager = manager(Clock.systemUTC()); + assertThat(manager.isRequestCacheable(request("GET", "/resource").build())).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { "POST", "PUT", "DELETE", "HEAD" }) + public void requestIsNotCacheableWhenMethodIsNotGet(String method) { + ResponseCacheManager manager = manager(Clock.systemUTC()); + assertThat(manager.isRequestCacheable(request(method, "/resource").build())).isFalse(); + } + + @Test + public void requestIsNotCacheableWhenBodyIsPresent() { + MockHttpServletRequest servletRequest = new MockHttpServletRequest("GET", "/resource"); + servletRequest.setContent("body".getBytes(StandardCharsets.UTF_8)); + servletRequest.addHeader(HttpHeaders.CONTENT_LENGTH, 4); + ResponseCacheManager manager = manager(Clock.systemUTC()); + assertThat(manager.isRequestCacheable(ServerRequest.create(servletRequest, Collections.emptyList()))).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = { "private", "no-store" }) + public void requestIsNotCacheableWhenCacheControlForbidsIt(String cacheControl) { + ResponseCacheManager manager = manager(Clock.systemUTC()); + assertThat(manager + .isRequestCacheable(request("GET", "/resource").header(HttpHeaders.CACHE_CONTROL, cacheControl).build())) + .isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = { "no-cache", "private,no-cache", " no-cache", "no-cache ", "s-no-cache, no-cache" }) + public void noCacheRequestIsDetected(String cacheControl) { + ResponseCacheManager manager = manager(Clock.systemUTC()); + assertThat(manager + .isNoCacheRequest(request("GET", "/resource").header(HttpHeaders.CACHE_CONTROL, cacheControl).build())) + .isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { "no-store", "no-store, wrong-no-cache", "s-no-cache" }) + public void noCacheRequestIsNotDetected(String cacheControl) { + ResponseCacheManager manager = manager(Clock.systemUTC()); + assertThat(manager + .isNoCacheRequest(request("GET", "/resource").header(HttpHeaders.CACHE_CONTROL, cacheControl).build())) + .isFalse(); + } + + @Test + public void responseIsCachedAndServedFromCache() throws Exception { + ResponseCacheManager manager = manager(Clock.systemUTC()); + ServerRequest request = request("GET", "/resource").build(); + String metadataKey = manager.resolveMetadataKey(request); + + assertThat(manager.getFromCache(request, metadataKey)).isEmpty(); + + ServerResponse response = manager.processFromUpstream(request, metadataKey, upstreamResponse("the body")); + // the cache is filled once the response body has been written + assertThat(manager.getFromCache(request, metadataKey)).isEmpty(); + MockHttpServletResponse servletResponse = write(response); + + Optional cachedResponse = manager.getFromCache(request, metadataKey); + assertThat(cachedResponse).isPresent(); + assertThat(cachedResponse.get().body()).isEqualTo("the body".getBytes(StandardCharsets.UTF_8)); + // the body is written through to the client while being captured + assertThat(servletResponse.getContentAsByteArray()).isEqualTo("the body".getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void bodyWrittenThroughWriterIsCachedAndPassedThrough() throws Exception { + ResponseCacheManager manager = manager(Clock.systemUTC()); + ServerRequest request = request("GET", "/resource").build(); + String metadataKey = manager.resolveMetadataKey(request); + + ServerResponse response = manager.processFromUpstream(request, metadataKey, + GatewayServerResponse.status(HttpStatus.OK).build((servletRequest, servletResponse) -> { + servletResponse.getWriter().write("the body"); + return null; + })); + MockHttpServletResponse servletResponse = write(response); + + Optional cachedResponse = manager.getFromCache(request, metadataKey); + assertThat(cachedResponse).isPresent(); + assertThat(cachedResponse.get().body()).isEqualTo("the body".getBytes(StandardCharsets.UTF_8)); + // the writer buffers, so the write is only complete once the filter flushed it + assertThat(servletResponse.getContentAsByteArray()).isEqualTo("the body".getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void upstreamResponseHeadersReceiveCacheControlMutations() { + Instant now = Instant.parse("2025-01-01T10:00:00Z"); + ResponseCacheManager manager = manager(Clock.fixed(now, ZoneOffset.UTC)); + ServerRequest request = request("GET", "/resource").build(); + String metadataKey = manager.resolveMetadataKey(request); + + ServerResponse result = manager.processFromUpstream(request, metadataKey, upstreamResponse("the body")); + + assertThat(result.headers().getCacheControl()).isEqualTo("max-age=" + TIME_TO_LIVE.getSeconds()); + } + + @Test + public void notModifiedWriteIsNotCached() throws Exception { + ResponseCacheManager manager = manager(Clock.systemUTC()); + ServerRequest request = request("GET", "/resource").build(); + String metadataKey = manager.resolveMetadataKey(request); + + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setETag("\"v1\""); + ServerResponse result = manager.processFromUpstream(request, metadataKey, + upstreamResponse("the body", HttpStatus.OK, responseHeaders)); + MockHttpServletRequest conditionalRequest = new MockHttpServletRequest("GET", "/resource"); + conditionalRequest.addHeader(HttpHeaders.IF_NONE_MATCH, "\"v1\""); + MockHttpServletResponse servletResponse = write(result, conditionalRequest); + + assertThat(servletResponse.getStatus()).isEqualTo(HttpStatus.NOT_MODIFIED.value()); + // the delegate answered 304 without a body, so nothing must be cached + assertThat(manager.getFromCache(request, metadataKey)).isEmpty(); + } + + @Test + public void mismatchedContentLengthIsNotCached() { + ResponseCacheManager manager = manager(Clock.systemUTC()); + ServerRequest request = request("GET", "/resource").build(); + String metadataKey = manager.resolveMetadataKey(request); + String key = cacheKeyGenerator.generateKey(request, Collections.emptyList()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentLength(1); + manager.cacheCapturedResponse(metadataKey, new CachedResponseMetadata(Collections.emptyList()), key, + HttpStatus.OK, headers, "the body".getBytes(StandardCharsets.UTF_8)); + + // the framing header does not match the written body, so the anomaly must + // not be cached + assertThat(manager.getFromCache(request, metadataKey)).isEmpty(); + } + + @Test + public void veryLargeTimeToLiveDoesNotOverflowMaxAge() { + Instant now = Instant.parse("2025-01-01T10:00:00Z"); + Duration timeToLive = Duration.ofDays(36500); + ResponseCacheManager manager = new ResponseCacheManager(cacheKeyGenerator, Caffeine.newBuilder().build(), + timeToLive, Clock.fixed(now, ZoneOffset.UTC)); + CachedResponse cachedResponse = new CachedResponse(HttpStatus.OK, new HttpHeaders(), + "the body".getBytes(StandardCharsets.UTF_8), now); + + ServerResponse response = manager.processFromCache("META_key", cachedResponse); + + assertThat(response.headers().getCacheControl()).isEqualTo("max-age=" + timeToLive.getSeconds()); + } + + @Test + public void responseIsNotCachedWhenStatusCodeIsNotCacheable() { + ResponseCacheManager manager = manager(Clock.systemUTC()); + ServerRequest request = request("GET", "/resource").build(); + String metadataKey = manager.resolveMetadataKey(request); + + ServerResponse response = upstreamResponse("the body", HttpStatus.BAD_GATEWAY, new HttpHeaders()); + ServerResponse result = manager.processFromUpstream(request, metadataKey, response); + + assertThat(result).isSameAs(response); + assertThat(manager.getFromCache(request, metadataKey)).isEmpty(); + } + + @ParameterizedTest + @EnumSource(value = HttpStatus.class, names = { "OK", "PARTIAL_CONTENT", "MOVED_PERMANENTLY" }) + public void responseIsCachedWhenStatusCodeIsCacheable(HttpStatus status) throws Exception { + ResponseCacheManager manager = manager(Clock.systemUTC()); + ServerRequest request = request("GET", "/resource").build(); + String metadataKey = manager.resolveMetadataKey(request); + + write(manager.processFromUpstream(request, metadataKey, + upstreamResponse("the body", status, new HttpHeaders()))); + + Optional cachedResponse = manager.getFromCache(request, metadataKey); + assertThat(cachedResponse).isPresent(); + assertThat(cachedResponse.get().statusCode()).isEqualTo(status); + } + + @Test + public void cachedEntriesVaryOnHeadersFromResponseVary() throws Exception { + ResponseCacheManager manager = manager(Clock.systemUTC()); + ServerRequest request = request("GET", "/resource").header("X-Custom", "one").build(); + String metadataKey = manager.resolveMetadataKey(request); + + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.add(HttpHeaders.VARY, "X-Custom"); + write(manager.processFromUpstream(request, metadataKey, + upstreamResponse("one", HttpStatus.OK, responseHeaders))); + + ServerRequest sameVaryValue = request("GET", "/resource").header("X-Custom", "one").build(); + ServerRequest otherVaryValue = request("GET", "/resource").header("X-Custom", "two").build(); + assertThat(manager.getFromCache(sameVaryValue, manager.resolveMetadataKey(sameVaryValue))).isPresent(); + assertThat(manager.getFromCache(otherVaryValue, manager.resolveMetadataKey(otherVaryValue))).isEmpty(); + } + + @Test + public void maxAgeIsRecalculatedFromEntryAge() { + Instant now = Instant.parse("2025-01-01T10:00:00Z"); + ResponseCacheManager manager = manager(Clock.fixed(now, ZoneOffset.UTC)); + CachedResponse cachedResponse = new CachedResponse(HttpStatus.OK, new HttpHeaders(), + "the body".getBytes(StandardCharsets.UTF_8), now.minusSeconds(120)); + + ServerResponse response = manager.processFromCache("META_key", cachedResponse); + + assertThat(response.headers().getCacheControl()).isEqualTo("max-age=" + (TIME_TO_LIVE.getSeconds() - 120)); + } + + @Test + public void existingMaxAgeIsRewrittenFromEntryAge() { + Instant now = Instant.parse("2025-01-01T10:00:00Z"); + ResponseCacheManager manager = manager(Clock.fixed(now, ZoneOffset.UTC)); + HttpHeaders headers = new HttpHeaders(); + headers.setCacheControl("max-age=999"); + CachedResponse cachedResponse = new CachedResponse(HttpStatus.OK, headers, + "the body".getBytes(StandardCharsets.UTF_8), now.minusSeconds(120)); + + ServerResponse response = manager.processFromCache("META_key", cachedResponse); + + assertThat(response.headers().getCacheControl()).isEqualTo("max-age=" + (TIME_TO_LIVE.getSeconds() - 120)); + } + + @Test + public void noCacheDirectivesAreStrippedWhenEntryIsFresh() { + Instant now = Instant.parse("2025-01-01T10:00:00Z"); + ResponseCacheManager manager = manager(Clock.fixed(now, ZoneOffset.UTC)); + HttpHeaders headers = new HttpHeaders(); + headers.setCacheControl("no-cache, must-revalidate, max-age=999"); + CachedResponse cachedResponse = new CachedResponse(HttpStatus.OK, headers, + "the body".getBytes(StandardCharsets.UTF_8), now.minusSeconds(120)); + + ServerResponse response = manager.processFromCache("META_key", cachedResponse); + + assertThat(response.headers().getCacheControl()).isEqualTo("max-age=" + (TIME_TO_LIVE.getSeconds() - 120)); + } + + @Test + public void expiredEntryYieldsMaxAgeZeroAndNoCacheDirectives() { + Instant now = Instant.parse("2025-01-01T10:00:00Z"); + ResponseCacheManager manager = manager(Clock.fixed(now, ZoneOffset.UTC)); + CachedResponse cachedResponse = new CachedResponse(HttpStatus.OK, new HttpHeaders(), + "the body".getBytes(StandardCharsets.UTF_8), now.minus(TIME_TO_LIVE).minusSeconds(1)); + + ServerResponse response = manager.processFromCache("META_key", cachedResponse); + + assertThat(response.headers().getCacheControl()).contains("max-age=0") + .contains("no-cache") + .contains("must-revalidate"); + } + + @Test + public void pragmaAndExpiresAreRemovedFromCachedResponse() { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.PRAGMA, "no-cache"); + headers.add(HttpHeaders.EXPIRES, "0"); + ResponseCacheManager manager = manager(Clock.systemUTC()); + CachedResponse cachedResponse = new CachedResponse(HttpStatus.OK, headers, + "the body".getBytes(StandardCharsets.UTF_8), Instant.now()); + + ServerResponse response = manager.processFromCache("META_key", cachedResponse); + + assertThat(response.headers().headerNames()).doesNotContain(HttpHeaders.PRAGMA, HttpHeaders.EXPIRES); + } + + @Test + public void keyIsStableForSameRequest() { + ServerRequest request = request("GET", "/resource").build(); + ServerRequest sameRequest = request("GET", "/resource").build(); + assertThat(cacheKeyGenerator.generateKey(request, Collections.emptyList())) + .isEqualTo(cacheKeyGenerator.generateKey(sameRequest, Collections.emptyList())); + } + + @Test + public void keyDiffersOnAuthorizationHeader() { + ServerRequest request = request("GET", "/resource").header(HttpHeaders.AUTHORIZATION, "Bearer one").build(); + ServerRequest otherRequest = request("GET", "/resource").header(HttpHeaders.AUTHORIZATION, "Bearer two") + .build(); + assertThat(cacheKeyGenerator.generateKey(request, Collections.emptyList())) + .isNotEqualTo(cacheKeyGenerator.generateKey(otherRequest, Collections.emptyList())); + } + + @Test + public void keyDiffersOnCookies() { + ServerRequest request = request("GET", "/resource").cookie("session", "one").build(); + ServerRequest otherRequest = request("GET", "/resource").cookie("session", "two").build(); + assertThat(cacheKeyGenerator.generateKey(request, Collections.emptyList())) + .isNotEqualTo(cacheKeyGenerator.generateKey(otherRequest, Collections.emptyList())); + } + + @Test + public void keyDiffersOnVaryHeaderValues() { + ServerRequest request = request("GET", "/resource").header("X-Custom", "one").build(); + ServerRequest otherRequest = request("GET", "/resource").header("X-Custom", "two").build(); + assertThat(cacheKeyGenerator.generateKey(request, List.of("X-Custom"))) + .isNotEqualTo(cacheKeyGenerator.generateKey(otherRequest, List.of("X-Custom"))); + } + + @Test + public void keyIgnoresHeadersTheResponseDoesNotVaryOn() { + ServerRequest request = request("GET", "/resource").header("X-Custom", "one").build(); + ServerRequest otherRequest = request("GET", "/resource").header("X-Custom", "two").build(); + assertThat(cacheKeyGenerator.generateKey(request, Collections.emptyList())) + .isEqualTo(cacheKeyGenerator.generateKey(otherRequest, Collections.emptyList())); + } + + private ResponseCacheManager manager(Clock clock) { + return new ResponseCacheManager(cacheKeyGenerator, Caffeine.newBuilder().build(), TIME_TO_LIVE, clock); + } + + private static RequestBuilder request(String method, String path) { + return new RequestBuilder(method, path); + } + + private static ServerResponse upstreamResponse(String body) { + return upstreamResponse(body, HttpStatus.OK, new HttpHeaders()); + } + + private static ServerResponse upstreamResponse(String body, HttpStatus status, HttpHeaders headers) { + return GatewayServerResponse.status(status) + .headers(httpHeaders -> httpHeaders.addAll(headers)) + .build((servletRequest, servletResponse) -> { + servletResponse.getOutputStream().write(body.getBytes(StandardCharsets.UTF_8)); + return null; + }); + } + + private static MockHttpServletResponse write(ServerResponse response) throws Exception { + return write(response, new MockHttpServletRequest()); + } + + private static MockHttpServletResponse write(ServerResponse response, MockHttpServletRequest servletRequest) + throws Exception { + MockHttpServletResponse servletResponse = new MockHttpServletResponse(); + response.writeTo(servletRequest, servletResponse, new ServerResponse.Context() { + @Override + public List> messageConverters() { + return Collections.emptyList(); + } + }); + return servletResponse; + } + + private static final class RequestBuilder { + + private final MockHttpServletRequest servletRequest; + + private RequestBuilder(String method, String path) { + this.servletRequest = new MockHttpServletRequest(method, path); + } + + private RequestBuilder header(String name, String value) { + servletRequest.addHeader(name, value); + return this; + } + + private RequestBuilder cookie(String name, String value) { + servletRequest.setCookies(new Cookie(name, value)); + return this; + } + + private ServerRequest build() { + return ServerRequest.create(servletRequest, Collections.emptyList()); + } + + } + +}