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