From 561c949f780fe89eeb49c70d62f0ded2d0b6faa9 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Mon, 24 Jul 2023 17:36:47 -0300 Subject: [PATCH] Implement Cache-Control Extension in Response Caching Policy. (#462) This commit adds the functionality to handle the 'immutable' directive in the Cache-Control header as per the RFC8246 specifications. Key changes include: - The 'immutable' directive is checked in the Cache-Control of an HTTP response, indicating that the origin server will not update the resource representation during the response's freshness lifetime. - If the 'immutable' directive is present and the response is still fresh, the response is considered cacheable without further validation. - Ignoring any arguments with the 'immutable' directive, as per RFC stipulations. - Treating multiple instances of the 'immutable' directive as equivalent to one. --- .../client5/http/cache/HeaderConstants.java | 1 + .../client5/http/impl/cache/CacheControl.java | 1 - .../impl/cache/CacheControlHeaderParser.java | 2 + .../http/impl/cache/RequestCacheControl.java | 24 ++++++++- .../http/impl/cache/ResponseCacheControl.java | 49 ++++++++++++++++++- .../impl/cache/ResponseCachingPolicy.java | 38 +++++++++++++- .../impl/cache/CacheControlParserTest.java | 14 ++++++ .../impl/cache/TestResponseCachingPolicy.java | 10 ++++ 8 files changed, 133 insertions(+), 6 deletions(-) diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/cache/HeaderConstants.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/cache/HeaderConstants.java index 60c0ba5d1..a04352e48 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/cache/HeaderConstants.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/cache/HeaderConstants.java @@ -165,6 +165,7 @@ public class HeaderConstants { public static final String CACHE_CONTROL_STALE_WHILE_REVALIDATE = "stale-while-revalidate"; public static final String CACHE_CONTROL_ONLY_IF_CACHED = "only-if-cached"; public static final String CACHE_CONTROL_MUST_UNDERSTAND = "must-understand"; + public static final String CACHE_CONTROL_IMMUTABLE= "immutable"; /** * @deprecated Use {@link #CACHE_CONTROL_STALE_IF_ERROR} */ diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheControl.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheControl.java index 8ad37633d..d451a4a19 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheControl.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheControl.java @@ -68,5 +68,4 @@ interface CacheControl { * @return The stale-if-error value. */ long getStaleIfError(); - } \ No newline at end of file diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheControlHeaderParser.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheControlHeaderParser.java index f912c3146..dea42089a 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheControlHeaderParser.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheControlHeaderParser.java @@ -198,6 +198,8 @@ class CacheControlHeaderParser { builder.setStaleIfError(parseSeconds(name, value)); } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_MUST_UNDERSTAND)) { builder.setMustUnderstand(true); + } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_IMMUTABLE)) { + builder.setImmutable(true); } }); return builder.build(); diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/RequestCacheControl.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/RequestCacheControl.java index a1f442ab1..1df87ac40 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/RequestCacheControl.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/RequestCacheControl.java @@ -49,8 +49,15 @@ final class RequestCacheControl implements CacheControl { private final boolean onlyIfCached; private final long staleIfError; + /** + * Flag for the 'no-transform' Cache-Control directive. + * If this field is true, then the 'no-transform' directive is present in the Cache-Control header. + * According to RFC 'no-transform' directive indicates that the cache MUST NOT transform the payload. + */ + private final boolean noTransform; + RequestCacheControl(final long maxAge, final long maxStale, final long minFresh, final boolean noCache, - final boolean noStore, final boolean onlyIfCached, final long staleIfError) { + final boolean noStore, final boolean onlyIfCached, final long staleIfError, final boolean noTransform) { this.maxAge = maxAge; this.maxStale = maxStale; this.minFresh = minFresh; @@ -58,6 +65,7 @@ final class RequestCacheControl implements CacheControl { this.noStore = noStore; this.onlyIfCached = onlyIfCached; this.staleIfError = staleIfError; + this.noTransform = noTransform; } /** @@ -137,6 +145,7 @@ final class RequestCacheControl implements CacheControl { ", noStore=" + noStore + ", onlyIfCached=" + onlyIfCached + ", staleIfError=" + staleIfError + + ", noTransform=" + noTransform + '}'; } @@ -153,6 +162,7 @@ final class RequestCacheControl implements CacheControl { private boolean noStore; private boolean onlyIfCached; private long staleIfError = -1; + private boolean noTransform; Builder() { } @@ -220,8 +230,18 @@ final class RequestCacheControl implements CacheControl { return this; } + public boolean isNoTransform() { + return noTransform; + } + + public Builder setNoTransform(final boolean noTransform) { + this.noTransform = noTransform; + return this; + } + + public RequestCacheControl build() { - return new RequestCacheControl(maxAge, maxStale, minFresh, noCache, noStore, onlyIfCached, staleIfError); + return new RequestCacheControl(maxAge, maxStale, minFresh, noCache, noStore, onlyIfCached, staleIfError, noTransform); } } diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCacheControl.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCacheControl.java index 866757c00..33469b533 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCacheControl.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCacheControl.java @@ -106,6 +106,14 @@ final class ResponseCacheControl implements CacheControl { private final boolean undefined; + /** + * Flag for the 'immutable' Cache-Control directive. + * If this field is true, then the 'immutable' directive is present in the Cache-Control header. + * The 'immutable' directive is meant to inform a cache or user agent that the response body will not + * change over time, even though it may be requested multiple times. + */ + private final boolean immutable; + /** * Creates a new instance of {@code CacheControl} with the specified values. * @@ -121,11 +129,12 @@ final class ResponseCacheControl implements CacheControl { * @param staleIfError The stale-if-error value from the Cache-Control header. * @param noCacheFields The set of field names specified in the "no-cache" directive of the Cache-Control header. * @param mustUnderstand The must-understand value from the Cache-Control header. + * @param immutable The immutable value from the Cache-Control header. */ ResponseCacheControl(final long maxAge, final long sharedMaxAge, final boolean mustRevalidate, final boolean noCache, final boolean noStore, final boolean cachePrivate, final boolean proxyRevalidate, final boolean cachePublic, final long staleWhileRevalidate, final long staleIfError, - final Set noCacheFields, final boolean mustUnderstand) { + final Set noCacheFields, final boolean mustUnderstand, final boolean immutable) { this.maxAge = maxAge; this.sharedMaxAge = sharedMaxAge; this.noCache = noCache; @@ -148,6 +157,7 @@ final class ResponseCacheControl implements CacheControl { staleWhileRevalidate == -1 && staleIfError == -1; this.mustUnderstand = mustUnderstand; + this.immutable = immutable; } /** @@ -263,10 +273,24 @@ final class ResponseCacheControl implements CacheControl { return noCacheFields; } + /** + * Returns the 'immutable' Cache-Control directive status. + * + * @return true if the 'immutable' directive is present in the Cache-Control header. + */ public boolean isUndefined() { return undefined; } + /** + * Returns the 'immutable' Cache-Control directive status. + * + * @return true if the 'immutable' directive is present in the Cache-Control header. + */ + public boolean isImmutable() { + return immutable; + } + @Override public String toString() { return "CacheControl{" + @@ -282,6 +306,7 @@ final class ResponseCacheControl implements CacheControl { ", staleIfError=" + staleIfError + ", noCacheFields=" + noCacheFields + ", mustUnderstand=" + mustUnderstand + + ", immutable=" + immutable + '}'; } @@ -303,6 +328,8 @@ final class ResponseCacheControl implements CacheControl { private long staleIfError = -1; private Set noCacheFields; private boolean mustUnderstand; + private boolean noTransform; + private boolean immutable; Builder() { } @@ -415,9 +442,27 @@ final class ResponseCacheControl implements CacheControl { return this; } + public boolean isNoTransform() { + return noStore; + } + + public Builder setNoTransform(final boolean noTransform) { + this.noTransform = noTransform; + return this; + } + + public boolean isImmutable() { + return immutable; + } + + public Builder setImmutable(final boolean immutable) { + this.immutable = immutable; + return this; + } + public ResponseCacheControl build() { return new ResponseCacheControl(maxAge, sharedMaxAge, mustRevalidate, noCache, noStore, cachePrivate, proxyRevalidate, - cachePublic, staleWhileRevalidate, staleIfError, noCacheFields, mustUnderstand); + cachePublic, staleWhileRevalidate, staleIfError, noCacheFields, mustUnderstand, immutable); } } diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java index fab185b4a..8237927fc 100644 --- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java +++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java @@ -231,8 +231,18 @@ class ResponseCachingPolicy { return false; } - // calculate freshness lifetime final Duration freshnessLifetime = calculateFreshnessLifetime(cacheControl, response); + + // If the 'immutable' directive is present and the response is still fresh, + // then the response is considered cacheable without further validation + if (cacheControl.isImmutable() && responseIsStillFresh(response, freshnessLifetime)) { + if (LOG.isDebugEnabled()) { + LOG.debug("Response is immutable and fresh, considered cacheable without further validation"); + } + return true; + } + + // calculate freshness lifetime if (freshnessLifetime.isNegative() || freshnessLifetime.isZero()) { if (LOG.isDebugEnabled()) { LOG.debug("Freshness lifetime is invalid"); @@ -521,4 +531,30 @@ class ResponseCachingPolicy { (status >= 500 && status <= 505); } + /** + * Determines if an HttpResponse is still fresh based on its Date header and calculated freshness lifetime. + * + *

+ * This method calculates the age of the response from its Date header and compares it with the provided freshness + * lifetime. If the age is less than the freshness lifetime, the response is considered fresh. + *

+ * + *

+ * Note: If the Date header is missing or invalid, this method assumes the response is not fresh. + *

+ * + * @param response The HttpResponse whose freshness is being checked. + * @param freshnessLifetime The calculated freshness lifetime of the HttpResponse. + * @return {@code true} if the response age is less than its freshness lifetime, {@code false} otherwise. + */ + private boolean responseIsStillFresh(final HttpResponse response, final Duration freshnessLifetime) { + final Instant date = DateUtils.parseStandardDate(response, HttpHeaders.DATE); + if (date == null) { + // The Date header is missing or invalid. Assuming the response is not fresh. + return false; + } + final Duration age = Duration.between(date, Instant.now()); + return age.compareTo(freshnessLifetime) < 0; + } + } diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/CacheControlParserTest.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/CacheControlParserTest.java index 66c892c48..31a7bc109 100644 --- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/CacheControlParserTest.java +++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/CacheControlParserTest.java @@ -243,4 +243,18 @@ public class CacheControlParserTest { ); } + @Test + public void testParseIsImmutable() { + final Header header = new BasicHeader("Cache-Control", "max-age=0 , immutable"); + final ResponseCacheControl cacheControl = parser.parseResponse(Collections.singletonList(header).iterator()); + assertTrue(cacheControl.isImmutable()); + } + + @Test + public void testParseMultipleIsImmutable() { + final Header header = new BasicHeader("Cache-Control", "immutable, nmax-age=0 , immutable"); + final ResponseCacheControl cacheControl = parser.parseResponse(Collections.singletonList(header).iterator()); + assertTrue(cacheControl.isImmutable()); + } + } diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java index 57344e31c..7ec81cdca 100644 --- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java +++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java @@ -1080,4 +1080,14 @@ public class TestResponseCachingPolicy { .build(); assertFalse(policy.isResponseCacheable(responseCacheControl, request, response)); } + + @Test + public void testImmutableAndFreshResponseIsCacheable() { + responseCacheControl = ResponseCacheControl.builder() + .setImmutable(true) + .setMaxAge(3600) // set this to a value that ensures the response is still fresh + .build(); + + Assertions.assertTrue(policy.isResponseCacheable(responseCacheControl, "GET", response)); + } } \ No newline at end of file