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 {
// Should return the cache policy that will be used when requests have their cache policy set to ASIUseDefaultCachePolicy
- (ASICachePolicy)defaultCachePolicy;
// Returns the date a cached response should expire on. Pass a non-zero max age to specify a custom date.
- (NSDate *)expiryDateForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge;
// Updates cached response headers with a new expiry date. Pass a non-zero max age to specify a custom date.
- (void)updateExpiryForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge;
// Looks at the request's cache policy and any cached headers to determine if the cache data is still valid
- (BOOL)canUseCachedDataForRequest:(ASIHTTPRequest *)request;
// Removes cached data for a particular request
... ...
... ... @@ -85,31 +85,24 @@ static NSString *permanentCacheFolder = @"PermanentStore";
[[self accessLock] unlock];
}
- (void)storeResponseForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge
- (void)updateExpiryForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge
{
[[self accessLock] lock];
if ([request error] || ![request responseHeaders] || ([request responseStatusCode] != 200) || ([request cachePolicy] & ASIDoNotWriteToCacheCachePolicy)) {
[[self accessLock] unlock];
NSString *headerPath = [self pathToStoreCachedResponseHeadersForRequest:request];
NSMutableDictionary *cachedHeaders = [NSMutableDictionary dictionaryWithContentsOfFile:headerPath];
if (!cachedHeaders) {
return;
}
if ([self shouldRespectCacheControlHeaders] && ![[self class] serverAllowsResponseCachingForRequest:request]) {
[[self accessLock] unlock];
NSDate *expires = [self expiryDateForRequest:request maxAge:maxAge];
if (!expires) {
return;
}
[cachedHeaders setObject:[NSNumber numberWithDouble:[expires timeIntervalSince1970]] forKey:@"X-ASIHTTPRequest-Expires"];
[cachedHeaders writeToFile:headerPath atomically:NO];
}
NSString *headerPath = [self pathToStoreCachedResponseHeadersForRequest:request];
NSString *dataPath = [self pathToStoreCachedResponseDataForRequest:request];
- (NSDate *)expiryDateForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge
{
NSMutableDictionary *responseHeaders = [NSMutableDictionary dictionaryWithDictionary:[request responseHeaders]];
if ([request isResponseCompressed]) {
[responseHeaders removeObjectForKey:@"Content-Encoding"];
}
// Create a special 'X-ASIHTTPRequest-Expires' header
// This is what we use for deciding if cached data is current, rather than parsing the expires / max-age headers individually each time
// We store this as a timestamp to make reading it easier as NSDateFormatter is quite expensive
// If we weren't given a custom max-age, lets look for one in the response headers
if (!maxAge) {
... ... @@ -126,16 +119,65 @@ static NSString *permanentCacheFolder = @"PermanentStore";
// RFC 2612 says max-age must override any Expires header
if (maxAge) {
[responseHeaders setObject:[NSNumber numberWithDouble:[[[NSDate date] addTimeInterval:maxAge] timeIntervalSince1970]] forKey:@"X-ASIHTTPRequest-Expires"];
return [[NSDate date] addTimeInterval:maxAge];
} else {
NSString *expires = [responseHeaders objectForKey:@"Expires"];
if (expires) {
[responseHeaders setObject:[NSNumber numberWithDouble:[[ASIHTTPRequest dateFromRFC1123String:expires] timeIntervalSince1970]] forKey:@"X-ASIHTTPRequest-Expires"];
return [ASIHTTPRequest dateFromRFC1123String:expires];
}
}
return nil;
}
- (void)storeResponseForRequest:(ASIHTTPRequest *)request maxAge:(NSTimeInterval)maxAge
{
[[self accessLock] lock];
if ([request error] || ![request responseHeaders] || ([request cachePolicy] & ASIDoNotWriteToCacheCachePolicy)) {
[[self accessLock] unlock];
return;
}
// We only cache 200/OK or redirect reponses (redirect responses are cached so the cache works better with no internet connection)
int responseCode = [request responseStatusCode];
if (responseCode != 200 && responseCode != 301 && responseCode != 302 && responseCode != 303 && responseCode != 307) {
[[self accessLock] unlock];
return;
}
if ([self shouldRespectCacheControlHeaders] && ![[self class] serverAllowsResponseCachingForRequest:request]) {
[[self accessLock] unlock];
return;
}
NSString *headerPath = [self pathToStoreCachedResponseHeadersForRequest:request];
NSString *dataPath = [self pathToStoreCachedResponseDataForRequest:request];
NSMutableDictionary *responseHeaders = [NSMutableDictionary dictionaryWithDictionary:[request responseHeaders]];
if ([request isResponseCompressed]) {
[responseHeaders removeObjectForKey:@"Content-Encoding"];
}
// Create a special 'X-ASIHTTPRequest-Expires' header
// This is what we use for deciding if cached data is current, rather than parsing the expires / max-age headers individually each time
// We store this as a timestamp to make reading it easier as NSDateFormatter is quite expensive
NSDate *expires = [self expiryDateForRequest:request maxAge:maxAge];
if (expires) {
[responseHeaders setObject:[NSNumber numberWithDouble:[expires timeIntervalSince1970]] forKey:@"X-ASIHTTPRequest-Expires"];
}
// Store the response code in a custom header so we can reuse it later
// We'll change 304/Not Modified to 200/OK because this is likely to be us updating the cached headers with a conditional GET
int statusCode = [request responseStatusCode];
if (statusCode == 304) {
statusCode = 200;
}
[responseHeaders setObject:[NSNumber numberWithInt:[request responseStatusCode]] forKey:@"X-ASIHTTPRequest-Response-Status-Code"];
[responseHeaders writeToFile:headerPath atomically:NO];
if ([request responseData]) {
[[request responseData] writeToFile:dataPath atomically:NO];
} else if ([request downloadDestinationPath] && ![[request downloadDestinationPath] isEqualToString:dataPath]) {
... ... @@ -282,14 +324,15 @@ static NSString *permanentCacheFolder = @"PermanentStore";
return NO;
}
// If we already have response headers for this request, check to see if the new content is different
if ([request responseHeaders]) {
// New content is not different
if ([request responseStatusCode] == 304) {
[[self accessLock] unlock];
return YES;
}
// New content is not different
if ([request responseStatusCode] == 304) {
[[self accessLock] unlock];
return YES;
}
// If we already have response headers for this request, check to see if the new content is different
// We check [request complete] so that we don't end up comparing response headers from a redirection with these
if ([request responseHeaders] && [request complete]) {
// If the Etag or Last-Modified date are different from the one we have, we'll have to fetch this resource again
NSArray *headersToCompare = [NSArray arrayWithObjects:@"Etag",@"Last-Modified",nil];
... ...
This diff is collapsed. Click to expand it.
... ... @@ -11,6 +11,7 @@
@interface ASIDownloadCacheTests : ASITestCase {
NSUInteger requestsFinishedCount;
BOOL requestRedirectedWasCalled;
}
@end
... ...
... ... @@ -14,6 +14,7 @@
@interface ASIDownloadCacheTests ()
- (void)runCacheOnlyCallsRequestFinishedOnceTest;
- (void)finishCached:(ASIHTTPRequest *)request;
- (void)runRedirectTest;
@end
... ... @@ -330,14 +331,16 @@
- (void)test304
{
NSURL *url = [NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/the_great_american_novel_(abridged).txt"];
// Test default cache policy
[[ASIDownloadCache sharedCache] clearCachedResponsesForStoragePolicy:ASICacheForSessionDurationCacheStoragePolicy];
[[ASIDownloadCache sharedCache] setDefaultCachePolicy:ASIUseDefaultCachePolicy];
ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/the_great_american_novel_(abridged).txt"]];
ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:url];
[request setDownloadCache:[ASIDownloadCache sharedCache]];
[request startSynchronous];
request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/the_great_american_novel_(abridged).txt"]];
request = [ASIHTTPRequest requestWithURL:url];
[request setDownloadCache:[ASIDownloadCache sharedCache]];
[request startSynchronous];
BOOL success = ([request responseStatusCode] == 200);
... ... @@ -348,6 +351,30 @@
success = ([[request responseData] length]);
GHAssertTrue(success,@"Response was empty");
// Test 304 updates expiry date
url = [NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/content_not_modified_but_expires_tomorrow"];
request = [ASIHTTPRequest requestWithURL:url];
[request setDownloadCache:[ASIDownloadCache sharedCache]];
[request startSynchronous];
NSTimeInterval expiryTimestamp = [[[[ASIDownloadCache sharedCache] cachedResponseHeadersForURL:url] objectForKey:@"X-ASIHTTPRequest-Expires"] doubleValue];
// Wait to give the expiry date a chance to change
sleep(2);
request = [ASIHTTPRequest requestWithURL:url];
[request setCachePolicy:ASIAskServerIfModifiedCachePolicy];
[request setDownloadCache:[ASIDownloadCache sharedCache]];
[request startSynchronous];
success = [request didUseCachedResponse];
GHAssertTrue(success, @"Cached data should have been used");
NSTimeInterval newExpiryTimestamp = [[[[ASIDownloadCache sharedCache] cachedResponseHeadersForURL:url] objectForKey:@"X-ASIHTTPRequest-Expires"] doubleValue];
NSLog(@"%@",[request responseString]);
success = (newExpiryTimestamp > expiryTimestamp);
GHAssertTrue(success, @"Failed to update expiry timestamp on 304");
}
- (void)testStringEncoding
... ... @@ -433,4 +460,50 @@
requestsFinishedCount++;
}
- (void)testRedirect
{
// Run this request on the main thread to force delegate calls to happen synchronously
[self performSelectorOnMainThread:@selector(runRedirectTest) withObject:nil waitUntilDone:YES];
}
- (void)runRedirectTest
{
[[ASIDownloadCache sharedCache] clearCachedResponsesForStoragePolicy:ASICacheForSessionDurationCacheStoragePolicy];
[[ASIDownloadCache sharedCache] clearCachedResponsesForStoragePolicy:ASICachePermanentlyCacheStoragePolicy];
[[ASIDownloadCache sharedCache] setDefaultCachePolicy:ASIUseDefaultCachePolicy];
[ASIHTTPRequest setDefaultCache:[ASIDownloadCache sharedCache]];
ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/cached-redirect"]];
[request startSynchronous];
BOOL success = ([[[request url] absoluteString] isEqualToString:@"http://allseeing-i.com/i/logo.png"]);
GHAssertTrue(success,@"Request did not redirect correctly, cannot proceed with test");
requestRedirectedWasCalled = NO;
request = [ASIHTTPRequest requestWithURL:[NSURL URLWithString:@"http://allseeing-i.com/ASIHTTPRequest/tests/cached-redirect"]];
[request setDelegate:self];
[request startSynchronous];
success = ([request didUseCachedResponse]);
GHAssertTrue(success,@"Failed to cache final response");
GHAssertTrue(requestRedirectedWasCalled,@"Failed to call requestRedirected");
}
- (void)requestRedirected:(ASIHTTPRequest *)redirected
{
requestRedirectedWasCalled = YES;
}
- (void)request:(ASIHTTPRequest *)request willRedirectToURL:(NSURL *)newURL
{
BOOL success = ([[newURL absoluteString] isEqualToString:@"http://allseeing-i.com/i/logo.png"]);
GHAssertTrue(success,@"Request did not redirect correctly, cannot proceed with test");
success = ([request didUseCachedResponse]);
GHAssertTrue(success,@"Failed to cache redirect response");
[request redirectToURL:newURL];
}
@end
... ...