1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
57
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
81
82
83
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
245
246
247
248
249
250
251
252
253
254 boolean isResponseNoCache(final ResponseCacheControl responseCacheControl, final HttpCacheEntry entry) {
255
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
276
277
278
279 public boolean isConditional(final HttpRequest request) {
280 return hasSupportedEtagValidator(request) || hasSupportedLastModifiedValidator(request);
281 }
282
283
284
285
286
287
288
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
327
328
329
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
348
349
350
351
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
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
390 if (staleifError && requestCacheControl.getStaleIfError() == -1 && responseCacheControl.getStaleIfError() == -1) {
391 return true;
392 }
393 return false;
394 }
395
396 }