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