/*
* Copyright 2014 Bazaarvoice, Inc.
*
* 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
*
* http://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 com.bazaarvoice.dropwizard.caching;
import com.google.common.base.Optional;
import com.google.common.collect.ImmutableMap;
import com.sun.jersey.core.header.reader.HttpHeaderReader;
import java.text.ParseException;
import java.util.Map;
import java.util.regex.Pattern;
import static com.google.common.base.Preconditions.checkNotNull;
/**
* Cache-Control header options that are valid for an HTTP request.
* <p/>
* For more info, see <a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9">RFC 2616, Section 14.9</a>.
* Also, <a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.4">RFC 2616, Section 14.9.4</a> applies
* to request cache control options.
*/
public class RequestCacheControl {
public static final RequestCacheControl DEFAULT = new RequestCacheControl();
private static final Pattern WHITESPACE = Pattern.compile("\\s");
private boolean _noCache;
private boolean _noStore;
private int _maxAge = -1;
private int _maxStale = -1;
private int _minFresh = -1;
private boolean _noTransform;
private boolean _onlyIfCached;
private Map<String, Optional<String>> _cacheExtension;
/**
* Creates a new instance of public class RequestCacheControl by parsing the supplied string.
*
* @param value the cache control string
* @return the newly created RequestCacheControl
* @throws IllegalArgumentException if the supplied string cannot be parsed
*/
public static RequestCacheControl valueOf(String value) {
checkNotNull(value);
try {
HttpHeaderReader reader = HttpHeaderReader.newInstance(value);
RequestCacheControl cacheControl = new RequestCacheControl();
ImmutableMap.Builder<String, Optional<String>> cacheExtension = ImmutableMap.builder();
while (reader.hasNext()) {
String directive = reader.nextToken();
if ("no-cache".equalsIgnoreCase(directive)) {
cacheControl._noCache = true;
} else if ("no-store".equalsIgnoreCase(directive)) {
cacheControl._noStore = true;
} else if ("max-stale".equalsIgnoreCase(directive)) {
cacheControl._maxStale = reader.hasNextSeparator('=', false)
? readDeltaSeconds(reader, directive)
: Integer.MAX_VALUE;
} else if ("max-age".equalsIgnoreCase(directive)) {
cacheControl._maxAge = readDeltaSeconds(reader, directive);
} else if ("min-fresh".equalsIgnoreCase(directive)) {
cacheControl._minFresh = readDeltaSeconds(reader, directive);
} else if ("no-transform".equalsIgnoreCase(directive)) {
cacheControl._noTransform = true;
} else if ("only-if-cached".equalsIgnoreCase(directive)) {
cacheControl._onlyIfCached = true;
} else {
String directiveValue = null;
if (reader.hasNextSeparator('=', false)) {
reader.nextSeparator('=');
directiveValue = reader.nextTokenOrQuotedString();
}
cacheExtension.put(directive.toLowerCase(), Optional.fromNullable(directiveValue));
}
if (reader.hasNextSeparator(',', true)) {
reader.nextSeparator(',');
}
}
cacheControl._cacheExtension = cacheExtension.build();
return cacheControl;
} catch (ParseException ex) {
throw new IllegalArgumentException("Error parsing request cache control: value='" + value + "'", ex);
}
}
/**
* If true, the server MUST NOT use a cached copy when responding to the request.
*
* @return true to force a fresh request
*/
public boolean isNoCache() {
return _noCache;
}
/**
* If true, a cache MUST NOT store any part of either this request or any response to it.
*
* @return true to force the response to not be cached
*/
public boolean isNoStore() {
return _noStore;
}
/**
* Indicates that the client is willing to accept a response whose age is no greater than the specified time in
* seconds.
* <p/>
* Unless {@link #getMaxStale()} directive is also included, the client is not willing to accept a stale response.
*
* @return max age, in seconds, or -1 if the max-age option was not set
*/
public int getMaxAge() {
return _maxAge;
}
/**
* Indicates that the client is willing to accept a response that has exceeded its expiration time.
* <p/>
* If max-stale is assigned a value, then the client is willing to accept a response that has exceeded its
* expiration time by no more than the specified number of seconds. If {@link Integer#MAX_VALUE} is assigned to
* max-stale, then the client is willing to accept a stale response of any age.
*
* @return max response staleness, in seconds, or -1 if the max-stale option was not set
*/
public int getMaxStale() {
return _maxStale;
}
/**
* Indicates that the client is willing to accept a response whose freshness lifetime is no less than its current
* age plus the specified time in seconds. That is, the client wants a response that will still be fresh for at
* least the specified number of seconds.
*
* @return min freshness, in seconds, or -1 if the min-fresh option was not set
*/
public int getMinFresh() {
return _minFresh;
}
/**
* If true, an intermediate cache or proxy MUST NOT change those headers that are listed in
* <a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec13.5.2">section 13.5.2</a> as being subject to
* the no-transform directive. This implies that the cache or proxy MUST NOT change any aspect of the entity-body
* that is specified by these headers, including the value of the entity-body itself.
*
* @return true if the cache/proxy must not modify the response
*/
public boolean isNoTransform() {
return _noTransform;
}
/**
* If true, a cache SHOULD either respond using a cached entry that is consistent with the other constraints of the
* request, or respond with a 504 (Gateway Timeout) status.
* <p/>
* If a group of caches is being operated as a unified system with good internal connectivity, such a request MAY be
* forwarded within that group of caches.
*
* @return true to only serve the response from a cache
*/
public boolean isOnlyIfCached() {
return _onlyIfCached;
}
/**
* Unknown or unsupported cache control directives.
* <p/>
* If the directive appeared as a bare value, the value in the map will be absent. The keys are lowercase.
* These directives can be ignored by an intermediate cache/proxy, but they must be passed to any upstream caches.
*
* @return immutable map from directive name to value
*/
public Map<String, Optional<String>> getCacheExtension() {
if (_cacheExtension == null) {
_cacheExtension = ImmutableMap.of();
}
return _cacheExtension;
}
@Override
public int hashCode() {
int hash = 7;
hash = 41 * hash + (_noCache ? 1 : 0);
hash = 41 * hash + (_noStore ? 1 : 0);
hash = 41 * hash + _maxAge;
hash = 41 * hash + _maxStale;
hash = 41 * hash + _minFresh;
hash = 41 * hash + (_noTransform ? 1 : 0);
hash = 41 * hash + (_onlyIfCached ? 1 : 0);
hash = 41 * hash + (_cacheExtension != null ? _cacheExtension.hashCode() : 0);
return hash;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof RequestCacheControl)) {
return false;
}
RequestCacheControl other = (RequestCacheControl) obj;
return _noCache == other._noCache &&
_noStore == other._noStore &&
_maxAge == other._maxAge &&
_maxStale == other._maxStale &&
_minFresh == other._minFresh &&
_noTransform == other._noTransform &&
_onlyIfCached == other._onlyIfCached &&
(
// Empty and null are equivalent
_cacheExtension == other._cacheExtension ||
(_cacheExtension == null && other._cacheExtension.size() == 0) ||
(other._cacheExtension == null && _cacheExtension.size() == 0) ||
_cacheExtension.equals(other._cacheExtension)
);
}
@Override
public String toString() {
StringBuilder buffer = new StringBuilder();
if (_noCache) {
appendDirective(buffer, "no-cache");
}
if (_noStore) {
appendDirective(buffer, "no-store");
}
if (_maxAge >= 0) {
appendDirective(buffer, "max-age", _maxAge);
}
if (_maxStale == Integer.MAX_VALUE) {
appendDirective(buffer, "max-stale");
} else if (_maxStale >= 0) {
appendDirective(buffer, "max-stale", _maxStale);
}
if (_minFresh >= 0) {
appendDirective(buffer, "min-fresh", _minFresh);
}
if (_noTransform) {
appendDirective(buffer, "no-transform");
}
if (_onlyIfCached) {
appendDirective(buffer, "only-if-cached");
}
if (_cacheExtension != null) {
for (Map.Entry<String, Optional<String>> entry : _cacheExtension.entrySet()) {
appendDirective(buffer, entry.getKey(), entry.getValue());
}
}
return buffer.toString();
}
private static void appendDirective(StringBuilder buffer, String key) {
if (buffer.length() > 0) {
buffer.append(", ");
}
buffer.append(key);
}
private static void appendDirective(StringBuilder buffer, String key, int value) {
appendDirective(buffer, key);
buffer.append('=').append(value);
}
private static void appendDirective(StringBuilder buffer, String key, Optional<String> value) {
appendDirective(buffer, key);
if (value.isPresent()) {
buffer.append('=').append(quoteDirective(value.get()));
}
}
private static String quoteDirective(String value) {
return WHITESPACE.matcher(value).find()
? '"' + value + '"'
: value;
}
private static int readDeltaSeconds(HttpHeaderReader reader, String directiveName)
throws ParseException {
reader.nextSeparator('=');
int index = reader.getIndex();
int value;
try {
value = Integer.parseInt(reader.nextToken());
} catch (NumberFormatException nfe) {
ParseException pe = new ParseException("Error parsing integer value for " + directiveName + " directive", index);
pe.initCause(nfe);
throw pe;
}
if (value < 0) {
throw new ParseException("Value for " + directiveName + " directive is negative", index);
}
return value;
}
}