Ben Copsey

Important changes to download cache behaviour:

ASIDownloadCache will now store 301, 302, 303 and 307 redirect responses in the cache. Requests pulling from the cache will automatically redirect (assuming shouldRedirect is true) as if they'd received a Location header from the server.
This change should allow the download cache to operate more effectively as an offline fallback when no internet connection is available.
Additionally, requests encountering a 304 will no longer read from the cache and then write back to it. Instead, they merely update the expiry date (if an updated date was supplied).
As part of this work, two new required methods were added to the ASICacheDelegate protocol, see ASICacheDelegate.h for more info
Finally, ASIDownloadCache now stores the response status code in a custom header in the cached headers dictionary, and requests will have their response status code set to this when they are pulled from the cache.
@@ -56,6 +56,13 @@ typedef enum _ASICacheStoragePolicy { @@ -56,6 +56,13 @@ typedef enum _ASICacheStoragePolicy {
56 // Should return the cache policy that will be used when requests have their cache policy set to ASIUseDefaultCachePolicy 56 // Should return the cache policy that will be used when requests have their cache policy set to ASIUseDefaultCachePolicy
57 - (ASICachePolicy)defaultCachePolicy; 57 - (ASICachePolicy)defaultCachePolicy;
58 58
  59 +// Returns the date a cached response should expire on. Pass a non-zero max age to specify a custom date.
  60 +- (NSDate *)expiryDateForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge;
  61 +
  62 +// Updates cached response headers with a new expiry date. Pass a non-zero max age to specify a custom date.
  63 +- (void)updateExpiryForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge;
  64 +
  65 +// Looks at the request's cache policy and any cached headers to determine if the cache data is still valid
59 - (BOOL)canUseCachedDataForRequest:(ASIHTTPRequest *)request; 66 - (BOOL)canUseCachedDataForRequest:(ASIHTTPRequest *)request;
60 67
61 // Removes cached data for a particular request 68 // Removes cached data for a particular request
@@ -85,31 +85,24 @@ static NSString *permanentCacheFolder = @"PermanentStore"; @@ -85,31 +85,24 @@ static NSString *permanentCacheFolder = @"PermanentStore";
85 [[self accessLock] unlock]; 85 [[self accessLock] unlock];
86 } 86 }
87 87
88 -- (void)storeResponseForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge 88 +- (void)updateExpiryForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge
89 { 89 {
90 - [[self accessLock] lock]; 90 + NSString *headerPath = [self pathToStoreCachedResponseHeadersForRequest:request];
91 - 91 + NSMutableDictionary *cachedHeaders = [NSMutableDictionary dictionaryWithContentsOfFile:headerPath];
92 - if ([request error] || ![request responseHeaders] || ([request responseStatusCode] != 200) || ([request cachePolicy] & ASIDoNotWriteToCacheCachePolicy)) { 92 + if (!cachedHeaders) {
93 - [[self accessLock] unlock];  
94 return; 93 return;
95 } 94 }
96 - 95 + NSDate *expires = [self expiryDateForRequest:request maxAge:maxAge];
97 - if ([self shouldRespectCacheControlHeaders] && ![[self class] serverAllowsResponseCachingForRequest:request]) { 96 + if (!expires) {
98 - [[self accessLock] unlock];  
99 return; 97 return;
100 } 98 }
  99 + [cachedHeaders setObject:[NSNumber numberWithDouble:[expires timeIntervalSince1970]] forKey:@"X-ASIHTTPRequest-Expires"];
  100 + [cachedHeaders writeToFile:headerPath atomically:NO];
  101 +}
101 102
102 - NSString *headerPath = [self pathToStoreCachedResponseHeadersForRequest:request]; 103 +- (NSDate *)expiryDateForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge
103 - NSString *dataPath = [self pathToStoreCachedResponseDataForRequest:request]; 104 +{
104 -  
105 NSMutableDictionary *responseHeaders = [NSMutableDictionary dictionaryWithDictionary:[request responseHeaders]]; 105 NSMutableDictionary *responseHeaders = [NSMutableDictionary dictionaryWithDictionary:[request responseHeaders]];
106 - if ([request isResponseCompressed]) {  
107 - [responseHeaders removeObjectForKey:@"Content-Encoding"];  
108 - }  
109 -  
110 - // Create a special 'X-ASIHTTPRequest-Expires' header  
111 - // This is what we use for deciding if cached data is current, rather than parsing the expires / max-age headers individually each time  
112 - // We store this as a timestamp to make reading it easier as NSDateFormatter is quite expensive  
113 106
114 // If we weren't given a custom max-age, lets look for one in the response headers 107 // If we weren't given a custom max-age, lets look for one in the response headers
115 if (!maxAge) { 108 if (!maxAge) {
@@ -126,16 +119,65 @@ static NSString *permanentCacheFolder = @"PermanentStore"; @@ -126,16 +119,65 @@ static NSString *permanentCacheFolder = @"PermanentStore";
126 119
127 // RFC 2612 says max-age must override any Expires header 120 // RFC 2612 says max-age must override any Expires header
128 if (maxAge) { 121 if (maxAge) {
129 - [responseHeaders setObject:[NSNumber numberWithDouble:[[[NSDate date] addTimeInterval:maxAge] timeIntervalSince1970]] forKey:@"X-ASIHTTPRequest-Expires"]; 122 + return [[NSDate date] addTimeInterval:maxAge];
130 } else { 123 } else {
131 NSString *expires = [responseHeaders objectForKey:@"Expires"]; 124 NSString *expires = [responseHeaders objectForKey:@"Expires"];
132 if (expires) { 125 if (expires) {
133 - [responseHeaders setObject:[NSNumber numberWithDouble:[[ASIHTTPRequest dateFromRFC1123String:expires] timeIntervalSince1970]] forKey:@"X-ASIHTTPRequest-Expires"]; 126 + return [ASIHTTPRequest dateFromRFC1123String:expires];
134 } 127 }
135 } 128 }
  129 + return nil;
  130 +}
  131 +
  132 +- (void)storeResponseForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge
  133 +{
  134 + [[self accessLock] lock];
  135 +
  136 + if ([request error] || ![request responseHeaders] || ([request cachePolicy] & ASIDoNotWriteToCacheCachePolicy)) {
  137 + [[self accessLock] unlock];
  138 + return;
  139 + }
  140 +
  141 + // We only cache 200/OK or redirect reponses (redirect responses are cached so the cache works better with no internet connection)
  142 + int responseCode = [request responseStatusCode];
  143 + if (responseCode != 200 && responseCode != 301 && responseCode != 302 && responseCode != 303 && responseCode != 307) {
  144 + [[self accessLock] unlock];
  145 + return;
  146 + }
  147 +
  148 + if ([self shouldRespectCacheControlHeaders] && ![[self class] serverAllowsResponseCachingForRequest:request]) {
  149 + [[self accessLock] unlock];
  150 + return;
  151 + }
  152 +
  153 + NSString *headerPath = [self pathToStoreCachedResponseHeadersForRequest:request];
  154 + NSString *dataPath = [self pathToStoreCachedResponseDataForRequest:request];
  155 +
  156 + NSMutableDictionary *responseHeaders = [NSMutableDictionary dictionaryWithDictionary:[request responseHeaders]];
  157 + if ([request isResponseCompressed]) {
  158 + [responseHeaders removeObjectForKey:@"Content-Encoding"];
  159 + }
  160 +
  161 + // Create a special 'X-ASIHTTPRequest-Expires' header
  162 + // This is what we use for deciding if cached data is current, rather than parsing the expires / max-age headers individually each time
  163 + // We store this as a timestamp to make reading it easier as NSDateFormatter is quite expensive
  164 +
  165 + NSDate *expires = [self expiryDateForRequest:request maxAge:maxAge];
  166 + if (expires) {
  167 + [responseHeaders setObject:[NSNumber numberWithDouble:[expires timeIntervalSince1970]] forKey:@"X-ASIHTTPRequest-Expires"];
  168 + }
  169 +
  170 + // Store the response code in a custom header so we can reuse it later
  171 +
  172 + // We'll change 304/Not Modified to 200/OK because this is likely to be us updating the cached headers with a conditional GET
  173 + int statusCode = [request responseStatusCode];
  174 + if (statusCode == 304) {
  175 + statusCode = 200;
  176 + }
  177 + [responseHeaders setObject:[NSNumber numberWithInt:[request responseStatusCode]] forKey:@"X-ASIHTTPRequest-Response-Status-Code"];
136 178
137 [responseHeaders writeToFile:headerPath atomically:NO]; 179 [responseHeaders writeToFile:headerPath atomically:NO];
138 - 180 +
139 if ([request responseData]) { 181 if ([request responseData]) {
140 [[request responseData] writeToFile:dataPath atomically:NO]; 182 [[request responseData] writeToFile:dataPath atomically:NO];
141 } else if ([request downloadDestinationPath] && ![[request downloadDestinationPath] isEqualToString:dataPath]) { 183 } else if ([request downloadDestinationPath] && ![[request downloadDestinationPath] isEqualToString:dataPath]) {
@@ -282,14 +324,15 @@ static NSString *permanentCacheFolder = @"PermanentStore"; @@ -282,14 +324,15 @@ static NSString *permanentCacheFolder = @"PermanentStore";
282 return NO; 324 return NO;
283 } 325 }
284 326
285 - // If we already have response headers for this request, check to see if the new content is different 327 + // New content is not different
286 - if ([request responseHeaders]) { 328 + if ([request responseStatusCode] == 304) {
  329 + [[self accessLock] unlock];
  330 + return YES;
  331 + }
287 332
288 - // New content is not different 333 + // If we already have response headers for this request, check to see if the new content is different
289 - if ([request responseStatusCode] == 304) { 334 + // We check [request complete] so that we don't end up comparing response headers from a redirection with these
290 - [[self accessLock] unlock]; 335 + if ([request responseHeaders] && [request complete]) {
291 - return YES;  
292 - }  
293 336
294 // If the Etag or Last-Modified date are different from the one we have, we'll have to fetch this resource again 337 // If the Etag or Last-Modified date are different from the one we have, we'll have to fetch this resource again
295 NSArray *headersToCompare = [NSArray arrayWithObjects:@"Etag",@"Last-Modified",nil]; 338 NSArray *headersToCompare = [NSArray arrayWithObjects:@"Etag",@"Last-Modified",nil];
This diff is collapsed. Click to expand it.
@@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
11 11
12 @interface ASIDownloadCacheTests : ASITestCase { 12 @interface ASIDownloadCacheTests : ASITestCase {
13 NSUInteger requestsFinishedCount; 13 NSUInteger requestsFinishedCount;
  14 + BOOL requestRedirectedWasCalled;
14 } 15 }
15 16
16 @end 17 @end
@@ -14,6 +14,7 @@ @@ -14,6 +14,7 @@
14 @interface ASIDownloadCacheTests () 14 @interface ASIDownloadCacheTests ()
15 - (void)runCacheOnlyCallsRequestFinishedOnceTest; 15 - (void)runCacheOnlyCallsRequestFinishedOnceTest;
16 - (void)finishCached:(ASIHTTPRequest *)request; 16 - (void)finishCached:(ASIHTTPRequest *)request;
  17 +- (void)runRedirectTest;
17 @end 18 @end
18 19
19 20
@@ -330,14 +331,16 @@ @@ -330,14 +331,16 @@
330 331
331 - (void)test304 332 - (void)test304
332 { 333 {
  334 + NSURL *url = [NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/the_great_american_novel_(abridged).txt"];
  335 +
333 // Test default cache policy 336 // Test default cache policy
334 [[ASIDownloadCache sharedCache] clearCachedResponsesForStoragePolicy:ASICacheForSessionDurationCacheStoragePolicy]; 337 [[ASIDownloadCache sharedCache] clearCachedResponsesForStoragePolicy:ASICacheForSessionDurationCacheStoragePolicy];
335 [[ASIDownloadCache sharedCache] setDefaultCachePolicy:ASIUseDefaultCachePolicy]; 338 [[ASIDownloadCache sharedCache] setDefaultCachePolicy:ASIUseDefaultCachePolicy];
336 - ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/the_great_american_novel_(abridged).txt"]]; 339 + ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:url];
337 [request setDownloadCache:[ASIDownloadCache sharedCache]]; 340 [request setDownloadCache:[ASIDownloadCache sharedCache]];
338 [request startSynchronous]; 341 [request startSynchronous];
339 342
340 - request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/the_great_american_novel_(abridged).txt"]]; 343 + request = [ASIHTTPRequest requestWithURL:url];
341 [request setDownloadCache:[ASIDownloadCache sharedCache]]; 344 [request setDownloadCache:[ASIDownloadCache sharedCache]];
342 [request startSynchronous]; 345 [request startSynchronous];
343 BOOL success = ([request responseStatusCode] == 200); 346 BOOL success = ([request responseStatusCode] == 200);
@@ -348,6 +351,30 @@ @@ -348,6 +351,30 @@
348 351
349 success = ([[request responseData] length]); 352 success = ([[request responseData] length]);
350 GHAssertTrue(success,@"Response was empty"); 353 GHAssertTrue(success,@"Response was empty");
  354 +
  355 + // Test 304 updates expiry date
  356 + url = [NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/content_not_modified_but_expires_tomorrow"];
  357 + request = [ASIHTTPRequest requestWithURL:url];
  358 + [request setDownloadCache:[ASIDownloadCache sharedCache]];
  359 + [request startSynchronous];
  360 +
  361 + NSTimeInterval expiryTimestamp = [[[[ASIDownloadCache sharedCache] cachedResponseHeadersForURL:url] objectForKey:@"X-ASIHTTPRequest-Expires"] doubleValue];
  362 +
  363 + // Wait to give the expiry date a chance to change
  364 + sleep(2);
  365 +
  366 + request = [ASIHTTPRequest requestWithURL:url];
  367 + [request setCachePolicy:ASIAskServerIfModifiedCachePolicy];
  368 + [request setDownloadCache:[ASIDownloadCache sharedCache]];
  369 + [request startSynchronous];
  370 +
  371 + success = [request didUseCachedResponse];
  372 + GHAssertTrue(success, @"Cached data should have been used");
  373 +
  374 + NSTimeInterval newExpiryTimestamp = [[[[ASIDownloadCache sharedCache] cachedResponseHeadersForURL:url] objectForKey:@"X-ASIHTTPRequest-Expires"] doubleValue];
  375 + NSLog(@"%@",[request responseString]);
  376 + success = (newExpiryTimestamp > expiryTimestamp);
  377 + GHAssertTrue(success, @"Failed to update expiry timestamp on 304");
351 } 378 }
352 379
353 - (void)testStringEncoding 380 - (void)testStringEncoding
@@ -433,4 +460,50 @@ @@ -433,4 +460,50 @@
433 requestsFinishedCount++; 460 requestsFinishedCount++;
434 } 461 }
435 462
  463 +- (void)testRedirect
  464 +{
  465 + // Run this request on the main thread to force delegate calls to happen synchronously
  466 + [self performSelectorOnMainThread:@selector(runRedirectTest) withObject:nil waitUntilDone:YES];
  467 +}
  468 +
  469 +- (void)runRedirectTest
  470 +{
  471 + [[ASIDownloadCache sharedCache] clearCachedResponsesForStoragePolicy:ASICacheForSessionDurationCacheStoragePolicy];
  472 + [[ASIDownloadCache sharedCache] clearCachedResponsesForStoragePolicy:ASICachePermanentlyCacheStoragePolicy];
  473 + [[ASIDownloadCache sharedCache] setDefaultCachePolicy:ASIUseDefaultCachePolicy];
  474 + [ASIHTTPRequest setDefaultCache:[ASIDownloadCache sharedCache]];
  475 +
  476 + ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/cached-redirect"]];
  477 + [request startSynchronous];
  478 +
  479 + BOOL success = ([[[request url] absoluteString] isEqualToString:@"http://allseeing-i.com/i/logo.png"]);
  480 + GHAssertTrue(success,@"Request did not redirect correctly, cannot proceed with test");
  481 +
  482 + requestRedirectedWasCalled = NO;
  483 + request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/cached-redirect"]];
  484 + [request setDelegate:self];
  485 + [request startSynchronous];
  486 +
  487 + success = ([request didUseCachedResponse]);
  488 + GHAssertTrue(success,@"Failed to cache final response");
  489 +
  490 + GHAssertTrue(requestRedirectedWasCalled,@"Failed to call requestRedirected");
  491 +}
  492 +
  493 +- (void)requestRedirected:(ASIHTTPRequest *)redirected
  494 +{
  495 + requestRedirectedWasCalled = YES;
  496 +}
  497 +
  498 +- (void)request:(ASIHTTPRequest *)request willRedirectToURL:(NSURL *)newURL
  499 +{
  500 + BOOL success = ([[newURL absoluteString] isEqualToString:@"http://allseeing-i.com/i/logo.png"]);
  501 + GHAssertTrue(success,@"Request did not redirect correctly, cannot proceed with test");
  502 +
  503 + success = ([request didUseCachedResponse]);
  504 + GHAssertTrue(success,@"Failed to cache redirect response");
  505 +
  506 + [request redirectToURL:newURL];
  507 +}
  508 +
436 @end 509 @end