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