ftw 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. data/README.md +7 -8
  2. data/lib/ftw.rb +4 -0
  3. data/lib/ftw/agent.rb +203 -20
  4. data/lib/ftw/connection.rb +117 -63
  5. data/lib/ftw/cookies.rb +87 -0
  6. data/lib/ftw/crlf.rb +1 -1
  7. data/lib/ftw/dns.rb +14 -5
  8. data/lib/ftw/http/headers.rb +15 -1
  9. data/lib/ftw/http/message.rb +9 -1
  10. data/lib/ftw/namespace.rb +1 -0
  11. data/lib/ftw/pool.rb +50 -0
  12. data/lib/ftw/poolable.rb +19 -0
  13. data/lib/ftw/request.rb +92 -28
  14. data/lib/ftw/response.rb +179 -0
  15. data/lib/ftw/version.rb +1 -1
  16. data/lib/ftw/websocket.rb +194 -0
  17. data/lib/ftw/websocket/parser.rb +183 -0
  18. data/test/all.rb +16 -0
  19. data/test/ftw/crlf.rb +12 -0
  20. data/test/ftw/http/dns.rb +6 -0
  21. data/test/{net/ftw → ftw}/http/headers.rb +5 -5
  22. data/test/testing.rb +0 -9
  23. metadata +13 -26
  24. data/lib/net-ftw.rb +0 -1
  25. data/lib/net/ftw.rb +0 -5
  26. data/lib/net/ftw/agent.rb +0 -10
  27. data/lib/net/ftw/connection.rb +0 -296
  28. data/lib/net/ftw/connection2.rb +0 -247
  29. data/lib/net/ftw/crlf.rb +0 -6
  30. data/lib/net/ftw/dns.rb +0 -57
  31. data/lib/net/ftw/http.rb +0 -2
  32. data/lib/net/ftw/http/client.rb +0 -116
  33. data/lib/net/ftw/http/client2.rb +0 -80
  34. data/lib/net/ftw/http/connection.rb +0 -42
  35. data/lib/net/ftw/http/headers.rb +0 -122
  36. data/lib/net/ftw/http/machine.rb +0 -38
  37. data/lib/net/ftw/http/message.rb +0 -91
  38. data/lib/net/ftw/http/request.rb +0 -80
  39. data/lib/net/ftw/http/response.rb +0 -80
  40. data/lib/net/ftw/http/server.rb +0 -5
  41. data/lib/net/ftw/machine.rb +0 -59
  42. data/lib/net/ftw/namespace.rb +0 -6
  43. data/lib/net/ftw/protocol/tls.rb +0 -12
  44. data/lib/net/ftw/websocket.rb +0 -139
  45. data/test/net/ftw/crlf.rb +0 -12
  46. data/test/net/ftw/http/dns.rb +0 -6
data/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # For The Web
2
2
 
3
- net/http is pretty much not good.
3
+ net/http is pretty much not good. dns behavior in ruby changes quite frequently.
4
+
5
+ Above all else, I want a consistent API and behavior that I can rely on. Ruby stdlib is not that thing.
4
6
 
5
7
  I want:
6
8
 
7
- * A HTTP client that acts as a full user agent, not just a single connection.
9
+ * A HTTP client that acts as a full user agent, not just a single connections. (With connection reuse)
8
10
  * HTTP and SPDY support.
9
11
  * WebSockets support.
10
12
  * SSL/TLS support.
@@ -12,12 +14,6 @@ I want:
12
14
  * Server and Client modes.
13
15
  * Support for both normal operation and EventMachine would be nice.
14
16
 
15
- ## DONE
16
-
17
- * TCP connection
18
- * DNS resolution (wraps Socket.gethostname)
19
- * HTTP client partially done
20
-
21
17
  ## TODO
22
18
 
23
19
  * Tests, yo.
@@ -32,6 +28,9 @@ I want:
32
28
  request = agent.get("http://www.google.com/")
33
29
  response = request.execute
34
30
 
31
+ # Simpler
32
+ response = agent.get!("http://www.google.com/")
33
+
35
34
  ### SPDY
36
35
 
37
36
  # SPDY should automatically be attempted. The caller should be unaware.
@@ -0,0 +1,4 @@
1
+ require "ftw/agent"
2
+ require "ftw/connection"
3
+ require "ftw/dns"
4
+ require "ftw/version"
@@ -1,40 +1,223 @@
1
1
  require "ftw/namespace"
2
2
  require "ftw/request"
3
3
  require "ftw/connection"
4
+ require "ftw/pool"
5
+ require "ftw/websocket"
4
6
  require "addressable/uri"
7
+ require "cabin"
8
+ require "logger"
5
9
 
6
- # This should act as a proper agent.
10
+ # This should act as a proper web agent.
7
11
  #
8
- # * Keep cookies. Offer local-storage of cookies
9
- # * Reuse connections. HTTP 1.1 Connection: keep-alive
10
- # * HTTP Upgrade support
11
- # * Websockets
12
- # * SSL/TLS
12
+ # * Reuse connections.
13
+ # * SSL/TLS.
14
+ # * HTTP Upgrade support.
15
+ # * HTTP 1.1 (RFC2616).
16
+ # * WebSockets (RFC6455).
17
+ # * Support Cookies.
18
+ #
19
+ # All standard HTTP methods defined by RFC2616 are available as methods on
20
+ # this agent: get, head, put, etc.
21
+ #
22
+ # Example:
23
+ #
24
+ # agent = FTW::Agent.new
25
+ # request = agent.get("http://www.google.com/")
26
+ # response = agent.execute(request)
27
+ # puts response.body.read
28
+ #
29
+ # For any standard http method (like 'get') you can invoke it with '!' on the end
30
+ # and it will execute and return a FTW::Response object:
31
+ #
32
+ # agent = FTW::Agent.new
33
+ # response = agent.get!("http://www.google.com/")
34
+ # puts response.body.head
35
+ #
36
+ # TODO(sissel): TBD: implement cookies... delicious chocolate chip cookies.
13
37
  class FTW::Agent
14
- # TODO(sissel): All standard HTTP methods should be defined here.
15
- # Also allow users to specify non-standard methods.
16
-
38
+ STANDARD_METHODS = %w(options get head post put delete trace connect)
39
+
17
40
  def initialize
18
- end
41
+ @pool = FTW::Pool.new
42
+ @logger = Cabin::Channel.get($0)
43
+ @logger.subscribe(Logger.new(STDOUT))
44
+ @logger.level = :warn
45
+
46
+ @redirect_max = 20
47
+ end # def initialize
48
+
49
+ # Define all the standard HTTP methods (Per RFC2616)
50
+ # As an example, for "get" method, this will define these methods:
51
+ #
52
+ # * FTW::Agent#get(uri, options={})
53
+ # * FTW::Agent#get!(uri, options={})
54
+ #
55
+ # The first one returns a FTW::Request you must pass to Agent#execute(...)
56
+ # The second does the execute for you and returns a FTW::Response.
57
+ #
58
+ # For a full list of these available methods, see STANDARD_METHODS.
59
+ STANDARD_METHODS.each do |name|
60
+ m = name.upcase
61
+
62
+ # define 'get' etc method.
63
+ define_method(name.to_sym) do |uri, options={}|
64
+ return request(m, uri, options)
65
+ end
66
+
67
+ # define 'get!' etc method.
68
+ define_method("#{name}!".to_sym) do |uri, options={}|
69
+ return execute(request(m, uri, options))
70
+ end
71
+ end # STANDARD_METHODS.each
19
72
 
20
- # Returns a FTW::Request
21
- # TODO(sissel): SSL/TLS support
22
- def get(uri, options={})
23
- return request("GET", uri, options)
24
- end # def get
73
+ # Send the request as an HTTP upgrade.
74
+ #
75
+ # Returns the response and the FTW::Connection for this connection.
76
+ # If the upgrade was denied, the connection returned will be nil.
77
+ def upgrade!(uri, protocol, options={})
78
+ req = request("GET", uri, options)
79
+ req.headers["Connection"] = "Upgrade"
80
+ req.headers["Upgrade"] = protocol
81
+ response = execute(req)
82
+ if response.status == 101
83
+ return response, response.body
84
+ else
85
+ return response, nil
86
+ end
87
+ end # def upgrade!
25
88
 
89
+ # Make a new websocket connection.
90
+ #
91
+ # This will send the http request. If the websocket handshake
92
+ # is successful, a FTW::WebSocket instance will be returned.
93
+ # Otherwise, a FTW::Response will be returned.
94
+ public
95
+ def websocket!(uri, options={})
96
+ # TODO(sissel): Use FTW::Agent#upgrade! ?
97
+ req = request("GET", uri, options)
98
+ ws = FTW::WebSocket.new(req)
99
+ response = execute(req)
100
+ if ws.handshake_ok?(response)
101
+ # response.body is a FTW::Connection
102
+ ws.connection = response.body
103
+
104
+ # TODO(sissel): Investigate this bug
105
+ # There seems to be a bug in http_parser.rb (or in this library) where
106
+ # websocket responses lead with a newline for some reason. Work around
107
+ # it.
108
+ data = response.body.read
109
+ if data[0] == "\n"
110
+ response.body.pushback(data[1..-1])
111
+ else
112
+ response.body.pushback(data)
113
+ end
114
+ return ws
115
+ else
116
+ return response
117
+ end
118
+ end # def websocket!
119
+
120
+ # Make a request. Returns a FTW::Request object.
121
+ #
122
+ # Arguments:
123
+ #
124
+ # * method - the http method
125
+ # * uri - the URI to make the request to
126
+ # * options - a hash of options
127
+ #
128
+ # uri can be a valid url or an Addressable::URI object.
129
+ # The uri will be used to choose the host/port to connect to. It also sets
130
+ # the protocol (https, etc). Further, it will set the 'Host' header.
131
+ #
132
+ # The 'options' hash supports the following keys:
133
+ #
134
+ # * :headers => { string => string, ... }. This allows you to set header values.
135
+ public
26
136
  def request(method, uri, options)
137
+ @logger.info("Creating new request", :method => method, :uri => uri, :options => options)
27
138
  request = FTW::Request.new(uri)
28
139
  request.method = method
29
- request.connection = connection(uri.host, uri.port)
140
+ request.headers.add("Connection", "keep-alive")
141
+
142
+ if options.include?(:headers)
143
+ options[:headers].each do |key, value|
144
+ request.headers.add(key, value)
145
+ end
146
+ end
147
+
30
148
  return request
31
149
  end # def request
32
150
 
151
+ # Execute a FTW::Request in this Agent.
152
+ #
153
+ # If an existing, idle connection is already open to the target server
154
+ # of this Request, it will be reused. Otherwise, a new connection
155
+ # is opened.
156
+ #
157
+ # Redirects are always followed.
158
+ public
159
+ def execute(request)
160
+ # TODO(sissel): Make redirection-following optional, but default.
161
+
162
+ connection = connect(request.headers["Host"], request.port)
163
+ connection.secure if request.protocol == "https"
164
+ response = request.execute(connection)
165
+
166
+ redirects = 0
167
+ while response.redirect? and response.headers.include?("Location")
168
+ redirects += 1
169
+ if redirects > @redirect_max
170
+ # TODO(sissel): Abort somehow...
171
+ end
172
+ # RFC2616 section 10.3.3 indicates HEAD redirects must not include a
173
+ # body. Otherwise, the redirect response can have a body, so let's
174
+ # throw it away.
175
+ if request.method == "HEAD"
176
+ # Head requests have no body
177
+ connection.release
178
+ elsif response.content?
179
+ # Throw away the body
180
+ response.body = connection
181
+ # read_body will release the connection
182
+ response.read_body { |chunk| }
183
+ end
184
+
185
+ # TODO(sissel): If this response has any cookies, store them in the
186
+ # agent's cookie store
187
+
188
+ @logger.debug("Redirecting", :location => response.headers["Location"])
189
+ redirects += 1
190
+ request.use_uri(response.headers["Location"])
191
+ connection = connect(request.headers["Host"], request.port)
192
+ connection.secure if request.protocol == "https"
193
+ response = request.execute(connection)
194
+ end
195
+
196
+ # RFC 2616 section 9.4, HEAD requests MUST NOT have a message body.
197
+ if request.method != "HEAD"
198
+ response.body = connection
199
+ else
200
+ connection.release
201
+ end
202
+
203
+ # TODO(sissel): If this response has any cookies, store them in the
204
+ # agent's cookie store
205
+ return response
206
+ end # def execute
207
+
33
208
  # Returns a FTW::Connection connected to this host:port.
34
- # TODO(sissel): Implement connection reuse
35
- # TODO(sissel): support SSL/TLS
36
209
  private
37
- def connection(host, port)
38
- return FTW::Connection.new("#{host}:#{port}")
210
+ def connect(host, port)
211
+ address = "#{host}:#{port}"
212
+ @logger.debug("Fetching from pool", :address => address)
213
+ connection = @pool.fetch(address) do
214
+ @logger.info("New connection to #{address}")
215
+ connection = FTW::Connection.new(address)
216
+ connection.connect
217
+ connection
218
+ end
219
+ @logger.debug("Pool fetched a connection", :connection => connection)
220
+ connection.mark
221
+ return connection
39
222
  end # def connect
40
223
  end # class FTW::Agent
@@ -1,6 +1,7 @@
1
1
  require "cabin" # rubygem "cabin"
2
- require "net/ftw/dns"
3
- require "net/ftw/namespace"
2
+ require "ftw/dns"
3
+ require "ftw/poolable"
4
+ require "ftw/namespace"
4
5
  require "socket"
5
6
  require "timeout" # ruby stdlib, just for the Timeout exception.
6
7
  require "backport-bij" # for Array#rotate, IO::WaitWritable, etc, in ruby < 1.9
@@ -9,7 +10,15 @@ require "backport-bij" # for Array#rotate, IO::WaitWritable, etc, in ruby < 1.9
9
10
  #
10
11
  # You can use IO::select on this objects of this type.
11
12
  # (at least, in MRI you can)
13
+ #
14
+ # You can activate SSL/TLS on this connection by invoking FTW::Connection#secure
12
15
  class FTW::Connection
16
+ class ConnectTimeout < StandardError; end
17
+ class ReadTimeout < StandardError; end
18
+ class WriteTimeout < StandardError; end
19
+ include FTW::Poolable
20
+ include Cabin::Inspectable
21
+
13
22
  # A new network connection.
14
23
  # The 'destination' argument can be an array of strings or a single string.
15
24
  # String format is expected to be "host:port"
@@ -28,21 +37,33 @@ class FTW::Connection
28
37
  @destinations = destinations
29
38
  end
30
39
 
40
+ @logger = Cabin::Channel.get($0)
31
41
  @connect_timeout = 2
32
42
 
33
43
  # Use a fixed-size string that we set to BINARY encoding.
34
44
  # Not all byte sequences are UTF-8 friendly :0
35
45
  @read_size = 16384
36
46
  @read_buffer = " " * @read_size
47
+ @pushback_buffer = ""
37
48
 
38
49
  # Tell Ruby 1.9 that this string is a binary string, not utf-8 or somesuch.
39
50
  if @read_buffer.respond_to?(:force_encoding)
40
51
  @read_buffer.force_encoding("BINARY")
41
52
  end
42
53
 
54
+ @inspectables = [:@destinations, :@connected, :@remote_address, :@secure]
55
+ @connected = false
56
+ @remote_address = nil
57
+ @secure = false
58
+
43
59
  # TODO(sissel): Validate @destinations
60
+ # TODO(sissel): Barf if a destination is not of the form "host:port"
44
61
  end # def initialize
45
62
 
63
+ # Connect now.
64
+ #
65
+ # Timeout value is optional. If no timeout is given, this method
66
+ # blocks until a connection is successful or an error occurs.
46
67
  public
47
68
  def connect(timeout=nil)
48
69
  # TODO(sissel): Raise if we're already connected?
@@ -53,6 +74,8 @@ class FTW::Connection
53
74
  # Do dns resolution on the host. If there are multiple
54
75
  # addresses resolved, return one at random.
55
76
  @remote_address = FTW::DNS.singleton.resolve_random(host)
77
+ @logger.debug("Connecting", :address => @remote_address,
78
+ :host => host, :port => port)
56
79
 
57
80
  # Addresses with colon ':' in them are assumed to be IPv6
58
81
  family = @remote_address.include?(":") ? Socket::AF_INET6 : Socket::AF_INET
@@ -72,20 +95,22 @@ class FTW::Connection
72
95
  if writable?(timeout)
73
96
  begin
74
97
  @socket.connect_nonblock(sockaddr) # check connection failure
75
- rescue Errno::EISCONN # Ignore, we're already connected.
98
+ rescue Errno::EISCONN
99
+ # Ignore, we're already connected.
76
100
  rescue Errno::ECONNREFUSED => e
77
101
  # Fire 'disconnected' event with reason :refused
78
- trigger(DISCONNECTED, :refused, e)
102
+ return e
79
103
  end
80
104
  else
81
105
  # Connection timeout
82
106
  # Fire 'disconnected' event with reason :timeout
83
- trigger(DISCONNECTED, :connect_timeout, nil)
107
+ return ConnectTimeout.new
84
108
  end
85
109
  end
86
110
 
87
111
  # We're now connected.
88
- trigger(CONNECTED, "#{host}:#{port}")
112
+ @connected = true
113
+ return true
89
114
  end # def connect
90
115
 
91
116
  # Is this Connection connected?
@@ -97,14 +122,16 @@ class FTW::Connection
97
122
  # Write data to this connection.
98
123
  # This method blocks until the write succeeds unless a timeout is given.
99
124
  #
100
- # Returns the number of bytes written (See IO#syswrite)
125
+ # This method is not guaranteed to have written the full data given.
126
+ #
127
+ # Returns the number of bytes written (See also IO#syswrite)
101
128
  public
102
129
  def write(data, timeout=nil)
103
130
  #connect if !connected?
104
131
  if writable?(timeout)
105
132
  return @socket.syswrite(data)
106
133
  else
107
- raise Timeout::Error.new
134
+ raise FTW::Connection::WriteTimeout.new(self.inspect)
108
135
  end
109
136
  end # def write
110
137
 
@@ -115,47 +142,53 @@ class FTW::Connection
115
142
  # IO#sysread
116
143
  public
117
144
  def read(timeout=nil)
118
- if readable?(timeout)
119
- if !@unread_buffer.empty?
120
- data = @unread_buffer
121
- @unread_buffer = ""
122
- return data
123
- end
145
+ data = ""
146
+ data.force_encoding("BINARY") if data.respond_to?(:force_encoding)
147
+ have_pushback = !@pushback_buffer.empty?
148
+ if have_pushback
149
+ data << @pushback_buffer
150
+ @pushback_buffer = ""
151
+ # We have data 'now' so don't wait.
152
+ timeout = 0
153
+ end
124
154
 
155
+ if readable?(timeout)
125
156
  begin
126
157
  @socket.sysread(@read_size, @read_buffer)
127
- return @read_buffer
128
- rescue EOFError
129
- trigger(READER_CLOSED)
158
+ data << @read_buffer
159
+ return data
160
+ rescue EOFError => e
161
+ raise e
130
162
  end
131
163
  else
132
- raise Timeout::Error.new
164
+ if have_pushback
165
+ return data
166
+ else
167
+ raise ReadTimeout.new
168
+ end
133
169
  end
134
170
  end # def read
135
171
 
136
- # Un-read some data
172
+ # Push back some data onto the connection's read buffer.
137
173
  public
138
- def unread(data)
139
- @unread_buffer << data
140
- end # def unread
174
+ def pushback(data)
175
+ @pushback_buffer << data
176
+ end # def pushback
141
177
 
142
178
  # End this connection, specifying why.
143
179
  public
144
180
  def disconnect(reason)
145
181
  begin
146
- #@reader_closed = true
147
182
  @socket.close_read
148
183
  rescue IOError => e
149
- # Ignore
184
+ # Ignore, perhaps we shouldn't ignore.
150
185
  end
151
186
 
152
187
  begin
153
188
  @socket.close_write
154
189
  rescue IOError => e
155
- # Ignore
190
+ # Ignore, perhaps we shouldn't ignore.
156
191
  end
157
-
158
- trigger(DISCONNECTED, reason)
159
192
  end # def disconnect
160
193
 
161
194
  # Is this connection writable? Returns true if it is writable within
@@ -179,53 +212,74 @@ class FTW::Connection
179
212
  return !ready.nil?
180
213
  end # def readable?
181
214
 
182
- protected
183
- def connected(address)
184
- @remote_address = nil
185
- @connected = true
186
- end # def connected
187
-
188
- protected
189
- def disconnected(reason, error)
190
- @remote_address = nil
191
- @connected = false
192
- end # def disconnected
193
-
194
215
  # The host:port
195
216
  public
196
217
  def peer
197
218
  return @remote_address
198
219
  end # def peer
199
220
 
200
- # Run this Connection.
201
- # This is generally meant for Threaded or synchronous operation.
202
- # For EventMachine, see TODO(sissel): Implement EventMachine support.
221
+ # Support 'to_io' so you can use IO::select on this object.
203
222
  public
204
- def run
205
- connect(@connect_timeout) if not connected?
206
- while connected?
207
- read_and_trigger
208
- end
209
- end # def run
223
+ def to_io
224
+ return @socket
225
+ end # def to_io
210
226
 
211
- # Read data and trigger data callbacks.
212
- #
213
- # This is mainly useful if you are implementing your own run loops
214
- # and IO::select shenanigans.
227
+ # Secure this connection with TLS.
215
228
  public
216
- def read_and_trigger
217
- data = read(@read_size)
218
- if data.length == 0
219
- disconnect(EOFError)
220
- else
221
- trigger(DATA, data)
229
+ def secure(timeout=nil, options={})
230
+ # Skip this if we're already secure.
231
+ return if secured?
232
+
233
+ @logger.debug("Securing this connection", :peer => peer, :connection => self)
234
+ # Wrap this connection with TLS/SSL
235
+ require "openssl"
236
+ sslcontext = OpenSSL::SSL::SSLContext.new
237
+ sslcontext.ssl_version = :TLSv1
238
+ # If you use VERIFY_NONE, you are removing an important piece
239
+ sslcontext.verify_mode = OpenSSL::SSL::VERIFY_PEER
240
+ # TODO(sissel): Try to be smart about setting this default.
241
+ sslcontext.ca_path = "/etc/ssl/certs"
242
+ @socket = OpenSSL::SSL::SSLSocket.new(@socket, sslcontext)
243
+
244
+ # SSLSocket#connect_nonblock will do the SSL/TLS handshake.
245
+ # TODO(sissel): refactor this into a method that both secure and connect
246
+ # methods can call.
247
+ start = Time.now
248
+ begin
249
+ @socket.connect_nonblock
250
+ rescue IO::WaitReadable, IO::WaitWritable
251
+ # The ruby OpenSSL docs for 1.9.3 have example code saying I should use
252
+ # IO::WaitReadable, but in the real world it raises an SSLError with
253
+ # a specific string message instead of Errno::EAGAIN or IO::WaitReadable
254
+ # explicitly...
255
+ #
256
+ # This SSLSocket#connect_nonblock raising WaitReadable (Technically,
257
+ # OpenSSL::SSL::SSLError) is in contrast to what Socket#connect_nonblock
258
+ # raises, WaitWritable (ok, Errno::EINPROGRESS, technically)
259
+ # Ruby's SSL exception for 'this call would block' is pretty shitty.
260
+ #
261
+ # If the exception string is *not* 'read would block' we have a real
262
+ # problem.
263
+
264
+ if !timeout.nil?
265
+ time_left = timeout - (Time.now - start)
266
+ raise ConnectTimeout.new if time_left < 0
267
+ r, w, e = IO.select([@socket], [@socket], nil, time_left)
268
+ else
269
+ r, w, e = IO.select([@socket], [@socket], nil, timeout)
270
+ end
271
+
272
+ # try connect_nonblock again if the socket is ready
273
+ retry if r.size > 0 || w.size > 0
222
274
  end
223
- end # def read_and_trigger
224
275
 
225
- # Support 'to_io' so you can use IO::select on this object.
276
+ @secure = true
277
+ end # def secure
278
+
279
+ # Has this connection been secured?
226
280
  public
227
- def to_io
228
- return @socket
229
- end
281
+ def secured?
282
+ return @secure
283
+ end # def secured?
230
284
  end # class FTW::Connection
231
285