CacheControlHeaderParser.java

/*
 * ====================================================================
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.
 * ====================================================================
 *
 * This software consists of voluntary contributions made by many
 * individuals on behalf of the Apache Software Foundation.  For more
 * information on the Apache Software Foundation, please see
 * <http://www.apache.org/>.
 *
 */
package org.apache.hc.client5.http.impl.cache;

import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.function.BiConsumer;

import org.apache.hc.client5.http.cache.HeaderConstants;
import org.apache.hc.client5.http.cache.HttpCacheEntry;
import org.apache.hc.client5.http.cache.RequestCacheControl;
import org.apache.hc.client5.http.cache.ResponseCacheControl;
import org.apache.hc.core5.annotation.Contract;
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.annotation.ThreadingBehavior;
import org.apache.hc.core5.http.FormattedHeader;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.message.ParserCursor;
import org.apache.hc.core5.util.Args;
import org.apache.hc.core5.util.CharArrayBuffer;
import org.apache.hc.core5.util.TextUtils;
import org.apache.hc.core5.util.Tokenizer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A parser for the HTTP Cache-Control header that can be used to extract information about caching directives.
 * <p>
 * This class is thread-safe and has a singleton instance ({@link #INSTANCE}).
 * </p>
 * <p>
 * The {@link #parseResponse(Iterator)} method takes an HTTP header and returns a {@link ResponseCacheControl} object containing
 * the relevant caching directives. The header can be either a {@link FormattedHeader} object, which contains a
 * pre-parsed {@link CharArrayBuffer}, or a plain {@link Header} object, in which case the value will be parsed and
 * stored in a new {@link CharArrayBuffer}.
 * </p>
 * <p>
 * This parser only supports two directives: "max-age" and "s-maxage". If either of these directives are present in the
 * header, their values will be parsed and stored in the {@link ResponseCacheControl} object. If both directives are
 * present, the value of "s-maxage" takes precedence.
 * </p>
 */
@Internal
@Contract(threading = ThreadingBehavior.IMMUTABLE)
class CacheControlHeaderParser {

    /**
     * The singleton instance of this parser.
     */
    public static final CacheControlHeaderParser INSTANCE = new CacheControlHeaderParser();

    /**
     * The logger for this class.
     */
    private static final Logger LOG = LoggerFactory.getLogger(CacheControlHeaderParser.class);


    private final static char EQUAL_CHAR = '=';

    /**
     * The set of characters that can delimit a token in the header.
     */
    private static final Tokenizer.Delimiter TOKEN_DELIMS = Tokenizer.delimiters(EQUAL_CHAR, ',');

    /**
     * The set of characters that can delimit a value in the header.
     */
    private static final Tokenizer.Delimiter VALUE_DELIMS = Tokenizer.delimiters(EQUAL_CHAR, ',');

    /**
     * The token parser used to extract values from the header.
     */
    private final Tokenizer tokenParser;

    /**
     * Constructs a new instance of this parser.
     */
    protected CacheControlHeaderParser() {
        super();
        this.tokenParser = Tokenizer.INSTANCE;
    }

    public void parse(final Iterator<Header> headerIterator, final BiConsumer<String, String> consumer) {
        while (headerIterator.hasNext()) {
            final Header header = headerIterator.next();
            final CharArrayBuffer buffer;
            final Tokenizer.Cursor cursor;
            if (header instanceof FormattedHeader) {
                buffer = ((FormattedHeader) header).getBuffer();
                cursor = new Tokenizer.Cursor(((FormattedHeader) header).getValuePos(), buffer.length());
            } else {
                final String s = header.getValue();
                if (s == null) {
                    continue;
                }
                buffer = new CharArrayBuffer(s.length());
                buffer.append(s);
                cursor = new Tokenizer.Cursor(0, buffer.length());
            }

            // Parse the header
            while (!cursor.atEnd()) {
                final String name = tokenParser.parseToken(buffer, cursor, TOKEN_DELIMS);
                String value = null;
                if (!cursor.atEnd()) {
                    final int valueDelim = buffer.charAt(cursor.getPos());
                    cursor.updatePos(cursor.getPos() + 1);
                    if (valueDelim == EQUAL_CHAR) {
                        value = tokenParser.parseValue(buffer, cursor, VALUE_DELIMS);
                        if (!cursor.atEnd()) {
                            cursor.updatePos(cursor.getPos() + 1);
                        }
                    }
                }
                consumer.accept(name, value);
            }
        }
    }

    /**
     * Parses the specified response header and returns a new {@link ResponseCacheControl} instance containing
     * the relevant caching directives.
     *
     * <p>The returned {@link ResponseCacheControl} instance will contain the values for "max-age" and "s-maxage"
     * caching directives parsed from the input header. If the input header does not contain any caching directives
     * or if the directives are malformed, the returned {@link ResponseCacheControl} instance will have default values
     * for "max-age" and "s-maxage" (-1).</p>
     *
     * @param headerIterator the header to parse, cannot be {@code null}
     * @return a new {@link ResponseCacheControl} instance containing the relevant caching directives parsed
     * from the response header
     * @throws IllegalArgumentException if the input header is {@code null}
     */
    public final ResponseCacheControl parseResponse(final Iterator<Header> headerIterator) {
        Args.notNull(headerIterator, "headerIterator");
        final ResponseCacheControl.Builder builder = ResponseCacheControl.builder();
        parse(headerIterator, (name, value) -> {
            if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_S_MAX_AGE)) {
                builder.setSharedMaxAge(parseSeconds(name, value));
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_MAX_AGE)) {
                builder.setMaxAge(parseSeconds(name, value));
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_MUST_REVALIDATE)) {
                builder.setMustRevalidate(true);
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_NO_CACHE)) {
                builder.setNoCache(true);
                if (value != null) {
                    final Tokenizer.Cursor valCursor = new ParserCursor(0, value.length());
                    final Set<String> noCacheFields = new HashSet<>();
                    while (!valCursor.atEnd()) {
                        final String token = tokenParser.parseToken(value, valCursor, VALUE_DELIMS);
                        if (!TextUtils.isBlank(token)) {
                            noCacheFields.add(token);
                        }
                        if (!valCursor.atEnd()) {
                            valCursor.updatePos(valCursor.getPos() + 1);
                        }
                    }
                    builder.setNoCacheFields(noCacheFields);
                }
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_NO_STORE)) {
                builder.setNoStore(true);
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_PRIVATE)) {
                builder.setCachePrivate(true);
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_PROXY_REVALIDATE)) {
                builder.setProxyRevalidate(true);
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_PUBLIC)) {
                builder.setCachePublic(true);
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_STALE_WHILE_REVALIDATE)) {
                builder.setStaleWhileRevalidate(parseSeconds(name, value));
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_STALE_IF_ERROR)) {
                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();
    }

    public final ResponseCacheControl parse(final HttpResponse response) {
        return parseResponse(response.headerIterator(HttpHeaders.CACHE_CONTROL));
    }

    public final ResponseCacheControl parse(final HttpCacheEntry cacheEntry) {
        return parseResponse(cacheEntry.headerIterator(HttpHeaders.CACHE_CONTROL));
    }

    /**
     * Parses the specified request header and returns a new {@link RequestCacheControl} instance containing
     * the relevant caching directives.
     *
     * @param headerIterator the header to parse, cannot be {@code null}
     * @return a new {@link RequestCacheControl} instance containing the relevant caching directives parsed
     * from the request header
     * @throws IllegalArgumentException if the input header is {@code null}
     */
    public final RequestCacheControl parseRequest(final Iterator<Header> headerIterator) {
        Args.notNull(headerIterator, "headerIterator");
        final RequestCacheControl.Builder builder = RequestCacheControl.builder();
        parse(headerIterator, (name, value) -> {
            if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_MAX_AGE)) {
                builder.setMaxAge(parseSeconds(name, value));
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_MAX_STALE)) {
                builder.setMaxStale(parseSeconds(name, value));
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_MIN_FRESH)) {
                builder.setMinFresh(parseSeconds(name, value));
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_NO_STORE)) {
                builder.setNoStore(true);
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_NO_CACHE)) {
                builder.setNoCache(true);
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_ONLY_IF_CACHED)) {
                builder.setOnlyIfCached(true);
            } else if (name.equalsIgnoreCase(HeaderConstants.CACHE_CONTROL_STALE_IF_ERROR)) {
                builder.setStaleIfError(parseSeconds(name, value));
            }
        });
        return builder.build();
    }

    public final RequestCacheControl parse(final HttpRequest request) {
        return parseRequest(request.headerIterator(HttpHeaders.CACHE_CONTROL));
    }

    private static long parseSeconds(final String name, final String value) {
        final long delta = CacheSupport.deltaSeconds(value);
        if (delta == -1 && LOG.isDebugEnabled()) {
            LOG.debug("Directive {} is malformed: {}", name, value);
        }
        return delta;
    }

}