From e060e01501615c99b8ce82751e5a9d984af7d62b Mon Sep 17 00:00:00 2001 From: Ingo Griebsch Date: Tue, 9 Jun 2026 15:16:52 +0200 Subject: [PATCH 01/10] feat(gh-4182): introduce ResponseCacheFilterFunctions Signed-off-by: Ingo Griebsch --- .../filter/ResponseCacheFilterFunctions.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheFilterFunctions.java 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..d7fe8b359 --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheFilterFunctions.java @@ -0,0 +1,32 @@ +/* + * 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; + +/** + * The central interface that enables the caching of certain HTTP responses, thereby + * reducing latency and overhead for the upstream server. + *

+ * I'm currently keeping all feature-specific code in this class. Whether and how the code + * should be moved to separate classes and packages can be decided later. + * + * @author Ingo Griebsch + */ +public abstract class ResponseCacheFilterFunctions { + + private ResponseCacheFilterFunctions() { + } + +} From 40668718617cea6c906fbdc372d642b810896fd3 Mon Sep 17 00:00:00 2001 From: Ingo Griebsch Date: Tue, 9 Jun 2026 16:32:15 +0200 Subject: [PATCH 02/10] feat(gh-4182): introduce FilterSupplier to pick RCF functions Signed-off-by: Ingo Griebsch --- .../filter/ResponseCacheFilterFunctions.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index d7fe8b359..4e0d484ac 100644 --- 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 @@ -15,6 +15,8 @@ */ package org.springframework.cloud.gateway.server.mvc.filter; +import org.springframework.web.servlet.function.HandlerFilterFunction; + /** * The central interface that enables the caching of certain HTTP responses, thereby * reducing latency and overhead for the upstream server. @@ -29,4 +31,19 @@ public abstract class ResponseCacheFilterFunctions { private ResponseCacheFilterFunctions() { } + /** + * A {@link FilterSupplier} implementation that provides all + * {@link HandlerFilterFunction handler filter functions} available to be able to + * cache HTTP responses. + * + * @author Ingo Griebsch + */ + static class FilterSupplier extends SimpleFilterSupplier { + + FilterSupplier() { + super(ResponseCacheFilterFunctions.class); + } + + } + } From 0d4b5d96b4265f5ee7455b48a41507c9bd0bab97 Mon Sep 17 00:00:00 2001 From: Ingo Griebsch Date: Tue, 9 Jun 2026 16:32:45 +0200 Subject: [PATCH 03/10] feat(gh-4182): auto configure ResponseCacheFilterFunctions Signed-off-by: Ingo Griebsch --- spring-cloud-gateway-server-webmvc/pom.xml | 9 ++++----- .../mvc/filter/FilterAutoConfiguration.java | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/spring-cloud-gateway-server-webmvc/pom.xml b/spring-cloud-gateway-server-webmvc/pom.xml index c3119f258..8b8942cec 100644 --- a/spring-cloud-gateway-server-webmvc/pom.xml +++ b/spring-cloud-gateway-server-webmvc/pom.xml @@ -92,6 +92,10 @@ true + + com.github.ben-manes.caffeine + caffeine + com.bucket4j bucket4j_jdk17-core @@ -163,11 +167,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..f24a0a52e 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,7 +20,8 @@ import java.util.Objects; import java.util.function.Function; -import io.github.bucket4j.BucketConfiguration; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Weigher; import org.springframework.beans.factory.BeanFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -35,6 +36,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import io.github.bucket4j.BucketConfiguration; + @AutoConfiguration public class FilterAutoConfiguration { @@ -113,4 +116,15 @@ public TokenRelayFilterFunctions.FilterSupplier tokenRelayFilterFunctionsSupplie } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ Weigher.class, Caffeine.class/* , CaffeineCacheManager.class */ }) + static class ResponseCacheFilterConfiguration { + + @Bean + public ResponseCacheFilterFunctions.FilterSupplier responseCacheFilterFunctionsSupplier() { + return new ResponseCacheFilterFunctions.FilterSupplier(); + } + + } + } From 9d9382b22b608181a8221e71623691761d849c23 Mon Sep 17 00:00:00 2001 From: Ingo Griebsch Date: Tue, 9 Jun 2026 16:36:14 +0200 Subject: [PATCH 04/10] feat(gh-4182): introduce ResponseCacheFilter Signed-off-by: Ingo Griebsch --- .../filter/ResponseCacheFilterFunctions.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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 index 4e0d484ac..4f0412670 100644 --- 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 @@ -16,6 +16,9 @@ package org.springframework.cloud.gateway.server.mvc.filter; 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; /** * The central interface that enables the caching of certain HTTP responses, thereby @@ -31,6 +34,35 @@ public abstract class ResponseCacheFilterFunctions { private ResponseCacheFilterFunctions() { } + /** + * A {@link HandlerFilterFunction} implementation that allows to cache certain HTTP + * responses. + * + * @author Ingo Griebsch + */ + static class ResponseCacheFilter implements HandlerFilterFunction { + + @Override + public ServerResponse filter(ServerRequest request, HandlerFunction next) throws Exception { + // FIXME implement me... + + // If the request is not cacheable, simple continue with the next filter. + + // FIXME Should we remember that this filter is now applied? + + // If the request should be revalidated continue with the request an put the + // response in the cache. + + // Check if a response for this request is already cached. + // If not, continue with the request an put the response in the cache. + // If so, return the cached response but update the metatdata built based on + // the request. + + return next.handle(request); + } + + } + /** * A {@link FilterSupplier} implementation that provides all * {@link HandlerFilterFunction handler filter functions} available to be able to From 91518b5a2b231072c4863ade99b6a1d42b75768d Mon Sep 17 00:00:00 2001 From: Ingo Griebsch Date: Tue, 9 Jun 2026 16:37:41 +0200 Subject: [PATCH 05/10] feat(gh-4182): introduce HandlerFilterFunction 'responseCache' Signed-off-by: Ingo Griebsch --- .../server/mvc/filter/ResponseCacheFilterFunctions.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 4f0412670..50457c658 100644 --- 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 @@ -15,6 +15,9 @@ */ package org.springframework.cloud.gateway.server.mvc.filter; +import java.time.Duration; + +import org.springframework.util.unit.DataSize; import org.springframework.web.servlet.function.HandlerFilterFunction; import org.springframework.web.servlet.function.HandlerFunction; import org.springframework.web.servlet.function.ServerRequest; @@ -34,6 +37,11 @@ public abstract class ResponseCacheFilterFunctions { private ResponseCacheFilterFunctions() { } + public static HandlerFilterFunction responseCache(Duration timeToLive, + DataSize cacheSize) { + return new ResponseCacheFilter(); + } + /** * A {@link HandlerFilterFunction} implementation that allows to cache certain HTTP * responses. From 9a78cd5b32bc815d730e3339ce007e342c1725f9 Mon Sep 17 00:00:00 2001 From: Ingo Griebsch Date: Tue, 9 Jun 2026 16:40:29 +0200 Subject: [PATCH 06/10] feat(gh-4182): introduce ServletUtils Signed-off-by: Ingo Griebsch --- .../filter/ResponseCacheFilterFunctions.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) 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 index 50457c658..ca20e8dcc 100644 --- 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 @@ -15,8 +15,20 @@ */ package org.springframework.cloud.gateway.server.mvc.filter; +import static org.springframework.http.HttpHeaders.CACHE_CONTROL; +import static org.springframework.http.HttpHeaders.VARY; +import static org.springframework.http.HttpStatus.MOVED_PERMANENTLY; +import static org.springframework.http.HttpStatus.OK; +import static org.springframework.http.HttpStatus.PARTIAL_CONTENT; + import java.time.Duration; +import java.util.List; +import java.util.Optional; +import org.jspecify.annotations.Nullable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.util.unit.DataSize; import org.springframework.web.servlet.function.HandlerFilterFunction; import org.springframework.web.servlet.function.HandlerFunction; @@ -86,4 +98,69 @@ static class FilterSupplier extends SimpleFilterSupplier { } + /** + * A set of servlet related utilities to ease the implementation of the + * {@link ResponseCacheFilterFunctions response cache filter functions}. + * + * @author Ingo Griebsch + */ + abstract static class ServletUtils { + + private ServletUtils() { + } + + static boolean shouldRevalidate(ServerRequest request) { + return Optional.ofNullable(request.headers().asHttpHeaders().getCacheControl()) + .map(v -> v.matches(".*(\s|,|^)no-cache(\\s|,|$).*")) + .orElse(false); + } + + static boolean isCacheable(ServerRequest request) { + return isGetMethod(request) && !hasBody(request) && isCacheControlAllowed(request); + } + + static boolean isCacheControlAllowed(ServerRequest request) { + return isCacheControlAllowed(request.headers().header(CACHE_CONTROL)); + } + + static boolean isCacheControlAllowed(ServerResponse response) { + return isCacheControlAllowed(response.headers().get(CACHE_CONTROL)); + } + + static boolean hasBody(ServerRequest request) { + // FIXME What if no Content-Length header is present? Should we assume that + // the request has no body or should we read + // the body to determine if it has content? + return request.headers().contentLength().orElse(0L) > 0; + } + + static boolean isGetMethod(ServerRequest request) { + return HttpMethod.GET.equals(request.method()); + } + + static boolean isCacheable(ServerResponse response) { + List cacheableStatusCodes = List.of(OK, PARTIAL_CONTENT, MOVED_PERMANENTLY); + return hasStatusCode(response, cacheableStatusCodes) && isCacheControlAllowed(response) + && !isVaryWildcard(response); + } + + static boolean hasStatusCode(ServerResponse response, List statusCodes) { + return statusCodes.contains(response.statusCode()); + } + + static boolean isVaryWildcard(ServerResponse response) { + HttpHeaders headers = response.headers(); + List varyValues = headers.getOrEmpty(VARY); + return varyValues.stream().anyMatch("*"::equals); + } + + private static boolean isCacheControlAllowed(@Nullable List headerValues) { + if (headerValues == null) { + return false; + } + return headerValues.stream().noneMatch(List.of("private", "no-store")::contains); + } + + } + } From 4ba37283eb1bf502b49a41239bc500dd3983cbcb Mon Sep 17 00:00:00 2001 From: Ingo Griebsch Date: Tue, 9 Jun 2026 16:51:48 +0200 Subject: [PATCH 07/10] feat(gh-4182): introduce CacheKeyFactory Signed-off-by: Ingo Griebsch --- .../filter/ResponseCacheFilterFunctions.java | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) 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 index ca20e8dcc..5274183c9 100644 --- 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 @@ -21,14 +21,19 @@ import static org.springframework.http.HttpStatus.OK; import static org.springframework.http.HttpStatus.PARTIAL_CONTENT; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; +import java.util.Base64; import java.util.List; +import java.util.Objects; import java.util.Optional; import org.jspecify.annotations.Nullable; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.util.StringUtils; import org.springframework.util.unit.DataSize; import org.springframework.web.servlet.function.HandlerFilterFunction; import org.springframework.web.servlet.function.HandlerFunction; @@ -98,6 +103,77 @@ static class FilterSupplier extends SimpleFilterSupplier { } + /** + * A factory that allows to create {@link CacheKey cache-keys} based on a + * {@link ServerRequest} and additional context related information. + * + * @author Ingo Griebsch + */ + static class CacheKeyFactory { + + private final ThreadLocal messageDigest; + + CacheKeyFactory() { + this.messageDigest = ThreadLocal.withInitial(() -> { + try { + return MessageDigest.getInstance("MD5"); + } + catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Caught exception while creating CacheKeyCalculator!", e); + } + }); + } + + CacheKey from(ServerRequest request, @Nullable String prefix) { + return from(request, List.of(), prefix); + } + + CacheKey from(ServerRequest request, List headers, @Nullable String prefix) { + byte[] digest = messageDigest.get().digest(calculate(request, headers)); + return new CacheKey(prefix, Base64.getEncoder().encodeToString(digest)); + } + + private byte[] calculate(ServerRequest request, List headers) { + // FIXME implement me... + return null; + } + + static class CacheKey { + + private final String value; + + CacheKey(@Nullable String prefix, String value) { + this.value = "%s%s".formatted(StringUtils.hasText(prefix) ? "%s_".formatted(prefix) : "", value); + } + + String getValue() { + return value; + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + CacheKey other = (CacheKey) obj; + return Objects.equals(value, other.value); + } + + } + + } + /** * A set of servlet related utilities to ease the implementation of the * {@link ResponseCacheFilterFunctions response cache filter functions}. From 934a663480b97f2db1552da0c1e817fe210aae28 Mon Sep 17 00:00:00 2001 From: Ingo Griebsch Date: Tue, 9 Jun 2026 17:02:21 +0200 Subject: [PATCH 08/10] feat(gh-4182): introduce ResponseCacheManager Signed-off-by: Ingo Griebsch --- .../filter/ResponseCacheFilterFunctions.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) 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 index 5274183c9..4117c58d4 100644 --- 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 @@ -25,14 +25,18 @@ import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.util.Base64; +import java.util.Date; import java.util.List; import java.util.Objects; import java.util.Optional; import org.jspecify.annotations.Nullable; +import org.springframework.cache.Cache; +import org.springframework.cloud.gateway.server.mvc.filter.ResponseCacheFilterFunctions.CacheKeyFactory.CacheKey; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.util.StringUtils; import org.springframework.util.unit.DataSize; import org.springframework.web.servlet.function.HandlerFilterFunction; @@ -103,6 +107,113 @@ static class FilterSupplier extends SimpleFilterSupplier { } + /** + * Allows registration and access to a specific {@link ResponseCacheManager}. + *

+ * Allows access to a {@link ResponseCacheManager} that is to be used for a specific + * route. + * + * @author Ingo Griebsch + */ + static class ResponseCacheManagerRegistry { + + void register(String id, ResponseCacheManager manager) { + // FIXME implement me... + } + + ResponseCacheManager get(String id) { + // FIXME implement me... + return null; + } + + } + + /** + * Allows responses and their metadata to be cached. + *

+ * Builds on the cache abstraction provided by Spring, enabling flexible configuration + * of the underlying cache. + * + * @author Ingo Griebsch + */ + static class ResponseCacheManager { + + private final Cache cache; + + ResponseCacheManager(Cache cache) { + this.cache = cache; + } + + Optional get(CacheKey key, Class type) { + T entry = null; + try { + entry = cache.get(key, type); + } + catch (Exception e) { + // FIXME log + } + return Optional.ofNullable(entry); + } + + void put(CacheKey key, Object object) { + cache.put(key, object); + } + + } + + /** + * Represents the metadata of a cached HTTP response. + * + * @author Ingo Griebsch + */ + static class CachedResponseMetadata { + + private final List headers; + + CachedResponseMetadata(List headers) { + this.headers = headers; + } + + List getHeaders() { + return headers; + } + + } + + /** + * Represents a cached HTTP response. + * + * @author Ingo Griebsch + */ + static class CachedResponse { + + // FIXME Add the body + private final HttpStatusCode statusCode; + + private final HttpHeaders headers; + + private final Date timestamp; + + CachedResponse(HttpStatusCode statusCode, HttpHeaders headers, Date timestamp) { + this.statusCode = statusCode; + this.headers = headers; + this.timestamp = timestamp; + } + + public HttpStatusCode getStatusCode() { + return statusCode; + } + + public HttpHeaders getHeaders() { + return headers; + } + + public Date getTimestamp() { + return timestamp; + } + + } + /** * A factory that allows to create {@link CacheKey cache-keys} based on a * {@link ServerRequest} and additional context related information. From 3b81509ac8c712f0c4e041d5bca95b6ae4d4a35c Mon Sep 17 00:00:00 2001 From: Ingo Griebsch Date: Tue, 9 Jun 2026 17:25:42 +0200 Subject: [PATCH 09/10] feat(gh-4182): sketch a first draft of the filter implementation Signed-off-by: Ingo Griebsch --- .../filter/ResponseCacheFilterFunctions.java | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) 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 index 4117c58d4..351c55325 100644 --- 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 @@ -76,18 +76,44 @@ public ServerResponse filter(ServerRequest request, HandlerFunction cachedResponse = obtainCachedResponse(request); + // If not, continue with the request an put the response in the cache. // If so, return the cached response but update the metatdata built based on // the request. + if (cachedResponse.isEmpty()) { + return cacheResponse(next.handle(request)); + } - return next.handle(request); + return createResponse(cachedResponse.get()); + } + + private ServerResponse createResponse(CachedResponse cachedResponse) { + // FIXME implement me... + return null; + } + + private ServerResponse cacheResponse(ServerResponse response) { + // FIXME implement me... + return response; + } + + private Optional obtainCachedResponse(ServerRequest request) { + // FIXME implement me... + return null; } } From 3abc6f60f9ec43c2075ad7607b27febfe32ce611 Mon Sep 17 00:00:00 2001 From: Nikita Kibitkin Date: Sat, 4 Jul 2026 02:52:44 +0200 Subject: [PATCH 10/10] GH-4182: Add LocalResponseCache filter to Spring Cloud Gateway Server Web MVC Implements the route-level LocalResponseCache filter for the Web MVC module, mirroring the cacheability semantics of the WebFlux filter: bodiless GET requests only, 200/206/301 responses, Cache-Control and Vary handling, no-cache revalidation, and max-age recalculation on cache hits. Backed by a per-route Caffeine cache with optional size-based eviction. The cache captures the response body at write time through a ServerResponse decorator, so the deferred proxy write path stays intact, composed filters keep working, and the cached body always belongs to the response that produced it. Writes that do not carry the full body are never cached: 304 Not Modified answers to conditional requests, async and view-rendering responses, writes with swallowed writer errors, and responses whose status was mutated after wrapping. Responses with a streaming media type are never cached. Both filter parameters are optional, matching the WebFlux configuration shortcut. Builds on the prototype started by Ingo Griebsch. Fixes gh-4182 Signed-off-by: Nikita Kibitkin --- docs/modules/ROOT/nav.adoc | 2 +- .../filters/local-cache-response-filter.adoc | 72 +- spring-cloud-gateway-server-webmvc/pom.xml | 1 + .../mvc/filter/FilterAutoConfiguration.java | 5 +- .../filter/ResponseCacheFilterFunctions.java | 766 +++++++++++++----- .../ResponseCacheFilterFunctionsTests.java | 606 ++++++++++++++ .../mvc/filter/ResponseCacheManagerTests.java | 426 ++++++++++ 7 files changed, 1624 insertions(+), 254 deletions(-) create mode 100644 spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheFilterFunctionsTests.java create mode 100644 spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/ResponseCacheManagerTests.java 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 8b8942cec..7bca83c2c 100644 --- a/spring-cloud-gateway-server-webmvc/pom.xml +++ b/spring-cloud-gateway-server-webmvc/pom.xml @@ -95,6 +95,7 @@ com.github.ben-manes.caffeine caffeine + true com.bucket4j 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 f24a0a52e..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 @@ -22,6 +22,7 @@ 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; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -36,8 +37,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; -import io.github.bucket4j.BucketConfiguration; - @AutoConfiguration public class FilterAutoConfiguration { @@ -117,7 +116,7 @@ public TokenRelayFilterFunctions.FilterSupplier tokenRelayFilterFunctionsSupplie } @Configuration(proxyBeanMethods = false) - @ConditionalOnClass({ Weigher.class, Caffeine.class/* , CaffeineCacheManager.class */ }) + @ConditionalOnClass({ Weigher.class, Caffeine.class }) static class ResponseCacheFilterConfiguration { @Bean 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 index 351c55325..9967b67eb 100644 --- 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 @@ -13,365 +13,695 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.cloud.gateway.server.mvc.filter; -import static org.springframework.http.HttpHeaders.CACHE_CONTROL; -import static org.springframework.http.HttpHeaders.VARY; -import static org.springframework.http.HttpStatus.MOVED_PERMANENTLY; -import static org.springframework.http.HttpStatus.OK; -import static org.springframework.http.HttpStatus.PARTIAL_CONTENT; +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.Date; +import java.util.Collection; +import java.util.Collections; import java.util.List; -import java.util.Objects; 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.cache.Cache; -import org.springframework.cloud.gateway.server.mvc.filter.ResponseCacheFilterFunctions.CacheKeyFactory.CacheKey; + +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.util.StringUtils; +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; /** - * The central interface that enables the caching of certain HTTP responses, thereby - * reducing latency and overhead for the upstream server. - *

- * I'm currently keeping all feature-specific code in this class. Whether and how the code - * should be moved to separate classes and packages can be decided later. + * {@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() { } - public static HandlerFilterFunction responseCache(Duration timeToLive, - DataSize cacheSize) { - return new ResponseCacheFilter(); + @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} implementation that allows to cache certain HTTP - * responses. - * - * @author Ingo Griebsch + * 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 { - // FIXME implement me... - - // If the request is not cacheable, simple continue with the next filter. - if (!ServletUtils.isCacheable(request)) { + if (!responseCacheManager.isRequestCacheable(request)) { return next.handle(request); } - - // FIXME Should we remember that this filter is now applied? - - // If the request should be revalidated continue with the request an put the - // response in the cache. - if (ServletUtils.shouldRevalidate(request)) { - return cacheResponse(next.handle(request)); + if (responseCacheManager.isNoCacheRequest(request)) { + // no-cache: revalidate against the upstream, skip the cache entry update + return next.handle(request); } - - // Check if a response for this request is already cached. - Optional cachedResponse = obtainCachedResponse(request); - - // If not, continue with the request an put the response in the cache. - // If so, return the cached response but update the metatdata built based on - // the request. - if (cachedResponse.isEmpty()) { - return cacheResponse(next.handle(request)); + String metadataKey = responseCacheManager.resolveMetadataKey(request); + Optional cachedResponse = responseCacheManager.getFromCache(request, metadataKey); + if (cachedResponse.isPresent()) { + return responseCacheManager.processFromCache(metadataKey, cachedResponse.get()); } - - return createResponse(cachedResponse.get()); - } - - private ServerResponse createResponse(CachedResponse cachedResponse) { - // FIXME implement me... - return null; + 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 ServerResponse cacheResponse(ServerResponse response) { - // FIXME implement me... - return 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 Optional obtainCachedResponse(ServerRequest request) { - // FIXME implement me... - return null; + 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; } } /** - * A {@link FilterSupplier} implementation that provides all - * {@link HandlerFilterFunction handler filter functions} available to be able to - * cache HTTP responses. - * - * @author Ingo Griebsch + * Caches responses and their metadata and applies the cache related response header + * mutations on both the cached and the upstream exchange path. */ - static class FilterSupplier extends SimpleFilterSupplier { + static class ResponseCacheManager { - FilterSupplier() { - super(ResponseCacheFilterFunctions.class); + 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()); + } - /** - * Allows registration and access to a specific {@link ResponseCacheManager}. - *

- * Allows access to a {@link ResponseCacheManager} that is to be used for a specific - * route. - * - * @author Ingo Griebsch - */ - static class ResponseCacheManagerRegistry { + boolean isNoCacheRequest(ServerRequest request) { + String cacheControl = request.headers().asHttpHeaders().getCacheControl(); + return cacheControl != null && NO_CACHE_PATTERN.matcher(cacheControl).matches(); + } - void register(String id, ResponseCacheManager manager) { - // FIXME implement me... + String resolveMetadataKey(ServerRequest request) { + return cacheKeyGenerator.generateMetadataKey(request); } - ResponseCacheManager get(String id) { - // FIXME implement me... - return null; + 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); + } - /** - * Allows responses and their metadata to be cached. - *

- * Builds on the cache abstraction provided by Spring, enabling flexible configuration - * of the underlying cache. - * - * @author Ingo Griebsch - */ - static class ResponseCacheManager { + 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())); + } - private final Cache cache; + 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; + }); + } - ResponseCacheManager(Cache cache) { - this.cache = cache; + private boolean isResponseCacheable(ServerResponse response) { + return STATUSES_TO_CACHE.contains(response.statusCode()) && isCacheControlAllowed(response.headers()) + && !isVaryWildcard(response.headers()); } - Optional get(CacheKey key, Class type) { - T entry = null; - try { - entry = cache.get(key, type); + 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; } - catch (Exception e) { - // FIXME log + 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); } - return Optional.ofNullable(entry); + headers.remove(HttpHeaders.CACHE_CONTROL); + headers.addAll(HttpHeaders.CACHE_CONTROL, newCacheControlValues); } - void put(CacheKey key, Object object) { - cache.put(key, object); + 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); + } - /** - * Represents the metadata of a cached HTTP response. - * - * @author Ingo Griebsch - */ - static class CachedResponseMetadata { + private static boolean isVaryWildcard(HttpHeaders headers) { + return headers.getOrEmpty(HttpHeaders.VARY).stream().anyMatch(VARY_WILDCARD::equals); + } - private final List headers; + private static boolean hasRequestBody(ServerRequest request) { + return request.headers().asHttpHeaders().getContentLength() > 0; + } - CachedResponseMetadata(List headers) { - this.headers = headers; + 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; + } } - List getHeaders() { - return headers; + 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); + } } } /** - * Represents a cached HTTP response. - * - * @author Ingo Griebsch + * Creates cache keys based on a {@link ServerRequest} and the headers the cached + * response varies on. */ - static class CachedResponse { + static class CacheKeyGenerator { - // FIXME Add the body - private final HttpStatusCode statusCode; + private static final String KEY_SEPARATOR = ";"; - private final HttpHeaders headers; + private static final String METADATA_KEY_PREFIX = "META_"; - private final Date timestamp; + private final ThreadLocal messageDigest = ThreadLocal.withInitial(() -> { + try { + return MessageDigest.getInstance("MD5"); + } + catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Error creating CacheKeyGenerator", e); + } + }); - CachedResponse(HttpStatusCode statusCode, HttpHeaders headers, Date timestamp) { - this.statusCode = statusCode; - this.headers = headers; - this.timestamp = timestamp; + String generateMetadataKey(ServerRequest request) { + return METADATA_KEY_PREFIX + generateKey(request, Collections.emptyList()); } - public HttpStatusCode getStatusCode() { - return statusCode; + String generateKey(ServerRequest request, List varyOnHeaders) { + byte[] digest = messageDigest.get().digest(generateRawKey(request, varyOnHeaders)); + return Base64.getEncoder().encodeToString(digest); } - public HttpHeaders getHeaders() { - return headers; + 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); } - public Date getTimestamp() { - return timestamp; + 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 factory that allows to create {@link CacheKey cache-keys} based on a - * {@link ServerRequest} and additional context related information. + * A cached HTTP response. * - * @author Ingo Griebsch + * @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 */ - static class CacheKeyFactory { + record CachedResponse(HttpStatusCode statusCode, HttpHeaders headers, byte[] body, Instant timestamp) { - private final ThreadLocal messageDigest; + } - CacheKeyFactory() { - this.messageDigest = ThreadLocal.withInitial(() -> { - try { - return MessageDigest.getInstance("MD5"); - } - catch (NoSuchAlgorithmException e) { - throw new RuntimeException("Caught exception while creating CacheKeyCalculator!", e); - } - }); - } + /** + * 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; - CacheKey from(ServerRequest request, @Nullable String prefix) { - return from(request, List.of(), prefix); + 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; } - CacheKey from(ServerRequest request, List headers, @Nullable String prefix) { - byte[] digest = messageDigest.get().digest(calculate(request, headers)); - return new CacheKey(prefix, Base64.getEncoder().encodeToString(digest)); + @Override + public HttpStatusCode statusCode() { + return delegate.statusCode(); } - private byte[] calculate(ServerRequest request, List headers) { - // FIXME implement me... - return null; + @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); + } } - static class CacheKey { + @Override + public HttpHeaders headers() { + return delegate.headers(); + } - private final String value; + @Override + public MultiValueMap cookies() { + return delegate.cookies(); + } - CacheKey(@Nullable String prefix, String value) { - this.value = "%s%s".formatted(StringUtils.hasText(prefix) ? "%s_".formatted(prefix) : "", value); + @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); } - - String getValue() { - return value; + finally { + // the capturing writer buffers; flush so the client receives the tail + // even when the write turns out not to be cacheable + capturingResponse.flushWriter(); } - - @Override - public int hashCode() { - return Objects.hash(value); + if (isCompletedCacheableWrite(request, capturingResponse, modelAndView)) { + responseCacheManager.cacheCapturedResponse(metadataKey, metadata, key, delegate.statusCode(), + headersToCache(capturingResponse), capturingResponse.getCapturedBody()); } + else { + capturingResponse.stopCapturing(); + } + return modelAndView; + } - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (getClass() != obj.getClass()) { - return false; - } - CacheKey other = (CacheKey) obj; - return Objects.equals(value, other.value); + 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 set of servlet related utilities to ease the implementation of the - * {@link ResponseCacheFilterFunctions response cache filter functions}. - * - * @author Ingo Griebsch + * A {@link HttpServletResponseWrapper} that copies everything written to the response + * body into a buffer while passing it through to the underlying response. */ - abstract static class ServletUtils { + private static final class BodyCapturingResponseWrapper extends HttpServletResponseWrapper { - private ServletUtils() { - } + private final ByteArrayOutputStream capturedBody = new ByteArrayOutputStream(); - static boolean shouldRevalidate(ServerRequest request) { - return Optional.ofNullable(request.headers().asHttpHeaders().getCacheControl()) - .map(v -> v.matches(".*(\s|,|^)no-cache(\\s|,|$).*")) - .orElse(false); - } + private volatile boolean capturing = true; - static boolean isCacheable(ServerRequest request) { - return isGetMethod(request) && !hasBody(request) && isCacheControlAllowed(request); + private @Nullable ServletOutputStream outputStream; + + private @Nullable PrintWriter writer; + + BodyCapturingResponseWrapper(HttpServletResponse response) { + super(response); } - static boolean isCacheControlAllowed(ServerRequest request) { - return isCacheControlAllowed(request.headers().header(CACHE_CONTROL)); + byte[] getCapturedBody() { + flushWriter(); + return capturedBody.toByteArray(); } - static boolean isCacheControlAllowed(ServerResponse response) { - return isCacheControlAllowed(response.headers().get(CACHE_CONTROL)); + boolean hasCaptureErrors() { + return writer != null && writer.checkError(); } - static boolean hasBody(ServerRequest request) { - // FIXME What if no Content-Length header is present? Should we assume that - // the request has no body or should we read - // the body to determine if it has content? - return request.headers().contentLength().orElse(0L) > 0; + void flushWriter() { + if (writer != null) { + writer.flush(); + } } - static boolean isGetMethod(ServerRequest request) { - return HttpMethod.GET.equals(request.method()); + 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(); } - static boolean isCacheable(ServerResponse response) { - List cacheableStatusCodes = List.of(OK, PARTIAL_CONTENT, MOVED_PERMANENTLY); - return hasStatusCode(response, cacheableStatusCodes) && isCacheControlAllowed(response) - && !isVaryWildcard(response); + @Override + public ServletOutputStream getOutputStream() throws IOException { + if (outputStream == null) { + outputStream = new BodyCapturingOutputStream(super.getOutputStream()); + } + return outputStream; } - static boolean hasStatusCode(ServerResponse response, List statusCodes) { - return statusCodes.contains(response.statusCode()); + @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; } - static boolean isVaryWildcard(ServerResponse response) { - HttpHeaders headers = response.headers(); - List varyValues = headers.getOrEmpty(VARY); - return varyValues.stream().anyMatch("*"::equals); + /** + * 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); + } + } - private static boolean isCacheControlAllowed(@Nullable List headerValues) { - if (headerValues == null) { - return false; + } + + /** + * 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 headerValues.stream().noneMatch(List.of("private", "no-store")::contains); + 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()); + } + + } + +}