httpx 0.0.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 +7 -0
- data/LICENSE.txt +191 -0
- data/README.md +119 -0
- data/lib/httpx.rb +50 -0
- data/lib/httpx/buffer.rb +34 -0
- data/lib/httpx/callbacks.rb +32 -0
- data/lib/httpx/chainable.rb +51 -0
- data/lib/httpx/channel.rb +222 -0
- data/lib/httpx/channel/http1.rb +220 -0
- data/lib/httpx/channel/http2.rb +224 -0
- data/lib/httpx/client.rb +173 -0
- data/lib/httpx/connection.rb +74 -0
- data/lib/httpx/errors.rb +7 -0
- data/lib/httpx/extensions.rb +52 -0
- data/lib/httpx/headers.rb +152 -0
- data/lib/httpx/io.rb +240 -0
- data/lib/httpx/loggable.rb +11 -0
- data/lib/httpx/options.rb +138 -0
- data/lib/httpx/plugins/authentication.rb +14 -0
- data/lib/httpx/plugins/basic_authentication.rb +20 -0
- data/lib/httpx/plugins/compression.rb +123 -0
- data/lib/httpx/plugins/compression/brotli.rb +55 -0
- data/lib/httpx/plugins/compression/deflate.rb +50 -0
- data/lib/httpx/plugins/compression/gzip.rb +59 -0
- data/lib/httpx/plugins/cookies.rb +63 -0
- data/lib/httpx/plugins/digest_authentication.rb +141 -0
- data/lib/httpx/plugins/follow_redirects.rb +72 -0
- data/lib/httpx/plugins/h2c.rb +85 -0
- data/lib/httpx/plugins/proxy.rb +108 -0
- data/lib/httpx/plugins/proxy/http.rb +115 -0
- data/lib/httpx/plugins/proxy/socks4.rb +110 -0
- data/lib/httpx/plugins/proxy/socks5.rb +152 -0
- data/lib/httpx/plugins/push_promise.rb +67 -0
- data/lib/httpx/plugins/stream.rb +33 -0
- data/lib/httpx/registry.rb +88 -0
- data/lib/httpx/request.rb +222 -0
- data/lib/httpx/response.rb +225 -0
- data/lib/httpx/selector.rb +155 -0
- data/lib/httpx/timeout.rb +68 -0
- data/lib/httpx/transcoder.rb +12 -0
- data/lib/httpx/transcoder/body.rb +56 -0
- data/lib/httpx/transcoder/chunker.rb +38 -0
- data/lib/httpx/transcoder/form.rb +41 -0
- data/lib/httpx/transcoder/json.rb +36 -0
- data/lib/httpx/version.rb +5 -0
- metadata +150 -0
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
module Plugins
|
5
|
+
module Compression
|
6
|
+
module Brotli
|
7
|
+
def self.load_dependencies(klass, *)
|
8
|
+
klass.plugin(:compression)
|
9
|
+
require "brotli"
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.configure(*)
|
13
|
+
Compression.register "br", self
|
14
|
+
end
|
15
|
+
|
16
|
+
module Encoder
|
17
|
+
module_function
|
18
|
+
|
19
|
+
def deflate(raw, buffer, chunk_size:)
|
20
|
+
while (chunk = raw.read(chunk_size))
|
21
|
+
compressed = ::Brotli.deflate(chunk)
|
22
|
+
buffer << compressed
|
23
|
+
yield compressed if block_given?
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
module BrotliWrapper
|
29
|
+
module_function
|
30
|
+
|
31
|
+
def inflate(text)
|
32
|
+
::Brotli.inflate(text)
|
33
|
+
end
|
34
|
+
|
35
|
+
def close; end
|
36
|
+
|
37
|
+
def finish
|
38
|
+
""
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
module_function
|
43
|
+
|
44
|
+
def encoder
|
45
|
+
Encoder
|
46
|
+
end
|
47
|
+
|
48
|
+
def decoder
|
49
|
+
Decoder.new(BrotliWrapper)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
register_plugin :"compression/brotli", Compression::Brotli
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
module Plugins
|
5
|
+
module Compression
|
6
|
+
module Deflate
|
7
|
+
def self.load_dependencies(*)
|
8
|
+
require "stringio"
|
9
|
+
require "zlib"
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.configure(*)
|
13
|
+
Compression.register "deflate", self
|
14
|
+
end
|
15
|
+
|
16
|
+
module Encoder
|
17
|
+
module_function
|
18
|
+
|
19
|
+
def deflate(raw, buffer, chunk_size:)
|
20
|
+
deflater = Zlib::Deflate.new(Zlib::BEST_COMPRESSION,
|
21
|
+
Zlib::MAX_WBITS,
|
22
|
+
Zlib::MAX_MEM_LEVEL,
|
23
|
+
Zlib::HUFFMAN_ONLY)
|
24
|
+
while (chunk = raw.read(chunk_size))
|
25
|
+
compressed = deflater.deflate(chunk)
|
26
|
+
buffer << compressed
|
27
|
+
yield compressed if block_given?
|
28
|
+
end
|
29
|
+
last = deflater.finish
|
30
|
+
buffer << last
|
31
|
+
yield last if block_given?
|
32
|
+
ensure
|
33
|
+
deflater.close
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
module_function
|
38
|
+
|
39
|
+
def encoder
|
40
|
+
Encoder
|
41
|
+
end
|
42
|
+
|
43
|
+
def decoder
|
44
|
+
Decoder.new(Zlib::Inflate.new(32 + Zlib::MAX_WBITS))
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
register_plugin :"compression/deflate", Compression::Deflate
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
module HTTPX
|
6
|
+
module Plugins
|
7
|
+
module Compression
|
8
|
+
module GZIP
|
9
|
+
def self.load_dependencies(*)
|
10
|
+
require "zlib"
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.configure(*)
|
14
|
+
Compression.register "gzip", self
|
15
|
+
end
|
16
|
+
|
17
|
+
class Encoder
|
18
|
+
def deflate(raw, buffer, chunk_size:)
|
19
|
+
gzip = Zlib::GzipWriter.new(self)
|
20
|
+
|
21
|
+
while (chunk = raw.read(chunk_size))
|
22
|
+
gzip.write(chunk)
|
23
|
+
gzip.flush
|
24
|
+
compressed = compressed_chunk
|
25
|
+
buffer << compressed
|
26
|
+
yield compressed if block_given?
|
27
|
+
end
|
28
|
+
ensure
|
29
|
+
gzip.close
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def write(chunk)
|
35
|
+
@compressed_chunk = chunk
|
36
|
+
end
|
37
|
+
|
38
|
+
def compressed_chunk
|
39
|
+
compressed = @compressed_chunk
|
40
|
+
compressed
|
41
|
+
ensure
|
42
|
+
@compressed_chunk = nil
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
module_function
|
47
|
+
|
48
|
+
def encoder
|
49
|
+
Encoder.new
|
50
|
+
end
|
51
|
+
|
52
|
+
def decoder
|
53
|
+
Decoder.new(Zlib::Inflate.new(32 + Zlib::MAX_WBITS))
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
register_plugin :"compression/gzip", Compression::GZIP
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
module Plugins
|
5
|
+
module Cookies
|
6
|
+
def self.load_dependencies(*)
|
7
|
+
require "http/cookie"
|
8
|
+
end
|
9
|
+
|
10
|
+
module InstanceMethods
|
11
|
+
def cookies(cookies)
|
12
|
+
branch(default_options.with_cookies(cookies))
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
module RequestMethods
|
17
|
+
def initialize(*)
|
18
|
+
super
|
19
|
+
@headers.cookies(@options.cookies, self)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
module HeadersMethods
|
24
|
+
def cookies(jar, request)
|
25
|
+
return unless jar
|
26
|
+
unless jar.is_a?(HTTP::CookieJar)
|
27
|
+
jar = jar.each_with_object(HTTP::CookieJar.new) do |(k, v), j|
|
28
|
+
cookie = k.is_a?(HTTP::Cookie) ? v : HTTP::Cookie.new(k.to_s, v.to_s)
|
29
|
+
cookie.domain = request.authority
|
30
|
+
cookie.path = request.path
|
31
|
+
j.add(cookie)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
self["cookie"] = HTTP::Cookie.cookie_value(jar.cookies)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
module ResponseMethods
|
39
|
+
def cookie_jar
|
40
|
+
return @cookies if defined?(@cookies)
|
41
|
+
return nil unless headers.key?("set-cookie")
|
42
|
+
@cookies ||= begin
|
43
|
+
jar = HTTP::CookieJar.new
|
44
|
+
jar.parse(headers["set-cookie"], @request.uri)
|
45
|
+
jar
|
46
|
+
end
|
47
|
+
end
|
48
|
+
alias_method :cookies, :cookie_jar
|
49
|
+
end
|
50
|
+
|
51
|
+
module OptionsMethods
|
52
|
+
def self.included(klass)
|
53
|
+
super
|
54
|
+
klass.def_option(:cookies) do |cookies|
|
55
|
+
cookies.split(/ *; */) if cookies.is_a?(String)
|
56
|
+
cookies
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
register_plugin :cookies, Cookies
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
module Plugins
|
5
|
+
module DigestAuthentication
|
6
|
+
DigestError = Class.new(Error)
|
7
|
+
|
8
|
+
def self.load_dependencies(*)
|
9
|
+
require "securerandom"
|
10
|
+
require "digest"
|
11
|
+
end
|
12
|
+
|
13
|
+
module InstanceMethods
|
14
|
+
def digest_authentication(user, password)
|
15
|
+
@_digest = Digest.new(user, password)
|
16
|
+
self
|
17
|
+
end
|
18
|
+
alias_method :digest_auth, :digest_authentication
|
19
|
+
|
20
|
+
def request(*args, keep_open: @keep_open, **options)
|
21
|
+
return super unless @_digest
|
22
|
+
begin
|
23
|
+
requests = __build_reqs(*args, **options)
|
24
|
+
probe_request = requests.first
|
25
|
+
prev_response = __send_reqs(*probe_request).first
|
26
|
+
|
27
|
+
unless prev_response.status == 401
|
28
|
+
raise Error, "request doesn't require authentication (status: #{prev_response})"
|
29
|
+
end
|
30
|
+
|
31
|
+
probe_request.transition(:idle)
|
32
|
+
responses = []
|
33
|
+
|
34
|
+
requests.each do |request|
|
35
|
+
token = @_digest.generate_header(request, prev_response)
|
36
|
+
request.headers["authorization"] = "Digest #{token}"
|
37
|
+
response = __send_reqs(*request).first
|
38
|
+
responses << response
|
39
|
+
prev_response = response
|
40
|
+
end
|
41
|
+
return responses.first if responses.size == 1
|
42
|
+
responses
|
43
|
+
ensure
|
44
|
+
close unless keep_open
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
class Digest
|
50
|
+
def initialize(user, password)
|
51
|
+
@user = user
|
52
|
+
@password = password
|
53
|
+
@nonce = 0
|
54
|
+
end
|
55
|
+
|
56
|
+
def generate_header(request, response, _iis = false)
|
57
|
+
method = request.verb.to_s.upcase
|
58
|
+
www = response.headers["www-authenticate"]
|
59
|
+
|
60
|
+
# TODO: assert if auth-type is Digest
|
61
|
+
auth_info = www[/^(\w+) (.*)/, 2]
|
62
|
+
|
63
|
+
uri = request.path
|
64
|
+
|
65
|
+
params = Hash[auth_info.scan(/(\w+)="(.*?)"/)]
|
66
|
+
|
67
|
+
nonce = params["nonce"]
|
68
|
+
nc = next_nonce
|
69
|
+
|
70
|
+
# verify qop
|
71
|
+
qop = params["qop"]
|
72
|
+
|
73
|
+
if params["algorithm"] =~ /(.*?)(-sess)?$/
|
74
|
+
algorithm = case Regexp.last_match(1)
|
75
|
+
when "MD5" then ::Digest::MD5
|
76
|
+
when "SHA1" then ::Digest::SHA1
|
77
|
+
when "SHA2" then ::Digest::SHA2
|
78
|
+
when "SHA256" then ::Digest::SHA256
|
79
|
+
when "SHA384" then ::Digest::SHA384
|
80
|
+
when "SHA512" then ::Digest::SHA512
|
81
|
+
when "RMD160" then ::Digest::RMD160
|
82
|
+
else raise DigestError, "unknown algorithm \"#{Regexp.last_match(1)}\""
|
83
|
+
end
|
84
|
+
sess = Regexp.last_match(2)
|
85
|
+
else
|
86
|
+
algorithm = ::Digest::MD5
|
87
|
+
end
|
88
|
+
|
89
|
+
if qop || sess
|
90
|
+
cnonce = make_cnonce
|
91
|
+
nc = format("%08x", nc)
|
92
|
+
end
|
93
|
+
|
94
|
+
a1 = if sess
|
95
|
+
[algorithm.hexdigest("#{@user}:#{params["realm"]}:#{@password}"),
|
96
|
+
nonce,
|
97
|
+
cnonce].join ":"
|
98
|
+
else
|
99
|
+
"#{@user}:#{params["realm"]}:#{@password}"
|
100
|
+
end
|
101
|
+
|
102
|
+
ha1 = algorithm.hexdigest(a1)
|
103
|
+
ha2 = algorithm.hexdigest("#{method}:#{uri}")
|
104
|
+
request_digest = [ha1, nonce]
|
105
|
+
request_digest.push(nc, cnonce, qop) if qop
|
106
|
+
request_digest << ha2
|
107
|
+
request_digest = request_digest.join(":")
|
108
|
+
|
109
|
+
header = [
|
110
|
+
%(username="#{@user}"),
|
111
|
+
%(nonce="#{nonce}"),
|
112
|
+
%(uri="#{uri}"),
|
113
|
+
%(response="#{algorithm.hexdigest(request_digest)}"),
|
114
|
+
]
|
115
|
+
header << %(realm="#{params["realm"]}") if params.key?("realm")
|
116
|
+
header << %(algorithm=#{params["algorithm"]}") if params.key?("algorithm")
|
117
|
+
header << %(opaque="#{params["opaque"]}") if params.key?("opaque")
|
118
|
+
header << %(cnonce="#{cnonce}") if cnonce
|
119
|
+
header << %(nc=#{nc})
|
120
|
+
header << %(qop=#{qop}) if qop
|
121
|
+
header.join ", "
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
def make_cnonce
|
127
|
+
::Digest::MD5.hexdigest [
|
128
|
+
Time.now.to_i,
|
129
|
+
Process.pid,
|
130
|
+
SecureRandom.random_number(2**32),
|
131
|
+
].join ":"
|
132
|
+
end
|
133
|
+
|
134
|
+
def next_nonce
|
135
|
+
@nonce += 1
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
register_plugin :digest_authentication, DigestAuthentication
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
module Plugins
|
5
|
+
module FollowRedirects
|
6
|
+
module InstanceMethods
|
7
|
+
MAX_REDIRECTS = 3
|
8
|
+
REDIRECT_STATUS = 300..399
|
9
|
+
|
10
|
+
def max_redirects(n)
|
11
|
+
branch(default_options.with_max_redirects(n.to_i))
|
12
|
+
end
|
13
|
+
|
14
|
+
def request(*args, **options)
|
15
|
+
# do not needlessly close channels
|
16
|
+
keep_open = @keep_open
|
17
|
+
@keep_open = true
|
18
|
+
|
19
|
+
max_redirects = @options.max_redirects || MAX_REDIRECTS
|
20
|
+
requests = __build_reqs(*args, **options)
|
21
|
+
responses = __send_reqs(*requests)
|
22
|
+
|
23
|
+
loop do
|
24
|
+
redirect_requests = []
|
25
|
+
indexes = responses.each_with_index.map do |response, index|
|
26
|
+
next unless REDIRECT_STATUS.include?(response.status)
|
27
|
+
request = requests[index]
|
28
|
+
retry_request = __build_redirect_req(request, response, options)
|
29
|
+
redirect_requests << retry_request
|
30
|
+
index
|
31
|
+
end.compact
|
32
|
+
break if redirect_requests.empty?
|
33
|
+
break if max_redirects <= 0
|
34
|
+
max_redirects -= 1
|
35
|
+
|
36
|
+
redirect_responses = __send_reqs(*redirect_requests)
|
37
|
+
indexes.each_with_index do |index, i2|
|
38
|
+
requests[index] = redirect_requests[i2]
|
39
|
+
responses[index] = redirect_responses[i2]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
return responses.first if responses.size == 1
|
44
|
+
responses
|
45
|
+
ensure
|
46
|
+
@keep_open = keep_open
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def __build_redirect_req(request, response, options)
|
52
|
+
redirect_uri = URI(response.headers["location"])
|
53
|
+
redirect_uri = response.uri.merge(redirect_uri) if redirect_uri.relative?
|
54
|
+
|
55
|
+
# TODO: integrate cookies in the next request
|
56
|
+
# redirects are **ALWAYS** GET
|
57
|
+
retry_options = options.merge(headers: request.headers,
|
58
|
+
body: request.body)
|
59
|
+
__build_req(:get, redirect_uri, retry_options)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
module OptionsMethods
|
64
|
+
def self.included(klass)
|
65
|
+
super
|
66
|
+
klass.def_option(:max_redirects)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
register_plugin :follow_redirects, FollowRedirects
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
module Plugins
|
5
|
+
module H2C
|
6
|
+
def self.load_dependencies(*)
|
7
|
+
require "base64"
|
8
|
+
end
|
9
|
+
|
10
|
+
module InstanceMethods
|
11
|
+
def request(*args, keep_open: @keep_open, **options)
|
12
|
+
return super if @_h2c_probed
|
13
|
+
begin
|
14
|
+
requests = __build_reqs(*args, **options)
|
15
|
+
|
16
|
+
upgrade_request = requests.first
|
17
|
+
return super unless valid_h2c_upgrade_request?(upgrade_request)
|
18
|
+
upgrade_request.headers["upgrade"] = "h2c"
|
19
|
+
upgrade_request.headers.add("connection", "upgrade")
|
20
|
+
upgrade_request.headers.add("connection", "http2-settings")
|
21
|
+
upgrade_request.headers["http2-settings"] = HTTP2::Client.settings_header(@options.http2_settings)
|
22
|
+
# TODO: validate!
|
23
|
+
upgrade_response = __send_reqs(*upgrade_request, **options).first
|
24
|
+
|
25
|
+
if upgrade_response.status == 101
|
26
|
+
channel = find_channel(upgrade_request)
|
27
|
+
parser = channel.upgrade_parser("h2")
|
28
|
+
parser.extend(UpgradeExtensions)
|
29
|
+
parser.upgrade(upgrade_request, upgrade_response, **options)
|
30
|
+
data = upgrade_response.to_s
|
31
|
+
parser << data
|
32
|
+
response = upgrade_request.response
|
33
|
+
if response.status == 200
|
34
|
+
requests.delete(upgrade_request)
|
35
|
+
return response if requests.empty?
|
36
|
+
end
|
37
|
+
responses = __send_reqs(*requests)
|
38
|
+
else
|
39
|
+
# proceed as usual
|
40
|
+
responses = [upgrade_response] + __send_reqs(*requests[1..-1])
|
41
|
+
end
|
42
|
+
return responses.first if responses.size == 1
|
43
|
+
responses
|
44
|
+
ensure
|
45
|
+
@_h2c_probed = true
|
46
|
+
close unless keep_open
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
VALID_H2C_METHODS = %i[get options head].freeze
|
53
|
+
private_constant :VALID_H2C_METHODS
|
54
|
+
|
55
|
+
def valid_h2c_upgrade_request?(request)
|
56
|
+
VALID_H2C_METHODS.include?(request.verb) &&
|
57
|
+
request.scheme == "http"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
module UpgradeExtensions
|
62
|
+
def upgrade(request, _response, **)
|
63
|
+
@connection.send_connection_preface
|
64
|
+
# skip checks, it is assumed that this is the first
|
65
|
+
# request in the connection
|
66
|
+
stream = @connection.upgrade
|
67
|
+
handle_stream(stream, request)
|
68
|
+
@streams[request] = stream
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
module FrameBuilder
|
73
|
+
include HTTP2
|
74
|
+
|
75
|
+
module_function
|
76
|
+
|
77
|
+
def settings_value(settings)
|
78
|
+
frame = Framer.new.generate(type: :settings, stream: 0, payload: settings)
|
79
|
+
Base64.urlsafe_encode64(frame[9..-1])
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
register_plugin(:h2c, H2C)
|
84
|
+
end
|
85
|
+
end
|