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 <ae@cy.md> 13 * Vincent Povirk <madewokherd@gmail.com> 14 * Simon Arlott 15 */ 16 17 module ae.net.http.client; 18 19 import std.algorithm.mutation : move, swap; 20 import std.exception : enforce; 21 import std.string; 22 import std.conv; 23 import std.datetime; 24 import std.uri; 25 import std.utf; 26 27 import ae.net.asockets; 28 import ae.net.ietf.headers; 29 import ae.net.ietf.headerparse; 30 import ae.net.ietf.url; 31 import ae.net.ssl; 32 import ae.utils.array : toArray, shift; 33 import ae.utils.exception : CaughtException; 34 import ae.sys.data; 35 36 debug(HTTP_CLIENT) debug = HTTP; 37 debug(HTTP) import std.stdio : stderr; 38 39 public import ae.net.http.common; 40 41 /// Implements a HTTP client connection to a single server. 42 class HttpClient 43 { 44 protected: 45 Connector connector; // Bottom-level transport factory. 46 TimeoutAdapter timer; // Timeout adapter. 47 IConnection conn; // Top-level abstract connection. Reused for new connections. 48 49 HttpRequest[] requestQueue; // Requests that have been enqueued to send after the connection is established. 50 51 HttpResponse currentResponse; // Response to the currently-processed request. 52 ulong sentRequests, receivedResponses; // Used to know when we're still waiting for something. 53 54 DataVec headerBuffer; // Received but un-parsed headers 55 size_t expect; // How much data do we expect to receive in the current request (size_t.max if until disconnect) 56 57 /// Connect to a request's destination. 58 void connect(HttpRequest request) 59 { 60 assert(conn.state == ConnectionState.disconnected); 61 if (request.proxy !is null) 62 connector.connect(request.proxyHost, request.proxyPort); 63 else 64 connector.connect(request.host, request.port); 65 assert(conn.state == ConnectionState.connecting); 66 67 // We must install a data read handler to indicate that we want to receive readable events. 68 // Though, this isn't going to be actually called. 69 // TODO: this should probably be fixed in OpenSSLAdapter instead. 70 conn.handleReadData = (Data _/*data*/) { assert(false); }; 71 } 72 73 /// Called when the underlying connection (TCP, TLS...) is established. 74 void onConnect() 75 { 76 onIdle(); 77 } 78 79 /// Called when we're ready to send a request. 80 void onIdle() 81 { 82 assert(isIdle); 83 84 if (pipelining) 85 { 86 assert(keepAlive, "keepAlive is required for pipelining"); 87 // Pipeline all queued requests 88 while (requestQueue.length) 89 sendRequest(requestQueue.shift); 90 } 91 else 92 { 93 // One request at a time 94 if (requestQueue.length) 95 sendRequest(requestQueue.shift); 96 } 97 98 expectResponse(); 99 } 100 101 /// Returns true when we are connected but not waiting for anything. 102 /// Requests can always be sent immediately when this is true. 103 bool isIdle() 104 { 105 if (conn.state == ConnectionState.connected && sentRequests == receivedResponses) 106 { 107 assert(!currentResponse); 108 return true; 109 } 110 return false; 111 } 112 113 /// Encode and send a request (headers and body) to the connection. 114 /// Has no other side effects other than incrementing `sentRequests`. 115 void sendRequest(HttpRequest request) 116 { 117 string reqMessage = request.method ~ " "; 118 if (request.proxy !is null) { 119 reqMessage ~= "http://" ~ request.host; 120 if (request.port != 80) 121 reqMessage ~= format(":%d", request.port); 122 } 123 reqMessage ~= request.resource ~ " HTTP/1.0\r\n"; 124 125 foreach (string header, string value; request.headers) 126 if (value !is null) 127 reqMessage ~= header ~ ": " ~ value ~ "\r\n"; 128 129 reqMessage ~= "\r\n"; 130 debug(HTTP) 131 { 132 stderr.writefln("Sending request:"); 133 foreach (line; reqMessage.split("\r\n")) 134 stderr.writeln("> ", line); 135 if (request.data) 136 stderr.writefln("} (%d bytes data follow)", request.data.bytes.length); 137 } 138 139 conn.send(Data(reqMessage)); 140 conn.send(request.data[]); 141 sentRequests++; 142 } 143 144 /// Called to set up the client to be ready to receive a response. 145 void expectResponse() 146 { 147 //assert(conn.handleReadData is null); 148 if (receivedResponses < sentRequests) 149 { 150 conn.handleReadData = &onNewResponse; 151 expect = 0; 152 } 153 } 154 155 /// Received data handler used while we are receiving headers. 156 void onNewResponse(Data data) 157 { 158 if (timer) 159 timer.markNonIdle(); 160 161 onHeaderData(data.toArray); 162 } 163 164 /// Called when we've received some data from the response headers. 165 void onHeaderData(scope Data[] data) 166 { 167 try 168 { 169 headerBuffer ~= data; 170 171 string statusLine; 172 Headers headers; 173 174 debug(HTTP) auto oldData = headerBuffer.dup; 175 176 if (!parseHeaders(headerBuffer, statusLine, headers)) 177 return; 178 179 debug(HTTP) 180 { 181 stderr.writefln("Got response:"); 182 auto reqMessage = cast(string)oldData.bytes[0..oldData.bytes.length-headerBuffer.bytes.length].joinToHeap(); 183 foreach (line; reqMessage.split("\r\n")) 184 stderr.writeln("< ", line); 185 } 186 187 currentResponse = new HttpResponse; 188 currentResponse.parseStatusLine(statusLine); 189 currentResponse.headers = headers; 190 191 onHeadersReceived(); 192 } 193 catch (CaughtException e) 194 { 195 if (conn.state == ConnectionState.connected) 196 conn.disconnect(e.msg.length ? e.msg : e.classinfo.name, DisconnectType.error); 197 else 198 throw new Exception("Unhandled exception after connection was closed: " ~ e.msg, e); 199 } 200 } 201 202 /// Called when we've read all headers (currentResponse.headers is populated). 203 void onHeadersReceived() 204 { 205 expect = size_t.max; 206 // TODO: HEAD responses have Content-Length but no data! 207 // We need to save a copy of the request (or at least the method) for that... 208 if ("Content-Length" in currentResponse.headers) 209 expect = currentResponse.headers["Content-Length"].strip().to!size_t(); 210 211 conn.handleReadData = &onContinuation; 212 213 // Any remaining data in headerBuffer is now part of the response body 214 // (and maybe even the headers of the next pipelined response). 215 auto rest = move(headerBuffer); 216 onData(rest[]); 217 } 218 219 /// Received data handler used while we are receiving the response body. 220 void onContinuation(Data data) 221 { 222 if (timer) 223 timer.markNonIdle(); 224 onData(data.toArray); 225 } 226 227 /// Called when we've received some data from the response body. 228 void onData(scope Data[] data) 229 { 230 assert(!headerBuffer.length); 231 232 currentResponse.data ~= data; 233 234 auto received = currentResponse.data.bytes.length; 235 if (expect != size_t.max && received >= expect) 236 { 237 // Any data past expect is part of the next response 238 auto rest = currentResponse.data.bytes[expect .. received]; 239 currentResponse.data = currentResponse.data.bytes[0 .. expect]; 240 onDone(rest[], null, false); 241 } 242 } 243 244 /// Called when we've read the entirety of the response. 245 /// Any left-over data is in `rest`. 246 /// `disconnectReason` is `null` if there was no disconnect. 247 void onDone(scope Data[] rest, string disconnectReason, bool error) 248 { 249 auto response = finalizeResponse(); 250 if (error) 251 response = null; // Discard partial response 252 253 if (disconnectReason) 254 { 255 assert(rest is null); 256 } 257 else 258 { 259 if (keepAlive) 260 { 261 if (isIdle()) 262 onIdle(); 263 else 264 expectResponse(); 265 } 266 else 267 { 268 enforce(rest.bytes.length == 0, "Left-over data after non-keepalive response"); 269 conn.disconnect("All data read"); 270 } 271 } 272 273 // This is done as the (almost) last step, so that we don't 274 // have to worry about the user response handler changing our 275 // state while we are in the middle of a function. 276 submitResponse(response, disconnectReason); 277 278 // We still have to handle any left-over data as the last 279 // step, because otherwise recursion will cause us to call the 280 // handleResponse functions in the wrong order. 281 if (rest.bytes.length) 282 onHeaderData(rest); 283 } 284 285 /// Wrap up and return the current response, 286 /// and clean up the client for another request. 287 HttpResponse finalizeResponse() 288 { 289 auto response = currentResponse; 290 currentResponse = null; 291 expect = -1; 292 293 if (!response || response.status != HttpStatusCode.Continue) 294 receivedResponses++; 295 296 conn.handleReadData = null; 297 298 return response; 299 } 300 301 /// Submit a received response. 302 void submitResponse(HttpResponse response, string reason) 303 { 304 if (!reason) 305 reason = "All data read"; 306 if (handleResponse) 307 handleResponse(response, reason); 308 } 309 310 /// Disconnect handler 311 void onDisconnect(string reason, DisconnectType type) 312 { 313 // If we were expecting any more responses, we're not getting them. 314 while (receivedResponses < sentRequests) 315 onDone(null, reason, type == DisconnectType.error); 316 317 // If there are more requests queued (keepAlive == false), 318 // reconnect and keep going. 319 if (requestQueue.length) 320 connect(requestQueue[0]); 321 } 322 323 IConnection adaptConnection(IConnection conn) 324 { 325 return conn; 326 } 327 328 public: 329 /// User-Agent header to advertise. 330 string agent = "ae.net.http.client (+https://github.com/CyberShadow/ae)"; 331 /// Keep connection alive after one request. 332 bool keepAlive = false; 333 /// Send requests without waiting for a response. Requires keepAlive. 334 bool pipelining = false; 335 336 /// Constructor. 337 this(Duration timeout = 30.seconds, Connector connector = new TcpConnector) 338 { 339 assert(timeout >= Duration.zero); 340 341 this.connector = connector; 342 IConnection c = connector.getConnection(); 343 344 c = adaptConnection(c); 345 346 if (timeout > Duration.zero) 347 { 348 timer = new TimeoutAdapter(c); 349 timer.setIdleTimeout(timeout); 350 c = timer; 351 } 352 353 conn = c; 354 conn.handleConnect = &onConnect; 355 conn.handleDisconnect = &onDisconnect; 356 } 357 358 /// Fix up a response to set up required headers, etc. 359 /// Done automatically by `request`, unless called with `normalize == false`. 360 void normalizeRequest(HttpRequest request) 361 { 362 if ("User-Agent" !in request.headers && agent) 363 request.headers["User-Agent"] = agent; 364 if ("Accept-Encoding" !in request.headers) 365 request.headers["Accept-Encoding"] = "gzip, deflate, identity;q=0.5, *;q=0"; 366 if (request.data) 367 request.headers["Content-Length"] = to!string(request.data.bytes.length); 368 if ("Connection" !in request.headers) 369 request.headers["Connection"] = keepAlive ? "keep-alive" : "close"; 370 } 371 372 /// Send a HTTP request. 373 void request(HttpRequest request, bool normalize = true) 374 { 375 if (normalize) 376 normalizeRequest(request); 377 378 if (conn.state == ConnectionState.disconnected) 379 connect(request); 380 assert(conn.state <= ConnectionState.connected, "Attempting a HTTP request on a %s connection".format(conn.state)); 381 382 requestQueue ~= request; 383 384 // |---------+------------+------------+---------------------------------------------------------------| 385 // | enqueue | keep-alive | pipelining | outcome | 386 // |---------+------------+------------+---------------------------------------------------------------| 387 // | no | no | no | one request and one connection at a time | 388 // | no | no | yes | error, need keep-alive for pipelining | 389 // | no | yes | no | keep connection alive so that we can send more requests later | 390 // | no | yes | yes | keep-alive + pipelining | 391 // | yes | no | no | disconnect and connect again, once per queued request | 392 // | yes | no | yes | error, need keep-alive for pipelining | 393 // | yes | yes | no | when one response is processed, send the next queued request | 394 // | yes | yes | yes | send all requests at once after connecting | 395 // |---------+------------+------------+---------------------------------------------------------------| 396 397 // |------------+------------+-----------------------------------------------------------------| 398 // | keep-alive | pipelining | wat do in request() | 399 // |------------+------------+-----------------------------------------------------------------| 400 // | no | no | assert(!connected), connect, enqueue | 401 // | no | yes | assert | 402 // | yes | no | enqueue or send now if connected; enqueue and connect otherwise | 403 // | yes | yes | send now if connected; enqueue and connect otherwise | 404 // |------------+------------+-----------------------------------------------------------------| 405 406 if (!keepAlive) 407 { 408 if (!pipelining) 409 {} 410 else 411 assert(false, "keepAlive is required for pipelining"); 412 } 413 else 414 { 415 if (!pipelining) 416 { 417 // Can we send it now? 418 if (isIdle()) 419 onIdle(); 420 } 421 else 422 { 423 // Can we send it now? 424 if (conn.state == ConnectionState.connected) 425 { 426 bool wasIdle = isIdle(); 427 assert(requestQueue.length == 1); 428 while (requestQueue.length) 429 sendRequest(requestQueue.shift); 430 if (wasIdle) 431 expectResponse(); 432 } 433 } 434 } 435 } 436 437 /// Returns true if a connection is active 438 /// (whether due to an in-flight request or due to keep-alive). 439 bool connected() 440 { 441 if (receivedResponses < sentRequests) 442 return true; 443 if (keepAlive && conn.state == ConnectionState.connected) 444 return true; 445 return false; 446 } 447 448 /// Close the connection to the HTTP server. 449 void disconnect(string reason = IConnection.defaultDisconnectReason) 450 { 451 conn.disconnect(reason); 452 } 453 454 /// User-supplied callback for handling the response. 455 void delegate(HttpResponse response, string disconnectReason) handleResponse; 456 } 457 458 /// HTTPS client. 459 class HttpsClient : HttpClient 460 { 461 /// SSL context and adapter to use for TLS. 462 SSLContext ctx; 463 SSLAdapter adapter; /// ditto 464 465 /// Constructor. 466 this(Duration timeout = 30.seconds) 467 { 468 ctx = ssl.createContext(SSLContext.Kind.client); 469 super(timeout); 470 } 471 472 protected override IConnection adaptConnection(IConnection conn) 473 { 474 adapter = ssl.createAdapter(ctx, conn); 475 return adapter; 476 } 477 478 protected override void connect(HttpRequest request) 479 { 480 super.connect(request); 481 assert(conn.state == ConnectionState.connecting); 482 adapter.setHostName(request.host); 483 } 484 } 485 486 // Experimental for now 487 class Connector 488 { 489 abstract IConnection getConnection(); 490 abstract void connect(string host, ushort port); 491 } 492 493 // ditto 494 class TcpConnector : Connector 495 { 496 protected TcpConnection conn; 497 498 this() 499 { 500 conn = new TcpConnection(); 501 } 502 503 override IConnection getConnection() 504 { 505 return conn; 506 } 507 508 override void connect(string host, ushort port) 509 { 510 conn.connect(host, port); 511 } 512 } 513 514 // ditto 515 version(Posix) 516 class UnixConnector : TcpConnector 517 { 518 string path; 519 520 this(string path) 521 { 522 this.path = path; 523 } 524 525 override void connect(string host, ushort port) 526 { 527 import std.socket; 528 auto addr = new UnixAddress(path); 529 conn.connect([AddressInfo(AddressFamily.UNIX, SocketType.STREAM, cast(ProtocolType)0, addr, path)]); 530 } 531 } 532 533 /// Asynchronous HTTP request 534 void httpRequest(HttpRequest request, void delegate(HttpResponse response, string disconnectReason) responseHandler) 535 { 536 HttpClient client; 537 if (request.protocol == "https") 538 client = new HttpsClient; 539 else 540 client = new HttpClient; 541 542 client.handleResponse = responseHandler; 543 client.request(request); 544 } 545 546 /// ditto 547 void httpRequest(HttpRequest request, void delegate(Data) resultHandler, void delegate(string) errorHandler, int redirectCount = 0) 548 { 549 void responseHandler(HttpResponse response, string disconnectReason) 550 { 551 if (!response) 552 if (errorHandler) 553 errorHandler(disconnectReason); 554 else 555 throw new Exception(disconnectReason); 556 else 557 if (response.status >= 300 && response.status < 400 && "Location" in response.headers) 558 { 559 if (redirectCount == 15) 560 throw new Exception("HTTP redirect loop: " ~ request.url); 561 request.resource = applyRelativeURL(request.url, response.headers["Location"]); 562 if (response.status == HttpStatusCode.SeeOther) 563 { 564 request.method = "GET"; 565 request.data = null; 566 } 567 httpRequest(request, resultHandler, errorHandler, redirectCount+1); 568 } 569 else 570 if (errorHandler) 571 try 572 resultHandler(response.getContent()); 573 catch (Exception e) 574 errorHandler(e.msg); 575 else 576 resultHandler(response.getContent()); 577 } 578 579 httpRequest(request, &responseHandler); 580 } 581 582 /// ditto 583 void httpGet(string url, void delegate(HttpResponse response, string disconnectReason) responseHandler) 584 { 585 httpRequest(new HttpRequest(url), responseHandler); 586 } 587 588 /// ditto 589 void httpGet(string url, void delegate(Data) resultHandler, void delegate(string) errorHandler) 590 { 591 httpRequest(new HttpRequest(url), resultHandler, errorHandler); 592 } 593 594 /// ditto 595 void httpGet(string url, void delegate(string) resultHandler, void delegate(string) errorHandler) 596 { 597 httpGet(url, 598 (Data data) 599 { 600 auto result = (cast(char[])data.contents).idup; 601 std.utf.validate(result); 602 resultHandler(result); 603 }, 604 errorHandler); 605 } 606 607 /// ditto 608 void httpPost(string url, DataVec postData, string contentType, void delegate(Data) resultHandler, void delegate(string) errorHandler) 609 { 610 auto request = new HttpRequest; 611 request.resource = url; 612 request.method = "POST"; 613 if (contentType) 614 request.headers["Content-Type"] = contentType; 615 request.data = move(postData); 616 httpRequest(request, resultHandler, errorHandler); 617 } 618 619 /// ditto 620 void httpPost(string url, DataVec postData, string contentType, void delegate(string) resultHandler, void delegate(string) errorHandler) 621 { 622 httpPost(url, move(postData), contentType, 623 (Data data) 624 { 625 auto result = (cast(char[])data.contents).idup; 626 std.utf.validate(result); 627 resultHandler(result); 628 }, 629 errorHandler); 630 } 631 632 /// ditto 633 void httpPost(string url, UrlParameters vars, void delegate(string) resultHandler, void delegate(string) errorHandler) 634 { 635 return httpPost(url, DataVec(Data(encodeUrlParameters(vars))), "application/x-www-form-urlencoded", resultHandler, errorHandler); 636 } 637 638 // https://issues.dlang.org/show_bug.cgi?id=7016 639 version (unittest) 640 { 641 static import ae.net.http.server; 642 static import ae.net.http.responseex; 643 } 644 645 unittest 646 { 647 import ae.net.http.common : HttpRequest, HttpResponse; 648 import ae.net.http.server : HttpServer, HttpServerConnection; 649 import ae.net.http.responseex : HttpResponseEx; 650 651 foreach (enqueue; [false, true]) 652 foreach (keepAlive; [false, true]) 653 foreach (pipelining; [false, true]) 654 { 655 if (pipelining && !keepAlive) 656 continue; 657 debug (HTTP) stderr.writefln("===== Testing enqueue=%s keepAlive=%s pipelining=%s", enqueue, keepAlive, pipelining); 658 659 auto s = new HttpServer; 660 s.handleRequest = (HttpRequest _/*request*/, HttpServerConnection conn) { 661 auto response = new HttpResponseEx; 662 conn.sendResponse(response.serveText("Hello!")); 663 }; 664 auto port = s.listen(0, "127.0.0.1"); 665 666 auto c = new HttpClient; 667 c.keepAlive = keepAlive; 668 c.pipelining = pipelining; 669 auto r = new HttpRequest("http://127.0.0.1:" ~ to!string(port)); 670 int count; 671 c.handleResponse = 672 (HttpResponse response, string _/*disconnectReason*/) 673 { 674 assert(response, "HTTP server error"); 675 assert(cast(string)response.getContent.toHeap == "Hello!"); 676 if (++count == 5) 677 { 678 s.close(); 679 if (keepAlive) 680 c.disconnect(); 681 } 682 else 683 if (!enqueue) 684 c.request(r); 685 }; 686 foreach (n; 0 .. enqueue ? 5 : 1) 687 c.request(r); 688 689 socketManager.loop(); 690 691 assert(count == 5); 692 } 693 }