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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.rubocop.yml +3 -1
- data/.travis.yml +10 -7
- data/CHANGES.md +135 -0
- data/README.md +14 -10
- data/Rakefile +1 -1
- data/http.gemspec +12 -5
- data/lib/http.rb +1 -2
- data/lib/http/chainable.rb +20 -29
- data/lib/http/client.rb +25 -19
- data/lib/http/connection.rb +5 -9
- data/lib/http/feature.rb +14 -0
- data/lib/http/features/auto_deflate.rb +27 -6
- data/lib/http/features/auto_inflate.rb +33 -6
- data/lib/http/features/instrumentation.rb +64 -0
- data/lib/http/features/logging.rb +55 -0
- data/lib/http/features/normalize_uri.rb +17 -0
- data/lib/http/headers/known.rb +3 -0
- data/lib/http/options.rb +27 -21
- data/lib/http/redirector.rb +2 -1
- data/lib/http/request.rb +38 -30
- data/lib/http/request/body.rb +30 -1
- data/lib/http/request/writer.rb +21 -7
- data/lib/http/response.rb +7 -15
- data/lib/http/response/parser.rb +56 -16
- data/lib/http/timeout/global.rb +12 -14
- data/lib/http/timeout/per_operation.rb +5 -7
- data/lib/http/uri.rb +13 -0
- data/lib/http/version.rb +1 -1
- data/spec/lib/http/client_spec.rb +34 -7
- data/spec/lib/http/features/auto_inflate_spec.rb +38 -22
- data/spec/lib/http/features/instrumentation_spec.rb +56 -0
- data/spec/lib/http/features/logging_spec.rb +67 -0
- data/spec/lib/http/redirector_spec.rb +13 -0
- data/spec/lib/http/request/body_spec.rb +51 -0
- data/spec/lib/http/request/writer_spec.rb +20 -0
- data/spec/lib/http/request_spec.rb +6 -0
- data/spec/lib/http/response/parser_spec.rb +45 -0
- data/spec/lib/http/response_spec.rb +3 -4
- data/spec/lib/http_spec.rb +45 -65
- data/spec/regression_specs.rb +7 -0
- data/spec/support/dummy_server/servlet.rb +5 -0
- data/spec/support/http_handling_shared.rb +60 -64
- metadata +32 -21
- data/.ruby-version +0 -1
data/lib/http/connection.rb
CHANGED
@@ -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
|
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.
|
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
|
-
|
103
|
-
|
104
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
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
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
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
|
data/lib/http/headers/known.rb
CHANGED
@@ -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
|
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
|
-
|
43
|
-
|
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 |
|
74
|
-
|
75
|
+
def_option :headers do |new_headers|
|
76
|
+
headers.merge(new_headers)
|
75
77
|
end
|
76
78
|
|
77
|
-
def_option :cookies do |
|
78
|
-
|
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 |
|
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
|
-
|
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
|
-
|
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
|
123
|
+
proxy params form json body response
|
122
124
|
socket_class nodelay ssl_socket_class ssl_context ssl
|
123
|
-
|
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
|
data/lib/http/redirector.rb
CHANGED
@@ -58,7 +58,8 @@ module HTTP
|
|
58
58
|
|
59
59
|
@response.flush
|
60
60
|
|
61
|
-
|
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
|
|