http 0.5.1 → 0.6.0.pre

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of http might be problematic. Click here for more details.

Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -3
  3. data/.rspec +3 -2
  4. data/.rubocop.yml +101 -0
  5. data/.travis.yml +19 -8
  6. data/Gemfile +24 -6
  7. data/LICENSE.txt +1 -1
  8. data/README.md +144 -29
  9. data/Rakefile +23 -1
  10. data/examples/parallel_requests_with_celluloid.rb +2 -2
  11. data/http.gemspec +14 -14
  12. data/lib/http.rb +5 -4
  13. data/lib/http/authorization_header.rb +37 -0
  14. data/lib/http/authorization_header/basic_auth.rb +24 -0
  15. data/lib/http/authorization_header/bearer_token.rb +29 -0
  16. data/lib/http/backports.rb +2 -0
  17. data/lib/http/backports/base64.rb +6 -0
  18. data/lib/http/{uri_backport.rb → backports/uri.rb} +10 -10
  19. data/lib/http/chainable.rb +24 -25
  20. data/lib/http/client.rb +97 -67
  21. data/lib/http/content_type.rb +27 -0
  22. data/lib/http/errors.rb +13 -0
  23. data/lib/http/headers.rb +154 -0
  24. data/lib/http/headers/mixin.rb +11 -0
  25. data/lib/http/mime_type.rb +61 -36
  26. data/lib/http/mime_type/adapter.rb +24 -0
  27. data/lib/http/mime_type/json.rb +23 -0
  28. data/lib/http/options.rb +21 -48
  29. data/lib/http/redirector.rb +12 -7
  30. data/lib/http/request.rb +82 -33
  31. data/lib/http/request/writer.rb +79 -0
  32. data/lib/http/response.rb +39 -68
  33. data/lib/http/response/body.rb +62 -0
  34. data/lib/http/{response_parser.rb → response/parser.rb} +3 -1
  35. data/lib/http/version.rb +1 -1
  36. data/logo.png +0 -0
  37. data/spec/http/authorization_header/basic_auth_spec.rb +29 -0
  38. data/spec/http/authorization_header/bearer_token_spec.rb +36 -0
  39. data/spec/http/authorization_header_spec.rb +41 -0
  40. data/spec/http/backports/base64_spec.rb +13 -0
  41. data/spec/http/client_spec.rb +181 -0
  42. data/spec/http/content_type_spec.rb +47 -0
  43. data/spec/http/headers/mixin_spec.rb +36 -0
  44. data/spec/http/headers_spec.rb +417 -0
  45. data/spec/http/options/body_spec.rb +6 -7
  46. data/spec/http/options/form_spec.rb +4 -5
  47. data/spec/http/options/headers_spec.rb +9 -17
  48. data/spec/http/options/json_spec.rb +17 -0
  49. data/spec/http/options/merge_spec.rb +18 -19
  50. data/spec/http/options/new_spec.rb +5 -19
  51. data/spec/http/options/proxy_spec.rb +6 -6
  52. data/spec/http/options_spec.rb +3 -9
  53. data/spec/http/redirector_spec.rb +100 -0
  54. data/spec/http/request/writer_spec.rb +25 -0
  55. data/spec/http/request_spec.rb +54 -14
  56. data/spec/http/response/body_spec.rb +24 -0
  57. data/spec/http/response_spec.rb +61 -32
  58. data/spec/http_spec.rb +77 -86
  59. data/spec/spec_helper.rb +25 -2
  60. data/spec/support/example_server.rb +58 -49
  61. data/spec/support/proxy_server.rb +27 -11
  62. metadata +60 -55
  63. data/lib/http/header.rb +0 -11
  64. data/lib/http/mime_types/json.rb +0 -19
  65. data/lib/http/request_stream.rb +0 -77
  66. data/spec/http/options/callbacks_spec.rb +0 -62
  67. data/spec/http/options/response_spec.rb +0 -24
  68. data/spec/http/request_stream_spec.rb +0 -25
@@ -1,7 +1,7 @@
1
1
  module HTTP
2
2
  class Redirector
3
3
  # Notifies that we reached max allowed redirect hops
4
- class TooManyRedirectsError < RuntimeError; end
4
+ class TooManyRedirectsError < ResponseError; end
5
5
 
6
6
  # Notifies that following redirects got into an endless loop
7
7
  class EndlessRedirectError < TooManyRedirectsError; end
@@ -10,8 +10,10 @@ module HTTP
10
10
  REDIRECT_CODES = [300, 301, 302, 303, 307, 308].freeze
11
11
 
12
12
  # :nodoc:
13
- def initialize(max_redirects)
14
- @max_redirects = max_redirects
13
+ def initialize(options = nil)
14
+ options = {:max_hops => 5} unless options.respond_to?(:fetch)
15
+ @max_hops = options.fetch(:max_hops, 5)
16
+ @max_hops = false if @max_hops && 1 > @max_hops.to_i
15
17
  end
16
18
 
17
19
  # Follows redirects until non-redirect response found
@@ -39,7 +41,12 @@ module HTTP
39
41
  uri = @response.headers['Location']
40
42
  fail StateError, 'no Location header in redirect' unless uri
41
43
 
42
- @request = @request.redirect(uri)
44
+ if 303 == @response.code
45
+ @request = @request.redirect uri, :get
46
+ else
47
+ @request = @request.redirect uri
48
+ end
49
+
43
50
  @response = yield @request
44
51
  end
45
52
 
@@ -48,13 +55,11 @@ module HTTP
48
55
 
49
56
  # Check if we reached max amount of redirect hops
50
57
  def too_many_hops?
51
- return false if @max_redirects.is_a?(TrueClass)
52
- @max_redirects.to_i < @visited.count
58
+ @max_hops < @visited.count if @max_hops
53
59
  end
54
60
 
55
61
  # Check if we got into an endless loop
56
62
  def endless_loop?
57
- # pretty naive condition
58
63
  2 < @visited.count(@visited.last)
59
64
  end
60
65
  end
data/lib/http/request.rb CHANGED
@@ -1,13 +1,18 @@
1
- require 'http/header'
2
- require 'http/request_stream'
1
+ require 'http/headers'
2
+ require 'http/request/writer'
3
+ require 'http/version'
3
4
  require 'uri'
5
+ require 'base64'
4
6
 
5
7
  module HTTP
6
8
  class Request
7
- include HTTP::Header
9
+ include HTTP::Headers::Mixin
8
10
 
9
11
  # The method given was not understood
10
- class UnsupportedMethodError < ArgumentError; end
12
+ class UnsupportedMethodError < RequestError; end
13
+
14
+ # The scheme of given URI was not understood
15
+ class UnsupportedSchemeError < RequestError; end
11
16
 
12
17
  # RFC 2616: Hypertext Transfer Protocol -- HTTP/1.1
13
18
  METHODS = [:options, :get, :head, :post, :put, :delete, :trace, :connect]
@@ -27,53 +32,97 @@ module HTTP
27
32
  # draft-reschke-webdav-search: WebDAV Search
28
33
  METHODS.concat [:search]
29
34
 
35
+ # Allowed schemes
36
+ SCHEMES = [:http, :https, :ws, :wss]
37
+
30
38
  # Method is given as a lowercase symbol e.g. :get, :post
31
- attr_reader :method
39
+ attr_reader :verb
40
+
41
+ # Scheme is normalized to be a lowercase symbol e.g. :http, :https
42
+ attr_reader :scheme
43
+
44
+ # The following alias may be removed in three minor versions (0.8.0) or one
45
+ # major version (1.0.0)
46
+ alias_method :__method__, :method
47
+
48
+ # The following method may be removed in two minor versions (0.7.0) or one
49
+ # major version (1.0.0)
50
+ def method(*args)
51
+ warn "#{Kernel.caller.first}: [DEPRECATION] HTTP::Request#method is deprecated. Use #verb instead. For Object#method, use #__method__."
52
+ @verb
53
+ end
32
54
 
33
55
  # "Request URI" as per RFC 2616
34
56
  # http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html
35
57
  attr_reader :uri
36
- attr_reader :headers, :proxy, :body, :version
58
+ attr_reader :proxy, :body, :version
37
59
 
38
60
  # :nodoc:
39
- def initialize(method, uri, headers = {}, proxy = {}, body = nil, version = "1.1")
40
- @method = method.to_s.downcase.to_sym
41
- raise UnsupportedMethodError, "unknown method: #{method}" unless METHODS.include? @method
42
-
43
- @uri = uri.is_a?(URI) ? uri : URI(uri.to_s)
44
-
45
- @headers = {}
46
- headers.each do |name, value|
47
- name = name.to_s
48
- key = name[CANONICAL_HEADER]
49
- key ||= canonicalize_header(name)
50
- @headers[key] = value
51
- end
52
- @headers["Host"] ||= @uri.host
61
+ def initialize(verb, uri, headers = {}, proxy = {}, body = nil, version = '1.1') # rubocop:disable ParameterLists
62
+ @verb = verb.to_s.downcase.to_sym
63
+ @uri = uri.is_a?(URI) ? uri : URI(uri.to_s)
64
+ @scheme = @uri.scheme.to_s.downcase.to_sym if @uri.scheme
65
+
66
+ fail(UnsupportedMethodError, "unknown method: #{verb}") unless METHODS.include?(@verb)
67
+ fail(UnsupportedSchemeError, "unknown scheme: #{scheme}") unless SCHEMES.include?(@scheme)
53
68
 
54
69
  @proxy, @body, @version = proxy, body, version
70
+
71
+ @headers = HTTP::Headers.coerce(headers || {})
72
+
73
+ @headers['Host'] ||= @uri.host
74
+ @headers['User-Agent'] ||= "RubyHTTPGem/#{HTTP::VERSION}"
55
75
  end
56
76
 
57
77
  # Returns new Request with updated uri
58
- def redirect(uri)
78
+ def redirect(uri, verb = @verb)
59
79
  uri = @uri.merge uri.to_s
60
- req = self.class.new(method, uri, headers, proxy, body, version)
61
- req.headers.merge!('Host' => req.uri.host)
80
+ req = self.class.new(verb, uri, headers, proxy, body, version)
81
+ req['Host'] = req.uri.host
62
82
  req
63
83
  end
64
84
 
65
- # Obtain the given header
66
- def [](header)
67
- @headers[canonicalize_header(header)]
68
- end
69
-
70
85
  # Stream the request to a socket
71
86
  def stream(socket)
72
- path = uri.query ? "#{uri.path}?#{uri.query}" : uri.path
73
- path = "/" if path.empty?
74
- request_header = "#{method.to_s.upcase} #{path} HTTP/#{version}"
75
- rs = HTTP::RequestStream.new socket, body, @headers, request_header
76
- rs.stream
87
+ include_proxy_authorization_header if using_authenticated_proxy?
88
+ Request::Writer.new(socket, body, headers, request_header).stream
89
+ end
90
+
91
+ # Is this request using a proxy?
92
+ def using_proxy?
93
+ proxy && proxy.keys.size >= 2
94
+ end
95
+
96
+ # Is this request using an authenticated proxy?
97
+ def using_authenticated_proxy?
98
+ proxy && proxy.keys.size == 4
99
+ end
100
+
101
+ # Compute and add the Proxy-Authorization header
102
+ def include_proxy_authorization_header
103
+ digest = Base64.encode64("#{proxy[:proxy_username]}:#{proxy[:proxy_password]}").chomp
104
+ headers['Proxy-Authorization'] = "Basic #{digest}"
105
+ end
106
+
107
+ # Compute HTTP request header for direct or proxy request
108
+ def request_header
109
+ if using_proxy?
110
+ "#{verb.to_s.upcase} #{uri} HTTP/#{version}"
111
+ else
112
+ path = uri.query && !uri.query.empty? ? "#{uri.path}?#{uri.query}" : uri.path
113
+ path = '/' if path.empty?
114
+ "#{verb.to_s.upcase} #{path} HTTP/#{version}"
115
+ end
116
+ end
117
+
118
+ # Host for tcp socket
119
+ def socket_host
120
+ using_proxy? ? proxy[:proxy_address] : uri.host
121
+ end
122
+
123
+ # Port for tcp socket
124
+ def socket_port
125
+ using_proxy? ? proxy[:proxy_port] : uri.port
77
126
  end
78
127
  end
79
128
  end
@@ -0,0 +1,79 @@
1
+ module HTTP
2
+ class Request
3
+ class Writer
4
+ # CRLF is the universal HTTP delimiter
5
+ CRLF = "\r\n"
6
+
7
+ def initialize(socket, body, headers, headerstart) # rubocop:disable ParameterLists
8
+ @body = body
9
+ fail(RequestError, 'body of wrong type') unless valid_body_type
10
+ @socket = socket
11
+ @headers = headers
12
+ @request_header = [headerstart]
13
+ end
14
+
15
+ def valid_body_type
16
+ valid_types = [String, NilClass, Enumerable]
17
+ checks = valid_types.map { |type| @body.is_a?(type) }
18
+ checks.any?
19
+ end
20
+
21
+ # Adds headers to the request header from the headers array
22
+ def add_headers
23
+ @headers.each do |field, value|
24
+ @request_header << "#{field}: #{value}"
25
+ end
26
+ end
27
+
28
+ # Stream the request to a socket
29
+ def stream
30
+ send_request_header
31
+ send_request_body
32
+ end
33
+
34
+ # Adds the headers to the header array for the given request body we are working
35
+ # with
36
+ def add_body_type_headers
37
+ if @body.is_a?(String) && !@headers['Content-Length']
38
+ @request_header << "Content-Length: #{@body.length}"
39
+ elsif @body.is_a?(Enumerable)
40
+ encoding = @headers['Transfer-Encoding']
41
+ if encoding == 'chunked'
42
+ @request_header << 'Transfer-Encoding: chunked'
43
+ else
44
+ fail(RequestError, 'invalid transfer encoding')
45
+ end
46
+ end
47
+ end
48
+
49
+ # Joins the headers specified in the request into a correctly formatted
50
+ # http request header string
51
+ def join_headers
52
+ # join the headers array with crlfs, stick two on the end because
53
+ # that ends the request header
54
+ @request_header.join(CRLF) + (CRLF) * 2
55
+ end
56
+
57
+ def send_request_header
58
+ add_headers
59
+ add_body_type_headers
60
+ header = join_headers
61
+
62
+ @socket << header
63
+ end
64
+
65
+ def send_request_body
66
+ if @body.is_a?(String)
67
+ @socket << @body
68
+ elsif @body.is_a?(Enumerable)
69
+ @body.each do |chunk|
70
+ @socket << chunk.bytesize.to_s(16) << CRLF
71
+ @socket << chunk
72
+ end
73
+
74
+ @socket << '0' << CRLF * 2
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
data/lib/http/response.rb CHANGED
@@ -1,9 +1,11 @@
1
1
  require 'delegate'
2
- require 'http/header'
2
+ require 'http/headers'
3
+ require 'http/content_type'
4
+ require 'http/mime_type'
3
5
 
4
6
  module HTTP
5
7
  class Response
6
- include HTTP::Header
8
+ include HTTP::Headers::Mixin
7
9
 
8
10
  STATUS_CODES = {
9
11
  100 => 'Continue',
@@ -65,96 +67,65 @@ module HTTP
65
67
  SYMBOL_TO_STATUS_CODE.freeze
66
68
 
67
69
  attr_reader :status
68
- attr_reader :headers
70
+ attr_reader :body
71
+ attr_reader :uri
69
72
 
70
73
  # Status aliases! TIMTOWTDI!!! (Want to be idiomatic? Just use status :)
71
74
  alias_method :code, :status
72
75
  alias_method :status_code, :status
73
76
 
74
- def initialize(status = nil, version = "1.1", headers = {}, body = nil, &body_proc)
75
- @status, @version, @body, @body_proc = status, version, body, body_proc
76
-
77
- @headers = {}
78
- headers.each do |field, value|
79
- @headers[canonicalize_header(field)] = value
80
- end
81
- end
82
-
83
- # Set a header
84
- def []=(name, value)
85
- # If we have a canonical header, we're done
86
- key = name[CANONICAL_HEADER]
87
-
88
- # Convert to canonical capitalization
89
- key ||= canonicalize_header(name)
90
-
91
- # Check if the header has already been set and group
92
- old_value = @headers[key]
93
- if old_value
94
- @headers[key] = [old_value].flatten << key
95
- else
96
- @headers[key] = value
97
- end
77
+ def initialize(status, version, headers, body, uri = nil) # rubocop:disable ParameterLists
78
+ @status, @version, @body, @uri = status, version, body, uri
79
+ @headers = HTTP::Headers.coerce(headers || {})
98
80
  end
99
81
 
100
82
  # Obtain the 'Reason-Phrase' for the response
101
83
  def reason
102
- # FIXME: should get the real reason phrase from the parser
103
84
  STATUS_CODES[@status]
104
85
  end
105
86
 
106
- # Get a header value
107
- def [](name)
108
- @headers[name] || @headers[canonicalize_header(name)]
87
+ # Returns an Array ala Rack: `[status, headers, body]`
88
+ def to_a
89
+ [status, headers.to_h, body.to_s]
109
90
  end
110
91
 
111
- # Obtain the response body
112
- def body
113
- @body ||= begin
114
- raise "no body available for this response" unless @body_proc
92
+ # Return the response body as a string
93
+ def to_s
94
+ body.to_s
95
+ end
96
+ alias_method :to_str, :to_s
115
97
 
116
- body = "" unless block_given?
117
- while (chunk = @body_proc.call)
118
- if block_given?
119
- yield chunk
120
- else
121
- body << chunk
122
- end
123
- end
124
- body unless block_given?
125
- end
98
+ # Parsed Content-Type header
99
+ # @return [HTTP::ContentType]
100
+ def content_type
101
+ @content_type ||= ContentType.parse headers['Content-Type']
126
102
  end
127
103
 
128
- # Parse the response body according to its content type
129
- def parse_body
130
- if @headers['Content-Type']
131
- mime_type = MimeType[@headers['Content-Type'].split(/;\s*/).first]
132
- return mime_type.parse(body) if mime_type
133
- end
104
+ # MIME type of response (if any)
105
+ # @return [String, nil]
106
+ def mime_type
107
+ @mime_type ||= content_type.mime_type
108
+ end
134
109
 
135
- body
110
+ # Charset of response (if any)
111
+ # @return [String, nil]
112
+ def charset
113
+ @charset ||= content_type.charset
136
114
  end
137
115
 
138
- # Returns an Array ala Rack: `[status, headers, body]`
139
- def to_a
140
- [status, headers, parse_body]
116
+ # Parse response body with corresponding MIME type adapter.
117
+ #
118
+ # @param [#to_s] as Parse as given MIME type
119
+ # instead of the one determined from headers
120
+ # @raise [Error] if adapter not found
121
+ # @return [Object]
122
+ def parse(as = nil)
123
+ MimeType[as || mime_type].decode to_s
141
124
  end
142
125
 
143
126
  # Inspect a response
144
127
  def inspect
145
- "#<#{self.class}/#{@version} #{status} #{reason} @headers=#{@headers.inspect}>"
146
- end
147
-
148
- class BodyDelegator < ::Delegator
149
- attr_reader :response
150
-
151
- def initialize(response, body = response.body)
152
- super(body)
153
- @response, @body = response, body
154
- end
155
-
156
- def __getobj__; @body; end
157
- def __setobj__(obj); @body = obj; end
128
+ "#<#{self.class}/#{@version} #{status} #{reason} headers=#{headers.inspect}>"
158
129
  end
159
130
  end
160
131
  end
@@ -0,0 +1,62 @@
1
+ require 'forwardable'
2
+
3
+ module HTTP
4
+ class Response
5
+ # A streamable response body, also easily converted into a string
6
+ class Body
7
+ extend Forwardable
8
+ include Enumerable
9
+ def_delegator :to_s, :empty?
10
+
11
+ def initialize(client)
12
+ @client = client
13
+ @streaming = nil
14
+ @contents = nil
15
+ end
16
+
17
+ # Read up to length bytes, but return any data that's available
18
+ def readpartial(length = nil)
19
+ stream!
20
+ @client.readpartial(length)
21
+ end
22
+
23
+ # Iterate over the body, allowing it to be enumerable
24
+ def each
25
+ while (chunk = readpartial)
26
+ yield chunk
27
+ end
28
+ end
29
+
30
+ # Eagerly consume the entire body as a string
31
+ def to_s
32
+ return @contents if @contents
33
+ fail StateError, 'body is being streamed' unless @streaming.nil?
34
+
35
+ begin
36
+ @streaming = false
37
+ @contents = ''
38
+ while (chunk = @client.readpartial)
39
+ @contents << chunk
40
+ end
41
+ rescue
42
+ @contents = nil
43
+ raise
44
+ end
45
+
46
+ @contents
47
+ end
48
+ alias_method :to_str, :to_s
49
+
50
+ # Assert that the body is actively being streamed
51
+ def stream!
52
+ fail StateError, 'body has already been consumed' if @streaming == false
53
+ @streaming = true
54
+ end
55
+
56
+ # Easier to interpret string inspect
57
+ def inspect
58
+ "#<#{self.class}:#{object_id.to_s(16)} @streaming=#{!!@streaming}>"
59
+ end
60
+ end
61
+ end
62
+ end