Index: src/OFHTTPClient.h ================================================================== --- src/OFHTTPClient.h +++ src/OFHTTPClient.h @@ -89,10 +89,13 @@ */ @interface OFHTTPClient: OFObject { id _delegate; bool _insecureRedirectsAllowed; + OFTCPSocket *_socket; + OFURL *_lastURL; + OFHTTPRequestReply *_lastReply; } #ifdef OF_HAVE_PROPERTIES @property (assign) id delegate; @property bool insecureRedirectsAllowed; @@ -147,9 +150,14 @@ * redirect is returned as an OFHTTPRequestReply * @return An OFHTTPRequestReply with the reply of the HTTP request */ - (OFHTTPRequestReply*)performRequest: (OFHTTPRequest*)request redirects: (size_t)redirects; + +/*! + * @brief Closes connections that are still open due to keep-alive. + */ +- (void)close; @end @interface OFObject (OFHTTPClientDelegate) @end Index: src/OFHTTPClient.m ================================================================== --- src/OFHTTPClient.m +++ src/OFHTTPClient.m @@ -16,10 +16,12 @@ #include "config.h" #include #include + +#include #import "OFHTTPClient.h" #import "OFHTTPRequest.h" #import "OFHTTPRequestReply.h" #import "OFString.h" @@ -30,15 +32,18 @@ #import "OFHTTPRequestFailedException.h" #import "OFInvalidEncodingException.h" #import "OFInvalidFormatException.h" #import "OFInvalidServerReplyException.h" +#import "OFNotConnectedException.h" #import "OFOutOfMemoryException.h" #import "OFOutOfRangeException.h" +#import "OFReadFailedException.h" #import "OFTruncatedDataException.h" #import "OFUnsupportedProtocolException.h" #import "OFUnsupportedVersionException.h" +#import "OFWriteFailedException.h" #import "autorelease.h" #import "macros.h" static OF_INLINE void @@ -62,15 +67,16 @@ } @interface OFHTTPClientReply: OFHTTPRequestReply { OFTCPSocket *_socket; - bool _chunked, _atEndOfStream; + bool _hasContentLength, _chunked, _keepAlive, _atEndOfStream; size_t _toRead; } - initWithSocket: (OFTCPSocket*)socket; +- (void)setKeepAlive: (bool)keepAlive; @end @implementation OFHTTPClientReply - initWithSocket: (OFTCPSocket*)socket { @@ -78,10 +84,15 @@ _socket = [socket retain]; return self; } + +- (void)setKeepAlive: (bool)keepAlive +{ + _keepAlive = keepAlive; +} - (void)dealloc { [_socket release]; @@ -88,23 +99,79 @@ [super dealloc]; } - (void)setHeaders: (OFDictionary*)headers { + OFString *contentLength; + [super setHeaders: headers]; _chunked = [[headers objectForKey: @"Transfer-Encoding"] isEqual: @"chunked"]; + + contentLength = [headers objectForKey: @"Content-Length"]; + if (contentLength != nil) { + _hasContentLength = true; + + @try { + _toRead = [contentLength decimalValue]; + } @catch (OFInvalidFormatException *e) { + @throw [OFInvalidServerReplyException + exceptionWithClass: [self class]]; + } + } } - (size_t)lowlevelReadIntoBuffer: (void*)buffer length: (size_t)length { - if (!_chunked) + if (_atEndOfStream) { + OFReadFailedException *e; + + e = [OFReadFailedException exceptionWithClass: [self class] + stream: self + requestedLength: length]; + +#ifndef _WIN32 + e->_errNo = ENOTCONN; +#else + e->_errNo = WSAENOTCONN; +#endif + + @throw e; + } + + if (!_hasContentLength && !_chunked) return [_socket readIntoBuffer: buffer length: length]; + /* Content-Length */ + if (!_chunked) { + size_t ret; + + if (_toRead == 0) { + _atEndOfStream = true; + + if (!_keepAlive) + [_socket close]; + + return 0; + } + + if (_toRead < length) + ret = [_socket readIntoBuffer: buffer + length: _toRead]; + else + ret = [_socket readIntoBuffer: buffer + length: length]; + + _toRead -= ret; + + return ret; + } + + /* Chunked */ if (_toRead > 0) { if (length > _toRead) length = _toRead; length = [_socket readIntoBuffer: buffer @@ -142,12 +209,25 @@ @throw [OFInvalidServerReplyException exceptionWithClass: [self class]]; } if (_toRead == 0) { - [_socket close]; _atEndOfStream = true; + + if (_keepAlive) { + @try { + line = [_socket readLine]; + } @catch (OFInvalidEncodingException *e) { + @throw [OFInvalidServerReplyException + exceptionWithClass: [self class]]; + } + + if ([line length] > 0) + @throw [OFInvalidServerReplyException + exceptionWithClass: [self class]]; + } else + [_socket close]; } objc_autoreleasePoolPop(pool); return 0; @@ -154,11 +234,11 @@ } } - (bool)lowlevelIsAtEndOfStream { - if (!_chunked) + if (!_hasContentLength && !_chunked) return [_socket isAtEndOfStream]; return _atEndOfStream; } @@ -182,10 +262,17 @@ @implementation OFHTTPClient + (instancetype)client { return [[[self alloc] init] autorelease]; } + +- (void)dealloc +{ + [self close]; + + [super dealloc]; +} - (void)setDelegate: (id )delegate { _delegate = delegate; } @@ -209,42 +296,27 @@ { return [self performRequest: request redirects: 10]; } -- (OFHTTPRequestReply*)performRequest: (OFHTTPRequest*)request - redirects: (size_t)redirects -{ - void *pool = objc_autoreleasePoolPush(); - OFURL *URL = [request URL]; - OFString *scheme = [URL scheme]; - of_http_request_type_t requestType = [request requestType]; - OFDictionary *headers = [request headers]; - OFDataArray *POSTData = [request POSTData]; +- (OFTCPSocket*)OF_createSocketForRequest: (OFHTTPRequest*)request +{ + OFURL *URL = [request URL]; OFTCPSocket *socket; - OFHTTPClientReply *reply; - OFString *line, *path, *version; - OFMutableDictionary *serverHeaders; - OFEnumerator *keyEnumerator, *objectEnumerator; - OFString *key, *object; - int status; - const char *type = NULL; - - if (![scheme isEqual: @"http"] && ![scheme isEqual: @"https"]) - @throw [OFUnsupportedProtocolException - exceptionWithClass: [self class] - URL: URL]; - - if ([scheme isEqual: @"http"]) + + [self close]; + + if ([[URL scheme] isEqual: @"http"]) socket = [OFTCPSocket socket]; else { if (of_tls_socket_class == Nil) @throw [OFUnsupportedProtocolException exceptionWithClass: [self class] URL: URL]; - socket = [[[of_tls_socket_class alloc] init] autorelease]; + socket = [[[of_tls_socket_class alloc] init] + autorelease]; } if ([_delegate respondsToSelector: @selector(client:didCreateSocket:request:)]) [_delegate client: self @@ -252,15 +324,64 @@ request: request]; [socket connectToHost: [URL host] port: [URL port]]; - /* - * Work around a bug with packet splitting in lighttpd when using - * HTTPS. - */ - [socket setWriteBufferEnabled: true]; + return socket; +} + +- (OFHTTPRequestReply*)performRequest: (OFHTTPRequest*)request + redirects: (size_t)redirects +{ + void *pool = objc_autoreleasePoolPush(); + OFURL *URL = [request URL]; + OFString *scheme = [URL scheme]; + of_http_request_type_t requestType = [request requestType]; + OFMutableString *requestString; + OFDictionary *headers = [request headers]; + OFDataArray *POSTData = [request POSTData]; + OFTCPSocket *socket; + OFHTTPClientReply *reply; + OFString *line, *path, *version, *redirect, *keepAlive; + OFMutableDictionary *serverHeaders; + OFEnumerator *keyEnumerator, *objectEnumerator; + OFString *key, *object; + int status; + const char *type = NULL; + + if (![scheme isEqual: @"http"] && ![scheme isEqual: @"https"]) + @throw [OFUnsupportedProtocolException + exceptionWithClass: [self class] + URL: URL]; + + /* Can we reuse the socket? */ + if (_socket != nil && [[_lastURL scheme] isEqual: [URL scheme]] && + [[_lastURL host] isEqual: [URL host]] && + [_lastURL port] == [URL port]) { + /* + * Set _socket to nil, so that in case of an error it won't be + * reused. If everything is successfull, we set _socket again + * at the end. + */ + socket = [_socket autorelease]; + _socket = nil; + + [_lastURL release]; + _lastURL = nil; + + /* Throw away content that has not been read yet */ + while (![_lastReply isAtEndOfStream]) { + char buffer[512]; + + [_lastReply readIntoBuffer: buffer + length: 512]; + } + + [_lastReply release]; + _lastReply = nil; + } else + socket = [self OF_createSocketForRequest: request]; if (requestType == OF_HTTP_REQUEST_TYPE_GET) type = "GET"; if (requestType == OF_HTTP_REQUEST_TYPE_HEAD) type = "HEAD"; @@ -268,53 +389,72 @@ type = "POST"; if ([(path = [URL path]) length] == 0) path = @"/"; + /* + * As a work around for a bug with split packets in lighttpd when using + * HTTPS, we construct the complete request in a buffer string and then + * send it all at once. + */ if ([URL query] != nil) - [socket writeFormat: @"%s %@?%@ HTTP/%@\r\n", + requestString = [OFMutableString stringWithFormat: + @"%s %@?%@ HTTP/%@\r\n", type, path, [URL query], [request protocolVersionString]]; else - [socket writeFormat: @"%s %@ HTTP/%@\r\n", + requestString = [OFMutableString stringWithFormat: + @"%s %@ HTTP/%@\r\n", type, path, [request protocolVersionString]]; if ([URL port] == 80) - [socket writeFormat: @"Host: %@\r\n", [URL host]]; + [requestString appendFormat: @"Host: %@\r\n", [URL host]]; else - [socket writeFormat: @"Host: %@:%d\r\n", [URL host], + [requestString appendFormat: @"Host: %@:%d\r\n", [URL host], [URL port]]; - [socket writeString: @"Connection: close\r\n"]; - if ([headers objectForKey: @"User-Agent"] == nil) - [socket writeString: @"User-Agent: Something using ObjFW " - @"\r\n"]; + [requestString appendString: + @"User-Agent: Something using ObjFW " + @"\r\n"]; keyEnumerator = [headers keyEnumerator]; objectEnumerator = [headers objectEnumerator]; while ((key = [keyEnumerator nextObject]) != nil && (object = [objectEnumerator nextObject]) != nil) - [socket writeFormat: @"%@: %@\r\n", key, object]; + [requestString appendFormat: @"%@: %@\r\n", key, object]; if (requestType == OF_HTTP_REQUEST_TYPE_POST) { OFString *contentType = [request MIMEType]; if (contentType == nil) contentType = @"application/x-www-form-urlencoded; " @"charset=UTF-8\r\n"; - [socket writeFormat: @"Content-Type: %@\r\n", contentType]; - [socket writeFormat: @"Content-Length: %d\r\n", - [POSTData count] * [POSTData itemSize]]; + [requestString appendFormat: + @"Content-Type: %@\r\n" + @"Content-Length: %d\r\n", + contentType, [POSTData count] * [POSTData itemSize]]; } - [socket writeString: @"\r\n"]; + if ([request protocolVersion].major == 1 && + [request protocolVersion].minor == 0) + [requestString appendString: @"Connection: keep-alive\r\n"]; + + [requestString appendString: @"\r\n"]; - /* Work around a bug in lighttpd, see above */ - [socket flushWriteBuffer]; - [socket setWriteBufferEnabled: false]; + @try { + [socket writeString: requestString]; + } @catch (OFWriteFailedException *e) { + /* Reconnect in case a keep-alive connection timed out */ + socket = [self OF_createSocketForRequest: request]; + [socket writeString: requestString]; + } @catch (OFNotConnectedException *e) { + /* Reconnect in case a keep-alive connection timed out */ + socket = [self OF_createSocketForRequest: request]; + [socket writeString: requestString]; + } if (requestType == OF_HTTP_REQUEST_TYPE_POST) [socket writeBuffer: [POSTData items] length: [POSTData count] * [POSTData itemSize]]; @@ -322,10 +462,32 @@ line = [socket readLine]; } @catch (OFInvalidEncodingException *e) { @throw [OFInvalidServerReplyException exceptionWithClass: [self class]]; } + + /* + * It's possible that the write succeeds on a connection that is + * keep-alive, but the connection has already been closed by the remote + * end due to a timeout. In this case, we need to reconnect. + */ + if (line == nil) { + socket = [self OF_createSocketForRequest: request]; + [socket writeString: requestString]; + + if (requestType == OF_HTTP_REQUEST_TYPE_POST) + [socket writeBuffer: [POSTData items] + length: [POSTData count] * + [POSTData itemSize]]; + + @try { + line = [socket readLine]; + } @catch (OFInvalidEncodingException *e) { + @throw [OFInvalidServerReplyException + exceptionWithClass: [self class]]; + } + } if (![line hasPrefix: @"HTTP/"] || [line characterAtIndex: 8] != ' ') @throw [OFInvalidServerReplyException exceptionWithClass: [self class]]; @@ -385,33 +547,58 @@ tmp++; } while (*tmp == ' '); value = [OFString stringWithUTF8String: tmp]; - if ((redirects > 0 && (status == 301 || status == 302 || - status == 303 || status == 307) && - [key isEqual: @"Location"]) && (_insecureRedirectsAllowed || - [scheme isEqual: @"http"] || - ![value hasPrefix: @"http://"])) { - OFURL *newURL; - OFHTTPRequest *newRequest; - bool follow = true; - - newURL = [OFURL URLWithString: value - relativeToURL: URL]; - - if ([_delegate respondsToSelector: - @selector(client:shouldFollowRedirect:request:)]) - follow = [_delegate client: self - shouldFollowRedirect: newURL - request: request]; - - if (!follow) { - [serverHeaders setObject: value - forKey: key]; - continue; - } + [serverHeaders setObject: value + forKey: key]; + } + + [serverHeaders makeImmutable]; + + if ([_delegate respondsToSelector: + @selector(client:didReceiveHeaders:statusCode:request:)]) + [_delegate client: self + didReceiveHeaders: serverHeaders + statusCode: status + request: request]; + + reply = [[[OFHTTPClientReply alloc] initWithSocket: socket] + autorelease]; + [reply setProtocolVersionFromString: version]; + [reply setStatusCode: status]; + [reply setHeaders: serverHeaders]; + + keepAlive = [serverHeaders objectForKey: @"Connection"]; + if ([version isEqual: @"1.1"] || + (keepAlive != nil && [keepAlive isEqual: @"keep-alive"])) { + [reply setKeepAlive: true]; + + _socket = [socket retain]; + _lastURL = [URL copy]; + _lastReply = [reply retain]; + } + + if (redirects > 0 && (status == 301 || status == 302 || + status == 303 || status == 307) && + (redirect = [serverHeaders objectForKey: @"Location"]) != nil && + (_insecureRedirectsAllowed || [scheme isEqual: @"http"] || + ![redirect hasPrefix: @"http://"])) { + OFURL *newURL; + bool follow = true; + + newURL = [OFURL URLWithString: redirect + relativeToURL: URL]; + + if ([_delegate respondsToSelector: + @selector(client:shouldFollowRedirect:request:)]) + follow = [_delegate client: self + shouldFollowRedirect: newURL + request: request]; + + if (follow) { + OFHTTPRequest *newRequest; newRequest = [OFHTTPRequest requestWithURL: newURL]; [newRequest setRequestType: requestType]; [newRequest setHeaders: headers]; [newRequest setPOSTData: POSTData]; @@ -429,31 +616,14 @@ [newRequest autorelease]; return [self performRequest: newRequest redirects: redirects - 1]; } - - [serverHeaders setObject: value - forKey: key]; - } - - [serverHeaders makeImmutable]; - - if ([_delegate respondsToSelector: - @selector(client:didReceiveHeaders:statusCode:request:)]) - [_delegate client: self - didReceiveHeaders: serverHeaders - statusCode: status - request: request]; - - reply = [[OFHTTPClientReply alloc] initWithSocket: socket]; - [reply setProtocolVersionFromString: version]; - [reply setStatusCode: status]; - [reply setHeaders: serverHeaders]; - - objc_autoreleasePoolPop(pool); - + } + + [reply retain]; + objc_autoreleasePoolPop(pool); [reply autorelease]; if (status / 100 != 2) @throw [OFHTTPRequestFailedException exceptionWithClass: [self class] @@ -460,6 +630,18 @@ request: request reply: reply]; return reply; } + +- (void)close +{ + [_socket release]; + _socket = nil; + + [_lastURL release]; + _lastURL = nil; + + [_lastReply release]; + _lastReply = nil; +} @end Index: src/exceptions/OFInvalidServerReplyException.m ================================================================== --- src/exceptions/OFInvalidServerReplyException.m +++ src/exceptions/OFInvalidServerReplyException.m @@ -21,8 +21,8 @@ @implementation OFInvalidServerReplyException - (OFString*)description { return [OFString stringWithFormat: - @"Got an invalid reply from the server in class %@", _inClass]; + @"Got an invalid reply from the server in class %@!", _inClass]; } @end Index: tests/OFHTTPClientTests.m ================================================================== --- tests/OFHTTPClientTests.m +++ tests/OFHTTPClientTests.m @@ -68,20 +68,17 @@ if (![[client readLine] isEqual: [OFString stringWithFormat: @"Host: 127.0.0.1:%" @PRIu16, port]]) assert(0); - if (![[client readLine] isEqual: @"Connection: close"]) - assert(0); - if (![[client readLine] hasPrefix: @"User-Agent:"]) assert(0); if (![[client readLine] isEqual: @""]) assert(0); - [client writeString: @"HTTP/1.1 200 OK\r\n" + [client writeString: @"HTTP/1.0 200 OK\r\n" @"cONTeNT-lENgTH: 7\r\n" @"\r\n" @"foo\n" @"bar"]; [client close];