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; 20 import std.string; 21 import std.conv; 22 import std.datetime; 23 import std.uri; 24 import std.utf; 25 26 import ae.net.asockets; 27 import ae.net.ietf.headers; 28 import ae.net.ietf.headerparse; 29 import ae.net.ietf.url; 30 import ae.net.ssl; 31 import ae.utils.array : toArray; 32 import ae.utils.exception : CaughtException; 33 import ae.sys.data; 34 debug(HTTP) import std.stdio : stderr; 35 36 public import ae.net.http.common; 37 38 /// Implements a HTTP client. 39 /// Generally used to send one HTTP request. 40 class HttpClient 41 { 42 protected: 43 Connector connector; // Bottom-level transport factory. 44 TimeoutAdapter timer; // Timeout adapter. 45 IConnection conn; // Top-level abstract connection. Reused for new connections. 46 47 DataVec inBuffer; 48 49 HttpRequest currentRequest; 50 51 HttpResponse currentResponse; 52 size_t expect; 53 54 void onConnect() 55 { 56 sendRequest(currentRequest); 57 } 58 59 void sendRequest(HttpRequest request) 60 { 61 if ("User-Agent" !in request.headers && agent) 62 request.headers["User-Agent"] = agent; 63 if ("Accept-Encoding" !in request.headers) 64 request.headers["Accept-Encoding"] = "gzip, deflate, identity;q=0.5, *;q=0"; 65 if (request.data) 66 request.headers["Content-Length"] = to!string(request.data.bytes.length); 67 if ("Connection" !in request.headers) 68 request.headers["Connection"] = keepAlive ? "keep-alive" : "close"; 69 70 sendRawRequest(request); 71 } 72 73 void sendRawRequest(HttpRequest request) 74 { 75 string reqMessage = request.method ~ " "; 76 if (request.proxy !is null) { 77 reqMessage ~= "http://" ~ request.host; 78 if (request.port != 80) 79 reqMessage ~= format(":%d", request.port); 80 } 81 reqMessage ~= request.resource ~ " HTTP/1.0\r\n"; 82 83 foreach (string header, string value; request.headers) 84 if (value !is null) 85 reqMessage ~= header ~ ": " ~ value ~ "\r\n"; 86 87 reqMessage ~= "\r\n"; 88 debug(HTTP) 89 { 90 stderr.writefln("Sending request:"); 91 foreach (line; reqMessage.split("\r\n")) 92 stderr.writeln("> ", line); 93 if (request.data) 94 stderr.writefln("} (%d bytes data follow)", request.data.bytes.length); 95 } 96 97 conn.send(Data(reqMessage)); 98 conn.send(request.data[]); 99 } 100 101 void onNewResponse(Data data) 102 { 103 try 104 { 105 inBuffer ~= data; 106 if (timer) 107 timer.markNonIdle(); 108 109 string statusLine; 110 Headers headers; 111 112 debug(HTTP) auto oldData = inBuffer.dup; 113 114 if (!parseHeaders(inBuffer, statusLine, headers)) 115 return; 116 117 debug(HTTP) 118 { 119 stderr.writefln("Got response:"); 120 auto reqMessage = cast(string)oldData.bytes[0..oldData.bytes.length-inBuffer.bytes.length].joinToHeap(); 121 foreach (line; reqMessage.split("\r\n")) 122 stderr.writeln("< ", line); 123 } 124 125 currentResponse = new HttpResponse; 126 currentResponse.parseStatusLine(statusLine); 127 currentResponse.headers = headers; 128 129 onHeadersReceived(); 130 } 131 catch (CaughtException e) 132 { 133 if (conn.state == ConnectionState.connected) 134 conn.disconnect(e.msg.length ? e.msg : e.classinfo.name, DisconnectType.error); 135 else 136 throw new Exception("Unhandled exception after connection was closed: " ~ e.msg, e); 137 } 138 } 139 140 void onHeadersReceived() 141 { 142 expect = size_t.max; 143 if ("Content-Length" in currentResponse.headers) 144 expect = to!size_t(strip(currentResponse.headers["Content-Length"])); 145 146 if (inBuffer.bytes.length < expect) 147 { 148 onData(inBuffer[]); 149 conn.handleReadData = &onContinuation; 150 } 151 else 152 { 153 onData(inBuffer.bytes[0 .. expect][]); // TODO: pipelining 154 onDone(); 155 } 156 157 inBuffer.destroy(); 158 } 159 160 void onData(scope Data[] data) 161 { 162 currentResponse.data ~= data; 163 } 164 165 void onContinuation(Data data) 166 { 167 onData(data.toArray); 168 if (timer) 169 timer.markNonIdle(); 170 171 // HACK (if onData override called disconnect). 172 // TODO: rewrite this entire pile of garbage 173 if (!currentResponse) 174 return; 175 176 auto received = currentResponse.data.bytes.length; 177 if (expect!=size_t.max && received >= expect) 178 { 179 inBuffer = currentResponse.data.bytes[expect..received]; 180 currentResponse.data = currentResponse.data.bytes[0..expect]; 181 onDone(); 182 } 183 } 184 185 void onDone() 186 { 187 if (keepAlive) 188 processResponse(); 189 else 190 conn.disconnect("All data read"); 191 } 192 193 void processResponse(string reason = "All data read") 194 { 195 auto response = currentResponse; 196 197 currentRequest = null; 198 currentResponse = null; 199 expect = -1; 200 conn.handleReadData = null; 201 202 if (handleResponse) 203 handleResponse(response, reason); 204 } 205 206 void onDisconnect(string reason, DisconnectType type) 207 { 208 if (type == DisconnectType.error) 209 currentResponse = null; 210 211 if (currentRequest) 212 processResponse(reason); 213 } 214 215 IConnection adaptConnection(IConnection conn) 216 { 217 return conn; 218 } 219 220 public: 221 /// User-Agent header to advertise. 222 string agent = "ae.net.http.client (+https://github.com/CyberShadow/ae)"; 223 /// Keep connection alive after one request. 224 bool keepAlive = false; 225 226 /// Constructor. 227 this(Duration timeout = 30.seconds, Connector connector = new TcpConnector) 228 { 229 assert(timeout >= Duration.zero); 230 231 this.connector = connector; 232 IConnection c = connector.getConnection(); 233 234 c = adaptConnection(c); 235 236 if (timeout > Duration.zero) 237 { 238 timer = new TimeoutAdapter(c); 239 timer.setIdleTimeout(timeout); 240 c = timer; 241 } 242 243 conn = c; 244 conn.handleConnect = &onConnect; 245 conn.handleDisconnect = &onDisconnect; 246 } 247 248 /// Send a HTTP request. 249 void request(HttpRequest request) 250 { 251 //debug writefln("New HTTP request: %s", request.url); 252 currentRequest = request; 253 currentResponse = null; 254 conn.handleReadData = &onNewResponse; 255 expect = 0; 256 257 if (conn.state != ConnectionState.disconnected) 258 { 259 assert(conn.state == ConnectionState.connected, "Attempting a HTTP request on a %s connection".format(conn.state)); 260 assert(keepAlive, "Attempting a second HTTP request on a connected non-keepalive connection"); 261 sendRequest(request); 262 } 263 else 264 { 265 if (request.proxy !is null) 266 connector.connect(request.proxyHost, request.proxyPort); 267 else 268 connector.connect(request.host, request.port); 269 } 270 } 271 272 /// Returns true if a connection is active 273 /// (whether due to an in-flight request or due to keep-alive). 274 bool connected() 275 { 276 if (currentRequest !is null) 277 return true; 278 if (keepAlive && conn.state == ConnectionState.connected) 279 return true; 280 return false; 281 } 282 283 /// Close the connection to the HTTP server. 284 void disconnect(string reason = IConnection.defaultDisconnectReason) 285 { 286 conn.disconnect(reason); 287 } 288 289 /// User-supplied callback for handling the response. 290 void delegate(HttpResponse response, string disconnectReason) handleResponse; 291 } 292 293 /// HTTPS client. 294 class HttpsClient : HttpClient 295 { 296 /// SSL context and adapter to use for TLS. 297 SSLContext ctx; 298 SSLAdapter adapter; /// ditto 299 300 /// Constructor. 301 this(Duration timeout = 30.seconds) 302 { 303 ctx = ssl.createContext(SSLContext.Kind.client); 304 super(timeout); 305 } 306 307 protected override IConnection adaptConnection(IConnection conn) 308 { 309 adapter = ssl.createAdapter(ctx, conn); 310 return adapter; 311 } 312 313 protected override void request(HttpRequest request) 314 { 315 super.request(request); 316 if (conn.state == ConnectionState.connecting) 317 adapter.setHostName(request.host); 318 } 319 } 320 321 // Experimental for now 322 class Connector 323 { 324 abstract IConnection getConnection(); 325 abstract void connect(string host, ushort port); 326 } 327 328 // ditto 329 class TcpConnector : Connector 330 { 331 protected TcpConnection conn; 332 333 this() 334 { 335 conn = new TcpConnection(); 336 } 337 338 override IConnection getConnection() 339 { 340 return conn; 341 } 342 343 override void connect(string host, ushort port) 344 { 345 conn.connect(host, port); 346 } 347 } 348 349 // ditto 350 version(Posix) 351 class UnixConnector : TcpConnector 352 { 353 string path; 354 355 this(string path) 356 { 357 this.path = path; 358 } 359 360 override void connect(string host, ushort port) 361 { 362 import std.socket; 363 auto addr = new UnixAddress(path); 364 conn.connect([AddressInfo(AddressFamily.UNIX, SocketType.STREAM, cast(ProtocolType)0, addr, path)]); 365 } 366 } 367 368 /// Asynchronous HTTP request 369 void httpRequest(HttpRequest request, void delegate(HttpResponse response, string disconnectReason) responseHandler) 370 { 371 HttpClient client; 372 if (request.protocol == "https") 373 client = new HttpsClient; 374 else 375 client = new HttpClient; 376 377 client.handleResponse = responseHandler; 378 client.request(request); 379 } 380 381 /// ditto 382 void httpRequest(HttpRequest request, void delegate(Data) resultHandler, void delegate(string) errorHandler, int redirectCount = 0) 383 { 384 void responseHandler(HttpResponse response, string disconnectReason) 385 { 386 if (!response) 387 if (errorHandler) 388 errorHandler(disconnectReason); 389 else 390 throw new Exception(disconnectReason); 391 else 392 if (response.status >= 300 && response.status < 400 && "Location" in response.headers) 393 { 394 if (redirectCount == 15) 395 throw new Exception("HTTP redirect loop: " ~ request.url); 396 request.resource = applyRelativeURL(request.url, response.headers["Location"]); 397 if (response.status == HttpStatusCode.SeeOther) 398 { 399 request.method = "GET"; 400 request.data = null; 401 } 402 httpRequest(request, resultHandler, errorHandler, redirectCount+1); 403 } 404 else 405 if (errorHandler) 406 try 407 resultHandler(response.getContent()); 408 catch (Exception e) 409 errorHandler(e.msg); 410 else 411 resultHandler(response.getContent()); 412 } 413 414 httpRequest(request, &responseHandler); 415 } 416 417 /// ditto 418 void httpGet(string url, void delegate(HttpResponse response, string disconnectReason) responseHandler) 419 { 420 httpRequest(new HttpRequest(url), responseHandler); 421 } 422 423 /// ditto 424 void httpGet(string url, void delegate(Data) resultHandler, void delegate(string) errorHandler) 425 { 426 httpRequest(new HttpRequest(url), resultHandler, errorHandler); 427 } 428 429 /// ditto 430 void httpGet(string url, void delegate(string) resultHandler, void delegate(string) errorHandler) 431 { 432 httpGet(url, 433 (Data data) 434 { 435 auto result = (cast(char[])data.contents).idup; 436 std.utf.validate(result); 437 resultHandler(result); 438 }, 439 errorHandler); 440 } 441 442 /// ditto 443 void httpPost(string url, DataVec postData, string contentType, void delegate(Data) resultHandler, void delegate(string) errorHandler) 444 { 445 auto request = new HttpRequest; 446 request.resource = url; 447 request.method = "POST"; 448 if (contentType) 449 request.headers["Content-Type"] = contentType; 450 request.data = move(postData); 451 httpRequest(request, resultHandler, errorHandler); 452 } 453 454 /// ditto 455 void httpPost(string url, DataVec postData, string contentType, void delegate(string) resultHandler, void delegate(string) errorHandler) 456 { 457 httpPost(url, move(postData), contentType, 458 (Data data) 459 { 460 auto result = (cast(char[])data.contents).idup; 461 std.utf.validate(result); 462 resultHandler(result); 463 }, 464 errorHandler); 465 } 466 467 /// ditto 468 void httpPost(string url, UrlParameters vars, void delegate(string) resultHandler, void delegate(string) errorHandler) 469 { 470 return httpPost(url, DataVec(Data(encodeUrlParameters(vars))), "application/x-www-form-urlencoded", resultHandler, errorHandler); 471 } 472 473 // https://issues.dlang.org/show_bug.cgi?id=7016 474 version (unittest) 475 { 476 static import ae.net.http.server; 477 static import ae.net.http.responseex; 478 } 479 480 unittest 481 { 482 import ae.net.http.common : HttpRequest, HttpResponse; 483 import ae.net.http.server : HttpServer, HttpServerConnection; 484 import ae.net.http.responseex : HttpResponseEx; 485 486 void test(bool keepAlive) 487 { 488 auto s = new HttpServer; 489 s.handleRequest = (HttpRequest request, HttpServerConnection conn) { 490 auto response = new HttpResponseEx; 491 conn.sendResponse(response.serveText("Hello!")); 492 }; 493 auto port = s.listen(0, "127.0.0.1"); 494 495 auto c = new HttpClient; 496 c.keepAlive = keepAlive; 497 auto r = new HttpRequest("http://127.0.0.1:" ~ to!string(port)); 498 int count; 499 c.handleResponse = 500 (HttpResponse response, string disconnectReason) 501 { 502 assert(response, "HTTP server error"); 503 assert(cast(string)response.getContent.toHeap == "Hello!"); 504 if (++count == 5) 505 { 506 s.close(); 507 if (c.connected) 508 c.disconnect(); 509 } 510 else 511 c.request(r); 512 }; 513 c.request(r); 514 515 socketManager.loop(); 516 517 assert(count == 5); 518 } 519 520 test(false); 521 test(true); 522 }