| /* Copyright (c) 2010 Google Inc. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| // |
| // GTMHTTPFetchHistory.m |
| // |
| |
| #import "GTMHTTPFetchHistory.h" |
| |
| static const NSTimeInterval kCachedURLReservationInterval = 60.0; // 1 minute |
| static NSString* const kGTMIfNoneMatchHeader = @"If-None-Match"; |
| static NSString* const kGTMETagHeader = @"Etag"; |
| |
| #if GTM_IPHONE |
| // iPhone: up to 1MB memory |
| const NSUInteger kGTMDefaultETaggedDataCacheMemoryCapacity = 1 * 1024 * 1024; |
| #else |
| // Mac OS X: up to 15MB memory |
| const NSUInteger kGTMDefaultETaggedDataCacheMemoryCapacity = 15 * 1024 * 1024; |
| #endif |
| |
| |
| @implementation GTMCookieStorage |
| |
| - (id)init { |
| self = [super init]; |
| if (self != nil) { |
| cookies_ = [[NSMutableArray alloc] init]; |
| } |
| return self; |
| } |
| |
| - (void)dealloc { |
| [cookies_ release]; |
| [super dealloc]; |
| } |
| |
| // Add all cookies in the new cookie array to the storage, |
| // replacing stored cookies as appropriate. |
| // |
| // Side effect: removes expired cookies from the storage array. |
| - (void)setCookies:(NSArray *)newCookies { |
| |
| @synchronized(cookies_) { |
| [self removeExpiredCookies]; |
| |
| for (NSHTTPCookie *newCookie in newCookies) { |
| if ([[newCookie name] length] > 0 |
| && [[newCookie domain] length] > 0 |
| && [[newCookie path] length] > 0) { |
| |
| // remove the cookie if it's currently in the array |
| NSHTTPCookie *oldCookie = [self cookieMatchingCookie:newCookie]; |
| if (oldCookie) { |
| [cookies_ removeObjectIdenticalTo:oldCookie]; |
| } |
| |
| // make sure the cookie hasn't already expired |
| NSDate *expiresDate = [newCookie expiresDate]; |
| if ((!expiresDate) || [expiresDate timeIntervalSinceNow] > 0) { |
| [cookies_ addObject:newCookie]; |
| } |
| |
| } else { |
| NSAssert1(NO, @"Cookie incomplete: %@", newCookie); |
| } |
| } |
| } |
| } |
| |
| - (void)deleteCookie:(NSHTTPCookie *)cookie { |
| @synchronized(cookies_) { |
| NSHTTPCookie *foundCookie = [self cookieMatchingCookie:cookie]; |
| if (foundCookie) { |
| [cookies_ removeObjectIdenticalTo:foundCookie]; |
| } |
| } |
| } |
| |
| // Retrieve all cookies appropriate for the given URL, considering |
| // domain, path, cookie name, expiration, security setting. |
| // Side effect: removed expired cookies from the storage array. |
| - (NSArray *)cookiesForURL:(NSURL *)theURL { |
| |
| NSMutableArray *foundCookies = nil; |
| |
| @synchronized(cookies_) { |
| [self removeExpiredCookies]; |
| |
| // We'll prepend "." to the desired domain, since we want the |
| // actual domain "nytimes.com" to still match the cookie domain |
| // ".nytimes.com" when we check it below with hasSuffix. |
| NSString *host = [[theURL host] lowercaseString]; |
| NSString *path = [theURL path]; |
| NSString *scheme = [theURL scheme]; |
| |
| NSString *domain = nil; |
| BOOL isLocalhostRetrieval = NO; |
| |
| if ([host isEqual:@"localhost"]) { |
| isLocalhostRetrieval = YES; |
| } else { |
| if (host) { |
| domain = [@"." stringByAppendingString:host]; |
| } |
| } |
| |
| NSUInteger numberOfCookies = [cookies_ count]; |
| for (NSUInteger idx = 0; idx < numberOfCookies; idx++) { |
| |
| NSHTTPCookie *storedCookie = [cookies_ objectAtIndex:idx]; |
| |
| NSString *cookieDomain = [[storedCookie domain] lowercaseString]; |
| NSString *cookiePath = [storedCookie path]; |
| BOOL cookieIsSecure = [storedCookie isSecure]; |
| |
| BOOL isDomainOK; |
| |
| if (isLocalhostRetrieval) { |
| // prior to 10.5.6, the domain stored into NSHTTPCookies for localhost |
| // is "localhost.local" |
| isDomainOK = [cookieDomain isEqual:@"localhost"] |
| || [cookieDomain isEqual:@"localhost.local"]; |
| } else { |
| isDomainOK = [domain hasSuffix:cookieDomain]; |
| } |
| |
| BOOL isPathOK = [cookiePath isEqual:@"/"] || [path hasPrefix:cookiePath]; |
| BOOL isSecureOK = (!cookieIsSecure) || [scheme isEqual:@"https"]; |
| |
| if (isDomainOK && isPathOK && isSecureOK) { |
| if (foundCookies == nil) { |
| foundCookies = [NSMutableArray arrayWithCapacity:1]; |
| } |
| [foundCookies addObject:storedCookie]; |
| } |
| } |
| } |
| return foundCookies; |
| } |
| |
| // Return a cookie from the array with the same name, domain, and path as the |
| // given cookie, or else return nil if none found. |
| // |
| // Both the cookie being tested and all cookies in the storage array should |
| // be valid (non-nil name, domains, paths). |
| // |
| // Note: this should only be called from inside a @synchronized(cookies_) block |
| - (NSHTTPCookie *)cookieMatchingCookie:(NSHTTPCookie *)cookie { |
| |
| NSUInteger numberOfCookies = [cookies_ count]; |
| NSString *name = [cookie name]; |
| NSString *domain = [cookie domain]; |
| NSString *path = [cookie path]; |
| |
| NSAssert3(name && domain && path, @"Invalid cookie (name:%@ domain:%@ path:%@)", |
| name, domain, path); |
| |
| for (NSUInteger idx = 0; idx < numberOfCookies; idx++) { |
| |
| NSHTTPCookie *storedCookie = [cookies_ objectAtIndex:idx]; |
| |
| if ([[storedCookie name] isEqual:name] |
| && [[storedCookie domain] isEqual:domain] |
| && [[storedCookie path] isEqual:path]) { |
| |
| return storedCookie; |
| } |
| } |
| return nil; |
| } |
| |
| |
| // Internal routine to remove any expired cookies from the array, excluding |
| // cookies with nil expirations. |
| // |
| // Note: this should only be called from inside a @synchronized(cookies_) block |
| - (void)removeExpiredCookies { |
| |
| // count backwards since we're deleting items from the array |
| for (NSInteger idx = (NSInteger)[cookies_ count] - 1; idx >= 0; idx--) { |
| |
| NSHTTPCookie *storedCookie = [cookies_ objectAtIndex:(NSUInteger)idx]; |
| |
| NSDate *expiresDate = [storedCookie expiresDate]; |
| if (expiresDate && [expiresDate timeIntervalSinceNow] < 0) { |
| [cookies_ removeObjectAtIndex:(NSUInteger)idx]; |
| } |
| } |
| } |
| |
| - (void)removeAllCookies { |
| @synchronized(cookies_) { |
| [cookies_ removeAllObjects]; |
| } |
| } |
| @end |
| |
| // |
| // GTMCachedURLResponse |
| // |
| |
| @implementation GTMCachedURLResponse |
| |
| @synthesize response = response_; |
| @synthesize data = data_; |
| @synthesize reservationDate = reservationDate_; |
| @synthesize useDate = useDate_; |
| |
| - (id)initWithResponse:(NSURLResponse *)response data:(NSData *)data { |
| self = [super init]; |
| if (self != nil) { |
| response_ = [response retain]; |
| data_ = [data retain]; |
| useDate_ = [[NSDate alloc] init]; |
| } |
| return self; |
| } |
| |
| - (void)dealloc { |
| [response_ release]; |
| [data_ release]; |
| [useDate_ release]; |
| [reservationDate_ release]; |
| [super dealloc]; |
| } |
| |
| - (NSString *)description { |
| NSString *reservationStr = reservationDate_ ? |
| [NSString stringWithFormat:@" resDate:%@", reservationDate_] : @""; |
| |
| return [NSString stringWithFormat:@"%@ %p: {bytes:%@ useDate:%@%@}", |
| [self class], self, |
| data_ ? [NSNumber numberWithInt:(int)[data_ length]] : nil, |
| useDate_, |
| reservationStr]; |
| } |
| |
| - (NSComparisonResult)compareUseDate:(GTMCachedURLResponse *)other { |
| return [useDate_ compare:[other useDate]]; |
| } |
| |
| @end |
| |
| // |
| // GTMURLCache |
| // |
| |
| @implementation GTMURLCache |
| |
| @dynamic memoryCapacity; |
| |
| - (id)init { |
| return [self initWithMemoryCapacity:kGTMDefaultETaggedDataCacheMemoryCapacity]; |
| } |
| |
| - (id)initWithMemoryCapacity:(NSUInteger)totalBytes { |
| self = [super init]; |
| if (self != nil) { |
| memoryCapacity_ = totalBytes; |
| |
| responses_ = [[NSMutableDictionary alloc] initWithCapacity:5]; |
| |
| reservationInterval_ = kCachedURLReservationInterval; |
| } |
| return self; |
| } |
| |
| - (void)dealloc { |
| [responses_ release]; |
| [super dealloc]; |
| } |
| |
| - (NSString *)description { |
| return [NSString stringWithFormat:@"%@ %p: {responses:%@}", |
| [self class], self, [responses_ allValues]]; |
| } |
| |
| // Setters/getters |
| |
| - (void)pruneCacheResponses { |
| // Internal routine to remove the least-recently-used responses when the |
| // cache has grown too large |
| if (memoryCapacity_ >= totalDataSize_) return; |
| |
| // Sort keys by date |
| SEL sel = @selector(compareUseDate:); |
| NSArray *sortedKeys = [responses_ keysSortedByValueUsingSelector:sel]; |
| |
| // The least-recently-used keys are at the beginning of the sorted array; |
| // remove those (except ones still reserved) until the total data size is |
| // reduced sufficiently |
| for (NSURL *key in sortedKeys) { |
| GTMCachedURLResponse *response = [responses_ objectForKey:key]; |
| |
| NSDate *resDate = [response reservationDate]; |
| BOOL isResponseReserved = (resDate != nil) |
| && ([resDate timeIntervalSinceNow] > -reservationInterval_); |
| |
| if (!isResponseReserved) { |
| // We can remove this response from the cache |
| NSUInteger storedSize = [[response data] length]; |
| totalDataSize_ -= storedSize; |
| [responses_ removeObjectForKey:key]; |
| } |
| |
| // If we've removed enough response data, then we're done |
| if (memoryCapacity_ >= totalDataSize_) break; |
| } |
| } |
| |
| - (void)storeCachedResponse:(GTMCachedURLResponse *)cachedResponse |
| forRequest:(NSURLRequest *)request { |
| @synchronized(self) { |
| // Remove any previous entry for this request |
| [self removeCachedResponseForRequest:request]; |
| |
| // cache this one only if it's not bigger than our cache |
| NSUInteger storedSize = [[cachedResponse data] length]; |
| if (storedSize < memoryCapacity_) { |
| |
| NSURL *key = [request URL]; |
| [responses_ setObject:cachedResponse forKey:key]; |
| totalDataSize_ += storedSize; |
| |
| [self pruneCacheResponses]; |
| } |
| } |
| } |
| |
| - (GTMCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request { |
| GTMCachedURLResponse *response; |
| |
| @synchronized(self) { |
| NSURL *key = [request URL]; |
| response = [[[responses_ objectForKey:key] retain] autorelease]; |
| |
| // Touch the date to indicate this was recently retrieved |
| [response setUseDate:[NSDate date]]; |
| } |
| return response; |
| } |
| |
| - (void)removeCachedResponseForRequest:(NSURLRequest *)request { |
| @synchronized(self) { |
| NSURL *key = [request URL]; |
| totalDataSize_ -= [[[responses_ objectForKey:key] data] length]; |
| [responses_ removeObjectForKey:key]; |
| } |
| } |
| |
| - (void)removeAllCachedResponses { |
| @synchronized(self) { |
| [responses_ removeAllObjects]; |
| totalDataSize_ = 0; |
| } |
| } |
| |
| - (NSUInteger)memoryCapacity { |
| return memoryCapacity_; |
| } |
| |
| - (void)setMemoryCapacity:(NSUInteger)totalBytes { |
| @synchronized(self) { |
| BOOL didShrink = (totalBytes < memoryCapacity_); |
| memoryCapacity_ = totalBytes; |
| |
| if (didShrink) { |
| [self pruneCacheResponses]; |
| } |
| } |
| } |
| |
| // Methods for unit testing. |
| - (void)setReservationInterval:(NSTimeInterval)secs { |
| reservationInterval_ = secs; |
| } |
| |
| - (NSDictionary *)responses { |
| return responses_; |
| } |
| |
| - (NSUInteger)totalDataSize { |
| return totalDataSize_; |
| } |
| |
| @end |
| |
| // |
| // GTMHTTPFetchHistory |
| // |
| |
| @interface GTMHTTPFetchHistory () |
| - (NSString *)cachedETagForRequest:(NSURLRequest *)request; |
| - (void)removeCachedDataForRequest:(NSURLRequest *)request; |
| @end |
| |
| @implementation GTMHTTPFetchHistory |
| |
| @synthesize cookieStorage = cookieStorage_; |
| |
| @dynamic shouldRememberETags; |
| @dynamic shouldCacheETaggedData; |
| @dynamic memoryCapacity; |
| |
| - (id)init { |
| return [self initWithMemoryCapacity:kGTMDefaultETaggedDataCacheMemoryCapacity |
| shouldCacheETaggedData:NO]; |
| } |
| |
| - (id)initWithMemoryCapacity:(NSUInteger)totalBytes |
| shouldCacheETaggedData:(BOOL)shouldCacheETaggedData { |
| self = [super init]; |
| if (self != nil) { |
| etaggedDataCache_ = [[GTMURLCache alloc] initWithMemoryCapacity:totalBytes]; |
| shouldRememberETags_ = shouldCacheETaggedData; |
| shouldCacheETaggedData_ = shouldCacheETaggedData; |
| cookieStorage_ = [[GTMCookieStorage alloc] init]; |
| } |
| return self; |
| } |
| |
| - (void)dealloc { |
| [etaggedDataCache_ release]; |
| [cookieStorage_ release]; |
| [super dealloc]; |
| } |
| |
| - (void)updateRequest:(NSMutableURLRequest *)request isHTTPGet:(BOOL)isHTTPGet { |
| @synchronized(self) { |
| if ([self shouldRememberETags]) { |
| // If this URL is in the history, and no ETag has been set, then |
| // set the ETag header field |
| |
| // If we have a history, we're tracking across fetches, so we don't |
| // want to pull results from any other cache |
| [request setCachePolicy:NSURLRequestReloadIgnoringCacheData]; |
| |
| if (isHTTPGet) { |
| // We'll only add an ETag if there's no ETag specified in the user's |
| // request |
| NSString *specifiedETag = [request valueForHTTPHeaderField:kGTMIfNoneMatchHeader]; |
| if (specifiedETag == nil) { |
| // No ETag: extract the previous ETag for this request from the |
| // fetch history, and add it to the request |
| NSString *cachedETag = [self cachedETagForRequest:request]; |
| |
| if (cachedETag != nil) { |
| [request addValue:cachedETag forHTTPHeaderField:kGTMIfNoneMatchHeader]; |
| } |
| } else { |
| // Has an ETag: remove any stored response in the fetch history |
| // for this request, as the If-None-Match header could lead to |
| // a 304 Not Modified, and we want that error delivered to the |
| // user since they explicitly specified the ETag |
| [self removeCachedDataForRequest:request]; |
| } |
| } |
| } |
| } |
| } |
| |
| - (void)updateFetchHistoryWithRequest:(NSURLRequest *)request |
| response:(NSURLResponse *)response |
| downloadedData:(NSData *)downloadedData { |
| @synchronized(self) { |
| if (![self shouldRememberETags]) return; |
| |
| if (![response respondsToSelector:@selector(allHeaderFields)]) return; |
| |
| NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode]; |
| |
| if (statusCode != kGTMHTTPFetcherStatusNotModified) { |
| // Save this ETag string for successful results (<300) |
| // If there's no last modified string, clear the dictionary |
| // entry for this URL. Also cache or delete the data, if appropriate |
| // (when etaggedDataCache is non-nil.) |
| NSDictionary *headers = [(NSHTTPURLResponse *)response allHeaderFields]; |
| NSString* etag = [headers objectForKey:kGTMETagHeader]; |
| |
| if (etag != nil && statusCode < 300) { |
| |
| // we want to cache responses for the headers, even if the client |
| // doesn't want the response body data caches |
| NSData *dataToStore = shouldCacheETaggedData_ ? downloadedData : nil; |
| |
| GTMCachedURLResponse *cachedResponse; |
| cachedResponse = [[[GTMCachedURLResponse alloc] initWithResponse:response |
| data:dataToStore] autorelease]; |
| [etaggedDataCache_ storeCachedResponse:cachedResponse |
| forRequest:request]; |
| } else { |
| [etaggedDataCache_ removeCachedResponseForRequest:request]; |
| } |
| } |
| } |
| } |
| |
| - (NSString *)cachedETagForRequest:(NSURLRequest *)request { |
| // Internal routine. |
| GTMCachedURLResponse *cachedResponse; |
| cachedResponse = [etaggedDataCache_ cachedResponseForRequest:request]; |
| |
| NSURLResponse *response = [cachedResponse response]; |
| NSDictionary *headers = [(NSHTTPURLResponse *)response allHeaderFields]; |
| NSString *cachedETag = [headers objectForKey:kGTMETagHeader]; |
| if (cachedETag) { |
| // Since the request having an ETag implies this request is about |
| // to be fetched again, reserve the cached response to ensure that |
| // that it will be around at least until the fetch completes. |
| // |
| // When the fetch completes, either the cached response will be replaced |
| // with a new response, or the cachedDataForRequest: method below will |
| // clear the reservation. |
| [cachedResponse setReservationDate:[NSDate date]]; |
| } |
| return cachedETag; |
| } |
| |
| - (NSData *)cachedDataForRequest:(NSURLRequest *)request { |
| @synchronized(self) { |
| GTMCachedURLResponse *cachedResponse; |
| cachedResponse = [etaggedDataCache_ cachedResponseForRequest:request]; |
| |
| NSData *cachedData = [cachedResponse data]; |
| |
| // Since the data for this cached request is being obtained from the cache, |
| // we can clear the reservation as the fetch has completed. |
| [cachedResponse setReservationDate:nil]; |
| |
| return cachedData; |
| } |
| } |
| |
| - (void)removeCachedDataForRequest:(NSURLRequest *)request { |
| @synchronized(self) { |
| [etaggedDataCache_ removeCachedResponseForRequest:request]; |
| } |
| } |
| |
| - (void)clearETaggedDataCache { |
| @synchronized(self) { |
| [etaggedDataCache_ removeAllCachedResponses]; |
| } |
| } |
| |
| - (void)clearHistory { |
| @synchronized(self) { |
| [self clearETaggedDataCache]; |
| [cookieStorage_ removeAllCookies]; |
| } |
| } |
| |
| - (void)removeAllCookies { |
| @synchronized(self) { |
| [cookieStorage_ removeAllCookies]; |
| } |
| } |
| |
| - (BOOL)shouldRememberETags { |
| return shouldRememberETags_; |
| } |
| |
| - (void)setShouldRememberETags:(BOOL)flag { |
| BOOL wasRemembering = shouldRememberETags_; |
| shouldRememberETags_ = flag; |
| |
| if (wasRemembering && !flag) { |
| // Free up the cache memory |
| [self clearETaggedDataCache]; |
| } |
| } |
| |
| - (BOOL)shouldCacheETaggedData { |
| return shouldCacheETaggedData_; |
| } |
| |
| - (void)setShouldCacheETaggedData:(BOOL)flag { |
| BOOL wasCaching = shouldCacheETaggedData_; |
| shouldCacheETaggedData_ = flag; |
| |
| if (flag) { |
| self.shouldRememberETags = YES; |
| } |
| |
| if (wasCaching && !flag) { |
| // users expect turning off caching to free up the cache memory |
| [self clearETaggedDataCache]; |
| } |
| } |
| |
| - (NSUInteger)memoryCapacity { |
| return [etaggedDataCache_ memoryCapacity]; |
| } |
| |
| - (void)setMemoryCapacity:(NSUInteger)totalBytes { |
| [etaggedDataCache_ setMemoryCapacity:totalBytes]; |
| } |
| |
| @end |