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             }
180             LOG.debug("The cache entry is fresh enough");
181             return CacheSuitability.FRESH_ENOUGH;
182         }
183 
184         if (fresh) {
185             LOG.debug("The cache entry is fresh");
186             return CacheSuitability.FRESH;
187         }
188         if (responseCacheControl.getStaleWhileRevalidate() > 0) {
189             final long stale = currentAge.compareTo(freshnessLifetime) > 0 ? currentAge.toSeconds() - freshnessLifetime.toSeconds() : 0;
190             if (stale < responseCacheControl.getStaleWhileRevalidate()) {
191                 LOG.debug("The cache entry is stale but suitable while being revalidated");
192                 return CacheSuitability.STALE_WHILE_REVALIDATED;
193             }
194         }
195         LOG.debug("The cache entry is stale");
196         return CacheSuitability.STALE;
197     }
198 
199     boolean requestMethodMatch(final HttpRequest request, final HttpCacheEntry entry) {
200         return request.getMethod().equalsIgnoreCase(entry.getRequestMethod()) ||
201                 (Method.HEAD.isSame(request.getMethod()) && Method.GET.isSame(entry.getRequestMethod()));
202     }
203 
204     boolean requestUriMatch(final HttpRequest request, final HttpCacheEntry entry) {
205         try {
206             final URI requestURI = CacheKeyGenerator.normalize(request.getUri());
207             final URI cacheURI = new URI(entry.getRequestURI());
208             if (requestURI.isAbsolute()) {
209                 return Objects.equals(requestURI, cacheURI);
210             }
211             return Objects.equals(requestURI.getPath(), cacheURI.getPath()) && Objects.equals(requestURI.getQuery(), cacheURI.getQuery());
212         } catch (final URISyntaxException ex) {
213             return false;
214         }
215     }
216 
217     boolean requestHeadersMatch(final HttpRequest request, final HttpCacheEntry entry) {
218         final Iterator<Header> it = entry.headerIterator(HttpHeaders.VARY);
219         if (it.hasNext()) {
220             final Set<String> headerNames = new HashSet<>();
221             while (it.hasNext()) {
222                 final Header header = it.next();
223                 MessageSupport.parseTokens(header, e -> {
224                     headerNames.add(e.toLowerCase(Locale.ROOT));
225                 });
226             }
227             final List<String> tokensInRequest = new ArrayList<>();
228             final List<String> tokensInCache = new ArrayList<>();
229             for (final String headerName: headerNames) {
230                 if (headerName.equalsIgnoreCase("*")) {
231                     return false;
232                 }
233                 CacheKeyGenerator.normalizeElements(request, headerName, tokensInRequest::add);
234                 CacheKeyGenerator.normalizeElements(entry.requestHeaders(), headerName, tokensInCache::add);
235                 if (!Objects.equals(tokensInRequest, tokensInCache)) {
236                     return false;
237                 }
238             }
239         }
240         return true;
241     }
242 
243     /**
244      * Determines if the given {@link HttpCacheEntry} requires revalidation based on the presence of the {@code no-cache} directive
245      * in the Cache-Control header.
246      * <p>
247      * The method returns true in the following cases:
248      * - If the {@code no-cache} directive is present without any field names (unqualified).
249      * - If the {@code no-cache} directive is present with field names, and at least one of these field names is present
250      * in the headers of the {@link HttpCacheEntry}.
251      * <p>
252      * If the {@code no-cache} directive is not present in the Cache-Control header, the method returns {@code false}.
253      */
254     boolean isResponseNoCache(final ResponseCacheControl responseCacheControl, final HttpCacheEntry entry) {
255         // If no-cache directive is present and has no field names
256         if (responseCacheControl.isNoCache()) {
257             final Set<String> noCacheFields = responseCacheControl.getNoCacheFields();
258             if (noCacheFields.isEmpty()) {
259                 LOG.debug("Revalidation required due to unqualified no-cache directive");
260                 return true;
261             }
262             for (final String field : noCacheFields) {
263                 if (entry.containsHeader(field)) {
264                     if (LOG.isDebugEnabled()) {
265                         LOG.debug("Revalidation required due to no-cache directive with field {}", field);
266                     }
267                     return true;
268                 }
269             }
270         }
271         return false;
272     }
273 
274     /**
275      * Is this request the type of conditional request we support?
276      * @param request The current httpRequest being made
277      * @return {@code true} if the request is supported
278      */
279     public boolean isConditional(final HttpRequest request) {
280         return hasSupportedEtagValidator(request) || hasSupportedLastModifiedValidator(request);
281     }
282 
283     /**
284      * Check that conditionals that are part of this request match
285      * @param request The current httpRequest being made
286      * @param entry the cache entry
287      * @param now right NOW in time
288      * @return {@code true} if the request matches all conditionals
289      */
290     public boolean allConditionalsMatch(final HttpRequest request, final HttpCacheEntry entry, final Instant now) {
291         final boolean hasEtagValidator = hasSupportedEtagValidator(request);
292         final boolean hasLastModifiedValidator = hasSupportedLastModifiedValidator(request);
293 
294         if (!hasEtagValidator && !hasLastModifiedValidator) {
295             return true;
296         }
297 
298         final boolean etagValidatorMatches = (hasEtagValidator) && etagValidatorMatches(request, entry);
299         final boolean lastModifiedValidatorMatches = (hasLastModifiedValidator) && lastModifiedValidatorMatches(request, entry, now);
300 
301         if ((hasEtagValidator && hasLastModifiedValidator)
302                 && !(etagValidatorMatches && lastModifiedValidatorMatches)) {
303             return false;
304         } else if (hasEtagValidator && !etagValidatorMatches) {
305             return false;
306         }
307 
308         return !hasLastModifiedValidator || lastModifiedValidatorMatches;
309     }
310 
311     boolean hasUnsupportedConditionalHeaders(final HttpRequest request) {
312         return (request.containsHeader(HttpHeaders.IF_RANGE)
313                 || request.containsHeader(HttpHeaders.IF_MATCH)
314                 || request.containsHeader(HttpHeaders.IF_UNMODIFIED_SINCE));
315     }
316 
317     boolean hasSupportedEtagValidator(final HttpRequest request) {
318         return request.containsHeader(HttpHeaders.IF_NONE_MATCH);
319     }
320 
321     boolean hasSupportedLastModifiedValidator(final HttpRequest request) {
322         return request.containsHeader(HttpHeaders.IF_MODIFIED_SINCE);
323     }
324 
325     /**
326      * Check entry against If-None-Match
327      * @param request The current httpRequest being made
328      * @param entry the cache entry
329      * @return boolean does the etag validator match
330      */
331     boolean etagValidatorMatches(final HttpRequest request, final HttpCacheEntry entry) {
332         final ETag etag = entry.getETag();
333         if (etag == null) {
334             return false;
335         }
336         final Iterator<String> it = MessageSupport.iterateTokens(request, HttpHeaders.IF_NONE_MATCH);
337         while (it.hasNext()) {
338             final String token = it.next();
339             if ("*".equals(token) || ETag.weakCompare(etag, ETag.parse(token))) {
340                 return true;
341             }
342         }
343         return false;
344     }
345 
346     /**
347      * Check entry against If-Modified-Since, if If-Modified-Since is in the future it is invalid
348      * @param request The current httpRequest being made
349      * @param entry the cache entry
350      * @param now right NOW in time
351      * @return  boolean Does the last modified header match
352      */
353     boolean lastModifiedValidatorMatches(final HttpRequest request, final HttpCacheEntry entry, final Instant now) {
354         final Instant lastModified = entry.getLastModified();
355         if (lastModified == null) {
356             return false;
357         }
358 
359         for (final Header h : request.getHeaders(HttpHeaders.IF_MODIFIED_SINCE)) {
360             final Instant ifModifiedSince = DateUtils.parseStandardDate(h.getValue());
361             if (ifModifiedSince != null) {
362                 if (ifModifiedSince.isAfter(now) || lastModified.isAfter(ifModifiedSince)) {
363                     return false;
364                 }
365             }
366         }
367         return true;
368     }
369 
370     public boolean isSuitableIfError(final RequestCacheControl requestCacheControl,
371                                      final ResponseCacheControl responseCacheControl,
372                                      final HttpCacheEntry entry,
373                                      final Instant now) {
374         // Explicit cache control
375         if (requestCacheControl.getStaleIfError() > 0 || responseCacheControl.getStaleIfError() > 0) {
376             final TimeValue currentAge = validityStrategy.getCurrentAge(entry, now);
377             final TimeValue freshnessLifetime = validityStrategy.getFreshnessLifetime(responseCacheControl, entry);
378             if (requestCacheControl.getMinFresh() > 0 && requestCacheControl.getMinFresh() < freshnessLifetime.toSeconds()) {
379                 return false;
380             }
381             final long stale = currentAge.compareTo(freshnessLifetime) > 0 ? currentAge.toSeconds() - freshnessLifetime.toSeconds() : 0;
382             if (requestCacheControl.getStaleIfError() > 0 && stale < requestCacheControl.getStaleIfError()) {
383                 return true;
384             }
385             if (responseCacheControl.getStaleIfError() > 0 && stale < responseCacheControl.getStaleIfError()) {
386                 return true;
387             }
388         }
389         // Global override
390         if (staleifError && requestCacheControl.getStaleIfError() == -1 && responseCacheControl.getStaleIfError() == -1) {
391             return true;
392         }
393         return false;
394     }
395 
396 }