ftw 0.0.1

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.
@@ -0,0 +1,49 @@
1
+ # For The Web
2
+
3
+ net/http is pretty much not good.
4
+
5
+ I want:
6
+
7
+ * A HTTP client that acts as a full user agent, not just a single connection.
8
+ * HTTP and SPDY support.
9
+ * WebSockets support.
10
+ * SSL/TLS support.
11
+ * An API that lets me do what I need.
12
+ * Server and Client modes.
13
+ * Support for both normal operation and EventMachine would be nice.
14
+
15
+ ## DONE
16
+
17
+ * TCP connection
18
+ * DNS resolution (wraps Socket.gethostname)
19
+ * HTTP client partially done
20
+
21
+ ## TODO
22
+
23
+ * Tests, yo.
24
+ * Logging, yo. With cabin, obviously.
25
+ * [DNS in Ruby stdlib is broken](https://github.com/jordansissel/experiments/tree/master/ruby/dns-resolving-bug), I need to write my own
26
+
27
+ ## API Scratch
28
+
29
+ ### Common case
30
+
31
+ agent = FTW::Agent.new
32
+ request = agent.get("http://www.google.com/")
33
+ response = request.execute
34
+
35
+ ### SPDY
36
+
37
+ # SPDY should automatically be attempted. The caller should be unaware.
38
+
39
+ ### WebSockets
40
+
41
+ # 'http(s)' or 'ws(s)' urls are valid here. They will mean the same thing.
42
+ request = agent.websocket("http://somehost/endpoint")
43
+ # Set auth header
44
+ request["Authorization"] = ...
45
+ request["Cookie"] = ...
46
+
47
+ websocket, error = request.execute
48
+ # Now websocket.read receives a message, websocket.write sends a message.
49
+
@@ -0,0 +1,40 @@
1
+ require "ftw/namespace"
2
+ require "ftw/request"
3
+ require "ftw/connection"
4
+ require "addressable/uri"
5
+
6
+ # This should act as a proper agent.
7
+ #
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
13
+ class FTW::Agent
14
+ # TODO(sissel): All standard HTTP methods should be defined here.
15
+ # Also allow users to specify non-standard methods.
16
+
17
+ def initialize
18
+ end
19
+
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
25
+
26
+ def request(method, uri, options)
27
+ request = FTW::Request.new(uri)
28
+ request.method = method
29
+ request.connection = connection(uri.host, uri.port)
30
+ return request
31
+ end # def request
32
+
33
+ # Returns a FTW::Connection connected to this host:port.
34
+ # TODO(sissel): Implement connection reuse
35
+ # TODO(sissel): support SSL/TLS
36
+ private
37
+ def connection(host, port)
38
+ return FTW::Connection.new("#{host}:#{port}")
39
+ end # def connect
40
+ end # class FTW::Agent
@@ -0,0 +1,231 @@
1
+ require "cabin" # rubygem "cabin"
2
+ require "net/ftw/dns"
3
+ require "net/ftw/namespace"
4
+ require "socket"
5
+ require "timeout" # ruby stdlib, just for the Timeout exception.
6
+ require "backport-bij" # for Array#rotate, IO::WaitWritable, etc, in ruby < 1.9
7
+
8
+ # A network connection. This is TCP.
9
+ #
10
+ # You can use IO::select on this objects of this type.
11
+ # (at least, in MRI you can)
12
+ class FTW::Connection
13
+ # A new network connection.
14
+ # The 'destination' argument can be an array of strings or a single string.
15
+ # String format is expected to be "host:port"
16
+ #
17
+ # Example:
18
+ #
19
+ # conn = FTW::Connection.new(["1.2.3.4:80", "1.2.3.5:80"])
20
+ #
21
+ # If you specify multiple destinations, they are used in a round-robin
22
+ # decision made during reconnection.
23
+ public
24
+ def initialize(destinations)
25
+ if destinations.is_a?(String)
26
+ @destinations = [destinations]
27
+ else
28
+ @destinations = destinations
29
+ end
30
+
31
+ @connect_timeout = 2
32
+
33
+ # Use a fixed-size string that we set to BINARY encoding.
34
+ # Not all byte sequences are UTF-8 friendly :0
35
+ @read_size = 16384
36
+ @read_buffer = " " * @read_size
37
+
38
+ # Tell Ruby 1.9 that this string is a binary string, not utf-8 or somesuch.
39
+ if @read_buffer.respond_to?(:force_encoding)
40
+ @read_buffer.force_encoding("BINARY")
41
+ end
42
+
43
+ # TODO(sissel): Validate @destinations
44
+ end # def initialize
45
+
46
+ public
47
+ def connect(timeout=nil)
48
+ # TODO(sissel): Raise if we're already connected?
49
+ close if connected?
50
+ host, port = @destinations.first.split(":")
51
+ @destinations = @destinations.rotate # round-robin
52
+
53
+ # Do dns resolution on the host. If there are multiple
54
+ # addresses resolved, return one at random.
55
+ @remote_address = FTW::DNS.singleton.resolve_random(host)
56
+
57
+ # Addresses with colon ':' in them are assumed to be IPv6
58
+ family = @remote_address.include?(":") ? Socket::AF_INET6 : Socket::AF_INET
59
+ @socket = Socket.new(family, Socket::SOCK_STREAM, 0)
60
+
61
+ # This api is terrible. pack_sockaddr_in? This isn't C, man...
62
+ sockaddr = Socket.pack_sockaddr_in(port, @remote_address)
63
+ # TODO(sissel): Support local address binding
64
+
65
+ # Connect with timeout
66
+ begin
67
+ @socket.connect_nonblock(sockaddr)
68
+ rescue IO::WaitWritable
69
+ # Ruby actually raises Errno::EINPROGRESS, but for some reason
70
+ # the documentation says to use this IO::WaitWritable thing...
71
+ # I don't get it, but whatever :(
72
+ if writable?(timeout)
73
+ begin
74
+ @socket.connect_nonblock(sockaddr) # check connection failure
75
+ rescue Errno::EISCONN # Ignore, we're already connected.
76
+ rescue Errno::ECONNREFUSED => e
77
+ # Fire 'disconnected' event with reason :refused
78
+ trigger(DISCONNECTED, :refused, e)
79
+ end
80
+ else
81
+ # Connection timeout
82
+ # Fire 'disconnected' event with reason :timeout
83
+ trigger(DISCONNECTED, :connect_timeout, nil)
84
+ end
85
+ end
86
+
87
+ # We're now connected.
88
+ trigger(CONNECTED, "#{host}:#{port}")
89
+ end # def connect
90
+
91
+ # Is this Connection connected?
92
+ public
93
+ def connected?
94
+ return @connected
95
+ end # def connected?
96
+
97
+ # Write data to this connection.
98
+ # This method blocks until the write succeeds unless a timeout is given.
99
+ #
100
+ # Returns the number of bytes written (See IO#syswrite)
101
+ public
102
+ def write(data, timeout=nil)
103
+ #connect if !connected?
104
+ if writable?(timeout)
105
+ return @socket.syswrite(data)
106
+ else
107
+ raise Timeout::Error.new
108
+ end
109
+ end # def write
110
+
111
+ # Read data from this connection
112
+ # This method blocks until the read succeeds unless a timeout is given.
113
+ #
114
+ # This method is not guaranteed to read exactly 'length' bytes. See
115
+ # IO#sysread
116
+ public
117
+ 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
124
+
125
+ begin
126
+ @socket.sysread(@read_size, @read_buffer)
127
+ return @read_buffer
128
+ rescue EOFError
129
+ trigger(READER_CLOSED)
130
+ end
131
+ else
132
+ raise Timeout::Error.new
133
+ end
134
+ end # def read
135
+
136
+ # Un-read some data
137
+ public
138
+ def unread(data)
139
+ @unread_buffer << data
140
+ end # def unread
141
+
142
+ # End this connection, specifying why.
143
+ public
144
+ def disconnect(reason)
145
+ begin
146
+ #@reader_closed = true
147
+ @socket.close_read
148
+ rescue IOError => e
149
+ # Ignore
150
+ end
151
+
152
+ begin
153
+ @socket.close_write
154
+ rescue IOError => e
155
+ # Ignore
156
+ end
157
+
158
+ trigger(DISCONNECTED, reason)
159
+ end # def disconnect
160
+
161
+ # Is this connection writable? Returns true if it is writable within
162
+ # the timeout period. False otherwise.
163
+ #
164
+ # The time out is in seconds. Fractional seconds are OK.
165
+ public
166
+ def writable?(timeout)
167
+ ready = IO.select(nil, [@socket], nil, timeout)
168
+ return !ready.nil?
169
+ end # def writable?
170
+
171
+ # Is this connection readable? Returns true if it is readable within
172
+ # the timeout period. False otherwise.
173
+ #
174
+ # The time out is in seconds. Fractional seconds are OK.
175
+ public
176
+ def readable?(timeout)
177
+ #return false if @reader_closed
178
+ ready = IO.select([@socket], nil, nil, timeout)
179
+ return !ready.nil?
180
+ end # def readable?
181
+
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
+ # The host:port
195
+ public
196
+ def peer
197
+ return @remote_address
198
+ end # def peer
199
+
200
+ # Run this Connection.
201
+ # This is generally meant for Threaded or synchronous operation.
202
+ # For EventMachine, see TODO(sissel): Implement EventMachine support.
203
+ public
204
+ def run
205
+ connect(@connect_timeout) if not connected?
206
+ while connected?
207
+ read_and_trigger
208
+ end
209
+ end # def run
210
+
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.
215
+ 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)
222
+ end
223
+ end # def read_and_trigger
224
+
225
+ # Support 'to_io' so you can use IO::select on this object.
226
+ public
227
+ def to_io
228
+ return @socket
229
+ end
230
+ end # class FTW::Connection
231
+
@@ -0,0 +1,6 @@
1
+ require "net/ftw/namespace"
2
+
3
+ module FTW::CRLF
4
+ # carriage-return + line-feed
5
+ CRLF = "\r\n"
6
+ end
@@ -0,0 +1,62 @@
1
+ require "ftw/namespace"
2
+ require "socket" # for Socket.gethostbyname
3
+
4
+ # TODO(sissel): Switch to using Resolv::DNS since it lets you (the programmer)
5
+ # choose dns configuration (servers, etc)
6
+ #
7
+ # I wrap whatever Ruby provides because it is historically very
8
+ # inconsistent in implementation behavior across ruby platforms and versions.
9
+ # In the future, this will probably implement the DNS protocol, but for now
10
+ # chill in the awkward, but already-written, ruby stdlib.
11
+ #
12
+ # I didn't really want to write a DNS library, but a consistent API and
13
+ # behavior is necessary for my continued sanity :)
14
+ class FTW::DNS
15
+ V4_IN_V6_PREFIX = "0:" * 12
16
+
17
+ def self.singleton
18
+ @resolver ||= self.new
19
+ end # def self.singleton
20
+
21
+ # This method is only intended to do A or AAAA lookups
22
+ # I may add PTR lookups later.
23
+ def resolve(hostname)
24
+ official, aliases, family, *addresses = Socket.gethostbyname(hostname)
25
+ # We ignore family, here. Ruby will return v6 *and* v4 addresses in
26
+ # the same gethostbyname() call. It is confusing.
27
+ #
28
+ # Let's just rely entirely on the length of the address string.
29
+ return addresses.collect do |address|
30
+ if address.length == 16
31
+ unpack_v6(address)
32
+ else
33
+ unpack_v4(address)
34
+ end
35
+ end
36
+ end # def resolve
37
+
38
+ def resolve_random(hostname)
39
+ addresses = resolve(hostname)
40
+ return addresses[rand(addresses.size)]
41
+ end # def resolve_random
42
+
43
+ private
44
+ def unpack_v4(address)
45
+ return address.unpack("C4").join(".")
46
+ end # def unpack_v4
47
+
48
+ private
49
+ def unpack_v6(address)
50
+ if address.length == 16
51
+ # Unpack 16 bit chunks, convert to hex, join with ":"
52
+ address.unpack("n8").collect { |p| p.to_s(16) } \
53
+ .join(":").sub(/(?:0:(?:0:)+)/, "::")
54
+ else
55
+ # assume ipv4
56
+ # Per the following sites, "::127.0.0.1" is valid and correct
57
+ # http://en.wikipedia.org/wiki/IPv6#IPv4-mapped_IPv6_addresses
58
+ # http://www.tcpipguide.com/free/t_IPv6IPv4AddressEmbedding.htm
59
+ "::" + unpack_v4(address)
60
+ end
61
+ end # def unpack_v6
62
+ end # class FTW::DNS
@@ -0,0 +1,122 @@
1
+ require "net/ftw/namespace"
2
+ require "ftw/crlf"
3
+
4
+ # HTTP Headers
5
+ #
6
+ # See RFC2616 section 4.2: <http://tools.ietf.org/html/rfc2616#section-4.2>
7
+ #
8
+ # Section 14.44 says Field Names in the header are case-insensitive, so
9
+ # this library always forces field names to be lowercase. This includes
10
+ # get() calls.
11
+ #
12
+ # headers.set("HELLO", "world")
13
+ # headers.get("hello") # ===> "world"
14
+ #
15
+ class FTW::HTTP::Headers
16
+ include Enumerable
17
+ include FTW::CRLF
18
+
19
+ # Make a new headers container. You can pass a hash of
20
+ public
21
+ def initialize(headers={})
22
+ super()
23
+ @version = 1.1
24
+ @headers = headers
25
+ end # def initialize
26
+
27
+ # Set a header field to a specific value.
28
+ # Any existing value(s) for this field are destroyed.
29
+ def set(field, value)
30
+ @headers[field.downcase] = value
31
+ end # def set
32
+
33
+ # Set a header field to a specific value.
34
+ # Any existing value(s) for this field are destroyed.
35
+ def include?(field)
36
+ @headers.include?(field.downcase)
37
+ end # def include?
38
+
39
+ # Add a header field with a value.
40
+ #
41
+ # If this field already exists, another value is added.
42
+ # If this field does not already exist, it is set.
43
+ def add(field, value)
44
+ field = field.downcase
45
+ if @headers.include?(field)
46
+ if @headers[field].is_a?(Array)
47
+ @headers[field] << value
48
+ else
49
+ @headers[field] = [@headers[field], value]
50
+ end
51
+ else
52
+ set(field, value)
53
+ end
54
+ end # def add
55
+
56
+ # Removes a header entry. If the header has multiple values
57
+ # (like X-Forwarded-For can), you can delete a specific entry
58
+ # by passing the value of the header field to remove.
59
+ #
60
+ # # Remove all X-Forwarded-For entries
61
+ # headers.remove("X-Forwarded-For")
62
+ # # Remove a specific X-Forwarded-For entry
63
+ # headers.remove("X-Forwarded-For", "1.2.3.4")
64
+ #
65
+ # * If you remove a field that doesn't exist, no error will occur.
66
+ # * If you remove a field value that doesn't exist, no error will occur.
67
+ # * If you remove a field value that is the only value, it is the same as
68
+ # removing that field by name.
69
+ def remove(field, value=nil)
70
+ field = field.downcase
71
+ if value.nil?
72
+ # no value, given, remove the entire field.
73
+ @headers.delete(field)
74
+ else
75
+ field_value = @headers[field]
76
+ if field_value.is_a?(Array)
77
+ # remove a specific value
78
+ field_value.delete(value)
79
+ # Down to a String again if there's only one value.
80
+ if field_value.size == 1
81
+ set(field, field_value.first)
82
+ end
83
+ else
84
+ # Remove this field if the value matches
85
+ if field_value == value
86
+ remove(field)
87
+ end
88
+ end
89
+ end
90
+ end # def remove
91
+
92
+ # Get a field value.
93
+ #
94
+ # This will return:
95
+ # * String if there is only a single value for this field
96
+ # * Array of String if there are multiple values for this field
97
+ def get(field)
98
+ field = field.downcase
99
+ return @headers[field]
100
+ end # def get
101
+
102
+ # Iterate over headers. Given to the block are two arguments, the field name
103
+ # and the field value. For fields with multiple values, you will receive
104
+ # that same field name multiple times, like:
105
+ # yield "Host", "www.example.com"
106
+ # yield "X-Forwarded-For", "1.2.3.4"
107
+ # yield "X-Forwarded-For", "1.2.3.5"
108
+ def each(&block)
109
+ @headers.each do |field_name, field_value|
110
+ if field_value.is_a?(Array)
111
+ field_value.map { |value| yield field_name, v }
112
+ else
113
+ yield field_name, field_value
114
+ end
115
+ end
116
+ end # end each
117
+
118
+ public
119
+ def to_s
120
+ return @headers.collect { |name, value| "#{name}: #{value}" }.join(CRLF) + CRLF
121
+ end # def to_s
122
+ end # class FTW::HTTP::Headers