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.time.Instant;
30  import java.util.Arrays;
31  import java.util.Collections;
32  import java.util.HashSet;
33  import java.util.Iterator;
34  import java.util.Set;
35  
36  import org.apache.hc.client5.http.cache.HeaderConstants;
37  import org.apache.hc.client5.http.utils.DateUtils;
38  import org.apache.hc.core5.http.Header;
39  import org.apache.hc.core5.http.HeaderElement;
40  import org.apache.hc.core5.http.HttpHeaders;
41  import org.apache.hc.core5.http.HttpMessage;
42  import org.apache.hc.core5.http.HttpRequest;
43  import org.apache.hc.core5.http.HttpResponse;
44  import org.apache.hc.core5.http.HttpStatus;
45  import org.apache.hc.core5.http.HttpVersion;
46  import org.apache.hc.core5.http.ProtocolVersion;
47  import org.apache.hc.core5.http.message.MessageSupport;
48  import org.slf4j.Logger;
49  import org.slf4j.LoggerFactory;
50  
51  class ResponseCachingPolicy {
52  
53      private static final String[] AUTH_CACHEABLE_PARAMS = {
54              "s-maxage", HeaderConstants.CACHE_CONTROL_MUST_REVALIDATE, HeaderConstants.PUBLIC
55      };
56  
57      private final static Set<Integer> CACHEABLE_STATUS_CODES =
58              new HashSet<>(Arrays.asList(HttpStatus.SC_OK,
59                      HttpStatus.SC_NON_AUTHORITATIVE_INFORMATION,
60                      HttpStatus.SC_MULTIPLE_CHOICES,
61                      HttpStatus.SC_MOVED_PERMANENTLY,
62                      HttpStatus.SC_GONE));
63  
64      private static final Logger LOG = LoggerFactory.getLogger(ResponseCachingPolicy.class);
65  
66      private final long maxObjectSizeBytes;
67      private final boolean sharedCache;
68      private final boolean neverCache1_0ResponsesWithQueryString;
69      private final Set<Integer> uncacheableStatusCodes;
70  
71      /**
72       * Define a cache policy that limits the size of things that should be stored
73       * in the cache to a maximum of {@link HttpResponse} bytes in size.
74       *
75       * @param maxObjectSizeBytes the size to limit items into the cache
76       * @param sharedCache whether to behave as a shared cache (true) or a
77       * non-shared/private cache (false)
78       * @param neverCache1_0ResponsesWithQueryString true to never cache HTTP 1.0 responses with a query string, false
79       * to cache if explicit cache headers are found.
80       * @param allow303Caching if this policy is permitted to cache 303 response
81       */
82      public ResponseCachingPolicy(final long maxObjectSizeBytes,
83              final boolean sharedCache,
84              final boolean neverCache1_0ResponsesWithQueryString,
85              final boolean allow303Caching) {
86          this.maxObjectSizeBytes = maxObjectSizeBytes;
87          this.sharedCache = sharedCache;
88          this.neverCache1_0ResponsesWithQueryString = neverCache1_0ResponsesWithQueryString;
89          if (allow303Caching) {
90              uncacheableStatusCodes = new HashSet<>(Collections.singletonList(HttpStatus.SC_PARTIAL_CONTENT));
91          } else {
92              uncacheableStatusCodes = new HashSet<>(Arrays.asList(HttpStatus.SC_PARTIAL_CONTENT, HttpStatus.SC_SEE_OTHER));
93          }
94      }
95  
96      /**
97       * Determines if an HttpResponse can be cached.
98       *
99       * @param httpMethod What type of request was this, a GET, PUT, other?
100      * @param response The origin response
101      * @return {@code true} if response is cacheable
102      */
103     public boolean isResponseCacheable(final String httpMethod, final HttpResponse response) {
104         boolean cacheable = false;
105 
106         if (!HeaderConstants.GET_METHOD.equals(httpMethod) && !HeaderConstants.HEAD_METHOD.equals(httpMethod)) {
107             if (LOG.isDebugEnabled()) {
108                 LOG.debug("{} method response is not cacheable", httpMethod);
109             }
110             return false;
111         }
112 
113         final int status = response.getCode();
114         if (CACHEABLE_STATUS_CODES.contains(status)) {
115             // these response codes MAY be cached
116             cacheable = true;
117         } else if (uncacheableStatusCodes.contains(status)) {
118             if (LOG.isDebugEnabled()) {
119                 LOG.debug("{} response is not cacheable", status);
120             }
121             return false;
122         } else if (unknownStatusCode(status)) {
123             // a response with an unknown status code MUST NOT be
124             // cached
125             if (LOG.isDebugEnabled()) {
126                 LOG.debug("{} response is unknown", status);
127             }
128             return false;
129         }
130 
131         final Header contentLength = response.getFirstHeader(HttpHeaders.CONTENT_LENGTH);
132         if (contentLength != null) {
133             final long contentLengthValue = Long.parseLong(contentLength.getValue());
134             if (contentLengthValue > this.maxObjectSizeBytes) {
135                 if (LOG.isDebugEnabled()) {
136                     LOG.debug("Response content length exceeds {}", this.maxObjectSizeBytes);
137                 }
138                 return false;
139             }
140         }
141 
142         if (response.countHeaders(HeaderConstants.AGE) > 1) {
143             LOG.debug("Multiple Age headers");
144             return false;
145         }
146 
147         if (response.countHeaders(HeaderConstants.EXPIRES) > 1) {
148             LOG.debug("Multiple Expires headers");
149             return false;
150         }
151 
152         if (response.countHeaders(HttpHeaders.DATE) > 1) {
153             LOG.debug("Multiple Date headers");
154             return false;
155         }
156 
157         final Instant date = DateUtils.parseStandardDate(response, HttpHeaders.DATE);
158         if (date == null) {
159             LOG.debug("Invalid / missing Date header");
160             return false;
161         }
162 
163         final Iterator<HeaderElement> it = MessageSupport.iterate(response, HeaderConstants.VARY);
164         while (it.hasNext()) {
165             final HeaderElement elem = it.next();
166             if ("*".equals(elem.getName())) {
167                 if (LOG.isDebugEnabled()) {
168                     LOG.debug("Vary * found");
169                 }
170                 return false;
171             }
172         }
173 
174         if (isExplicitlyNonCacheable(response)) {
175             LOG.debug("Response is explicitly non-cacheable");
176             return false;
177         }
178 
179         return cacheable || isExplicitlyCacheable(response);
180     }
181 
182     private boolean unknownStatusCode(final int status) {
183         if (status >= 100 && status <= 101) {
184             return false;
185         }
186         if (status >= 200 && status <= 206) {
187             return false;
188         }
189         if (status >= 300 && status <= 307) {
190             return false;
191         }
192         if (status >= 400 && status <= 417) {
193             return false;
194         }
195         return status < 500 || status > 505;
196     }
197 
198     protected boolean isExplicitlyNonCacheable(final HttpResponse response) {
199         final Iterator<HeaderElement> it = MessageSupport.iterate(response, HeaderConstants.CACHE_CONTROL);
200         while (it.hasNext()) {
201             final HeaderElement elem = it.next();
202             if (HeaderConstants.CACHE_CONTROL_NO_STORE.equals(elem.getName())
203                     || HeaderConstants.CACHE_CONTROL_NO_CACHE.equals(elem.getName())
204                     || (sharedCache && HeaderConstants.PRIVATE.equals(elem.getName()))) {
205                 return true;
206             }
207         }
208         return false;
209     }
210 
211     protected boolean hasCacheControlParameterFrom(final HttpMessage msg, final String[] params) {
212         final Iterator<HeaderElement> it = MessageSupport.iterate(msg, HeaderConstants.CACHE_CONTROL);
213         while (it.hasNext()) {
214             final HeaderElement elem = it.next();
215             for (final String param : params) {
216                 if (param.equalsIgnoreCase(elem.getName())) {
217                     return true;
218                 }
219             }
220         }
221         return false;
222     }
223 
224     protected boolean isExplicitlyCacheable(final HttpResponse response) {
225         if (response.getFirstHeader(HeaderConstants.EXPIRES) != null) {
226             return true;
227         }
228         final String[] cacheableParams = { HeaderConstants.CACHE_CONTROL_MAX_AGE, "s-maxage",
229                 HeaderConstants.CACHE_CONTROL_MUST_REVALIDATE,
230                 HeaderConstants.CACHE_CONTROL_PROXY_REVALIDATE,
231                 HeaderConstants.PUBLIC
232         };
233         return hasCacheControlParameterFrom(response, cacheableParams);
234     }
235 
236     /**
237      * Determine if the {@link HttpResponse} gotten from the origin is a
238      * cacheable response.
239      *
240      * @param request the {@link HttpRequest} that generated an origin hit
241      * @param response the {@link HttpResponse} from the origin
242      * @return {@code true} if response is cacheable
243      */
244     public boolean isResponseCacheable(final HttpRequest request, final HttpResponse response) {
245         final ProtocolVersion version = request.getVersion() != null ? request.getVersion() : HttpVersion.DEFAULT;
246         if (version.compareToVersion(HttpVersion.HTTP_1_1) > 0) {
247             if (LOG.isDebugEnabled()) {
248                 LOG.debug("Protocol version {} is non-cacheable", version);
249             }
250             return false;
251         }
252 
253         final String[] uncacheableRequestDirectives = { HeaderConstants.CACHE_CONTROL_NO_STORE };
254         if (hasCacheControlParameterFrom(request,uncacheableRequestDirectives)) {
255             LOG.debug("Response is explicitly non-cacheable per cache control directive");
256             return false;
257         }
258 
259         if (request.getRequestUri().contains("?")) {
260             if (neverCache1_0ResponsesWithQueryString && from1_0Origin(response)) {
261                 LOG.debug("Response is not cacheable as it had a query string");
262                 return false;
263             } else if (!isExplicitlyCacheable(response)) {
264                 LOG.debug("Response is not cacheable as it is missing explicit caching headers");
265                 return false;
266             }
267         }
268 
269         if (expiresHeaderLessOrEqualToDateHeaderAndNoCacheControl(response)) {
270             LOG.debug("Expires header less or equal to Date header and no cache control directives");
271             return false;
272         }
273 
274         if (sharedCache) {
275             if (request.countHeaders(HeaderConstants.AUTHORIZATION) > 0
276                     && !hasCacheControlParameterFrom(response, AUTH_CACHEABLE_PARAMS)) {
277                 LOG.debug("Request contains private credentials");
278                 return false;
279             }
280         }
281 
282         final String method = request.getMethod();
283         return isResponseCacheable(method, response);
284     }
285 
286     private boolean expiresHeaderLessOrEqualToDateHeaderAndNoCacheControl(final HttpResponse response) {
287         if (response.getFirstHeader(HeaderConstants.CACHE_CONTROL) != null) {
288             return false;
289         }
290         final Header expiresHdr = response.getFirstHeader(HeaderConstants.EXPIRES);
291         final Header dateHdr = response.getFirstHeader(HttpHeaders.DATE);
292         if (expiresHdr == null || dateHdr == null) {
293             return false;
294         }
295         final Instant expires = DateUtils.parseStandardDate(expiresHdr.getValue());
296         final Instant date = DateUtils.parseStandardDate(dateHdr.getValue());
297         if (expires == null || date == null) {
298             return false;
299         }
300         return expires.equals(date) || expires.isBefore(date);
301     }
302 
303     private boolean from1_0Origin(final HttpResponse response) {
304         final Iterator<HeaderElement> it = MessageSupport.iterate(response, HeaderConstants.VIA);
305         if (it.hasNext()) {
306             final HeaderElement elt = it.next();
307             final String proto = elt.toString().split("\\s")[0];
308             if (proto.contains("/")) {
309                 return proto.equals("HTTP/1.0");
310             } else {
311                 return proto.equals("1.0");
312             }
313         }
314         final ProtocolVersion version = response.getVersion() != null ? response.getVersion() : HttpVersion.DEFAULT;
315         return HttpVersion.HTTP_1_0.equals(version);
316     }
317 
318 }