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 		if (conn.state == ConnectionState.connecting)
309 			adapter.setHostName(request.host);
310 	}
311 }
312 
313 // Experimental for now
314 class Connector
315 {
316 	abstract IConnection getConnection();
317 	abstract void connect(string host, ushort port);
318 }
319 
320 // ditto
321 class TcpConnector : Connector
322 {
323 	protected TcpConnection conn;
324 
325 	this()
326 	{
327 		conn = new TcpConnection();
328 	}
329 
330 	override IConnection getConnection()
331 	{
332 		return conn;
333 	}
334 
335 	override void connect(string host, ushort port)
336 	{
337 		conn.connect(host, port);
338 	}
339 }
340 
341 // ditto
342 version(Posix)
343 class UnixConnector : TcpConnector
344 {
345 	string path;
346 
347 	this(string path)
348 	{
349 		this.path = path;
350 	}
351 
352 	override void connect(string host, ushort port)
353 	{
354 		import std.socket;
355 		auto addr = new UnixAddress(path);
356 		conn.connect([AddressInfo(AddressFamily.UNIX, SocketType.STREAM, cast(ProtocolType)0, addr, path)]);
357 	}
358 }
359 
360 /// Asynchronous HTTP request
361 void httpRequest(HttpRequest request, void delegate(HttpResponse response, string disconnectReason) responseHandler)
362 {
363 	HttpClient client;
364 	if (request.protocol == "https")
365 		client = new HttpsClient;
366 	else
367 		client = new HttpClient;
368 
369 	client.handleResponse = responseHandler;
370 	client.request(request);
371 }
372 
373 /// ditto
374 void httpRequest(HttpRequest request, void delegate(Data) resultHandler, void delegate(string) errorHandler, int redirectCount = 0)
375 {
376 	void responseHandler(HttpResponse response, string disconnectReason)
377 	{
378 		if (!response)
379 			if (errorHandler)
380 				errorHandler(disconnectReason);
381 			else
382 				throw new Exception(disconnectReason);
383 		else
384 		if (response.status >= 300 && response.status < 400 && "Location" in response.headers)
385 		{
386 			if (redirectCount == 15)
387 				throw new Exception("HTTP redirect loop: " ~ request.url);
388 			request.resource = applyRelativeURL(request.url, response.headers["Location"]);
389 			if (response.status == HttpStatusCode.SeeOther)
390 			{
391 				request.method = "GET";
392 				request.data = null;
393 			}
394 			httpRequest(request, resultHandler, errorHandler, redirectCount+1);
395 		}
396 		else
397 			if (errorHandler)
398 				try
399 					resultHandler(response.getContent());
400 				catch (Exception e)
401 					errorHandler(e.msg);
402 			else
403 				resultHandler(response.getContent());
404 	}
405 
406 	httpRequest(request, &responseHandler);
407 }
408 
409 /// ditto
410 void httpGet(string url, void delegate(HttpResponse response, string disconnectReason) responseHandler)
411 {
412 	httpRequest(new HttpRequest(url), responseHandler);
413 }
414 
415 /// ditto
416 void httpGet(string url, void delegate(Data) resultHandler, void delegate(string) errorHandler)
417 {
418 	httpRequest(new HttpRequest(url), resultHandler, errorHandler);
419 }
420 
421 /// ditto
422 void httpGet(string url, void delegate(string) resultHandler, void delegate(string) errorHandler)
423 {
424 	httpGet(url,
425 		(Data data)
426 		{
427 			auto result = (cast(char[])data.contents).idup;
428 			std.utf.validate(result);
429 			resultHandler(result);
430 		},
431 		errorHandler);
432 }
433 
434 /// ditto
435 void httpPost(string url, Data[] postData, string contentType, void delegate(Data) resultHandler, void delegate(string) errorHandler)
436 {
437 	auto request = new HttpRequest;
438 	request.resource = url;
439 	request.method = "POST";
440 	if (contentType)
441 		request.headers["Content-Type"] = contentType;
442 	request.data = postData;
443 	httpRequest(request, resultHandler, errorHandler);
444 }
445 
446 /// ditto
447 void httpPost(string url, Data[] postData, string contentType, void delegate(string) resultHandler, void delegate(string) errorHandler)
448 {
449 	httpPost(url, postData, contentType,
450 		(Data data)
451 		{
452 			auto result = (cast(char[])data.contents).idup;
453 			std.utf.validate(result);
454 			resultHandler(result);
455 		},
456 		errorHandler);
457 }
458 
459 /// ditto
460 void httpPost(string url, UrlParameters vars, void delegate(string) resultHandler, void delegate(string) errorHandler)
461 {
462 	return httpPost(url, [Data(encodeUrlParameters(vars))], "application/x-www-form-urlencoded", resultHandler, errorHandler);
463 }
464 
465 // http://d.puremagic.com/issues/show_bug.cgi?id=7016
466 version (unittest)
467 {
468 	static import ae.net.http.server;
469 	static import ae.net.http.responseex;
470 }
471 
472 unittest
473 {
474 	import ae.net.http.server;
475 	import ae.net.http.responseex;
476 
477 	void test(bool keepAlive)
478 	{
479 		auto s = new HttpServer;
480 		s.handleRequest = (HttpRequest request, HttpServerConnection conn) {
481 			auto response = new HttpResponseEx;
482 			conn.sendResponse(response.serveText("Hello!"));
483 		};
484 		auto port = s.listen(0, "127.0.0.1");
485 
486 		auto c = new HttpClient;
487 		c.keepAlive = keepAlive;
488 		auto r = new HttpRequest("http://127.0.0.1:" ~ to!string(port));
489 		int count;
490 		c.handleResponse =
491 			(HttpResponse response, string disconnectReason)
492 			{
493 				assert(response, "HTTP server error");
494 				assert(cast(string)response.getContent.toHeap == "Hello!");
495 				if (++count == 5)
496 				{
497 					s.close();
498 					if (c.connected)
499 						c.disconnect();
500 				}
501 				else
502 					c.request(r);
503 			};
504 		c.request(r);
505 
506 		socketManager.loop();
507 
508 		assert(count == 5);
509 	}
510 
511 	test(false);
512 	test(true);
513 }