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
@@ -11,8 +11,6 @@ module HTTP
11
11
  WRITE_TIMEOUT = 0.25
12
12
  READ_TIMEOUT = 0.25
13
13
 
14
- attr_reader :read_timeout, :write_timeout, :connect_timeout
15
-
16
14
  def initialize(*args)
17
15
  super
18
16
 
@@ -22,7 +20,7 @@ module HTTP
22
20
  end
23
21
 
24
22
  def connect(socket_class, host, port, nodelay = false)
25
- ::Timeout.timeout(connect_timeout, TimeoutError) do
23
+ ::Timeout.timeout(@connect_timeout, TimeoutError) do
26
24
  @socket = socket_class.open(host, port)
27
25
  @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
28
26
  end
@@ -67,7 +65,7 @@ module HTTP
67
65
  return :eof if result.nil?
68
66
  return result if result != :wait_readable
69
67
 
70
- raise TimeoutError, "Read timed out after #{read_timeout} seconds" if timeout
68
+ raise TimeoutError, "Read timed out after #{@read_timeout} seconds" if timeout
71
69
  # marking the socket for timeout. Why is this not being raised immediately?
72
70
  # it seems there is some race-condition on the network level between calling
73
71
  # #read_nonblock and #wait_readable, in which #read_nonblock signalizes waiting
@@ -78,7 +76,7 @@ module HTTP
78
76
  # timeout. Else, the first timeout was a proper timeout.
79
77
  # This hack has to be done because io/wait#wait_readable doesn't provide a value for when
80
78
  # the socket is closed by the server, and HTTP::Parser doesn't provide the limit for the chunks.
81
- timeout = true unless @socket.to_io.wait_readable(read_timeout)
79
+ timeout = true unless @socket.to_io.wait_readable(@read_timeout)
82
80
  end
83
81
  end
84
82
 
@@ -89,9 +87,9 @@ module HTTP
89
87
  result = @socket.write_nonblock(data, :exception => false)
90
88
  return result unless result == :wait_writable
91
89
 
92
- raise TimeoutError, "Write timed out after #{write_timeout} seconds" if timeout
90
+ raise TimeoutError, "Write timed out after #{@write_timeout} seconds" if timeout
93
91
 
94
- timeout = true unless @socket.to_io.wait_writable(write_timeout)
92
+ timeout = true unless @socket.to_io.wait_writable(@write_timeout)
95
93
  end
96
94
  end
97
95
 
data/lib/http/uri.rb CHANGED
@@ -26,6 +26,19 @@ module HTTP
26
26
  # @private
27
27
  HTTPS_SCHEME = "https"
28
28
 
29
+ # @private
30
+ NORMALIZER = lambda do |uri|
31
+ uri = HTTP::URI.parse uri
32
+
33
+ HTTP::URI.new(
34
+ :scheme => uri.normalized_scheme,
35
+ :authority => uri.normalized_authority,
36
+ :path => uri.normalized_path,
37
+ :query => uri.query,
38
+ :fragment => uri.normalized_fragment
39
+ )
40
+ end
41
+
29
42
  # Parse the given URI string, returning an HTTP::URI object
30
43
  #
31
44
  # @param [HTTP::URI, String, #to_str] uri to parse
data/lib/http/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HTTP
4
- VERSION = "3.3.0"
4
+ VERSION = "4.4.1"
5
5
  end
@@ -98,9 +98,8 @@ RSpec.describe HTTP::Client do
98
98
  end
99
99
 
100
100
  it "works like a charm in real world" do
101
- url = "http://git.io/jNeY"
102
- client = HTTP.follow
103
- expect(client.get(url).to_s).to include "support for non-ascii URIs"
101
+ expect(HTTP.follow.get("https://bit.ly/2UaBT4R").parse(:json)).
102
+ to include("url" => "https://httpbin.org/anything/könig")
104
103
  end
105
104
  end
106
105
  end
@@ -190,6 +189,22 @@ RSpec.describe HTTP::Client do
190
189
 
191
190
  client.get("http://example.com/", :form => {:foo => HTTP::FormData::Part.new("content")})
192
191
  end
192
+
193
+ context "when passing an HTTP::FormData object directly" do
194
+ it "creates url encoded form data object" do
195
+ client = HTTP::Client.new
196
+ form_data = HTTP::FormData::Multipart.new(:foo => "bar")
197
+
198
+ allow(client).to receive(:perform)
199
+
200
+ expect(HTTP::Request).to receive(:new) do |opts|
201
+ expect(opts[:body]).to be form_data
202
+ expect(opts[:body].to_s).to match(/^Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n/m)
203
+ end
204
+
205
+ client.get("http://example.com/", :form => form_data)
206
+ end
207
+ end
193
208
  end
194
209
 
195
210
  describe "passing json" do
@@ -213,9 +228,9 @@ RSpec.describe HTTP::Client do
213
228
  end
214
229
 
215
230
  it "works like a charm in real world" do
216
- url = "https://github.com/httprb/http.rb/pull/197/ö無"
217
- client = HTTP.follow
218
- expect(client.get(url).to_s).to include "support for non-ascii URIs"
231
+ url = "https://httpbin.org/anything/ö無"
232
+
233
+ expect(HTTP.follow.get(url).parse(:json)).to include("url" => url)
219
234
  end
220
235
  end
221
236
 
@@ -234,7 +249,7 @@ RSpec.describe HTTP::Client do
234
249
 
235
250
  context "when :auto_deflate was specified" do
236
251
  let(:headers) { {"Content-Length" => "12"} }
237
- let(:client) { described_class.new :headers => headers, :features => {:auto_deflate => {}} }
252
+ let(:client) { described_class.new :headers => headers, :features => {:auto_deflate => {}}, :body => "foo" }
238
253
 
239
254
  it "deletes Content-Length header" do
240
255
  expect(client).to receive(:perform) do |req, _|
@@ -251,6 +266,18 @@ RSpec.describe HTTP::Client do
251
266
 
252
267
  client.request(:get, "http://example.com/")
253
268
  end
269
+
270
+ context "and there is no body" do
271
+ let(:client) { described_class.new :headers => headers, :features => {:auto_deflate => {}} }
272
+
273
+ it "doesn't set Content-Encoding header" do
274
+ expect(client).to receive(:perform) do |req, _|
275
+ expect(req.headers).not_to include "Content-Encoding"
276
+ end
277
+
278
+ client.request(:get, "http://example.com/")
279
+ end
280
+ end
254
281
  end
255
282
  end
256
283
 
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  RSpec.describe HTTP::Features::AutoInflate do
4
- subject { HTTP::Features::AutoInflate.new }
4
+ subject(:feature) { HTTP::Features::AutoInflate.new }
5
+
5
6
  let(:connection) { double }
6
- let(:headers) { {} }
7
+ let(:headers) { {} }
8
+
7
9
  let(:response) do
8
10
  HTTP::Response.new(
9
11
  :version => "1.1",
@@ -13,56 +15,70 @@ RSpec.describe HTTP::Features::AutoInflate do
13
15
  )
14
16
  end
15
17
 
16
- describe "stream_for" do
18
+ describe "#wrap_response" do
19
+ subject(:result) { feature.wrap_response(response) }
20
+
17
21
  context "when there is no Content-Encoding header" do
18
- it "returns connection" do
19
- stream = subject.stream_for(connection, response)
20
- expect(stream).to eq(connection)
22
+ it "returns original request" do
23
+ expect(result).to be response
21
24
  end
22
25
  end
23
26
 
24
27
  context "for identity Content-Encoding header" do
25
- let(:headers) { {:content_encoding => "not-supported"} }
28
+ let(:headers) { {:content_encoding => "identity"} }
26
29
 
27
- it "returns connection" do
28
- stream = subject.stream_for(connection, response)
29
- expect(stream).to eq(connection)
30
+ it "returns original request" do
31
+ expect(result).to be response
30
32
  end
31
33
  end
32
34
 
33
35
  context "for unknown Content-Encoding header" do
34
36
  let(:headers) { {:content_encoding => "not-supported"} }
35
37
 
36
- it "returns connection" do
37
- stream = subject.stream_for(connection, response)
38
- expect(stream).to eq(connection)
38
+ it "returns original request" do
39
+ expect(result).to be response
39
40
  end
40
41
  end
41
42
 
42
43
  context "for deflate Content-Encoding header" do
43
44
  let(:headers) { {:content_encoding => "deflate"} }
44
45
 
45
- it "returns HTTP::Response::Inflater instance - connection wrapper" do
46
- stream = subject.stream_for(connection, response)
47
- expect(stream).to be_instance_of HTTP::Response::Inflater
46
+ it "returns a HTTP::Response wrapping the inflated response body" do
47
+ expect(result.body).to be_instance_of HTTP::Response::Body
48
48
  end
49
49
  end
50
50
 
51
51
  context "for gzip Content-Encoding header" do
52
52
  let(:headers) { {:content_encoding => "gzip"} }
53
53
 
54
- it "returns HTTP::Response::Inflater instance - connection wrapper" do
55
- stream = subject.stream_for(connection, response)
56
- expect(stream).to be_instance_of HTTP::Response::Inflater
54
+ it "returns a HTTP::Response wrapping the inflated response body" do
55
+ expect(result.body).to be_instance_of HTTP::Response::Body
57
56
  end
58
57
  end
59
58
 
60
59
  context "for x-gzip Content-Encoding header" do
61
60
  let(:headers) { {:content_encoding => "x-gzip"} }
62
61
 
63
- it "returns HTTP::Response::Inflater instance - connection wrapper" do
64
- stream = subject.stream_for(connection, response)
65
- expect(stream).to be_instance_of HTTP::Response::Inflater
62
+ it "returns a HTTP::Response wrapping the inflated response body" do
63
+ expect(result.body).to be_instance_of HTTP::Response::Body
64
+ end
65
+ end
66
+
67
+ # TODO(ixti): We should refactor API to either make uri non-optional,
68
+ # or add reference to request into response object (better).
69
+ context "when response has uri" do
70
+ let(:response) do
71
+ HTTP::Response.new(
72
+ :version => "1.1",
73
+ :status => 200,
74
+ :headers => {:content_encoding => "gzip"},
75
+ :connection => connection,
76
+ :uri => "https://example.com"
77
+ )
78
+ end
79
+
80
+ it "preserves uri in wrapped response" do
81
+ expect(result.uri).to eq HTTP::URI.parse("https://example.com")
66
82
  end
67
83
  end
68
84
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe HTTP::Features::Instrumentation do
4
+ subject(:feature) { HTTP::Features::Instrumentation.new(:instrumenter => instrumenter) }
5
+ let(:instrumenter) { TestInstrumenter.new }
6
+
7
+ describe "logging the request" do
8
+ let(:request) do
9
+ HTTP::Request.new(
10
+ :verb => :post,
11
+ :uri => "https://example.com/",
12
+ :headers => {:accept => "application/json"},
13
+ :body => '{"hello": "world!"}'
14
+ )
15
+ end
16
+
17
+ it "should log the request" do
18
+ feature.wrap_request(request)
19
+
20
+ expect(instrumenter.output[:start]).to eq(:request => request)
21
+ end
22
+ end
23
+
24
+ describe "logging the response" do
25
+ let(:response) do
26
+ HTTP::Response.new(
27
+ :version => "1.1",
28
+ :uri => "https://example.com",
29
+ :status => 200,
30
+ :headers => {:content_type => "application/json"},
31
+ :body => '{"success": true}'
32
+ )
33
+ end
34
+
35
+ it "should log the response" do
36
+ feature.wrap_response(response)
37
+
38
+ expect(instrumenter.output[:finish]).to eq(:response => response)
39
+ end
40
+ end
41
+
42
+ class TestInstrumenter < HTTP::Features::Instrumentation::NullInstrumenter
43
+ attr_reader :output
44
+ def initialize
45
+ @output = {}
46
+ end
47
+
48
+ def start(_name, payload)
49
+ output[:start] = payload
50
+ end
51
+
52
+ def finish(_name, payload)
53
+ output[:finish] = payload
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ RSpec.describe HTTP::Features::Logging do
6
+ subject(:feature) do
7
+ logger = Logger.new(logdev)
8
+ logger.formatter = ->(severity, _, _, message) do
9
+ format("** %s **\n%s\n", severity, message)
10
+ end
11
+
12
+ described_class.new(:logger => logger)
13
+ end
14
+
15
+ let(:logdev) { StringIO.new }
16
+
17
+ describe "logging the request" do
18
+ let(:request) do
19
+ HTTP::Request.new(
20
+ :verb => :post,
21
+ :uri => "https://example.com/",
22
+ :headers => {:accept => "application/json"},
23
+ :body => '{"hello": "world!"}'
24
+ )
25
+ end
26
+
27
+ it "should log the request" do
28
+ feature.wrap_request(request)
29
+
30
+ expect(logdev.string).to eq <<~OUTPUT
31
+ ** INFO **
32
+ > POST https://example.com/
33
+ ** DEBUG **
34
+ Accept: application/json
35
+ Host: example.com
36
+ User-Agent: http.rb/#{HTTP::VERSION}
37
+
38
+ {"hello": "world!"}
39
+ OUTPUT
40
+ end
41
+ end
42
+
43
+ describe "logging the response" do
44
+ let(:response) do
45
+ HTTP::Response.new(
46
+ :version => "1.1",
47
+ :uri => "https://example.com",
48
+ :status => 200,
49
+ :headers => {:content_type => "application/json"},
50
+ :body => '{"success": true}'
51
+ )
52
+ end
53
+
54
+ it "should log the response" do
55
+ feature.wrap_response(response)
56
+
57
+ expect(logdev.string).to eq <<~OUTPUT
58
+ ** INFO **
59
+ < 200 OK
60
+ ** DEBUG **
61
+ Content-Type: application/json
62
+
63
+ {"success": true}
64
+ OUTPUT
65
+ end
66
+ end
67
+ end
@@ -75,6 +75,19 @@ RSpec.describe HTTP::Redirector do
75
75
  expect(res.to_s).to eq "foo"
76
76
  end
77
77
 
78
+ it "concatenates multiple Location headers" do
79
+ req = HTTP::Request.new :verb => :head, :uri => "http://example.com"
80
+ headers = HTTP::Headers.new
81
+
82
+ %w[http://example.com /123].each { |loc| headers.add("Location", loc) }
83
+
84
+ res = redirector.perform(req, simple_response(301, "", headers)) do |redirect|
85
+ simple_response(200, redirect.uri.to_s)
86
+ end
87
+
88
+ expect(res.to_s).to eq "http://example.com/123"
89
+ end
90
+
78
91
  context "following 300 redirect" do
79
92
  context "with strict mode" do
80
93
  let(:options) { {:strict => true} }
@@ -125,6 +125,28 @@ RSpec.describe HTTP::Request::Body do
125
125
  end
126
126
  end
127
127
 
128
+ context "when body is a pipe" do
129
+ let(:ios) { IO.pipe }
130
+ let(:body) { ios[0] }
131
+
132
+ around do |example|
133
+ writer = Thread.new(ios[1]) do |io|
134
+ io << "abcdef"
135
+ io.close
136
+ end
137
+
138
+ begin
139
+ example.run
140
+ ensure
141
+ writer.join
142
+ end
143
+ end
144
+
145
+ it "yields chunks of content" do
146
+ expect(chunks.inject("", :+)).to eq("abcdef")
147
+ end
148
+ end
149
+
128
150
  context "when body is an Enumerable IO" do
129
151
  let(:data) { "a" * 16 * 1024 + "b" * 10 * 1024 }
130
152
  let(:body) { StringIO.new data }
@@ -157,4 +179,33 @@ RSpec.describe HTTP::Request::Body do
157
179
  end
158
180
  end
159
181
  end
182
+
183
+ describe "#==" do
184
+ context "when sources are equivalent" do
185
+ let(:body1) { HTTP::Request::Body.new("content") }
186
+ let(:body2) { HTTP::Request::Body.new("content") }
187
+
188
+ it "returns true" do
189
+ expect(body1).to eq body2
190
+ end
191
+ end
192
+
193
+ context "when sources are not equivalent" do
194
+ let(:body1) { HTTP::Request::Body.new("content") }
195
+ let(:body2) { HTTP::Request::Body.new(nil) }
196
+
197
+ it "returns false" do
198
+ expect(body1).not_to eq body2
199
+ end
200
+ end
201
+
202
+ context "when objects are not of the same class" do
203
+ let(:body1) { HTTP::Request::Body.new("content") }
204
+ let(:body2) { "content" }
205
+
206
+ it "returns false" do
207
+ expect(body1).not_to eq body2
208
+ end
209
+ end
210
+ end
160
211
  end