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
@@ -7,7 +7,7 @@ require "http/response/parser"
7
7
 
8
8
  module HTTP
9
9
  # A connection to the HTTP server
10
- class Connection # rubocop: disable Metrics/ClassLength
10
+ class Connection
11
11
  extend Forwardable
12
12
 
13
13
  # Allowed values for CONNECTION header
@@ -93,19 +93,15 @@ module HTTP
93
93
  chunk = @parser.read(size)
94
94
  finish_response if finished
95
95
 
96
- chunk.to_s
96
+ chunk || "".b
97
97
  end
98
98
 
99
99
  # Reads data from socket up until headers are loaded
100
100
  # @return [void]
101
101
  def read_headers!
102
- loop do
103
- if read_more(BUFFER_SIZE) == :eof
104
- raise ConnectionError, "couldn't read response headers" unless @parser.headers?
105
- break
106
- elsif @parser.headers?
107
- break
108
- end
102
+ until @parser.headers?
103
+ result = read_more(BUFFER_SIZE)
104
+ raise ConnectionError, "couldn't read response headers" if result == :eof
109
105
  end
110
106
 
111
107
  set_keep_alive
data/lib/http/feature.rb CHANGED
@@ -5,5 +5,19 @@ module HTTP
5
5
  def initialize(opts = {}) # rubocop:disable Style/OptionHash
6
6
  @opts = opts
7
7
  end
8
+
9
+ def wrap_request(request)
10
+ request
11
+ end
12
+
13
+ def wrap_response(response)
14
+ response
15
+ end
8
16
  end
9
17
  end
18
+
19
+ require "http/features/auto_inflate"
20
+ require "http/features/auto_deflate"
21
+ require "http/features/logging"
22
+ require "http/features/instrumentation"
23
+ require "http/features/normalize_uri"
@@ -3,12 +3,14 @@
3
3
  require "zlib"
4
4
  require "tempfile"
5
5
 
6
+ require "http/request/body"
7
+
6
8
  module HTTP
7
9
  module Features
8
10
  class AutoDeflate < Feature
9
11
  attr_reader :method
10
12
 
11
- def initialize(*)
13
+ def initialize(**)
12
14
  super
13
15
 
14
16
  @method = @opts.key?(:method) ? @opts[:method].to_s : "gzip"
@@ -16,20 +18,39 @@ module HTTP
16
18
  raise Error, "Only gzip and deflate methods are supported" unless %w[gzip deflate].include?(@method)
17
19
  end
18
20
 
21
+ def wrap_request(request)
22
+ return request unless method
23
+ return request if request.body.size.zero?
24
+
25
+ # We need to delete Content-Length header. It will be set automatically by HTTP::Request::Writer
26
+ request.headers.delete(Headers::CONTENT_LENGTH)
27
+ request.headers[Headers::CONTENT_ENCODING] = method
28
+
29
+ Request.new(
30
+ :version => request.version,
31
+ :verb => request.verb,
32
+ :uri => request.uri,
33
+ :headers => request.headers,
34
+ :proxy => request.proxy,
35
+ :body => deflated_body(request.body),
36
+ :uri_normalizer => request.uri_normalizer
37
+ )
38
+ end
39
+
19
40
  def deflated_body(body)
20
41
  case method
21
42
  when "gzip"
22
43
  GzippedBody.new(body)
23
44
  when "deflate"
24
45
  DeflatedBody.new(body)
25
- else
26
- raise ArgumentError, "Unsupported deflate method: #{method}"
27
46
  end
28
47
  end
29
48
 
30
- class CompressedBody
31
- def initialize(body)
32
- @body = body
49
+ HTTP::Options.register_feature(:auto_deflate, self)
50
+
51
+ class CompressedBody < HTTP::Request::Body
52
+ def initialize(uncompressed_body)
53
+ @body = uncompressed_body
33
54
  @compressed = nil
34
55
  end
35
56
 
@@ -1,15 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module HTTP
4
6
  module Features
5
7
  class AutoInflate < Feature
6
- def stream_for(connection, response)
7
- if %w[deflate gzip x-gzip].include?(response.headers[:content_encoding])
8
- Response::Inflater.new(connection)
9
- else
10
- connection
11
- end
8
+ SUPPORTED_ENCODING = Set.new(%w[deflate gzip x-gzip]).freeze
9
+ private_constant :SUPPORTED_ENCODING
10
+
11
+ def wrap_response(response)
12
+ return response unless supported_encoding?(response)
13
+
14
+ options = {
15
+ :status => response.status,
16
+ :version => response.version,
17
+ :headers => response.headers,
18
+ :proxy_headers => response.proxy_headers,
19
+ :connection => response.connection,
20
+ :body => stream_for(response.connection)
21
+ }
22
+
23
+ options[:uri] = response.uri if response.uri
24
+
25
+ Response.new(options)
12
26
  end
27
+
28
+ def stream_for(connection)
29
+ Response::Body.new(Response::Inflater.new(connection))
30
+ end
31
+
32
+ private
33
+
34
+ def supported_encoding?(response)
35
+ content_encoding = response.headers.get(Headers::CONTENT_ENCODING).first
36
+ content_encoding && SUPPORTED_ENCODING.include?(content_encoding)
37
+ end
38
+
39
+ HTTP::Options.register_feature(:auto_inflate, self)
13
40
  end
14
41
  end
15
42
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ module Features
5
+ # Instrument requests and responses. Expects an
6
+ # ActiveSupport::Notifications-compatible instrumenter. Defaults to use a
7
+ # namespace of 'http' which may be overridden with a `:namespace` param.
8
+ # Emits a single event like `"request.{namespace}"`, eg `"request.http"`.
9
+ # Be sure to specify the instrumenter when enabling the feature:
10
+ #
11
+ # HTTP
12
+ # .use(instrumentation: {instrumenter: ActiveSupport::Notifications.instrumenter})
13
+ # .get("https://example.com/")
14
+ #
15
+ # Emits two events on every request:
16
+ #
17
+ # * `start_request.http` before the request is made, so you can log the reqest being started
18
+ # * `request.http` after the response is recieved, and contains `start`
19
+ # and `finish` so the duration of the request can be calculated.
20
+ #
21
+ class Instrumentation < Feature
22
+ attr_reader :instrumenter, :name
23
+
24
+ def initialize(instrumenter: NullInstrumenter.new, namespace: "http")
25
+ @instrumenter = instrumenter
26
+ @name = "request.#{namespace}"
27
+ end
28
+
29
+ def wrap_request(request)
30
+ # Emit a separate "start" event, so a logger can print the request
31
+ # being run without waiting for a response
32
+ instrumenter.instrument("start_#{name}", :request => request) {}
33
+ instrumenter.start(name, :request => request)
34
+ request
35
+ end
36
+
37
+ def wrap_response(response)
38
+ instrumenter.finish(name, :response => response)
39
+ response
40
+ end
41
+
42
+ HTTP::Options.register_feature(:instrumentation, self)
43
+
44
+ class NullInstrumenter
45
+ def instrument(name, payload = {})
46
+ start(name, payload)
47
+ begin
48
+ yield payload if block_given?
49
+ ensure
50
+ finish name, payload
51
+ end
52
+ end
53
+
54
+ def start(_name, _payload)
55
+ true
56
+ end
57
+
58
+ def finish(_name, _payload)
59
+ true
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP
4
+ module Features
5
+ # Log requests and responses. Request verb and uri, and Response status are
6
+ # logged at `info`, and the headers and bodies of both are logged at
7
+ # `debug`. Be sure to specify the logger when enabling the feature:
8
+ #
9
+ # HTTP.use(logging: {logger: Logger.new(STDOUT)}).get("https://example.com/")
10
+ #
11
+ class Logging < Feature
12
+ attr_reader :logger
13
+
14
+ def initialize(logger: NullLogger.new)
15
+ @logger = logger
16
+ end
17
+
18
+ def wrap_request(request)
19
+ logger.info { "> #{request.verb.to_s.upcase} #{request.uri}" }
20
+ logger.debug do
21
+ headers = request.headers.map { |name, value| "#{name}: #{value}" }.join("\n")
22
+ body = request.body.source
23
+
24
+ headers + "\n\n" + body.to_s
25
+ end
26
+ request
27
+ end
28
+
29
+ def wrap_response(response)
30
+ logger.info { "< #{response.status}" }
31
+ logger.debug do
32
+ headers = response.headers.map { |name, value| "#{name}: #{value}" }.join("\n")
33
+ body = response.body.to_s
34
+
35
+ headers + "\n\n" + body
36
+ end
37
+ response
38
+ end
39
+
40
+ class NullLogger
41
+ %w[fatal error warn info debug].each do |level|
42
+ define_method(level.to_sym) do |*_args|
43
+ nil
44
+ end
45
+
46
+ define_method(:"#{level}?") do
47
+ true
48
+ end
49
+ end
50
+ end
51
+
52
+ HTTP::Options.register_feature(:logging, self)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "http/uri"
4
+
5
+ module HTTP
6
+ module Features
7
+ class NormalizeUri < Feature
8
+ attr_reader :normalizer
9
+
10
+ def initialize(normalizer: HTTP::URI::NORMALIZER)
11
+ @normalizer = normalizer
12
+ end
13
+
14
+ HTTP::Options.register_feature(:normalize_uri, self)
15
+ end
16
+ end
17
+ end
@@ -5,6 +5,9 @@ module HTTP
5
5
  # Content-Types that are acceptable for the response.
6
6
  ACCEPT = "Accept"
7
7
 
8
+ # Content-codings that are acceptable in the response.
9
+ ACCEPT_ENCODING = "Accept-Encoding"
10
+
8
11
  # The age the object has been in a proxy cache in seconds.
9
12
  AGE = "Age"
10
13
 
data/lib/http/options.rb CHANGED
@@ -1,24 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/ClassLength, Style/RedundantSelf
3
+ # rubocop:disable Metrics/ClassLength
4
4
 
5
5
  require "http/headers"
6
6
  require "openssl"
7
7
  require "socket"
8
8
  require "http/uri"
9
- require "http/feature"
10
- require "http/features/auto_inflate"
11
- require "http/features/auto_deflate"
12
9
 
13
10
  module HTTP
14
11
  class Options
15
12
  @default_socket_class = TCPSocket
16
13
  @default_ssl_socket_class = OpenSSL::SSL::SSLSocket
17
14
  @default_timeout_class = HTTP::Timeout::Null
18
- @available_features = {
19
- :auto_inflate => Features::AutoInflate,
20
- :auto_deflate => Features::AutoDeflate
21
- }
15
+ @available_features = {}
22
16
 
23
17
  class << self
24
18
  attr_accessor :default_socket_class, :default_ssl_socket_class, :default_timeout_class
@@ -33,14 +27,22 @@ module HTTP
33
27
  @defined_options ||= []
34
28
  end
35
29
 
30
+ def register_feature(name, impl)
31
+ @available_features[name] = impl
32
+ end
33
+
36
34
  protected
37
35
 
38
- def def_option(name, &interpreter)
36
+ def def_option(name, reader_only: false, &interpreter)
39
37
  defined_options << name.to_sym
40
38
  interpreter ||= lambda { |v| v }
41
39
 
42
- attr_accessor name
43
- protected :"#{name}="
40
+ if reader_only
41
+ attr_reader name
42
+ else
43
+ attr_accessor name
44
+ protected :"#{name}="
45
+ end
44
46
 
45
47
  define_method(:"with_#{name}") do |value|
46
48
  dup { |opts| opts.send(:"#{name}=", instance_exec(value, &interpreter)) }
@@ -70,12 +72,12 @@ module HTTP
70
72
  opts_w_defaults.each { |(k, v)| self[k] = v }
71
73
  end
72
74
 
73
- def_option :headers do |headers|
74
- self.headers.merge(headers)
75
+ def_option :headers do |new_headers|
76
+ headers.merge(new_headers)
75
77
  end
76
78
 
77
- def_option :cookies do |cookies|
78
- cookies.each_with_object self.cookies.dup do |(k, v), jar|
79
+ def_option :cookies do |new_cookies|
80
+ new_cookies.each_with_object cookies.dup do |(k, v), jar|
79
81
  cookie = k.is_a?(Cookie) ? k : Cookie.new(k.to_s, v.to_s)
80
82
  jar[cookie.name] = cookie.cookie_value
81
83
  end
@@ -85,7 +87,7 @@ module HTTP
85
87
  self.encoding = Encoding.find(encoding)
86
88
  end
87
89
 
88
- def_option :features do |features|
90
+ def_option :features, :reader_only => true do |new_features|
89
91
  # Normalize features from:
90
92
  #
91
93
  # [{feature_one: {opt: 'val'}}, :feature_two]
@@ -93,7 +95,7 @@ module HTTP
93
95
  # into:
94
96
  #
95
97
  # {feature_one: {opt: 'val'}, feature_two: {}}
96
- features = features.each_with_object({}) do |feature, h|
98
+ normalized_features = new_features.each_with_object({}) do |feature, h|
97
99
  if feature.is_a?(Hash)
98
100
  h.merge!(feature)
99
101
  else
@@ -101,7 +103,7 @@ module HTTP
101
103
  end
102
104
  end
103
105
 
104
- self.features.merge(features)
106
+ features.merge(normalized_features)
105
107
  end
106
108
 
107
109
  def features=(features)
@@ -112,19 +114,21 @@ module HTTP
112
114
  unless (feature = self.class.available_features[name])
113
115
  argument_error! "Unsupported feature: #{name}"
114
116
  end
115
- feature.new(opts_or_feature)
117
+ feature.new(**opts_or_feature)
116
118
  end
117
119
  end
118
120
  end
119
121
 
120
122
  %w[
121
- proxy params form json body follow response
123
+ proxy params form json body response
122
124
  socket_class nodelay ssl_socket_class ssl_context ssl
123
- persistent keep_alive_timeout timeout_class timeout_options
125
+ keep_alive_timeout timeout_class timeout_options
124
126
  ].each do |method_name|
125
127
  def_option method_name
126
128
  end
127
129
 
130
+ def_option :follow, :reader_only => true
131
+
128
132
  def follow=(value)
129
133
  @follow =
130
134
  case
@@ -135,6 +139,8 @@ module HTTP
135
139
  end
136
140
  end
137
141
 
142
+ def_option :persistent, :reader_only => true
143
+
138
144
  def persistent=(value)
139
145
  @persistent = value ? HTTP::URI.parse(value).origin : nil
140
146
  end
@@ -58,7 +58,8 @@ module HTTP
58
58
 
59
59
  @response.flush
60
60
 
61
- @request = redirect_to @response.headers[Headers::LOCATION]
61
+ # XXX(ixti): using `Array#inject` to return `nil` if no Location header.
62
+ @request = redirect_to(@response.headers.get(Headers::LOCATION).inject(:+))
62
63
  @response = yield @request
63
64
  end
64
65