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