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.
This commit is contained in:
Arturo Bernal 2023-07-24 17:36:47 -03:00 committed by Oleg Kalnichevski
parent 9bde706ae7
commit 561c949f78
8 changed files with 133 additions and 6 deletions

View File

@ -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}
*/

View File

@ -68,5 +68,4 @@ interface CacheControl {
* @return The stale-if-error value.
*/
long getStaleIfError();
}

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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<String> noCacheFields, final boolean mustUnderstand) {
final Set<String> 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<String> 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);
}
}

View File

@ -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.
*
* <p>
* 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.
* </p>
*
* <p>
* Note: If the Date header is missing or invalid, this method assumes the response is not fresh.
* </p>
*
* @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;
}
}

View File

@ -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());
}
}

View File

@ -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));
}
}