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.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
55
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
79
80
81
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
246
247
248
249
250
251
252
253
254
255 boolean isResponseNoCache(final ResponseCacheControl responseCacheControl, final HttpCacheEntry entry) {
256
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
277
278
279
280 public boolean isConditional(final HttpRequest request) {
281 return hasSupportedEtagValidator(request) || hasSupportedLastModifiedValidator(request);
282 }
283
284
285
286
287
288
289
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
328
329
330
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
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 }