View Javadoc
1   /*
2    * ====================================================================
3    * Licensed to the Apache Software Foundation (ASF) under one
4    * or more contributor license agreements.  See the NOTICE file
5    * distributed with this work for additional information
6    * regarding copyright ownership.  The ASF licenses this file
7    * to you under the Apache License, Version 2.0 (the
8    * "License"); you may not use this file except in compliance
9    * with the License.  You may obtain a copy of the License at
10   *
11   *   http://www.apache.org/licenses/LICENSE-2.0
12   *
13   * Unless required by applicable law or agreed to in writing,
14   * software distributed under the License is distributed on an
15   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16   * KIND, either express or implied.  See the License for the
17   * specific language governing permissions and limitations
18   * under the License.
19   * ====================================================================
20   *
21   * This software consists of voluntary contributions made by many
22   * individuals on behalf of the Apache Software Foundation.  For more
23   * information on the Apache Software Foundation, please see
24   * <http://www.apache.org/>.
25   *
26   */
27  package org.apache.hc.client5.http.impl.cache;
28  
29  import java.net.URI;
30  import java.net.URISyntaxException;
31  import java.time.Instant;
32  import java.util.ArrayList;
33  import java.util.HashSet;
34  import java.util.Iterator;
35  import java.util.List;
36  import java.util.Locale;
37  import java.util.Objects;
38  import java.util.Set;
39  
40  import org.apache.hc.client5.http.cache.HttpCacheEntry;
41  import org.apache.hc.client5.http.cache.RequestCacheControl;
42  import org.apache.hc.client5.http.cache.ResponseCacheControl;
43  import org.apache.hc.client5.http.utils.DateUtils;
44  import org.apache.hc.client5.http.validator.ETag;
45  import org.apache.hc.core5.http.Header;
46  import org.apache.hc.core5.http.HttpHeaders;
47  import org.apache.hc.core5.http.HttpRequest;
48  import org.apache.hc.core5.http.HttpStatus;
49  import org.apache.hc.core5.http.Method;
50  import org.apache.hc.core5.http.message.MessageSupport;
51  import org.apache.hc.core5.util.TimeValue;
52  import org.slf4j.Logger;
53  import org.slf4j.LoggerFactory;
54  
55  /**
56   * Determines whether a given {@link HttpCacheEntry} is suitable to be
57   * used as a response for a given {@link HttpRequest}.
58   */
59  class CachedResponseSuitabilityChecker {
60  
61      private static final Logger LOG = LoggerFactory.getLogger(CachedResponseSuitabilityChecker.class);
62  
63      private final CacheValidityPolicy validityStrategy;
64      private final boolean sharedCache;
65      private final boolean staleifError;
66  
67      CachedResponseSuitabilityChecker(final CacheValidityPolicy validityStrategy,
68                                       final CacheConfig config) {
69          super();
70          this.validityStrategy = validityStrategy;
71          this.sharedCache = config.isSharedCache();
72          this.staleifError = config.isStaleIfErrorEnabled();
73      }
74  
75      CachedResponseSuitabilityChecker(final CacheConfig config) {
76          this(new CacheValidityPolicy(config), config);
77      }
78  
79      /**
80       * Determine if I can utilize the given {@link HttpCacheEntry} to respond to the given
81       * {@link HttpRequest}.
82       *
83       * @since 5.4
84       */
85      public CacheSuitability assessSuitability(final RequestCacheControl requestCacheControl,
86                                                final ResponseCacheControl responseCacheControl,
87                                                final HttpRequest request,
88                                                final HttpCacheEntry entry,
89                                                final Instant now) {
90          if (!requestMethodMatch(request, entry)) {
91              LOG.debug("Request method and the cache entry method do not match");
92              return CacheSuitability.MISMATCH;
93          }
94  
95          if (!requestUriMatch(request, entry)) {
96              LOG.debug("Target request URI and the cache entry request URI do not match");
97              return CacheSuitability.MISMATCH;
98          }
99  
100         if (!requestHeadersMatch(request, entry)) {
101             LOG.debug("Request headers nominated by the cached response do not match those of the request associated with the cache entry");
102             return CacheSuitability.MISMATCH;
103         }
104 
105         if (!requestHeadersMatch(request, entry)) {
106             LOG.debug("Request headers nominated by the cached response do not match those of the request associated with the cache entry");
107             return CacheSuitability.MISMATCH;
108         }
109 
110         if (requestCacheControl.isNoCache()) {
111             LOG.debug("Request contained no-cache directive; the cache entry must be re-validated");
112             return CacheSuitability.REVALIDATION_REQUIRED;
113         }
114 
115         if (isResponseNoCache(responseCacheControl, entry)) {
116             LOG.debug("Response contained no-cache directive; the cache entry must be re-validated");
117             return CacheSuitability.REVALIDATION_REQUIRED;
118         }
119 
120         if (hasUnsupportedConditionalHeaders(request)) {
121             LOG.debug("Response from cache is not suitable due to the request containing unsupported conditional headers");
122             return CacheSuitability.REVALIDATION_REQUIRED;
123         }
124 
125         if (!isConditional(request) && entry.getStatus() == HttpStatus.SC_NOT_MODIFIED) {
126             LOG.debug("Unconditional request and non-modified cached response");
127             return CacheSuitability.REVALIDATION_REQUIRED;
128         }
129 
130         if (!allConditionalsMatch(request, entry, now)) {
131             LOG.debug("Response from cache is not suitable due to the conditional request and with mismatched conditions");
132             return CacheSuitability.REVALIDATION_REQUIRED;
133         }
134 
135         final TimeValue currentAge = validityStrategy.getCurrentAge(entry, now);
136         if (LOG.isDebugEnabled()) {
137             LOG.debug("Cache entry current age: {}", currentAge);
138         }
139         final TimeValue freshnessLifetime = validityStrategy.getFreshnessLifetime(responseCacheControl, entry);
140         if (LOG.isDebugEnabled()) {
141             LOG.debug("Cache entry freshness lifetime: {}", freshnessLifetime);
142         }
143 
144         final boolean fresh = currentAge.compareTo(freshnessLifetime) < 0;
145 
146         if (!fresh && responseCacheControl.isMustRevalidate()) {
147             LOG.debug("Response from cache is not suitable due to the response must-revalidate requirement");
148             return CacheSuitability.REVALIDATION_REQUIRED;
149         }
150 
151         if (!fresh && sharedCache && responseCacheControl.isProxyRevalidate()) {
152             LOG.debug("Response from cache is not suitable due to the response proxy-revalidate requirement");
153             return CacheSuitability.REVALIDATION_REQUIRED;
154         }
155 
156         if (fresh && requestCacheControl.getMaxAge() >= 0) {
157             if (currentAge.toSeconds() > requestCacheControl.getMaxAge() && requestCacheControl.getMaxStale() == -1) {
158                 LOG.debug("Response from cache is not suitable due to the request max-age requirement");
159                 return CacheSuitability.REVALIDATION_REQUIRED;
160             }
161         }
162 
163         if (fresh && requestCacheControl.getMinFresh() >= 0) {
164             if (requestCacheControl.getMinFresh() == 0 ||
165                     freshnessLifetime.toSeconds() - currentAge.toSeconds() < requestCacheControl.getMinFresh()) {
166                 LOG.debug("Response from cache is not suitable due to the request min-fresh requirement");
167                 return CacheSuitability.REVALIDATION_REQUIRED;
168             }
169         }
170 
171         if (requestCacheControl.getMaxStale() >= 0) {
172             final long stale = currentAge.compareTo(freshnessLifetime) > 0 ? currentAge.toSeconds() - freshnessLifetime.toSeconds() : 0;
173             if (LOG.isDebugEnabled()) {
174                 LOG.debug("Cache entry staleness: {} SECONDS", stale);
175             }
176             if (stale >= requestCacheControl.getMaxStale()) {
177                 LOG.debug("Response from cache is not suitable due to the request max-stale requirement");
178                 return CacheSuitability.REVALIDATION_REQUIRED;
179             } else {
180                 LOG.debug("The cache entry is fresh enough");
181                 return CacheSuitability.FRESH_ENOUGH;
182             }
183         }
184 
185         if (fresh) {
186             LOG.debug("The cache entry is fresh");
187             return CacheSuitability.FRESH;
188         } else {
189             if (responseCacheControl.getStaleWhileRevalidate() > 0) {
190                 final long stale = currentAge.compareTo(freshnessLifetime) > 0 ? currentAge.toSeconds() - freshnessLifetime.toSeconds() : 0;
191                 if (stale < responseCacheControl.getStaleWhileRevalidate()) {
192                     LOG.debug("The cache entry is stale but suitable while being revalidated");
193                     return CacheSuitability.STALE_WHILE_REVALIDATED;
194                 }
195             }
196             LOG.debug("The cache entry is stale");
197             return CacheSuitability.STALE;
198         }
199     }
200 
201     boolean requestMethodMatch(final HttpRequest request, final HttpCacheEntry entry) {
202         return request.getMethod().equalsIgnoreCase(entry.getRequestMethod()) ||
203                 (Method.HEAD.isSame(request.getMethod()) && Method.GET.isSame(entry.getRequestMethod()));
204     }
205 
206     boolean requestUriMatch(final HttpRequest request, final HttpCacheEntry entry) {
207         try {
208             final URI requestURI = CacheKeyGenerator.normalize(request.getUri());
209             final URI cacheURI = new URI(entry.getRequestURI());
210             if (requestURI.isAbsolute()) {
211                 return Objects.equals(requestURI, cacheURI);
212             } else {
213                 return Objects.equals(requestURI.getPath(), cacheURI.getPath()) && Objects.equals(requestURI.getQuery(), cacheURI.getQuery());
214             }
215         } catch (final URISyntaxException ex) {
216             return false;
217         }
218     }
219 
220     boolean requestHeadersMatch(final HttpRequest request, final HttpCacheEntry entry) {
221         final Iterator<Header> it = entry.headerIterator(HttpHeaders.VARY);
222         if (it.hasNext()) {
223             final Set<String> headerNames = new HashSet<>();
224             while (it.hasNext()) {
225                 final Header header = it.next();
226                 MessageSupport.parseTokens(header, e -> {
227                     headerNames.add(e.toLowerCase(Locale.ROOT));
228                 });
229             }
230             final List<String> tokensInRequest = new ArrayList<>();
231             final List<String> tokensInCache = new ArrayList<>();
232             for (final String headerName: headerNames) {
233                 if (headerName.equalsIgnoreCase("*")) {
234                     return false;
235                 }
236                 CacheKeyGenerator.normalizeElements(request, headerName, tokensInRequest::add);
237                 CacheKeyGenerator.normalizeElements(entry.requestHeaders(), headerName, tokensInCache::add);
238                 if (!Objects.equals(tokensInRequest, tokensInCache)) {
239                     return false;
240                 }
241             }
242         }
243         return true;
244     }
245 
246     /**
247      * Determines if the given {@link HttpCacheEntry} requires revalidation based on the presence of the {@code no-cache} directive
248      * in the Cache-Control header.
249      * <p>
250      * The method returns true in the following cases:
251      * - If the {@code no-cache} directive is present without any field names (unqualified).
252      * - If the {@code no-cache} directive is present with field names, and at least one of these field names is present
253      * in the headers of the {@link HttpCacheEntry}.
254      * <p>
255      * If the {@code no-cache} directive is not present in the Cache-Control header, the method returns {@code false}.
256      */
257     boolean isResponseNoCache(final ResponseCacheControl responseCacheControl, final HttpCacheEntry entry) {
258         // If no-cache directive is present and has no field names
259         if (responseCacheControl.isNoCache()) {
260             final Set<String> noCacheFields = responseCacheControl.getNoCacheFields();
261             if (noCacheFields.isEmpty()) {
262                 LOG.debug("Revalidation required due to unqualified no-cache directive");
263                 return true;
264             }
265             for (final String field : noCacheFields) {
266                 if (entry.containsHeader(field)) {
267                     if (LOG.isDebugEnabled()) {
268                         LOG.debug("Revalidation required due to no-cache directive with field {}", field);
269                     }
270                     return true;
271                 }
272             }
273         }
274         return false;
275     }
276 
277     /**
278      * Is this request the type of conditional request we support?
279      * @param request The current httpRequest being made
280      * @return {@code true} if the request is supported
281      */
282     public boolean isConditional(final HttpRequest request) {
283         return hasSupportedEtagValidator(request) || hasSupportedLastModifiedValidator(request);
284     }
285 
286     /**
287      * Check that conditionals that are part of this request match
288      * @param request The current httpRequest being made
289      * @param entry the cache entry
290      * @param now right NOW in time
291      * @return {@code true} if the request matches all conditionals
292      */
293     public boolean allConditionalsMatch(final HttpRequest request, final HttpCacheEntry entry, final Instant now) {
294         final boolean hasEtagValidator = hasSupportedEtagValidator(request);
295         final boolean hasLastModifiedValidator = hasSupportedLastModifiedValidator(request);
296 
297         if (!hasEtagValidator && !hasLastModifiedValidator) {
298             return true;
299         }
300 
301         final boolean etagValidatorMatches = (hasEtagValidator) && etagValidatorMatches(request, entry);
302         final boolean lastModifiedValidatorMatches = (hasLastModifiedValidator) && lastModifiedValidatorMatches(request, entry, now);
303 
304         if ((hasEtagValidator && hasLastModifiedValidator)
305                 && !(etagValidatorMatches && lastModifiedValidatorMatches)) {
306             return false;
307         } else if (hasEtagValidator && !etagValidatorMatches) {
308             return false;
309         }
310 
311         return !hasLastModifiedValidator || lastModifiedValidatorMatches;
312     }
313 
314     boolean hasUnsupportedConditionalHeaders(final HttpRequest request) {
315         return (request.containsHeader(HttpHeaders.IF_RANGE)
316                 || request.containsHeader(HttpHeaders.IF_MATCH)
317                 || request.containsHeader(HttpHeaders.IF_UNMODIFIED_SINCE));
318     }
319 
320     boolean hasSupportedEtagValidator(final HttpRequest request) {
321         return request.containsHeader(HttpHeaders.IF_NONE_MATCH);
322     }
323 
324     boolean hasSupportedLastModifiedValidator(final HttpRequest request) {
325         return request.containsHeader(HttpHeaders.IF_MODIFIED_SINCE);
326     }
327 
328     /**
329      * Check entry against If-None-Match
330      * @param request The current httpRequest being made
331      * @param entry the cache entry
332      * @return boolean does the etag validator match
333      */
334     boolean etagValidatorMatches(final HttpRequest request, final HttpCacheEntry entry) {
335         final ETag etag = entry.getETag();
336         if (etag == null) {
337             return false;
338         }
339         final Iterator<String> it = MessageSupport.iterateTokens(request, HttpHeaders.IF_NONE_MATCH);
340         while (it.hasNext()) {
341             final String token = it.next();
342             if ("*".equals(token) || ETag.weakCompare(etag, ETag.parse(token))) {
343                 return true;
344             }
345         }
346         return false;
347     }
348 
349     /**
350      * Check entry against If-Modified-Since, if If-Modified-Since is in the future it is invalid
351      * @param request The current httpRequest being made
352      * @param entry the cache entry
353      * @param now right NOW in time
354      * @return  boolean Does the last modified header match
355      */
356     boolean lastModifiedValidatorMatches(final HttpRequest request, final HttpCacheEntry entry, final Instant now) {
357         final Instant lastModified = entry.getLastModified();
358         if (lastModified == null) {
359             return false;
360         }
361 
362         for (final Header h : request.getHeaders(HttpHeaders.IF_MODIFIED_SINCE)) {
363             final Instant ifModifiedSince = DateUtils.parseStandardDate(h.getValue());
364             if (ifModifiedSince != null) {
365                 if (ifModifiedSince.isAfter(now) || lastModified.isAfter(ifModifiedSince)) {
366                     return false;
367                 }
368             }
369         }
370         return true;
371     }
372 
373     public boolean isSuitableIfError(final RequestCacheControl requestCacheControl,
374                                      final ResponseCacheControl responseCacheControl,
375                                      final HttpCacheEntry entry,
376                                      final Instant now) {
377         // Explicit cache control
378         if (requestCacheControl.getStaleIfError() > 0 || responseCacheControl.getStaleIfError() > 0) {
379             final TimeValue currentAge = validityStrategy.getCurrentAge(entry, now);
380             final TimeValue freshnessLifetime = validityStrategy.getFreshnessLifetime(responseCacheControl, entry);
381             if (requestCacheControl.getMinFresh() > 0 && requestCacheControl.getMinFresh() < freshnessLifetime.toSeconds()) {
382                 return false;
383             }
384             final long stale = currentAge.compareTo(freshnessLifetime) > 0 ? currentAge.toSeconds() - freshnessLifetime.toSeconds() : 0;
385             if (requestCacheControl.getStaleIfError() > 0 && stale < requestCacheControl.getStaleIfError()) {
386                 return true;
387             }
388             if (responseCacheControl.getStaleIfError() > 0 && stale < responseCacheControl.getStaleIfError()) {
389                 return true;
390             }
391         }
392         // Global override
393         if (staleifError && requestCacheControl.getStaleIfError() == -1 && responseCacheControl.getStaleIfError() == -1) {
394             return true;
395         }
396         return false;
397     }
398 
399 }