1 /** 2 * A simple HTTP client. 3 * 4 * License: 5 * This Source Code Form is subject to the terms of 6 * the Mozilla Public License, v. 2.0. If a copy of 7 * the MPL was not distributed with this file, You 8 * can obtain one at http://mozilla.org/MPL/2.0/. 9 * 10 * Authors: 11 * Stéphan Kochen <stephan@kochen.nl> 12 * Vladimir Panteleev <vladimir@thecybershadow.net> 13 * Vincent Povirk <madewokherd@gmail.com> 14 * Simon Arlott 15 */ 16 17 module ae.net.http.client; 18 19 import std.string; 20 import std.conv; 21 import std.datetime; 22 import std.uri; 23 import std.utf; 24 25 import ae.net.asockets; 26 import ae.net.ietf.headers; 27 import ae.net.ietf.headerparse; 28 import ae.net.ietf.url; 29 import ae.net.ssl; 30 import ae.utils.array : toArray; 31 import ae.utils.exception : CaughtException; 32 import ae.sys.data; 33 debug(HTTP) import std.stdio : stderr; 34 35 public import ae.net.http.common; 36 37 class HttpClient 38 { 39 private: 40 TcpConnection tcp; // Bottom-level transport. Reused for new connections. 41 TimeoutAdapter timer; // Timeout adapter. 42 IConnection conn; // Top-level abstract connection. 43 44 Data[] inBuffer; 45 46 protected: 47 HttpRequest currentRequest; 48 49 HttpResponse currentResponse; 50 size_t expect; 51 52 void onConnect() 53 { 54 sendRequest(currentRequest); 55 } 56 57 void sendRequest(HttpRequest request) 58 { 59 string reqMessage = request.method ~ " "; 60 if (request.proxy !is null) { 61 reqMessage ~= "http://" ~ request.host; 62 if (compat || request.port != 80) 63 reqMessage ~= format(":%d", request.port); 64 } 65 reqMessage ~= request.resource ~ " HTTP/1.0\r\n"; 66 67 if ("User-Agent" !in request.headers && agent) 68 request.headers["User-Agent"] = agent; 69 if (!compat) { 70 if ("Accept-Encoding" !in request.headers) 71 request.headers["Accept-Encoding"] = "gzip, deflate, *;q=0"; 72 if (request.data) 73 request.headers["Content-Length"] = to!string(request.data.bytes.length); 74 } else { 75 if ("Pragma" !in request.headers) 76 request.headers["Pragma"] = "No-Cache"; 77 } 78 if ("Connection" !in request.headers) 79 request.headers["Connection"] = keepAlive ? "keep-alive" : "close"; 80 81 foreach (string header, string value; request.headers) 82 reqMessage ~= header ~ ": " ~ value ~ "\r\n"; 83 84 reqMessage ~= "\r\n"; 85 debug(HTTP) 86 { 87 stderr.writefln("Sending request:"); 88 foreach (line; reqMessage.split("\r\n")) 89 stderr.writeln("> ", line); 90 if (request.data) 91 stderr.writefln("} (%d bytes data follow)", request.data.bytes.length); 92 } 93 94 conn.send(Data(reqMessage)); 95 conn.send(request.data); 96 } 97 98 void onNewResponse(Data data) 99 { 100 try 101 { 102 inBuffer ~= data; 103 timer.markNonIdle(); 104 105 string statusLine; 106 Headers headers; 107 108 debug(HTTP) auto oldData = inBuffer.dup; 109 110 if (!parseHeaders(inBuffer, statusLine, headers)) 111 return; 112 113 debug(HTTP) 114 { 115 stderr.writefln("Got response:"); 116 auto reqMessage = cast(string)oldData.bytes[0..oldData.bytes.length-inBuffer.bytes.length].joinToHeap(); 117 foreach (line; reqMessage.split("\r\n")) 118 stderr.writeln("< ", line); 119 } 120 121 currentResponse = new HttpResponse; 122 currentResponse.parseStatusLine(statusLine); 123 currentResponse.headers = headers; 124 125 onHeadersReceived(); 126 } 127 catch (CaughtException e) 128 { 129 if (conn.state == ConnectionState.connected) 130 conn.disconnect(e.msg.length ? e.msg : e.classinfo.name, DisconnectType.error); 131 else 132 throw new Exception("Unhandled exception after connection was closed", e); 133 } 134 } 135 136 void onHeadersReceived() 137 { 138 expect = size_t.max; 139 if ("Content-Length" in currentResponse.headers) 140 expect = to!size_t(strip(currentResponse.headers["Content-Length"])); 141 142 if (inBuffer.bytes.length < expect) 143 { 144 onData(inBuffer); 145 conn.handleReadData = &onContinuation; 146 } 147 else 148 { 149 onData(inBuffer.bytes[0 .. expect]); // TODO: pipelining 150 onDone(); 151 } 152 153 inBuffer.destroy(); 154 } 155 156 void onData(Data[] data) 157 { 158 currentResponse.data ~= data; 159 } 160 161 void onContinuation(Data data) 162 { 163 onData(data.toArray); 164 timer.markNonIdle(); 165 166 auto received = currentResponse.data.bytes.length; 167 if (expect!=size_t.max && received >= expect) 168 { 169 inBuffer = currentResponse.data.bytes[expect..received]; 170 currentResponse.data = currentResponse.data.bytes[0..expect]; 171 onDone(); 172 } 173 } 174 175 void onDone() 176 { 177 if (keepAlive) 178 processResponse(); 179 else 180 conn.disconnect("All data read"); 181 } 182 183 void processResponse(string reason = "All data read") 184 { 185 auto response = currentResponse; 186 187 currentRequest = null; 188 currentResponse = null; 189 expect = -1; 190 conn.handleReadData = null; 191 192 if (handleResponse) 193 handleResponse(response, reason); 194 } 195 196 void onDisconnect(string reason, DisconnectType type) 197 { 198 if (type == DisconnectType.error) 199 currentResponse = null; 200 201 if (currentRequest) 202 processResponse(reason); 203 } 204 205 IConnection adaptConnection(IConnection conn) 206 { 207 return conn; 208 } 209 210 public: 211 string agent = "ae.net.http.client (+https://github.com/CyberShadow/ae)"; 212 bool compat = false; 213 bool keepAlive = false; 214 string[] cookies; 215 216 public: 217 this(Duration timeout = 30.seconds) 218 { 219 assert(timeout > Duration.zero); 220 221 IConnection c = tcp = new TcpConnection; 222 223 c = adaptConnection(c); 224 225 timer = new TimeoutAdapter(c); 226 timer.setIdleTimeout(timeout); 227 c = timer; 228 229 conn = c; 230 conn.handleConnect = &onConnect; 231 conn.handleDisconnect = &onDisconnect; 232 } 233 234 void request(HttpRequest request) 235 { 236 //debug writefln("New HTTP request: %s", request.url); 237 currentRequest = request; 238 currentResponse = null; 239 conn.handleReadData = &onNewResponse; 240 expect = 0; 241 242 if (conn.state != ConnectionState.disconnected) 243 { 244 assert(conn.state == ConnectionState.connected, "Attempting a HTTP request on a %s connection".format(conn.state)); 245 assert(keepAlive, "Attempting a second HTTP request on a connected non-keepalive connection"); 246 sendRequest(request); 247 } 248 else 249 { 250 if (request.proxy !is null) 251 tcp.connect(request.proxyHost, request.proxyPort); 252 else 253 tcp.connect(request.host, request.port); 254 } 255 } 256 257 bool connected() 258 { 259 if (currentRequest !is null) 260 return true; 261 if (keepAlive && conn.state == ConnectionState.connected) 262 return true; 263 return false; 264 } 265 266 void disconnect(string reason = IConnection.defaultDisconnectReason) 267 { 268 conn.disconnect(reason); 269 } 270 271 public: 272 // Provide the following callbacks 273 void delegate(HttpResponse response, string disconnectReason) handleResponse; 274 } 275 276 class HttpsClient : HttpClient 277 { 278 SSLContext ctx; 279 280 this(Duration timeout = 30.seconds) 281 { 282 ctx = ssl.createContext(SSLContext.Kind.client); 283 super(timeout); 284 } 285 286 override IConnection adaptConnection(IConnection conn) 287 { 288 return ssl.createAdapter(ctx, conn); 289 } 290 } 291 292 /// Asynchronous HTTP request 293 void httpRequest(HttpRequest request, void delegate(HttpResponse response, string disconnectReason) responseHandler) 294 { 295 HttpClient client; 296 if (request.protocol == "https") 297 client = new HttpsClient; 298 else 299 client = new HttpClient; 300 301 client.handleResponse = responseHandler; 302 client.request(request); 303 } 304 305 /// ditto 306 void httpRequest(HttpRequest request, void delegate(Data) resultHandler, void delegate(string) errorHandler, int redirectCount = 0) 307 { 308 void responseHandler(HttpResponse response, string disconnectReason) 309 { 310 if (!response) 311 if (errorHandler) 312 errorHandler(disconnectReason); 313 else 314 throw new Exception(disconnectReason); 315 else 316 if (response.status >= 300 && response.status < 400 && "Location" in response.headers) 317 { 318 if (redirectCount == 15) 319 throw new Exception("HTTP redirect loop: " ~ request.url); 320 request.resource = applyRelativeURL(request.url, response.headers["Location"]); 321 if (response.status == HttpStatusCode.SeeOther) 322 { 323 request.method = "GET"; 324 request.data = null; 325 } 326 httpRequest(request, resultHandler, errorHandler, redirectCount+1); 327 } 328 else 329 if (errorHandler) 330 try 331 resultHandler(response.getContent()); 332 catch (Exception e) 333 errorHandler(e.msg); 334 else 335 resultHandler(response.getContent()); 336 } 337 338 httpRequest(request, &responseHandler); 339 } 340 341 /// ditto 342 void httpGet(string url, void delegate(Data) resultHandler, void delegate(string) errorHandler) 343 { 344 auto request = new HttpRequest; 345 request.resource = url; 346 httpRequest(request, resultHandler, errorHandler); 347 } 348 349 /// ditto 350 void httpGet(string url, void delegate(string) resultHandler, void delegate(string) errorHandler) 351 { 352 httpGet(url, 353 (Data data) 354 { 355 auto result = (cast(char[])data.contents).idup; 356 std.utf.validate(result); 357 resultHandler(result); 358 }, 359 errorHandler); 360 } 361 362 /// ditto 363 void httpPost(string url, Data[] postData, string contentType, void delegate(string) resultHandler, void delegate(string) errorHandler) 364 { 365 auto request = new HttpRequest; 366 request.resource = url; 367 request.method = "POST"; 368 request.headers["Content-Type"] = contentType; 369 request.data = postData; 370 httpRequest(request, 371 (Data data) 372 { 373 auto result = (cast(char[])data.contents).idup; 374 std.utf.validate(result); 375 resultHandler(result); 376 }, 377 errorHandler); 378 } 379 380 /// ditto 381 void httpPost(string url, UrlParameters vars, void delegate(string) resultHandler, void delegate(string) errorHandler) 382 { 383 return httpPost(url, [Data(encodeUrlParameters(vars))], "application/x-www-form-urlencoded", resultHandler, errorHandler); 384 } 385 386 // http://d.puremagic.com/issues/show_bug.cgi?id=7016 387 version (unittest) 388 { 389 static import ae.net.http.server; 390 static import ae.net.http.responseex; 391 } 392 393 unittest 394 { 395 import ae.net.http.server; 396 import ae.net.http.responseex; 397 398 void test(bool keepAlive) 399 { 400 auto s = new HttpServer; 401 s.handleRequest = (HttpRequest request, HttpServerConnection conn) { 402 auto response = new HttpResponseEx; 403 conn.sendResponse(response.serveText("Hello!")); 404 }; 405 auto port = s.listen(0, "127.0.0.1"); 406 407 auto c = new HttpClient; 408 c.keepAlive = keepAlive; 409 auto r = new HttpRequest("http://127.0.0.1:" ~ to!string(port)); 410 int count; 411 c.handleResponse = 412 (HttpResponse response, string disconnectReason) 413 { 414 assert(response, "HTTP server error"); 415 assert(cast(string)response.getContent.toHeap == "Hello!"); 416 if (++count == 5) 417 { 418 s.close(); 419 if (c.connected) 420 c.disconnect(); 421 } 422 else 423 c.request(r); 424 }; 425 c.request(r); 426 427 socketManager.loop(); 428 429 assert(count == 5); 430 } 431 432 test(false); 433 test(true); 434 }