http 3.3.0 → 4.4.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +3 -1
  4. data/.travis.yml +10 -7
  5. data/CHANGES.md +135 -0
  6. data/README.md +14 -10
  7. data/Rakefile +1 -1
  8. data/http.gemspec +12 -5
  9. data/lib/http.rb +1 -2
  10. data/lib/http/chainable.rb +20 -29
  11. data/lib/http/client.rb +25 -19
  12. data/lib/http/connection.rb +5 -9
  13. data/lib/http/feature.rb +14 -0
  14. data/lib/http/features/auto_deflate.rb +27 -6
  15. data/lib/http/features/auto_inflate.rb +33 -6
  16. data/lib/http/features/instrumentation.rb +64 -0
  17. data/lib/http/features/logging.rb +55 -0
  18. data/lib/http/features/normalize_uri.rb +17 -0
  19. data/lib/http/headers/known.rb +3 -0
  20. data/lib/http/options.rb +27 -21
  21. data/lib/http/redirector.rb +2 -1
  22. data/lib/http/request.rb +38 -30
  23. data/lib/http/request/body.rb +30 -1
  24. data/lib/http/request/writer.rb +21 -7
  25. data/lib/http/response.rb +7 -15
  26. data/lib/http/response/parser.rb +56 -16
  27. data/lib/http/timeout/global.rb +12 -14
  28. data/lib/http/timeout/per_operation.rb +5 -7
  29. data/lib/http/uri.rb +13 -0
  30. data/lib/http/version.rb +1 -1
  31. data/spec/lib/http/client_spec.rb +34 -7
  32. data/spec/lib/http/features/auto_inflate_spec.rb +38 -22
  33. data/spec/lib/http/features/instrumentation_spec.rb +56 -0
  34. data/spec/lib/http/features/logging_spec.rb +67 -0
  35. data/spec/lib/http/redirector_spec.rb +13 -0
  36. data/spec/lib/http/request/body_spec.rb +51 -0
  37. data/spec/lib/http/request/writer_spec.rb +20 -0
  38. data/spec/lib/http/request_spec.rb +6 -0
  39. data/spec/lib/http/response/parser_spec.rb +45 -0
  40. data/spec/lib/http/response_spec.rb +3 -4
  41. data/spec/lib/http_spec.rb +45 -65
  42. data/spec/regression_specs.rb +7 -0
  43. data/spec/support/dummy_server/servlet.rb +5 -0
  44. data/spec/support/http_handling_shared.rb +60 -64
  45. metadata +32 -21
  46. data/.ruby-version +0 -1
data/lib/http/request.rb CHANGED
@@ -66,6 +66,8 @@ module HTTP
66
66
  # Scheme is normalized to be a lowercase symbol e.g. :http, :https
67
67
  attr_reader :scheme
68
68
 
69
+ attr_reader :uri_normalizer
70
+
69
71
  # "Request URI" as per RFC 2616
70
72
  # http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html
71
73
  attr_reader :uri
@@ -73,25 +75,25 @@ module HTTP
73
75
 
74
76
  # @option opts [String] :version
75
77
  # @option opts [#to_s] :verb HTTP request method
78
+ # @option opts [#call] :uri_normalizer (HTTP::URI::NORMALIZER)
76
79
  # @option opts [HTTP::URI, #to_s] :uri
77
80
  # @option opts [Hash] :headers
78
81
  # @option opts [Hash] :proxy
79
82
  # @option opts [String, Enumerable, IO, nil] :body
80
83
  def initialize(opts)
81
- @verb = opts.fetch(:verb).to_s.downcase.to_sym
82
- @uri = normalize_uri(opts.fetch(:uri))
84
+ @verb = opts.fetch(:verb).to_s.downcase.to_sym
85
+ @uri_normalizer = opts[:uri_normalizer] || HTTP::URI::NORMALIZER
86
+
87
+ @uri = @uri_normalizer.call(opts.fetch(:uri))
83
88
  @scheme = @uri.scheme.to_s.downcase.to_sym if @uri.scheme
84
89
 
85
90
  raise(UnsupportedMethodError, "unknown method: #{verb}") unless METHODS.include?(@verb)
86
91
  raise(UnsupportedSchemeError, "unknown scheme: #{scheme}") unless SCHEMES.include?(@scheme)
87
92
 
88
93
  @proxy = opts[:proxy] || {}
89
- @body = request_body(opts[:body], opts)
90
94
  @version = opts[:version] || "1.1"
91
- @headers = HTTP::Headers.coerce(opts[:headers] || {})
92
-
93
- @headers[Headers::HOST] ||= default_host_header_value
94
- @headers[Headers::USER_AGENT] ||= USER_AGENT
95
+ @headers = prepare_headers(opts[:headers])
96
+ @body = prepare_body(opts[:body])
95
97
  end
96
98
 
97
99
  # Returns new Request with updated uri
@@ -100,12 +102,13 @@ module HTTP
100
102
  headers.delete(Headers::HOST)
101
103
 
102
104
  self.class.new(
103
- :verb => verb,
104
- :uri => @uri.join(uri),
105
- :headers => headers,
106
- :proxy => proxy,
107
- :body => body,
108
- :version => version
105
+ :verb => verb,
106
+ :uri => @uri.join(uri),
107
+ :headers => headers,
108
+ :proxy => proxy,
109
+ :body => body.source,
110
+ :version => version,
111
+ :uri_normalizer => uri_normalizer
109
112
  )
110
113
  end
111
114
 
@@ -184,15 +187,20 @@ module HTTP
184
187
  using_proxy? ? proxy[:proxy_port] : port
185
188
  end
186
189
 
187
- private
188
-
189
- # Transforms body to an object suitable for streaming.
190
- def request_body(body, opts)
191
- body = Request::Body.new(body) unless body.is_a?(Request::Body)
192
- body = opts[:auto_deflate].deflated_body(body) if opts[:auto_deflate]
193
- body
190
+ # Human-readable representation of base request info.
191
+ #
192
+ # @example
193
+ #
194
+ # req.inspect
195
+ # # => #<HTTP::Request/1.1 GET https://example.com>
196
+ #
197
+ # @return [String]
198
+ def inspect
199
+ "#<#{self.class}/#{@version} #{verb.to_s.upcase} #{uri}>"
194
200
  end
195
201
 
202
+ private
203
+
196
204
  # @!attribute [r] host
197
205
  # @return [String]
198
206
  def_delegator :@uri, :host
@@ -208,17 +216,17 @@ module HTTP
208
216
  PORTS[@scheme] != port ? "#{host}:#{port}" : host
209
217
  end
210
218
 
211
- # @return [HTTP::URI] URI with all componentes but query being normalized.
212
- def normalize_uri(uri)
213
- uri = HTTP::URI.parse uri
219
+ def prepare_body(body)
220
+ body.is_a?(Request::Body) ? body : Request::Body.new(body)
221
+ end
214
222
 
215
- HTTP::URI.new(
216
- :scheme => uri.normalized_scheme,
217
- :authority => uri.normalized_authority,
218
- :path => uri.normalized_path,
219
- :query => uri.query,
220
- :fragment => uri.normalized_fragment
221
- )
223
+ def prepare_headers(headers)
224
+ headers = HTTP::Headers.coerce(headers || {})
225
+
226
+ headers[Headers::HOST] ||= default_host_header_value
227
+ headers[Headers::USER_AGENT] ||= USER_AGENT
228
+
229
+ headers
222
230
  end
223
231
  end
224
232
  end
@@ -35,14 +35,43 @@ module HTTP
35
35
  yield @source
36
36
  elsif @source.respond_to?(:read)
37
37
  IO.copy_stream(@source, ProcIO.new(block))
38
- @source.rewind if @source.respond_to?(:rewind)
38
+ rewind(@source)
39
39
  elsif @source.is_a?(Enumerable)
40
40
  @source.each(&block)
41
41
  end
42
+
43
+ self
44
+ end
45
+
46
+ # Request bodies are equivalent when they have the same source.
47
+ def ==(other)
48
+ self.class == other.class && self.source == other.source # rubocop:disable Style/RedundantSelf
42
49
  end
43
50
 
44
51
  private
45
52
 
53
+ def rewind(io)
54
+ io.rewind if io.respond_to? :rewind
55
+ rescue Errno::ESPIPE, Errno::EPIPE
56
+ # Pipe IOs respond to `:rewind` but fail when you call it.
57
+ #
58
+ # Calling `IO#rewind` on a pipe, fails with *ESPIPE* on MRI,
59
+ # but *EPIPE* on jRuby.
60
+ #
61
+ # - **ESPIPE** -- "Illegal seek."
62
+ # Invalid seek operation (such as on a pipe).
63
+ #
64
+ # - **EPIPE** -- "Broken pipe."
65
+ # There is no process reading from the other end of a pipe. Every
66
+ # library function that returns this error code also generates
67
+ # a SIGPIPE signal; this signal terminates the program if not handled
68
+ # or blocked. Thus, your program will never actually see EPIPE unless
69
+ # it has handled or blocked SIGPIPE.
70
+ #
71
+ # See: https://www.gnu.org/software/libc/manual/html_node/Error-Codes.html
72
+ nil
73
+ end
74
+
46
75
  def validate_source_type!
47
76
  return if @source.is_a?(String)
48
77
  return if @source.respond_to?(:read)
@@ -60,22 +60,32 @@ module HTTP
60
60
  @request_header.join(CRLF) + CRLF * 2
61
61
  end
62
62
 
63
+ # Writes HTTP request data into the socket.
63
64
  def send_request
64
- # It's important to send the request in a single write call when
65
- # possible in order to play nicely with Nagle's algorithm. Making
66
- # two writes in a row triggers a pathological case where Nagle is
67
- # expecting a third write that never happens.
65
+ each_chunk { |chunk| write chunk }
66
+ rescue Errno::EPIPE
67
+ # server doesn't need any more data
68
+ nil
69
+ end
70
+
71
+ # Yields chunks of request data that should be sent to the socket.
72
+ #
73
+ # It's important to send the request in a single write call when possible
74
+ # in order to play nicely with Nagle's algorithm. Making two writes in a
75
+ # row triggers a pathological case where Nagle is expecting a third write
76
+ # that never happens.
77
+ def each_chunk
68
78
  data = join_headers
69
79
 
70
80
  @body.each do |chunk|
71
81
  data << encode_chunk(chunk)
72
- write(data)
82
+ yield data
73
83
  data.clear
74
84
  end
75
85
 
76
- write(data) unless data.empty?
86
+ yield data unless data.empty?
77
87
 
78
- write(CHUNKED_END) if chunked?
88
+ yield CHUNKED_END if chunked?
79
89
  end
80
90
 
81
91
  # Returns the chunk encoded for to the specified "Transfer-Encoding" header.
@@ -100,6 +110,10 @@ module HTTP
100
110
  break unless data.bytesize > length
101
111
  data = data.byteslice(length..-1)
102
112
  end
113
+ rescue Errno::EPIPE
114
+ raise
115
+ rescue IOError, SocketError, SystemCallError => ex
116
+ raise ConnectionError, "error writing to socket: #{ex}", ex.backtrace
103
117
  end
104
118
  end
105
119
  end
data/lib/http/response.rb CHANGED
@@ -20,6 +20,9 @@ module HTTP
20
20
  # @return [Status]
21
21
  attr_reader :status
22
22
 
23
+ # @return [String]
24
+ attr_reader :version
25
+
23
26
  # @return [Body]
24
27
  attr_reader :body
25
28
 
@@ -46,14 +49,13 @@ module HTTP
46
49
  @headers = HTTP::Headers.coerce(opts[:headers] || {})
47
50
  @proxy_headers = HTTP::Headers.coerce(opts[:proxy_headers] || {})
48
51
 
49
- if opts.include?(:connection)
52
+ if opts.include?(:body)
53
+ @body = opts.fetch(:body)
54
+ else
50
55
  connection = opts.fetch(:connection)
51
56
  encoding = opts[:encoding] || charset || Encoding::BINARY
52
- stream = body_stream_for(connection, opts)
53
57
 
54
- @body = Response::Body.new(stream, :encoding => encoding)
55
- else
56
- @body = opts.fetch(:body)
58
+ @body = Response::Body.new(connection, :encoding => encoding)
57
59
  end
58
60
  end
59
61
 
@@ -160,15 +162,5 @@ module HTTP
160
162
  def inspect
161
163
  "#<#{self.class}/#{@version} #{code} #{reason} #{headers.to_h.inspect}>"
162
164
  end
163
-
164
- private
165
-
166
- def body_stream_for(connection, opts)
167
- if opts[:auto_inflate]
168
- opts[:auto_inflate].stream_for(connection, self)
169
- else
170
- connection
171
- end
172
- end
173
165
  end
174
166
  end
@@ -1,41 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "http-parser"
4
+
3
5
  module HTTP
4
6
  class Response
7
+ # @api private
8
+ #
9
+ # NOTE(ixti): This class is a subject of future refactoring, thus don't
10
+ # expect this class API to be stable until this message disappears and
11
+ # class is not marked as private anymore.
5
12
  class Parser
6
13
  attr_reader :headers
7
14
 
8
15
  def initialize
9
- @parser = HTTP::Parser.new(self)
16
+ @state = HttpParser::Parser.new_instance { |i| i.type = :response }
17
+ @parser = HttpParser::Parser.new(self)
18
+
10
19
  reset
11
20
  end
12
21
 
22
+ # @return [self]
13
23
  def add(data)
14
- @parser << data
24
+ # XXX(ixti): API doc of HttpParser::Parser is misleading, it says that
25
+ # it returns boolean true if data was parsed successfully, but instead
26
+ # it's response tells if there was an error; So when it's `true` that
27
+ # means parse failed, and `false` means parse was successful.
28
+ # case of success.
29
+ return self unless @parser.parse(@state, data)
30
+
31
+ raise IOError, "Could not parse data"
15
32
  end
16
33
  alias << add
17
34
 
18
35
  def headers?
19
- !!@headers
36
+ @finished[:headers]
20
37
  end
21
38
 
22
39
  def http_version
23
- @parser.http_version.join(".")
40
+ @state.http_version
24
41
  end
25
42
 
26
43
  def status_code
27
- @parser.status_code
44
+ @state.http_status
28
45
  end
29
46
 
30
47
  #
31
48
  # HTTP::Parser callbacks
32
49
  #
33
50
 
34
- def on_headers_complete(headers)
35
- @headers = headers
51
+ def on_header_field(_response, field)
52
+ append_header if @reading_header_value
53
+ @field << field
54
+ end
55
+
56
+ def on_header_value(_response, value)
57
+ @reading_header_value = true
58
+ @field_value << value
59
+ end
60
+
61
+ def on_headers_complete(_reposse)
62
+ append_header if @reading_header_value
63
+ @finished[:headers] = true
36
64
  end
37
65
 
38
- def on_body(chunk)
66
+ def on_body(_response, chunk)
39
67
  if @chunk
40
68
  @chunk << chunk
41
69
  else
@@ -57,20 +85,32 @@ module HTTP
57
85
  chunk
58
86
  end
59
87
 
60
- def on_message_complete
61
- @finished = true
88
+ def on_message_complete(_response)
89
+ @finished[:message] = true
62
90
  end
63
91
 
64
92
  def reset
65
- @parser.reset!
66
-
67
- @finished = false
68
- @headers = nil
69
- @chunk = nil
93
+ @state.reset!
94
+
95
+ @finished = Hash.new(false)
96
+ @headers = HTTP::Headers.new
97
+ @reading_header_value = false
98
+ @field = +""
99
+ @field_value = +""
100
+ @chunk = nil
70
101
  end
71
102
 
72
103
  def finished?
73
- @finished
104
+ @finished[:message]
105
+ end
106
+
107
+ private
108
+
109
+ def append_header
110
+ @headers.add(@field, @field_value)
111
+ @reading_header_value = false
112
+ @field_value = +""
113
+ @field = +""
74
114
  end
75
115
  end
76
116
  end
@@ -3,27 +3,25 @@
3
3
  require "timeout"
4
4
  require "io/wait"
5
5
 
6
- require "http/timeout/per_operation"
6
+ require "http/timeout/null"
7
7
 
8
8
  module HTTP
9
9
  module Timeout
10
- class Global < PerOperation
11
- attr_reader :time_left, :total_timeout
12
-
10
+ class Global < Null
13
11
  def initialize(*args)
14
12
  super
15
- reset_counter
13
+
14
+ @timeout = @time_left = options.fetch(:global_timeout)
16
15
  end
17
16
 
18
17
  # To future me: Don't remove this again, past you was smarter.
19
18
  def reset_counter
20
- @time_left = connect_timeout + read_timeout + write_timeout
21
- @total_timeout = time_left
19
+ @time_left = @timeout
22
20
  end
23
21
 
24
22
  def connect(socket_class, host, port, nodelay = false)
25
23
  reset_timer
26
- ::Timeout.timeout(time_left, TimeoutError) do
24
+ ::Timeout.timeout(@time_left, TimeoutError) do
27
25
  @socket = socket_class.open(host, port)
28
26
  @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
29
27
  end
@@ -37,11 +35,11 @@ module HTTP
37
35
  begin
38
36
  @socket.connect_nonblock
39
37
  rescue IO::WaitReadable
40
- IO.select([@socket], nil, nil, time_left)
38
+ IO.select([@socket], nil, nil, @time_left)
41
39
  log_time
42
40
  retry
43
41
  rescue IO::WaitWritable
44
- IO.select(nil, [@socket], nil, time_left)
42
+ IO.select(nil, [@socket], nil, @time_left)
45
43
  log_time
46
44
  retry
47
45
  end
@@ -105,13 +103,13 @@ module HTTP
105
103
 
106
104
  # Wait for a socket to become readable
107
105
  def wait_readable_or_timeout
108
- @socket.to_io.wait_readable(time_left)
106
+ @socket.to_io.wait_readable(@time_left)
109
107
  log_time
110
108
  end
111
109
 
112
110
  # Wait for a socket to become writable
113
111
  def wait_writable_or_timeout
114
- @socket.to_io.wait_writable(time_left)
112
+ @socket.to_io.wait_writable(@time_left)
115
113
  log_time
116
114
  end
117
115
 
@@ -123,8 +121,8 @@ module HTTP
123
121
 
124
122
  def log_time
125
123
  @time_left -= (Time.now - @started)
126
- if time_left <= 0
127
- raise TimeoutError, "Timed out after using the allocated #{total_timeout} seconds"
124
+ if @time_left <= 0
125
+ raise TimeoutError, "Timed out after using the allocated #{@timeout} seconds"
128
126
  end
129
127
 
130
128
  reset_timer