blob: 4a6cb9c16ce0c74982a61d1c2413d161312a904b [file] [log] [blame]
/* 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.
*/
#if !STRIP_GTM_FETCH_LOGGING
#include <sys/stat.h>
#include <unistd.h>
#import "GTMHTTPFetcherLogging.h"
// Sensitive credential strings are replaced in logs with _snip_
//
// Apps that must see the contents of sensitive tokens can set this to 1
#ifndef SKIP_GTM_FETCH_LOGGING_SNIPPING
#define SKIP_GTM_FETCH_LOGGING_SNIPPING 0
#endif
// If GTMReadMonitorInputStream is available, it can be used for
// capturing uploaded streams of data
//
// We locally declare methods of GTMReadMonitorInputStream so we
// do not need to import the header, as some projects may not have it available
#ifndef GTM_NSSTREAM_DELEGATE
@interface GTMReadMonitorInputStream : NSInputStream
+ (instancetype)inputStreamWithStream:(NSInputStream *)input;
@property (assign) id readDelegate;
@property (assign) SEL readSelector;
@property (retain) NSArray *runLoopModes;
@end
#endif
// If GTMNSJSONSerialization is available, it is used for formatting JSON
#if (TARGET_OS_MAC && !TARGET_OS_IPHONE && (MAC_OS_X_VERSION_MAX_ALLOWED < 1070)) || \
(TARGET_OS_IPHONE && (__IPHONE_OS_VERSION_MAX_ALLOWED < 50000))
@interface GTMNSJSONSerialization : NSObject
+ (NSData *)dataWithJSONObject:(id)obj options:(NSUInteger)opt error:(NSError **)error;
+ (id)JSONObjectWithData:(NSData *)data options:(NSUInteger)opt error:(NSError **)error;
@end
#endif
// Otherwise, if SBJSON is available, it is used for formatting JSON
@interface GTMFetcherSBJSON
- (void)setHumanReadable:(BOOL)flag;
- (NSString*)stringWithObject:(id)value error:(NSError**)error;
- (id)objectWithString:(NSString*)jsonrep error:(NSError**)error;
@end
@interface GTMHTTPFetcher (GTMHTTPFetcherLoggingUtilities)
+ (NSString *)headersStringForDictionary:(NSDictionary *)dict;
- (void)inputStream:(GTMReadMonitorInputStream *)stream
readIntoBuffer:(void *)buffer
length:(NSUInteger)length;
// internal file utilities for logging
+ (BOOL)fileOrDirExistsAtPath:(NSString *)path;
+ (BOOL)makeDirectoryUpToPath:(NSString *)path;
+ (BOOL)removeItemAtPath:(NSString *)path;
+ (BOOL)createSymbolicLinkAtPath:(NSString *)newPath
withDestinationPath:(NSString *)targetPath;
+ (NSString *)snipSubstringOfString:(NSString *)originalStr
betweenStartString:(NSString *)startStr
endString:(NSString *)endStr;
+ (id)JSONObjectWithData:(NSData *)data;
+ (id)stringWithJSONObject:(id)obj;
@end
@implementation GTMHTTPFetcher (GTMHTTPFetcherLogging)
// fetchers come and fetchers go, but statics are forever
static BOOL gIsLoggingEnabled = NO;
static BOOL gIsLoggingToFile = YES;
static NSString *gLoggingDirectoryPath = nil;
static NSString *gLoggingDateStamp = nil;
static NSString *gLoggingProcessName = nil;
static NSString *gLogDirectoryForCurrentRun = nil;
+ (void)setLoggingDirectory:(NSString *)path {
[gLoggingDirectoryPath autorelease];
gLoggingDirectoryPath = [path copy];
}
+ (NSString *)loggingDirectory {
if (!gLoggingDirectoryPath) {
NSArray *arr = nil;
#if GTM_IPHONE && TARGET_IPHONE_SIMULATOR
// default to a directory called GTMHTTPDebugLogs into a sandbox-safe
// directory that a developer can find easily, the application home
arr = [NSArray arrayWithObject:NSHomeDirectory()];
#elif GTM_IPHONE
// Neither ~/Desktop nor ~/Home is writable on an actual iPhone device.
// Put it in ~/Documents.
arr = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,
NSUserDomainMask, YES);
#else
// default to a directory called GTMHTTPDebugLogs in the desktop folder
arr = NSSearchPathForDirectoriesInDomains(NSDesktopDirectory,
NSUserDomainMask, YES);
#endif
if ([arr count] > 0) {
NSString *const kGTMLogFolderName = @"GTMHTTPDebugLogs";
NSString *desktopPath = [arr objectAtIndex:0];
NSString *logsFolderPath = [desktopPath stringByAppendingPathComponent:kGTMLogFolderName];
BOOL doesFolderExist = [[self class] fileOrDirExistsAtPath:logsFolderPath];
if (!doesFolderExist) {
// make the directory
doesFolderExist = [self makeDirectoryUpToPath:logsFolderPath];
}
if (doesFolderExist) {
// it's there; store it in the global
gLoggingDirectoryPath = [logsFolderPath copy];
}
}
}
return gLoggingDirectoryPath;
}
+ (NSString *)logDirectoryForCurrentRun {
if (gLogDirectoryForCurrentRun) return gLogDirectoryForCurrentRun;
NSString *parentDir = [[self class] loggingDirectory];
NSString *dateStamp = [[self class] loggingDateStamp];
NSString *logNamePrefix = [[self class] processNameLogPrefix];
// make a directory for this run's logs, like
// SyncProto_logs_10-16_01-56-58PM
NSString *dirName = [NSString stringWithFormat:@"%@%@",
logNamePrefix, dateStamp];
NSString *logDirectory = [parentDir stringByAppendingPathComponent:dirName];
if (gIsLoggingToFile) {
// be sure that the first time this app runs, it's not writing to
// a preexisting folder
static BOOL shouldReuseFolder = NO;
if (!shouldReuseFolder) {
shouldReuseFolder = YES;
NSString *origLogDir = logDirectory;
for (int ctr = 2; ctr < 20; ctr++) {
if (![[self class] fileOrDirExistsAtPath:logDirectory]) break;
// append a digit
logDirectory = [origLogDir stringByAppendingFormat:@"_%d", ctr];
}
}
if (![[self class] makeDirectoryUpToPath:logDirectory]) return nil;
}
gLogDirectoryForCurrentRun = [logDirectory copy];
return gLogDirectoryForCurrentRun;
}
+ (void)setLoggingEnabled:(BOOL)flag {
gIsLoggingEnabled = flag;
}
+ (BOOL)isLoggingEnabled {
return gIsLoggingEnabled;
}
+ (void)setLoggingToFileEnabled:(BOOL)flag {
gIsLoggingToFile = flag;
}
+ (BOOL)isLoggingToFileEnabled {
return gIsLoggingToFile;
}
+ (void)setLoggingProcessName:(NSString *)str {
[gLoggingProcessName release];
gLoggingProcessName = [str copy];
}
+ (NSString *)loggingProcessName {
// get the process name (once per run) replacing spaces with underscores
if (!gLoggingProcessName) {
NSString *procName = [[NSProcessInfo processInfo] processName];
NSMutableString *loggingProcessName;
loggingProcessName = [[NSMutableString alloc] initWithString:procName];
[loggingProcessName replaceOccurrencesOfString:@" "
withString:@"_"
options:0
range:NSMakeRange(0, [loggingProcessName length])];
gLoggingProcessName = loggingProcessName;
}
return gLoggingProcessName;
}
+ (void)setLoggingDateStamp:(NSString *)str {
[gLoggingDateStamp release];
gLoggingDateStamp = [str copy];
}
+ (NSString *)loggingDateStamp {
// we'll pick one date stamp per run, so a run that starts at a later second
// will get a unique results html file
if (!gLoggingDateStamp) {
// produce a string like 08-21_01-41-23PM
NSDateFormatter *formatter = [[[NSDateFormatter alloc] init] autorelease];
[formatter setFormatterBehavior:NSDateFormatterBehavior10_4];
[formatter setDateFormat:@"M-dd_hh-mm-ssa"];
gLoggingDateStamp = [[formatter stringFromDate:[NSDate date]] retain] ;
}
return gLoggingDateStamp;
}
+ (NSString *)processNameLogPrefix {
static NSString *prefix = nil;
if (!prefix) {
NSString *processName = [[self class] loggingProcessName];
prefix = [[NSString alloc] initWithFormat:@"%@_log_", processName];
}
return prefix;
}
+ (NSString *)symlinkNameSuffix {
return @"_log_newest.html";
}
+ (NSString *)htmlFileName {
return @"aperçu_http_log.html";
}
// formattedStringFromData returns a prettyprinted string for XML or JSON input,
// and a plain string for other input data
- (NSString *)formattedStringFromData:(NSData *)inputData
contentType:(NSString *)contentType
JSON:(NSDictionary **)outJSON {
if (inputData == nil) return nil;
// if the content type is JSON and we have the parsing class available,
// use that
if ([contentType hasPrefix:@"application/json"]
&& [inputData length] > 5) {
// convert from JSON string to NSObjects and back to a formatted string
NSMutableDictionary *obj = [[self class] JSONObjectWithData:inputData];
if (obj) {
if (outJSON) *outJSON = obj;
if ([obj isKindOfClass:[NSMutableDictionary class]]) {
// for security and privacy, omit OAuth 2 response access and refresh
// tokens
if ([obj valueForKey:@"refresh_token"] != nil) {
[obj setObject:@"_snip_" forKey:@"refresh_token"];
}
if ([obj valueForKey:@"access_token"] != nil) {
[obj setObject:@"_snip_" forKey:@"access_token"];
}
}
NSString *formatted = [[self class] stringWithJSONObject:obj];
if (formatted) return formatted;
}
}
#if !GTM_FOUNDATION_ONLY && !GTM_SKIP_LOG_XMLFORMAT
// verify that this data starts with the bytes indicating XML
NSString *const kXMLLintPath = @"/usr/bin/xmllint";
static BOOL hasCheckedAvailability = NO;
static BOOL isXMLLintAvailable;
if (!hasCheckedAvailability) {
isXMLLintAvailable = [[self class] fileOrDirExistsAtPath:kXMLLintPath];
hasCheckedAvailability = YES;
}
if (isXMLLintAvailable
&& [inputData length] > 5
&& strncmp([inputData bytes], "<?xml", 5) == 0) {
// call xmllint to format the data
NSTask *task = [[[NSTask alloc] init] autorelease];
[task setLaunchPath:kXMLLintPath];
// use the dash argument to specify stdin as the source file
[task setArguments:[NSArray arrayWithObjects:@"--format", @"-", nil]];
[task setEnvironment:[NSDictionary dictionary]];
NSPipe *inputPipe = [NSPipe pipe];
NSPipe *outputPipe = [NSPipe pipe];
[task setStandardInput:inputPipe];
[task setStandardOutput:outputPipe];
[task launch];
[[inputPipe fileHandleForWriting] writeData:inputData];
[[inputPipe fileHandleForWriting] closeFile];
// drain the stdout before waiting for the task to exit
NSData *formattedData = [[outputPipe fileHandleForReading] readDataToEndOfFile];
[task waitUntilExit];
int status = [task terminationStatus];
if (status == 0 && [formattedData length] > 0) {
// success
inputData = formattedData;
}
}
#else
// we can't call external tasks on the iPhone; leave the XML unformatted
#endif
NSString *dataStr = [[[NSString alloc] initWithData:inputData
encoding:NSUTF8StringEncoding] autorelease];
return dataStr;
}
- (void)setupStreamLogging {
// if logging is enabled, it needs a buffer to accumulate data from any
// NSInputStream used for uploading. Logging will wrap the input
// stream with a stream that lets us keep a copy the data being read.
if ([GTMHTTPFetcher isLoggingEnabled]
&& loggedStreamData_ == nil
&& postStream_ != nil) {
loggedStreamData_ = [[NSMutableData alloc] init];
BOOL didCapture = [self logCapturePostStream];
if (!didCapture) {
// upload stream logging requires the class
// GTMReadMonitorInputStream be available
NSString const *str = @"<<Uploaded stream log unavailable without GTMReadMonitorInputStream>>";
[loggedStreamData_ setData:[str dataUsingEncoding:NSUTF8StringEncoding]];
}
}
}
- (void)setLogRequestBody:(NSString *)bodyString {
@synchronized(self) {
[logRequestBody_ release];
logRequestBody_ = [bodyString copy];
}
}
- (NSString *)logRequestBody {
@synchronized(self) {
return logRequestBody_;
}
}
- (void)setLogResponseBody:(NSString *)bodyString {
@synchronized(self) {
[logResponseBody_ release];
logResponseBody_ = [bodyString copy];
}
}
- (NSString *)logResponseBody {
@synchronized(self) {
return logResponseBody_;
}
}
- (void)setShouldDeferResponseBodyLogging:(BOOL)flag {
@synchronized(self) {
if (flag != shouldDeferResponseBodyLogging_) {
shouldDeferResponseBodyLogging_ = flag;
if (!flag && !hasLoggedError_) {
[self performSelectorOnMainThread:@selector(logFetchWithError:)
withObject:nil
waitUntilDone:NO];
}
}
}
}
- (BOOL)shouldDeferResponseBodyLogging {
@synchronized(self) {
return shouldDeferResponseBodyLogging_;
}
}
// stringFromStreamData creates a string given the supplied data
//
// If NSString can create a UTF-8 string from the data, then that is returned.
//
// Otherwise, this routine tries to find a MIME boundary at the beginning of
// the data block, and uses that to break up the data into parts. Each part
// will be used to try to make a UTF-8 string. For parts that fail, a
// replacement string showing the part header and <<n bytes>> is supplied
// in place of the binary data.
- (NSString *)stringFromStreamData:(NSData *)data
contentType:(NSString *)contentType {
if (data == nil) return nil;
// optimistically, see if the whole data block is UTF-8
NSString *streamDataStr = [self formattedStringFromData:data
contentType:contentType
JSON:NULL];
if (streamDataStr) return streamDataStr;
// Munge a buffer by replacing non-ASCII bytes with underscores,
// and turn that munged buffer an NSString. That gives us a string
// we can use with NSScanner.
NSMutableData *mutableData = [NSMutableData dataWithData:data];
unsigned char *bytes = (unsigned char *)[mutableData mutableBytes];
for (unsigned int idx = 0; idx < [mutableData length]; idx++) {
if (bytes[idx] > 0x7F || bytes[idx] == 0) {
bytes[idx] = '_';
}
}
NSString *mungedStr = [[[NSString alloc] initWithData:mutableData
encoding:NSUTF8StringEncoding] autorelease];
if (mungedStr != nil) {
// scan for the boundary string
NSString *boundary = nil;
NSScanner *scanner = [NSScanner scannerWithString:mungedStr];
if ([scanner scanUpToString:@"\r\n" intoString:&boundary]
&& [boundary hasPrefix:@"--"]) {
// we found a boundary string; use it to divide the string into parts
NSArray *mungedParts = [mungedStr componentsSeparatedByString:boundary];
// look at each of the munged parts in the original string, and try to
// convert those into UTF-8
NSMutableArray *origParts = [NSMutableArray array];
NSUInteger offset = 0;
for (NSString *mungedPart in mungedParts) {
NSUInteger partSize = [mungedPart length];
NSRange range = NSMakeRange(offset, partSize);
NSData *origPartData = [data subdataWithRange:range];
NSString *origPartStr = [[[NSString alloc] initWithData:origPartData
encoding:NSUTF8StringEncoding] autorelease];
if (origPartStr) {
// we could make this original part into UTF-8; use the string
[origParts addObject:origPartStr];
} else {
// this part can't be made into UTF-8; scan the header, if we can
NSString *header = nil;
NSScanner *headerScanner = [NSScanner scannerWithString:mungedPart];
if (![headerScanner scanUpToString:@"\r\n\r\n" intoString:&header]) {
// we couldn't find a header
header = @"";
}
// make a part string with the header and <<n bytes>>
NSString *binStr = [NSString stringWithFormat:@"\r%@\r<<%lu bytes>>\r",
header, (long)(partSize - [header length])];
[origParts addObject:binStr];
}
offset += partSize + [boundary length];
}
// rejoin the original parts
streamDataStr = [origParts componentsJoinedByString:boundary];
}
}
if (!streamDataStr) {
// give up; just make a string showing the uploaded bytes
streamDataStr = [NSString stringWithFormat:@"<<%u bytes>>",
(unsigned int)[data length]];
}
return streamDataStr;
}
// logFetchWithError is called following a successful or failed fetch attempt
//
// This method does all the work for appending to and creating log files
- (void)logFetchWithError:(NSError *)error {
if (![[self class] isLoggingEnabled]) return;
// TODO: (grobbins) add Javascript to display response data formatted in hex
NSString *parentDir = [[self class] loggingDirectory];
NSString *processName = [[self class] loggingProcessName];
NSString *dateStamp = [[self class] loggingDateStamp];
NSString *logDirectory = [[self class] logDirectoryForCurrentRun];
if (!logDirectory) return;
// each response's NSData goes into its own xml or txt file, though all
// responses for this run of the app share a main html file. This
// counter tracks all fetch responses for this run of the app.
//
// we'll use a local variable since this routine may be reentered while
// waiting for XML formatting to be completed by an external task
static int zResponseCounter = 0;
int responseCounter = ++zResponseCounter;
// file name for an image data file
NSString *responseDataFileName = nil;
NSUInteger responseDataLength;
if (downloadFileHandle_) {
responseDataLength = (NSUInteger) [downloadFileHandle_ offsetInFile];
} else {
responseDataLength = [downloadedData_ length];
}
NSURLResponse *response = [self response];
NSDictionary *responseHeaders = [self responseHeaders];
NSString *responseBaseName = nil;
NSString *responseDataStr = nil;
NSDictionary *responseJSON = nil;
// if there's response data, decide what kind of file to put it in based
// on the first bytes of the file or on the mime type supplied by the server
NSString *responseMIMEType = [response MIMEType];
BOOL isResponseImage = NO;
NSData *dataToWrite = nil;
if (responseDataLength > 0) {
NSString *responseDataExtn = nil;
// generate a response file base name like
responseBaseName = [NSString stringWithFormat:@"httpfetcher_%d_response",
responseCounter];
NSString *responseType = [responseHeaders valueForKey:@"Content-Type"];
responseDataStr = [self formattedStringFromData:downloadedData_
contentType:responseType
JSON:&responseJSON];
if (responseDataStr) {
// we were able to make a UTF-8 string from the response data
if ([responseMIMEType isEqual:@"application/atom+xml"]
|| [responseMIMEType hasSuffix:@"/xml"]) {
responseDataExtn = @"xml";
dataToWrite = [responseDataStr dataUsingEncoding:NSUTF8StringEncoding];
}
} else if ([responseMIMEType isEqual:@"image/jpeg"]) {
responseDataExtn = @"jpg";
dataToWrite = downloadedData_;
isResponseImage = YES;
} else if ([responseMIMEType isEqual:@"image/gif"]) {
responseDataExtn = @"gif";
dataToWrite = downloadedData_;
isResponseImage = YES;
} else if ([responseMIMEType isEqual:@"image/png"]) {
responseDataExtn = @"png";
dataToWrite = downloadedData_;
isResponseImage = YES;
} else {
// add more non-text types here
}
// if we have an extension, save the raw data in a file with that
// extension
if (responseDataExtn && dataToWrite) {
responseDataFileName = [responseBaseName stringByAppendingPathExtension:responseDataExtn];
NSString *responseDataFilePath = [logDirectory stringByAppendingPathComponent:responseDataFileName];
NSError *downloadedError = nil;
if (gIsLoggingToFile
&& ![dataToWrite writeToFile:responseDataFilePath
options:0
error:&downloadedError]) {
NSLog(@"%@ logging write error:%@ (%@)",
[self class], downloadedError, responseDataFileName);
}
}
}
// we'll have one main html file per run of the app
NSString *htmlName = [[self class] htmlFileName];
NSString *htmlPath =[logDirectory stringByAppendingPathComponent:htmlName];
// if the html file exists (from logging previous fetches) we don't need
// to re-write the header or the scripts
BOOL didFileExist = [[self class] fileOrDirExistsAtPath:htmlPath];
NSMutableString* outputHTML = [NSMutableString string];
NSURLRequest *request = [self mutableRequest];
// we need a header to say we'll have UTF-8 text
if (!didFileExist) {
[outputHTML appendFormat:@"<html><head><meta http-equiv=\"content-type\" "
"content=\"text/html; charset=UTF-8\"><title>%@ HTTP fetch log %@</title>",
processName, dateStamp];
}
// now write the visible html elements
NSString *copyableFileName = [NSString stringWithFormat:@"httpfetcher_%d.txt",
responseCounter];
// write the date & time, the comment, and the link to the plain-text
// (copyable) log
NSString *const dateLineFormat = @"<b>%@ &nbsp;&nbsp;&nbsp;&nbsp; ";
[outputHTML appendFormat:dateLineFormat, [NSDate date]];
NSString *comment = [self comment];
if (comment) {
NSString *const commentFormat = @"%@ &nbsp;&nbsp;&nbsp;&nbsp; ";
[outputHTML appendFormat:commentFormat, comment];
}
NSString *const reqRespFormat = @"</b><a href='%@'><i>request/response log</i></a><br>";
[outputHTML appendFormat:reqRespFormat, copyableFileName];
// write the request URL
NSString *requestMethod = [request HTTPMethod];
NSURL *requestURL = [request URL];
NSURL *redirectedFromHost = [[[redirectedFromURL_ host] copy] autorelease];
// Save the request URL for next time in case this redirects.
[redirectedFromURL_ release];
redirectedFromURL_ = [requestURL copy];
if (redirectedFromHost) {
[outputHTML appendFormat:@"<FONT COLOR='#990066'><i>redirected from %@</i></FONT><br>",
redirectedFromHost];
}
[outputHTML appendFormat:@"<b>request:</b> %@ <code>%@</code><br>\n",
requestMethod, requestURL];
// write the request headers
NSDictionary *requestHeaders = [request allHTTPHeaderFields];
NSUInteger numberOfRequestHeaders = [requestHeaders count];
if (numberOfRequestHeaders > 0) {
// Indicate if the request is authorized; warn if the request is
// authorized but non-SSL
NSString *auth = [requestHeaders objectForKey:@"Authorization"];
NSString *headerDetails = @"";
if (auth) {
headerDetails = @"&nbsp;&nbsp;&nbsp;<i>authorized</i>";
BOOL isInsecure = [[requestURL scheme] isEqual:@"http"];
if (isInsecure) {
headerDetails = @"&nbsp;&nbsp;&nbsp;<i>authorized, non-SSL</i>"
"<FONT COLOR='#FF00FF'> &#x26A0;</FONT> "; // 26A0 = ⚠
}
}
NSString *cookiesHdr = [requestHeaders objectForKey:@"Cookie"];
if (cookiesHdr) {
headerDetails = [headerDetails stringByAppendingString:
@"&nbsp;&nbsp;&nbsp;<i>cookies</i>"];
}
NSString *matchHdr = [requestHeaders objectForKey:@"If-Match"];
if (matchHdr) {
headerDetails = [headerDetails stringByAppendingString:
@"&nbsp;&nbsp;&nbsp;<i>if-match</i>"];
}
matchHdr = [requestHeaders objectForKey:@"If-None-Match"];
if (matchHdr) {
headerDetails = [headerDetails stringByAppendingString:
@"&nbsp;&nbsp;&nbsp;<i>if-none-match</i>"];
}
[outputHTML appendFormat:@"&nbsp;&nbsp; headers: %d %@<br>",
(int)numberOfRequestHeaders, headerDetails];
} else {
[outputHTML appendFormat:@"&nbsp;&nbsp; headers: none<br>"];
}
// write the request post data, toggleable
NSData *bodyData;
if (loggedStreamData_) {
bodyData = loggedStreamData_;
} else if (bodyData_) {
bodyData = bodyData_;
} else {
bodyData = [request_ HTTPBody];
}
NSString *bodyDataStr = nil;
NSUInteger bodyDataLength = [bodyData length];
NSString *postType = [requestHeaders valueForKey:@"Content-Type"];
if (bodyDataLength > 0) {
[outputHTML appendFormat:@"&nbsp;&nbsp; data: %d bytes, <code>%@</code><br>\n",
(int)bodyDataLength, postType ? postType : @"<no type>"];
if (logRequestBody_) {
bodyDataStr = [[logRequestBody_ copy] autorelease];
[logRequestBody_ release];
logRequestBody_ = nil;
} else {
bodyDataStr = [self stringFromStreamData:bodyData
contentType:postType];
if (bodyDataStr) {
// remove OAuth 2 client secret and refresh token
bodyDataStr = [[self class] snipSubstringOfString:bodyDataStr
betweenStartString:@"client_secret="
endString:@"&"];
bodyDataStr = [[self class] snipSubstringOfString:bodyDataStr
betweenStartString:@"refresh_token="
endString:@"&"];
// remove ClientLogin password
bodyDataStr = [[self class] snipSubstringOfString:bodyDataStr
betweenStartString:@"&Passwd="
endString:@"&"];
}
}
} else {
// no post data
}
// write the response status, MIME type, URL
NSInteger status = [self statusCode];
if (response) {
NSString *statusString = @"";
if (status != 0) {
if (status == 200 || status == 201) {
statusString = [NSString stringWithFormat:@"%ld", (long)status];
// report any JSON-RPC error
if ([responseJSON isKindOfClass:[NSDictionary class]]) {
NSDictionary *jsonError = [responseJSON objectForKey:@"error"];
if ([jsonError isKindOfClass:[NSDictionary class]]) {
NSString *jsonCode = [[jsonError valueForKey:@"code"] description];
NSString *jsonMessage = [jsonError valueForKey:@"message"];
if (jsonCode || jsonMessage) {
NSString *const jsonErrFmt = @"&nbsp;&nbsp;&nbsp;<i>JSON error:</i> <FONT"
@" COLOR='#FF00FF'>%@ %@ &nbsp;&#x2691;</FONT>"; // 2691 = ⚑
statusString = [statusString stringByAppendingFormat:jsonErrFmt,
jsonCode ? jsonCode : @"",
jsonMessage ? jsonMessage : @""];
}
}
}
} else {
// purple for anything other than 200 or 201
NSString *flag = (status >= 400 ? @"&nbsp;&#x2691;" : @""); // 2691 = ⚑
NSString *const statusFormat = @"<FONT COLOR='#FF00FF'>%ld %@</FONT>";
statusString = [NSString stringWithFormat:statusFormat,
(long)status, flag];
}
}
// show the response URL only if it's different from the request URL
NSString *responseURLStr = @"";
NSURL *responseURL = [response URL];
if (responseURL && ![responseURL isEqual:[request URL]]) {
NSString *const responseURLFormat = @"<FONT COLOR='#FF00FF'>response URL:"
"</FONT> <code>%@</code><br>\n";
responseURLStr = [NSString stringWithFormat:responseURLFormat,
[responseURL absoluteString]];
}
[outputHTML appendFormat:@"<b>response:</b>&nbsp;&nbsp;status %@<br>\n%@",
statusString, responseURLStr];
// Write the response headers
NSUInteger numberOfResponseHeaders = [responseHeaders count];
if (numberOfResponseHeaders > 0) {
// Indicate if the server is setting cookies
NSString *cookiesSet = [responseHeaders valueForKey:@"Set-Cookie"];
NSString *cookiesStr = (cookiesSet ? @"&nbsp;&nbsp;<FONT COLOR='#990066'>"
"<i>sets cookies</i></FONT>" : @"");
// Indicate if the server is redirecting
NSString *location = [responseHeaders valueForKey:@"Location"];
BOOL isRedirect = (status >= 300 && status <= 399 && location != nil);
NSString *redirectsStr = (isRedirect ? @"&nbsp;&nbsp;<FONT COLOR='#990066'>"
"<i>redirects</i></FONT>" : @"");
[outputHTML appendFormat:@"&nbsp;&nbsp; headers: %d %@ %@<br>\n",
(int)numberOfResponseHeaders, cookiesStr, redirectsStr];
} else {
[outputHTML appendString:@"&nbsp;&nbsp; headers: none<br>\n"];
}
}
// error
if (error) {
[outputHTML appendFormat:@"<b>Error:</b> %@ <br>\n", [error description]];
}
// Write the response data
if (responseDataFileName) {
if (isResponseImage) {
// Make a small inline image that links to the full image file
[outputHTML appendFormat:@"&nbsp;&nbsp; data: %d bytes, <code>%@</code><br>",
(int)responseDataLength, responseMIMEType];
NSString *const fmt = @"<a href=\"%@\"><img src='%@' alt='image'"
" style='border:solid thin;max-height:32'></a>\n";
[outputHTML appendFormat:fmt, responseDataFileName, responseDataFileName];
} else {
// The response data was XML; link to the xml file
NSString *const fmt = @"&nbsp;&nbsp; data: %d bytes, <code>"
"%@</code>&nbsp;&nbsp;&nbsp;<i><a href=\"%@\">%@</a></i>\n";
[outputHTML appendFormat:fmt,
(int)responseDataLength, responseMIMEType,
responseDataFileName, [responseDataFileName pathExtension]];
}
} else {
// The response data was not an image; just show the length and MIME type
[outputHTML appendFormat:@"&nbsp;&nbsp; data: %d bytes, <code>%@</code>\n",
(int)responseDataLength, responseMIMEType];
}
// Make a single string of the request and response, suitable for copying
// to the clipboard and pasting into a bug report
NSMutableString *copyable = [NSMutableString string];
if (comment) {
[copyable appendFormat:@"%@\n\n", comment];
}
[copyable appendFormat:@"%@\n", [NSDate date]];
if (redirectedFromHost) {
[copyable appendFormat:@"Redirected from %@\n", redirectedFromHost];
}
[copyable appendFormat:@"Request: %@ %@\n", requestMethod, requestURL];
if ([requestHeaders count] > 0) {
[copyable appendFormat:@"Request headers:\n%@\n",
[[self class] headersStringForDictionary:requestHeaders]];
}
if (bodyDataLength > 0) {
[copyable appendFormat:@"Request body: (%u bytes)\n",
(unsigned int) bodyDataLength];
if (bodyDataStr) {
[copyable appendFormat:@"%@\n", bodyDataStr];
}
[copyable appendString:@"\n"];
}
if (response) {
[copyable appendFormat:@"Response: status %d\n", (int) status];
[copyable appendFormat:@"Response headers:\n%@\n",
[[self class] headersStringForDictionary:responseHeaders]];
[copyable appendFormat:@"Response body: (%u bytes)\n",
(unsigned int) responseDataLength];
if (responseDataLength > 0) {
if (logResponseBody_) {
responseDataStr = [[logResponseBody_ copy] autorelease];
[logResponseBody_ release];
logResponseBody_ = nil;
}
if (responseDataStr != nil) {
[copyable appendFormat:@"%@\n", responseDataStr];
} else if (status >= 400 && [temporaryDownloadPath_ length] > 0) {
// Try to read in the saved data, which is probably a server error
// message
NSStringEncoding enc;
responseDataStr = [NSString stringWithContentsOfFile:temporaryDownloadPath_
usedEncoding:&enc
error:NULL];
if ([responseDataStr length] > 0) {
[copyable appendFormat:@"%@\n", responseDataStr];
} else {
[copyable appendFormat:@"<<%u bytes to file>>\n",
(unsigned int) responseDataLength];
}
} else {
// Even though it's redundant, we'll put in text to indicate that all
// the bytes are binary
[copyable appendFormat:@"<<%u bytes>>\n",
(unsigned int) responseDataLength];
}
}
}
if (error) {
[copyable appendFormat:@"Error: %@\n", error];
}
// Save to log property before adding the separator
self.log = copyable;
[copyable appendString:@"-----------------------------------------------------------\n"];
// Write the copyable version to another file (linked to at the top of the
// html file, above)
//
// Ideally, something to just copy this to the clipboard like
// <span onCopy='window.event.clipboardData.setData(\"Text\",
// \"copyable stuff\");return false;'>Copy here.</span>"
// would work everywhere, but it only works in Safari as of 8/2010
if (gIsLoggingToFile) {
NSString *copyablePath = [logDirectory stringByAppendingPathComponent:copyableFileName];
NSError *copyableError = nil;
if (![copyable writeToFile:copyablePath
atomically:NO
encoding:NSUTF8StringEncoding
error:&copyableError]) {
// Error writing to file
NSLog(@"%@ logging write error:%@ (%@)",
[self class], copyableError, copyablePath);
}
[outputHTML appendString:@"<br><hr><p>"];
// Append the HTML to the main output file
const char* htmlBytes = [outputHTML UTF8String];
NSOutputStream *stream = [NSOutputStream outputStreamToFileAtPath:htmlPath
append:YES];
[stream open];
[stream write:(const uint8_t *) htmlBytes maxLength:strlen(htmlBytes)];
[stream close];
// Make a symlink to the latest html
NSString *const symlinkNameSuffix = [[self class] symlinkNameSuffix];
NSString *symlinkName = [processName stringByAppendingString:symlinkNameSuffix];
NSString *symlinkPath = [parentDir stringByAppendingPathComponent:symlinkName];
[[self class] removeItemAtPath:symlinkPath];
[[self class] createSymbolicLinkAtPath:symlinkPath
withDestinationPath:htmlPath];
#if GTM_IPHONE
static BOOL gReportedLoggingPath = NO;
if (!gReportedLoggingPath) {
gReportedLoggingPath = YES;
NSLog(@"GTMHTTPFetcher logging to \"%@\"", parentDir);
}
#endif
}
}
- (BOOL)logCapturePostStream {
// This is called when beginning a fetch. The caller should have already
// verified that logging is enabled, and should have allocated
// loggedStreamData_ as a mutable object.
// If the class GTMReadMonitorInputStream is not available, bail now, since
// we cannot capture this upload stream
Class monitorClass = NSClassFromString(@"GTMReadMonitorInputStream");
if (!monitorClass) return NO;
// If we're logging, we need to wrap the upload stream with our monitor
// stream that will call us back with the bytes being read from the stream
// Our wrapper will retain the old post stream
[postStream_ autorelease];
postStream_ = [monitorClass inputStreamWithStream:postStream_];
[postStream_ retain];
[(GTMReadMonitorInputStream *)postStream_ setReadDelegate:self];
[(GTMReadMonitorInputStream *)postStream_ setRunLoopModes:[self runLoopModes]];
SEL readSel = @selector(inputStream:readIntoBuffer:length:);
[(GTMReadMonitorInputStream *)postStream_ setReadSelector:readSel];
return YES;
}
@end
@implementation GTMHTTPFetcher (GTMHTTPFetcherLoggingUtilities)
- (void)inputStream:(GTMReadMonitorInputStream *)stream
readIntoBuffer:(void *)buffer
length:(NSUInteger)length {
// append the captured data
[loggedStreamData_ appendBytes:buffer length:length];
}
#pragma mark Internal file routines
// We implement plain Unix versions of NSFileManager methods to avoid
// NSFileManager's issues with being used from multiple threads
+ (BOOL)fileOrDirExistsAtPath:(NSString *)path {
struct stat buffer;
int result = stat([path fileSystemRepresentation], &buffer);
return (result == 0);
}
+ (BOOL)makeDirectoryUpToPath:(NSString *)path {
int result = 0;
// Recursively create the parent directory of the requested path
NSString *parent = [path stringByDeletingLastPathComponent];
if (![self fileOrDirExistsAtPath:parent]) {
result = [self makeDirectoryUpToPath:parent];
}
// Make the leaf directory
if (result == 0 && ![self fileOrDirExistsAtPath:path]) {
result = mkdir([path fileSystemRepresentation], S_IRWXU); // RWX for owner
}
return (result == 0);
}
+ (BOOL)removeItemAtPath:(NSString *)path {
int result = unlink([path fileSystemRepresentation]);
return (result == 0);
}
+ (BOOL)createSymbolicLinkAtPath:(NSString *)newPath
withDestinationPath:(NSString *)targetPath {
int result = symlink([targetPath fileSystemRepresentation],
[newPath fileSystemRepresentation]);
return (result == 0);
}
#pragma mark Fomatting Utilities
+ (NSString *)snipSubstringOfString:(NSString *)originalStr
betweenStartString:(NSString *)startStr
endString:(NSString *)endStr {
#if SKIP_GTM_FETCH_LOGGING_SNIPPING
return originalStr;
#else
if (originalStr == nil) return nil;
// Find the start string, and replace everything between it
// and the end string (or the end of the original string) with "_snip_"
NSRange startRange = [originalStr rangeOfString:startStr];
if (startRange.location == NSNotFound) return originalStr;
// We found the start string
NSUInteger originalLength = [originalStr length];
NSUInteger startOfTarget = NSMaxRange(startRange);
NSRange targetAndRest = NSMakeRange(startOfTarget,
originalLength - startOfTarget);
NSRange endRange = [originalStr rangeOfString:endStr
options:0
range:targetAndRest];
NSRange replaceRange;
if (endRange.location == NSNotFound) {
// Found no end marker so replace to end of string
replaceRange = targetAndRest;
} else {
// Replace up to the endStr
replaceRange = NSMakeRange(startOfTarget,
endRange.location - startOfTarget);
}
NSString *result = [originalStr stringByReplacingCharactersInRange:replaceRange
withString:@"_snip_"];
return result;
#endif // SKIP_GTM_FETCH_LOGGING_SNIPPING
}
+ (NSString *)headersStringForDictionary:(NSDictionary *)dict {
// Format the dictionary in http header style, like
// Accept: application/json
// Cache-Control: no-cache
// Content-Type: application/json; charset=utf-8
//
// Pad the key names, but not beyond 16 chars, since long custom header
// keys just create too much whitespace
NSArray *keys = [[dict allKeys] sortedArrayUsingSelector:@selector(compare:)];
NSMutableString *str = [NSMutableString string];
for (NSString *key in keys) {
NSString *value = [dict valueForKey:key];
if ([key isEqual:@"Authorization"]) {
// Remove OAuth 1 token
value = [[self class] snipSubstringOfString:value
betweenStartString:@"oauth_token=\""
endString:@"\""];
// Remove OAuth 2 bearer token (draft 16, and older form)
value = [[self class] snipSubstringOfString:value
betweenStartString:@"Bearer "
endString:@"\n"];
value = [[self class] snipSubstringOfString:value
betweenStartString:@"OAuth "
endString:@"\n"];
// Remove Google ClientLogin
value = [[self class] snipSubstringOfString:value
betweenStartString:@"GoogleLogin auth="
endString:@"\n"];
}
[str appendFormat:@" %@: %@\n", key, value];
}
return str;
}
+ (id)JSONObjectWithData:(NSData *)data {
Class serializer = NSClassFromString(@"NSJSONSerialization");
if (serializer) {
const NSUInteger kOpts = (1UL << 0); // NSJSONReadingMutableContainers
NSMutableDictionary *obj;
obj = [serializer JSONObjectWithData:data
options:kOpts
error:NULL];
return obj;
} else {
// Try SBJsonParser or SBJSON
Class jsonParseClass = NSClassFromString(@"SBJsonParser");
if (!jsonParseClass) {
jsonParseClass = NSClassFromString(@"SBJSON");
}
if (jsonParseClass) {
GTMFetcherSBJSON *parser = [[[jsonParseClass alloc] init] autorelease];
NSString *jsonStr = [[[NSString alloc] initWithData:data
encoding:NSUTF8StringEncoding] autorelease];
if (jsonStr) {
NSMutableDictionary *obj = [parser objectWithString:jsonStr error:NULL];
return obj;
}
}
}
return nil;
}
+ (id)stringWithJSONObject:(id)obj {
Class serializer = NSClassFromString(@"NSJSONSerialization");
if (serializer) {
const NSUInteger kOpts = (1UL << 0); // NSJSONWritingPrettyPrinted
NSData *data;
data = [serializer dataWithJSONObject:obj
options:kOpts
error:NULL];
if (data) {
NSString *jsonStr = [[[NSString alloc] initWithData:data
encoding:NSUTF8StringEncoding] autorelease];
return jsonStr;
}
} else {
// Try SBJsonParser or SBJSON
Class jsonWriterClass = NSClassFromString(@"SBJsonWriter");
if (!jsonWriterClass) {
jsonWriterClass = NSClassFromString(@"SBJSON");
}
if (jsonWriterClass) {
GTMFetcherSBJSON *writer = [[[jsonWriterClass alloc] init] autorelease];
[writer setHumanReadable:YES];
NSString *jsonStr = [writer stringWithObject:obj error:NULL];
return jsonStr;
}
}
return nil;
}
@end
#endif // !STRIP_GTM_FETCH_LOGGING