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