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.utils.DateUtils;
42  import org.apache.hc.core5.http.Header;
43  import org.apache.hc.core5.http.HeaderElement;
44  import org.apache.hc.core5.http.HttpHeaders;
45  import org.apache.hc.core5.http.HttpRequest;
46  import org.apache.hc.core5.http.HttpStatus;
47  import org.apache.hc.core5.http.Method;
48  import org.apache.hc.core5.http.message.MessageSupport;
49  import org.apache.hc.core5.util.TimeValue;
50  import org.slf4j.Logger;
51  import org.slf4j.LoggerFactory;
52  
53  /**
54   * Determines whether a given {@link HttpCacheEntry} is suitable to be
55   * used as a response for a given {@link HttpRequest}.
56   */
57  class CachedResponseSuitabilityChecker {
58  
59      private static final Logger LOG = LoggerFactory.getLogger(CachedResponseSuitabilityChecker.class);
60  
61      private final CacheValidityPolicy validityStrategy;
62      private final boolean sharedCache;
63      private final boolean staleifError;
64  
65      CachedResponseSuitabilityChecker(final CacheValidityPolicy validityStrategy,
66                                       final CacheConfig config) {
67          super();
68          this.validityStrategy = validityStrategy;
69          this.sharedCache = config.isSharedCache();
70          this.staleifError = config.isStaleIfErrorEnabled();
71      }
72  
73      CachedResponseSuitabilityChecker(final CacheConfig config) {
74          this(new CacheValidityPolicy(config), config);
75      }
76  
77      /**
78       * Determine if I can utilize the given {@link HttpCacheEntry} to respond to the given
79       * {@link HttpRequest}.
80       *
81       * @since 5.3
82       */
83      public CacheSuitability assessSuitability(final RequestCacheControl requestCacheControl,
84                                                final ResponseCacheControl responseCacheControl,
85                                                final HttpRequest request,
86                                                final HttpCacheEntry entry,
87                                                final Instant now) {
88          if (!requestMethodMatch(request, entry)) {
89              LOG.debug("Request method and the cache entry method do not match");
90              return CacheSuitability.MISMATCH;
91          }
92  
93          if (!requestUriMatch(request, entry)) {
94              LOG.debug("Target request URI and the cache entry request URI do not match");
95              return CacheSuitability.MISMATCH;
96          }
97  
98          if (!requestHeadersMatch(request, entry)) {
99              LOG.debug("Request headers nominated by the cached response do not match those of the request associated with the cache entry");
100             return CacheSuitability.MISMATCH;
101         }
102 
103         if (!requestHeadersMatch(request, entry)) {
104             LOG.debug("Request headers nominated by the cached response do not match those of the request associated with the cache entry");
105             return CacheSuitability.MISMATCH;
106         }
107 
108         if (requestCacheControl.isNoCache()) {
109             LOG.debug("Request contained no-cache directive; the cache entry must be re-validated");
110             return CacheSuitability.REVALIDATION_REQUIRED;
111         }
112 
113         if (isResponseNoCache(responseCacheControl, entry)) {
114             LOG.debug("Response contained no-cache directive; the cache entry must be re-validated");
115             return CacheSuitability.REVALIDATION_REQUIRED;
116         }
117 
118         if (hasUnsupportedConditionalHeaders(request)) {
119             LOG.debug("Response from cache is not suitable due to the request containing unsupported conditional headers");
120             return CacheSuitability.REVALIDATION_REQUIRED;
121         }
122 
123         if (!isConditional(request) && entry.getStatus() == HttpStatus.SC_NOT_MODIFIED) {
124             LOG.debug("Unconditional request and non-modified cached response");
125             return CacheSuitability.REVALIDATION_REQUIRED;
126         }
127 
128         if (!allConditionalsMatch(request, entry, now)) {
129             LOG.debug("Response from cache is not suitable due to the conditional request and with mismatched conditions");
130             return CacheSuitability.REVALIDATION_REQUIRED;
131         }
132 
133         final TimeValue currentAge = validityStrategy.getCurrentAge(entry, now);
134         if (LOG.isDebugEnabled()) {
135             LOG.debug("Cache entry current age: {}", currentAge);
136         }
137         final TimeValue freshnessLifetime = validityStrategy.getFreshnessLifetime(responseCacheControl, entry);
138         if (LOG.isDebugEnabled()) {
139             LOG.debug("Cache entry freshness lifetime: {}", freshnessLifetime);
140         }
141 
142         final boolean fresh = currentAge.compareTo(freshnessLifetime) < 0;
143 
144         if (!fresh && responseCacheControl.isMustRevalidate()) {
145             LOG.debug("Response from cache is not suitable due to the response must-revalidate requirement");
146             return CacheSuitability.REVALIDATION_REQUIRED;
147         }
148 
149         if (!fresh && sharedCache && responseCacheControl.isProxyRevalidate()) {
150             LOG.debug("Response from cache is not suitable due to the response proxy-revalidate requirement");
151             return CacheSuitability.REVALIDATION_REQUIRED;
152         }
153 
154         if (fresh && requestCacheControl.getMaxAge() >= 0) {
155             if (currentAge.toSeconds() > requestCacheControl.getMaxAge() && requestCacheControl.getMaxStale() == -1) {
156                 LOG.debug("Response from cache is not suitable due to the request max-age requirement");
157                 return CacheSuitability.REVALIDATION_REQUIRED;
158             }
159         }
160 
161         if (fresh && requestCacheControl.getMinFresh() >= 0) {
162             if (requestCacheControl.getMinFresh() == 0 ||
163                     freshnessLifetime.toSeconds() - currentAge.toSeconds() < requestCacheControl.getMinFresh()) {
164                 LOG.debug("Response from cache is not suitable due to the request min-fresh requirement");
165                 return CacheSuitability.REVALIDATION_REQUIRED;
166             }
167         }
168 
169         if (requestCacheControl.getMaxStale() >= 0) {
170             final long stale = currentAge.compareTo(freshnessLifetime) > 0 ? currentAge.toSeconds() - freshnessLifetime.toSeconds() : 0;
171             if (LOG.isDebugEnabled()) {
172                 LOG.debug("Cache entry staleness: {} SECONDS", stale);
173             }
174             if (stale >= requestCacheControl.getMaxStale()) {
175                 LOG.debug("Response from cache is not suitable due to the request max-stale requirement");
176                 return CacheSuitability.REVALIDATION_REQUIRED;
177             } else {
178                 LOG.debug("The cache entry is fresh enough");
179                 return CacheSuitability.FRESH_ENOUGH;
180             }
181         }
182 
183         if (fresh) {
184             LOG.debug("The cache entry is fresh");
185             return CacheSuitability.FRESH;
186         } else {
187             if (responseCacheControl.getStaleWhileRevalidate() > 0) {
188                 final long stale = currentAge.compareTo(freshnessLifetime) > 0 ? currentAge.toSeconds() - freshnessLifetime.toSeconds() : 0;
189                 if (stale < responseCacheControl.getStaleWhileRevalidate()) {
190                     LOG.debug("The cache entry is stale but suitable while being revalidated");
191                     return CacheSuitability.STALE_WHILE_REVALIDATED;
192                 }
193             }
194             LOG.debug("The cache entry is stale");
195             return CacheSuitability.STALE;
196         }
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             } else {
211                 return Objects.equals(requestURI.getPath(), cacheURI.getPath()) && Objects.equals(requestURI.getQuery(), cacheURI.getQuery());
212             }
213         } catch (final URISyntaxException ex) {
214             return false;
215         }
216     }
217 
218     boolean requestHeadersMatch(final HttpRequest request, final HttpCacheEntry entry) {
219         final Iterator<Header> it = entry.headerIterator(HttpHeaders.VARY);
220         if (it.hasNext()) {
221             final Set<String> headerNames = new HashSet<>();
222             while (it.hasNext()) {
223                 final Header header = it.next();
224                 MessageSupport.parseTokens(header, e -> {
225                     headerNames.add(e.toLowerCase(Locale.ROOT));
226                 });
227             }
228             final List<String> tokensInRequest = new ArrayList<>();
229             final List<String> tokensInCache = new ArrayList<>();
230             for (final String headerName: headerNames) {
231                 if (headerName.equalsIgnoreCase("*")) {
232                     return false;
233                 }
234                 CacheKeyGenerator.normalizeElements(request, headerName, tokensInRequest::add);
235                 CacheKeyGenerator.normalizeElements(entry.requestHeaders(), headerName, tokensInCache::add);
236                 if (!Objects.equals(tokensInRequest, tokensInCache)) {
237                     return false;
238                 }
239             }
240         }
241         return true;
242     }
243 
244     /**
245      * Determines if the given {@link HttpCacheEntry} requires revalidation based on the presence of the {@code no-cache} directive
246      * in the Cache-Control header.
247      * <p>
248      * The method returns true in the following cases:
249      * - If the {@code no-cache} directive is present without any field names (unqualified).
250      * - If the {@code no-cache} directive is present with field names, and at least one of these field names is present
251      * in the headers of the {@link HttpCacheEntry}.
252      * <p>
253      * If the {@code no-cache} directive is not present in the Cache-Control header, the method returns {@code false}.
254      */
255     boolean isResponseNoCache(final ResponseCacheControl responseCacheControl, final HttpCacheEntry entry) {
256         // If no-cache directive is present and has no field names
257         if (responseCacheControl.isNoCache()) {
258             final Set<String> noCacheFields = responseCacheControl.getNoCacheFields();
259             if (noCacheFields.isEmpty()) {
260                 LOG.debug("Revalidation required due to unqualified no-cache directive");
261                 return true;
262             }
263             for (final String field : noCacheFields) {
264                 if (entry.containsHeader(field)) {
265                     if (LOG.isDebugEnabled()) {
266                         LOG.debug("Revalidation required due to no-cache directive with field {}", field);
267                     }
268                     return true;
269                 }
270             }
271         }
272         return false;
273     }
274 
275     /**
276      * Is this request the type of conditional request we support?
277      * @param request The current httpRequest being made
278      * @return {@code true} if the request is supported
279      */
280     public boolean isConditional(final HttpRequest request) {
281         return hasSupportedEtagValidator(request) || hasSupportedLastModifiedValidator(request);
282     }
283 
284     /**
285      * Check that conditionals that are part of this request match
286      * @param request The current httpRequest being made
287      * @param entry the cache entry
288      * @param now right NOW in time
289      * @return {@code true} if the request matches all conditionals
290      */
291     public boolean allConditionalsMatch(final HttpRequest request, final HttpCacheEntry entry, final Instant now) {
292         final boolean hasEtagValidator = hasSupportedEtagValidator(request);
293         final boolean hasLastModifiedValidator = hasSupportedLastModifiedValidator(request);
294 
295         if (!hasEtagValidator && !hasLastModifiedValidator) {
296             return true;
297         }
298 
299         final boolean etagValidatorMatches = (hasEtagValidator) && etagValidatorMatches(request, entry);
300         final boolean lastModifiedValidatorMatches = (hasLastModifiedValidator) && lastModifiedValidatorMatches(request, entry, now);
301 
302         if ((hasEtagValidator && hasLastModifiedValidator)
303                 && !(etagValidatorMatches && lastModifiedValidatorMatches)) {
304             return false;
305         } else if (hasEtagValidator && !etagValidatorMatches) {
306             return false;
307         }
308 
309         return !hasLastModifiedValidator || lastModifiedValidatorMatches;
310     }
311 
312     boolean hasUnsupportedConditionalHeaders(final HttpRequest request) {
313         return (request.containsHeader(HttpHeaders.IF_RANGE)
314                 || request.containsHeader(HttpHeaders.IF_MATCH)
315                 || request.containsHeader(HttpHeaders.IF_UNMODIFIED_SINCE));
316     }
317 
318     boolean hasSupportedEtagValidator(final HttpRequest request) {
319         return request.containsHeader(HttpHeaders.IF_NONE_MATCH);
320     }
321 
322     boolean hasSupportedLastModifiedValidator(final HttpRequest request) {
323         return request.containsHeader(HttpHeaders.IF_MODIFIED_SINCE);
324     }
325 
326     /**
327      * Check entry against If-None-Match
328      * @param request The current httpRequest being made
329      * @param entry the cache entry
330      * @return boolean does the etag validator match
331      */
332     boolean etagValidatorMatches(final HttpRequest request, final HttpCacheEntry entry) {
333         final Header etagHeader = entry.getFirstHeader(HttpHeaders.ETAG);
334         final String etag = (etagHeader != null) ? etagHeader.getValue() : null;
335         final Iterator<HeaderElement> it = MessageSupport.iterate(request, HttpHeaders.IF_NONE_MATCH);
336         while (it.hasNext()) {
337             final HeaderElement elt = it.next();
338             final String reqEtag = elt.toString();
339             if (("*".equals(reqEtag) && etag != null) || reqEtag.equals(etag)) {
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 }