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 } 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
248
249
250
251
252
253
254
255
256
257 boolean isResponseNoCache(final ResponseCacheControl responseCacheControl, final HttpCacheEntry entry) {
258
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
279
280
281
282 public boolean isConditional(final HttpRequest request) {
283 return hasSupportedEtagValidator(request) || hasSupportedLastModifiedValidator(request);
284 }
285
286
287
288
289
290
291
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
330
331
332
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
351
352
353
354
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
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
393 if (staleifError && requestCacheControl.getStaleIfError() == -1 && responseCacheControl.getStaleIfError() == -1) {
394 return true;
395 }
396 return false;
397 }
398
399 }