ftw 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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