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 }