1 /**
2 
3 Introduces a simple but under-powered HTTP client.
4 
5 Consider using $(WEB vibed.org,vibe.d) if you need something better.
6 
7  */
8 module gfm.net.httpclient;
9 
10 import std.socketstream,
11        std.stream,
12        std.socket,
13        std.string,
14        std.conv,
15        std.stdio;
16 
17 import gfm.net.uri;
18 
19 /// The exception type for HTTP errors.
20 class HTTPException : Exception
21 {
22     public
23     {
24         this(string msg)
25         {
26             super(msg);
27         }
28     }
29 }
30 
31 /// Results of a HTTP request.
32 class HTTPResponse
33 {
34     int statusCode;         /// HTTP status code received.
35     string[string] headers; /// HTTP headers received.
36     ubyte[] content;        /// Request body.
37 }
38 
39 
40 enum HTTPMethod
41 {
42     OPTIONS,
43     GET,
44     HEAD,
45     POST,
46     PUT,
47     DELETE,
48     TRACE,
49     CONNECT
50 }
51 
52 /**
53 
54   Minimalistic HTTP client. 
55   At this point it only support simple GET requests which return the 
56   whole response body.
57 
58   Bugs: We might need to pool TCP connections later on.
59  */
60 class HTTPClient
61 {
62     public
63     {
64         /// Creates a HTTP client with user-specified User-Agent.
65         this(string userAgent = "gfm-http-client")
66         {
67             buffer.length = 4096;
68             _userAgent = userAgent;
69         }
70 
71         ~this()
72         {
73             close();
74         }
75 
76         void close()
77         {
78             if (_socket !is null)
79             {
80                 _socket.close();
81                 _socket = null;
82             }
83         }
84 
85         /// Perform a HTTP GET request.
86         HTTPResponse GET(URI uri)
87         {
88             return request(HTTPMethod.GET, uri, defaultHeaders(uri));
89         }
90 
91         /// Perform a HTTP HEAD request (same as GET but without content).
92         HTTPResponse HEAD(URI uri)
93         {
94             return request(HTTPMethod.HEAD, uri, defaultHeaders(uri));
95         }
96 
97         /// Performs a HTTP request.
98         /// Requested URI can be "*", an absolute URI, an absolute path, or an authority
99         /// depending on the method.
100         /// Throws: $(D HTTPException) on error.
101         HTTPResponse request(HTTPMethod method, URI uri, string[string] headers)
102         {
103             checkURI(uri);
104             auto res = new HTTPResponse();
105 
106 
107             try
108             {
109                 connectTo(uri);
110                 assert(_socket !is null);
111 
112                 string request = format("%s %s HTTP/1.0\r\n", to!string(method), uri.toString());
113 
114                 foreach (header; headers.byKey())
115                 {
116                     request ~= format("%s: %s\r\n", header, headers[header]);
117                 }
118                 request ~= "\r\n";
119 
120                 auto scope ss = new SocketStream(_socket);
121                 ss.writeString(request);
122 
123                 // parse status line
124                 auto line = ss.readLine();
125                 if (line.length < 12 || line[0..5] != "HTTP/" || line[6] != '.')
126                     throw new HTTPException("Cannot parse HTTP status line");
127 
128                 if (line[5] != '1' || (line[7] != '0' && line[7] != '1'))
129                     throw new HTTPException("Unsupported HTTP version");
130 
131                 // parse error code
132                 res.statusCode = 0;
133                 for (int i = 0; i < 3; ++i)
134                 {
135                     char c = line[9 + i];
136                     if (c >= '0' && c <= '9')
137                         res.statusCode = res.statusCode * 10 + (c - '0');
138                     else
139                         throw new HTTPException("Expected digit in HTTP status code");
140                 }
141 
142                 // parse headers
143                 while(true)
144                 {
145                     auto headerLine = ss.readLine();
146 
147                     if (headerLine.length == 0)
148                         break;
149 
150                     sizediff_t colonIdx = indexOf(headerLine, ':');
151                     if (colonIdx == -1)
152                         throw new HTTPException("Cannot parse HTTP header: missing colon");
153 
154                     string key = headerLine[0..colonIdx].idup;
155 
156                     // trim leading spaces and tabs
157                     sizediff_t valueStart = colonIdx + 1;
158                     for ( ; valueStart <= headerLine.length; ++valueStart)
159                     {
160                         char c = headerLine[valueStart];
161                         if (c != ' ' && c != '\t')
162                             break;
163                     }
164 
165                     // trim trailing spaces and tabs
166                     sizediff_t valueEnd = headerLine.length;
167                     for ( ; valueEnd > valueStart; --valueEnd)
168                     {
169                         char c = headerLine[valueEnd - 1];
170                         if (c != ' ' && c != '\t')
171                             break;
172                     }
173 
174                     string value = headerLine[valueStart..valueEnd].idup;
175                     res.headers[key] = value;
176                 }
177 
178                 while (!ss.eof())
179                 {
180                     int read = cast(int)( ss.readBlock(buffer.ptr, buffer.length));
181                     res.content ~= buffer[0..read];
182                 }
183 
184                 return res;
185             }
186             catch (Exception e)
187             {
188                 throw new HTTPException(e.msg);
189             }
190         }
191     }
192 
193     private
194     {
195         TcpSocket _socket;
196         ubyte[] buffer;
197         string _userAgent;
198 
199         void connectTo(URI uri)
200         {
201             if (_socket !is null)
202             {
203                 _socket.close();
204                 _socket = null;
205             }
206             _socket = new TcpSocket(uri.resolveAddress());
207         }
208 
209         static checkURI(URI uri)
210         {
211             if (uri.scheme() != "http")
212                 throw new HTTPException(format("'%' is not an HTTP absolute url", uri.toString()));
213         }
214 
215         string[string] defaultHeaders(URI uri)
216         {
217             string hostName = uri.hostName();
218             auto headers = ["Host": hostName, 
219                             "User-Agent": _userAgent];
220             return headers;
221         }
222     }
223 }
224