http 3.3.0 → 4.4.1

Sign up to get free protection for your applications and to get access to all the features.
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