z-http-request 0.1.0

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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/.gemtest +0 -0
  3. data/.gitignore +10 -0
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +8 -0
  7. data/Gemfile +17 -0
  8. data/README.md +38 -0
  9. data/Rakefile +3 -0
  10. data/benchmarks/clients.rb +170 -0
  11. data/benchmarks/em-excon.rb +87 -0
  12. data/benchmarks/em-profile.gif +0 -0
  13. data/benchmarks/em-profile.txt +65 -0
  14. data/benchmarks/server.rb +48 -0
  15. data/examples/.gitignore +1 -0
  16. data/examples/digest_auth/client.rb +25 -0
  17. data/examples/digest_auth/server.rb +28 -0
  18. data/examples/fetch.rb +30 -0
  19. data/examples/fibered-http.rb +51 -0
  20. data/examples/multi.rb +25 -0
  21. data/examples/oauth-tweet.rb +35 -0
  22. data/examples/socks5.rb +23 -0
  23. data/lib/z-http/client.rb +318 -0
  24. data/lib/z-http/core_ext/bytesize.rb +6 -0
  25. data/lib/z-http/decoders.rb +254 -0
  26. data/lib/z-http/http_client_options.rb +51 -0
  27. data/lib/z-http/http_connection.rb +214 -0
  28. data/lib/z-http/http_connection_options.rb +44 -0
  29. data/lib/z-http/http_encoding.rb +142 -0
  30. data/lib/z-http/http_header.rb +83 -0
  31. data/lib/z-http/http_status_codes.rb +57 -0
  32. data/lib/z-http/middleware/digest_auth.rb +112 -0
  33. data/lib/z-http/middleware/json_response.rb +15 -0
  34. data/lib/z-http/middleware/oauth.rb +40 -0
  35. data/lib/z-http/middleware/oauth2.rb +28 -0
  36. data/lib/z-http/multi.rb +57 -0
  37. data/lib/z-http/request.rb +23 -0
  38. data/lib/z-http/version.rb +5 -0
  39. data/lib/z-http-request.rb +1 -0
  40. data/lib/z-http.rb +18 -0
  41. data/spec/client_spec.rb +892 -0
  42. data/spec/digest_auth_spec.rb +48 -0
  43. data/spec/dns_spec.rb +44 -0
  44. data/spec/encoding_spec.rb +49 -0
  45. data/spec/external_spec.rb +150 -0
  46. data/spec/fixtures/google.ca +16 -0
  47. data/spec/fixtures/gzip-sample.gz +0 -0
  48. data/spec/gzip_spec.rb +68 -0
  49. data/spec/helper.rb +30 -0
  50. data/spec/middleware_spec.rb +143 -0
  51. data/spec/multi_spec.rb +104 -0
  52. data/spec/pipelining_spec.rb +66 -0
  53. data/spec/redirect_spec.rb +321 -0
  54. data/spec/socksify_proxy_spec.rb +60 -0
  55. data/spec/spec_helper.rb +6 -0
  56. data/spec/ssl_spec.rb +20 -0
  57. data/spec/stallion.rb +296 -0
  58. data/spec/stub_server.rb +42 -0
  59. data/z-http-request.gemspec +33 -0
  60. metadata +248 -0
@@ -0,0 +1,23 @@
1
+ require 'rubygems'
2
+ require 'ZMachine'
3
+ require '../lib/z-http'
4
+
5
+ ZMachine.run do
6
+ # Establish a SOCKS5 tunnel via SSH
7
+ # ssh -D 8000 some_remote_machine
8
+
9
+ connection_options = {:proxy => {:host => '127.0.0.1', :port => 8000, :type => :socks5}}
10
+ http = ZMachine::HttpRequest.new('http://igvita.com/', connection_options).get :redirects => 2
11
+
12
+ http.callback {
13
+ puts "#{http.response_header.status} - #{http.response.length} bytes\n"
14
+ puts http.response
15
+ ZMachine.stop
16
+ }
17
+
18
+ http.errback {
19
+ puts "Error: " + http.error
20
+ puts http.inspect
21
+ ZMachine.stop
22
+ }
23
+ end
@@ -0,0 +1,318 @@
1
+ require 'cookiejar'
2
+
3
+ module ZMachine
4
+
5
+
6
+ class HttpClient
7
+ include Deferrable
8
+ include HttpEncoding
9
+ include HttpStatus
10
+
11
+ TRANSFER_ENCODING="TRANSFER_ENCODING"
12
+ CONTENT_ENCODING="CONTENT_ENCODING"
13
+ CONTENT_LENGTH="CONTENT_LENGTH"
14
+ CONTENT_TYPE="CONTENT_TYPE"
15
+ LAST_MODIFIED="LAST_MODIFIED"
16
+ KEEP_ALIVE="CONNECTION"
17
+ SET_COOKIE="SET_COOKIE"
18
+ LOCATION="LOCATION"
19
+ HOST="HOST"
20
+ ETAG="ETAG"
21
+
22
+ CRLF="\r\n"
23
+
24
+ attr_accessor :state, :response
25
+ attr_reader :response_header, :error, :content_charset, :req, :cookies
26
+
27
+ def initialize(conn, options)
28
+ @conn = conn
29
+ @req = options
30
+
31
+ @stream = nil
32
+ @headers = nil
33
+ @cookies = []
34
+ @cookiejar = CookieJar.new
35
+
36
+ reset!
37
+ end
38
+
39
+ def reset!
40
+ @response_header = HttpResponseHeader.new
41
+ @state = :response_header
42
+
43
+ @response = ''
44
+ @error = nil
45
+ @content_decoder = nil
46
+ @content_charset = nil
47
+ end
48
+
49
+ def last_effective_url; @req.uri; end
50
+ def redirects; @req.followed; end
51
+ def peer; @conn.peer; end
52
+
53
+ def connection_completed
54
+ @state = :response_header
55
+
56
+ head, body = build_request, @req.body
57
+ @conn.middleware.each do |m|
58
+ head, body = m.request(self, head, body) if m.respond_to?(:request)
59
+ end
60
+
61
+ send_request(head, body)
62
+ end
63
+
64
+ def on_request_complete
65
+ begin
66
+ @content_decoder.finalize! if @content_decoder
67
+ rescue HttpDecoders::DecoderError
68
+ on_error "Content-decoder error"
69
+ end
70
+
71
+ unbind
72
+ end
73
+
74
+ def continue?
75
+ @response_header.status == 100 && (@req.method == 'POST' || @req.method == 'PUT')
76
+ end
77
+
78
+ def finished?
79
+ @state == :finished || (@state == :body && @response_header.content_length.nil?)
80
+ end
81
+
82
+ def redirect?
83
+ @response_header.redirection? && @req.follow_redirect?
84
+ end
85
+
86
+ def unbind(reason = nil)
87
+ if finished?
88
+ if redirect?
89
+
90
+ begin
91
+ @conn.middleware.each do |m|
92
+ m.response(self) if m.respond_to?(:response)
93
+ end
94
+
95
+ # one of the injected middlewares could have changed
96
+ # our redirect settings, check if we still want to
97
+ # follow the location header
98
+ if redirect?
99
+ @req.followed += 1
100
+
101
+ @cookies.clear
102
+ @cookies = @cookiejar.get(@response_header.location).map(&:to_s) if @req.pass_cookies
103
+ @req.set_uri(@response_header.location)
104
+
105
+ @conn.redirect(self)
106
+ else
107
+ succeed(self)
108
+ end
109
+
110
+ rescue Exception => e
111
+ on_error(e.message)
112
+ end
113
+ else
114
+ succeed(self)
115
+ end
116
+
117
+ else
118
+ on_error(reason || 'connection closed by server')
119
+ end
120
+ end
121
+
122
+ def on_error(msg = nil)
123
+ @error = msg
124
+ fail(self)
125
+ end
126
+ alias :close :on_error
127
+
128
+ def stream(&blk); @stream = blk; end
129
+ def headers(&blk); @headers = blk; end
130
+
131
+ def normalize_body(body)
132
+ body.is_a?(Hash) ? form_encode_body(body) : body
133
+ end
134
+
135
+ def build_request
136
+ head = @req.headers ? munge_header_keys(@req.headers) : {}
137
+
138
+ if @conn.connopts.http_proxy?
139
+ proxy = @conn.connopts.proxy
140
+ head['proxy-authorization'] = proxy[:authorization] if proxy[:authorization]
141
+ end
142
+
143
+ # Set the cookie header if provided
144
+ if cookie = head['cookie']
145
+ @cookies << encode_cookie(cookie) if cookie
146
+ end
147
+ head['cookie'] = @cookies.compact.uniq.join("; ").squeeze(";") unless @cookies.empty?
148
+
149
+ # Set connection close unless keepalive
150
+ if !@req.keepalive
151
+ head['connection'] = 'close'
152
+ end
153
+
154
+ # Set the Host header if it hasn't been specified already
155
+ head['host'] ||= encode_host
156
+
157
+ # Set the User-Agent if it hasn't been specified
158
+ if !head.key?('user-agent')
159
+ head['user-agent'] = "ZMachine HttpClient"
160
+ elsif head['user-agent'].nil?
161
+ head.delete('user-agent')
162
+ end
163
+
164
+ # Set the auth from the URI if given
165
+ head['Authorization'] = @req.uri.userinfo.split(/:/, 2) if @req.uri.userinfo
166
+
167
+ head
168
+ end
169
+
170
+ def send_request(head, body)
171
+ body = normalize_body(body)
172
+ file = @req.file
173
+ query = @req.query
174
+
175
+ # Set the Content-Length if file is given
176
+ head['content-length'] = File.size(file) if file
177
+
178
+ # Set the Content-Length if body is given,
179
+ # or we're doing an empty post or put
180
+ if body
181
+ head['content-length'] = body.bytesize
182
+ elsif @req.method == 'POST' or @req.method == 'PUT'
183
+ # wont happen if body is set and we already set content-length above
184
+ head['content-length'] ||= 0
185
+ end
186
+
187
+ # Set content-type header if missing and body is a Ruby hash
188
+ if !head['content-type'] and @req.body.is_a? Hash
189
+ head['content-type'] = 'application/x-www-form-urlencoded'
190
+ end
191
+
192
+ request_header ||= encode_request(@req.method, @req.uri, query, @conn.connopts.proxy)
193
+ request_header << encode_headers(head)
194
+ request_header << CRLF
195
+ @conn.send_data request_header
196
+
197
+ if body
198
+ @conn.send_data body
199
+ elsif @req.file
200
+ @conn.stream_file_data @req.file, :http_chunks => false
201
+ end
202
+ end
203
+
204
+ def on_body_data(data)
205
+ if @content_decoder
206
+ begin
207
+ @content_decoder << data
208
+ rescue HttpDecoders::DecoderError
209
+ on_error "Content-decoder error"
210
+ end
211
+ else
212
+ on_decoded_body_data(data)
213
+ end
214
+ end
215
+
216
+ def on_decoded_body_data(data)
217
+ data.force_encoding @content_charset if @content_charset
218
+ if @stream
219
+ @stream.call(data)
220
+ else
221
+ @response << data
222
+ end
223
+ end
224
+
225
+ def parse_response_header(header, version, status)
226
+ @response_header.raw = header
227
+ header.each do |key, val|
228
+ @response_header[key.upcase.gsub('-','_')] = val
229
+ end
230
+
231
+ @response_header.http_version = version.join('.')
232
+ @response_header.http_status = status
233
+ @response_header.http_reason = CODE[status] || 'unknown'
234
+
235
+ # invoke headers callback after full parse
236
+ # if one is specified by the user
237
+ @headers.call(@response_header) if @headers
238
+
239
+ unless @response_header.http_status and @response_header.http_reason
240
+ @state = :invalid
241
+ on_error "no HTTP response"
242
+ return
243
+ end
244
+
245
+ # add set-cookie's to cookie list
246
+ if @response_header.cookie && @req.pass_cookies
247
+ [@response_header.cookie].flatten.each {|cookie| @cookiejar.set(cookie, @req.uri)}
248
+ end
249
+
250
+ # correct location header - some servers will incorrectly give a relative URI
251
+ if @response_header.location
252
+ begin
253
+ location = Addressable::URI.parse(@response_header.location)
254
+ location.path = "/" if location.path.empty?
255
+
256
+ if location.relative?
257
+ location = @req.uri.join(location)
258
+ else
259
+ # if redirect is to an absolute url, check for correct URI structure
260
+ raise if location.host.nil?
261
+ end
262
+
263
+ @response_header[LOCATION] = location.to_s
264
+
265
+ rescue
266
+ on_error "Location header format error"
267
+ return
268
+ end
269
+ end
270
+
271
+ # Fire callbacks immediately after recieving header requests
272
+ # if the request method is HEAD. In case of a redirect, terminate
273
+ # current connection and reinitialize the process.
274
+ if @req.method == "HEAD"
275
+ @state = :finished
276
+ return
277
+ end
278
+
279
+ if @response_header.chunked_encoding?
280
+ @state = :chunk_header
281
+ elsif @response_header.content_length
282
+ @state = :body
283
+ else
284
+ @state = :body
285
+ end
286
+
287
+ if @req.decoding && decoder_class = HttpDecoders.decoder_for_encoding(response_header[CONTENT_ENCODING])
288
+ begin
289
+ @content_decoder = decoder_class.new do |s| on_decoded_body_data(s) end
290
+ rescue HttpDecoders::DecoderError
291
+ on_error "Content-decoder error"
292
+ end
293
+ end
294
+
295
+ # handle malformed header - Content-Type repetitions.
296
+ content_type = [response_header[CONTENT_TYPE]].flatten.first
297
+
298
+ if String.method_defined?(:force_encoding) && /;\s*charset=\s*(.+?)\s*(;|$)/.match(content_type)
299
+ @content_charset = Encoding.find($1.gsub(/^\"|\"$/, '')) rescue Encoding.default_external
300
+ end
301
+ end
302
+
303
+ class CookieJar
304
+ def initialize
305
+ @jar = ::CookieJar::Jar.new
306
+ end
307
+
308
+ def set string, uri
309
+ @jar.set_cookie(uri, string) rescue nil # drop invalid cookies
310
+ end
311
+
312
+ def get uri
313
+ uri = URI.parse(uri) rescue nil
314
+ uri ? @jar.get_cookies(uri) : []
315
+ end
316
+ end # CookieJar
317
+ end
318
+ end
@@ -0,0 +1,6 @@
1
+ # bytesize was introduced in 1.8.7+
2
+ if RUBY_VERSION <= "1.8.6"
3
+ class String
4
+ def bytesize; self.size; end
5
+ end
6
+ end
@@ -0,0 +1,254 @@
1
+ require 'zlib'
2
+ require 'stringio'
3
+
4
+ ##
5
+ # Provides a unified callback interface to decompression libraries.
6
+ module ZMachine::HttpDecoders
7
+
8
+ class DecoderError < StandardError
9
+ end
10
+
11
+ class << self
12
+ def accepted_encodings
13
+ DECODERS.inject([]) { |r, d| r + d.encoding_names }
14
+ end
15
+
16
+ def decoder_for_encoding(encoding)
17
+ DECODERS.each { |d|
18
+ return d if d.encoding_names.include? encoding
19
+ }
20
+ nil
21
+ end
22
+ end
23
+
24
+ class Base
25
+ def self.encoding_names
26
+ name = to_s.split('::').last.downcase
27
+ [name]
28
+ end
29
+
30
+ ##
31
+ # chunk_callback:: [Block] To handle a decompressed chunk
32
+ def initialize(&chunk_callback)
33
+ @chunk_callback = chunk_callback
34
+ end
35
+
36
+ def <<(compressed)
37
+ return unless compressed && compressed.size > 0
38
+
39
+ decompressed = decompress(compressed)
40
+ receive_decompressed decompressed
41
+ end
42
+
43
+ def finalize!
44
+ decompressed = finalize
45
+ receive_decompressed decompressed
46
+ end
47
+
48
+ private
49
+
50
+ def receive_decompressed(decompressed)
51
+ if decompressed && decompressed.size > 0
52
+ @chunk_callback.call(decompressed)
53
+ end
54
+ end
55
+
56
+ protected
57
+
58
+ ##
59
+ # Must return a part of decompressed
60
+ def decompress(compressed)
61
+ nil
62
+ end
63
+
64
+ ##
65
+ # May return last part
66
+ def finalize
67
+ nil
68
+ end
69
+ end
70
+
71
+ class Deflate < Base
72
+ def decompress(compressed)
73
+ begin
74
+ @zstream ||= Zlib::Inflate.new(-Zlib::MAX_WBITS)
75
+ @zstream.inflate(compressed)
76
+ rescue Zlib::Error
77
+ raise DecoderError
78
+ end
79
+ end
80
+
81
+ def finalize
82
+ return nil unless @zstream
83
+
84
+ begin
85
+ r = @zstream.inflate(nil)
86
+ @zstream.close
87
+ r
88
+ rescue Zlib::Error
89
+ raise DecoderError
90
+ end
91
+ end
92
+ end
93
+
94
+ ##
95
+ # Partial implementation of RFC 1952 to extract the deflate stream from a gzip file
96
+ class GZipHeader
97
+ def initialize
98
+ @state = :begin
99
+ @data = ""
100
+ @pos = 0
101
+ end
102
+
103
+ def finished?
104
+ @state == :finish
105
+ end
106
+
107
+ def read(n, buffer)
108
+ if (@pos + n) <= @data.size
109
+ buffer << @data[@pos..(@pos + n - 1)]
110
+ @pos += n
111
+ return true
112
+ else
113
+ return false
114
+ end
115
+ end
116
+
117
+ def readbyte
118
+ if (@pos + 1) <= @data.size
119
+ @pos += 1
120
+ @data.getbyte(@pos - 1)
121
+ end
122
+ end
123
+
124
+ def eof?
125
+ @pos >= @data.size
126
+ end
127
+
128
+ def extract_stream(compressed)
129
+ @data << compressed
130
+ pos = @pos
131
+
132
+ while !eof? && !finished?
133
+ buffer = ""
134
+
135
+ case @state
136
+ when :begin
137
+ break if !read(10, buffer)
138
+
139
+ if buffer.getbyte(0) != 0x1f || buffer.getbyte(1) != 0x8b
140
+ raise DecoderError.new("magic header not found")
141
+ end
142
+
143
+ if buffer.getbyte(2) != 0x08
144
+ raise DecoderError.new("unknown compression method")
145
+ end
146
+
147
+ @flags = buffer.getbyte(3)
148
+ if (@flags & 0xe0).nonzero?
149
+ raise DecoderError.new("unknown header flags set")
150
+ end
151
+
152
+ # We don't care about these values, I'm leaving the code for reference
153
+ # @time = buffer[4..7].unpack("V")[0] # little-endian uint32
154
+ # @extra_flags = buffer.getbyte(8)
155
+ # @os = buffer.getbyte(9)
156
+
157
+ @state = :extra_length
158
+
159
+ when :extra_length
160
+ if (@flags & 0x04).nonzero?
161
+ break if !read(2, buffer)
162
+ @extra_length = buffer.unpack("v")[0] # little-endian uint16
163
+ @state = :extra
164
+ else
165
+ @state = :extra
166
+ end
167
+
168
+ when :extra
169
+ if (@flags & 0x04).nonzero?
170
+ break if read(@extra_length, buffer)
171
+ @state = :name
172
+ else
173
+ @state = :name
174
+ end
175
+
176
+ when :name
177
+ if (@flags & 0x08).nonzero?
178
+ while !(buffer = readbyte).nil?
179
+ if buffer == 0
180
+ @state = :comment
181
+ break
182
+ end
183
+ end
184
+ else
185
+ @state = :comment
186
+ end
187
+
188
+ when :comment
189
+ if (@flags & 0x10).nonzero?
190
+ while !(buffer = readbyte).nil?
191
+ if buffer == 0
192
+ @state = :hcrc
193
+ break
194
+ end
195
+ end
196
+ else
197
+ @state = :hcrc
198
+ end
199
+
200
+ when :hcrc
201
+ if (@flags & 0x02).nonzero?
202
+ break if !read(2, buffer)
203
+ @state = :finish
204
+ else
205
+ @state = :finish
206
+ end
207
+ end
208
+ end
209
+
210
+ if finished?
211
+ compressed[(@pos - pos)..-1]
212
+ else
213
+ ""
214
+ end
215
+ end
216
+ end
217
+
218
+ class GZip < Base
219
+ def self.encoding_names
220
+ %w(gzip compressed)
221
+ end
222
+
223
+ def decompress(compressed)
224
+ compressed.force_encoding('BINARY')
225
+ @header ||= GZipHeader.new
226
+ if !@header.finished?
227
+ compressed = @header.extract_stream(compressed)
228
+ end
229
+
230
+ @zstream ||= Zlib::Inflate.new(-Zlib::MAX_WBITS)
231
+ @zstream.inflate(compressed)
232
+ rescue Zlib::Error
233
+ raise DecoderError
234
+ end
235
+
236
+ def finalize
237
+ if @zstream
238
+ if !@zstream.finished?
239
+ r = @zstream.finish
240
+ end
241
+ @zstream.close
242
+ r
243
+ else
244
+ nil
245
+ end
246
+ rescue Zlib::Error
247
+ raise DecoderError
248
+ end
249
+
250
+ end
251
+
252
+ DECODERS = [Deflate, GZip]
253
+
254
+ end
@@ -0,0 +1,51 @@
1
+ class HttpClientOptions
2
+ attr_reader :uri, :method, :host, :port
3
+ attr_reader :headers, :file, :body, :query, :path
4
+ attr_reader :keepalive, :pass_cookies, :decoding
5
+
6
+ attr_accessor :followed, :redirects
7
+
8
+ def initialize(uri, options, method)
9
+ @keepalive = options[:keepalive] || false # default to single request per connection
10
+ @redirects = options[:redirects] ||= 0 # default number of redirects to follow
11
+ @followed = options[:followed] ||= 0 # keep track of number of followed requests
12
+
13
+ @method = method.to_s.upcase
14
+ @headers = options[:head] || {}
15
+ @query = options[:query]
16
+
17
+
18
+ @file = options[:file]
19
+ @body = options[:body]
20
+
21
+ @pass_cookies = options.fetch(:pass_cookies, true) # pass cookies between redirects
22
+ @decoding = options.fetch(:decoding, true) # auto-decode compressed response
23
+
24
+ set_uri(uri, options[:path])
25
+ end
26
+
27
+ def follow_redirect?; @followed < @redirects; end
28
+ def ssl?; @uri.scheme == "https" || @uri.port == 443; end
29
+ def no_body?; @method == "HEAD"; end
30
+
31
+ def set_uri(uri, path = nil)
32
+ uri = uri.kind_of?(Addressable::URI) ? uri : Addressable::URI::parse(uri.to_s)
33
+ uri.path = path if path
34
+ uri.path = '/' if uri.path.empty?
35
+
36
+ @uri = uri
37
+ @path = uri.path
38
+
39
+ # Make sure the ports are set as Addressable::URI doesn't
40
+ # set the port if it isn't there
41
+ if @uri.scheme == "https"
42
+ @uri.port ||= 443
43
+ else
44
+ @uri.port ||= 80
45
+ end
46
+
47
+ @host = @uri.host
48
+ @port = @uri.port
49
+
50
+ end
51
+ end