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