httpx 0.1.0 → 0.2.0
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/README.md +4 -0
- data/lib/httpx/buffer.rb +2 -0
- data/lib/httpx/chainable.rb +5 -5
- data/lib/httpx/channel/http2.rb +22 -3
- data/lib/httpx/channel.rb +74 -20
- data/lib/httpx/client.rb +33 -23
- data/lib/httpx/connection.rb +77 -9
- data/lib/httpx/errors.rb +4 -0
- data/lib/httpx/io/ssl.rb +19 -1
- data/lib/httpx/io/tcp.rb +25 -7
- data/lib/httpx/io/udp.rb +56 -0
- data/lib/httpx/io/unix.rb +4 -1
- data/lib/httpx/io.rb +2 -0
- data/lib/httpx/options.rb +3 -2
- data/lib/httpx/plugins/proxy.rb +14 -11
- data/lib/httpx/resolver/https.rb +181 -0
- data/lib/httpx/resolver/native.rb +251 -0
- data/lib/httpx/resolver/options.rb +25 -0
- data/lib/httpx/resolver/resolver_mixin.rb +61 -0
- data/lib/httpx/resolver/system.rb +34 -0
- data/lib/httpx/resolver.rb +103 -0
- data/lib/httpx/response.rb +8 -1
- data/lib/httpx/selector.rb +10 -4
- data/lib/httpx/timeout.rb +1 -1
- data/lib/httpx/version.rb +1 -1
- metadata +9 -2
data/lib/httpx/errors.rb
CHANGED
@@ -5,6 +5,8 @@ module HTTPX
|
|
5
5
|
|
6
6
|
TimeoutError = Class.new(Error)
|
7
7
|
|
8
|
+
ResolveError = Class.new(Error)
|
9
|
+
|
8
10
|
HTTPError = Class.new(Error) do
|
9
11
|
attr_reader :response
|
10
12
|
|
@@ -17,4 +19,6 @@ module HTTPX
|
|
17
19
|
@response.status
|
18
20
|
end
|
19
21
|
end
|
22
|
+
|
23
|
+
MisdirectedRequestError = Class.new(HTTPError)
|
20
24
|
end
|
data/lib/httpx/io/ssl.rb
CHANGED
@@ -10,7 +10,7 @@ module HTTPX
|
|
10
10
|
{}
|
11
11
|
end
|
12
12
|
|
13
|
-
def initialize(_, options)
|
13
|
+
def initialize(_, _, options)
|
14
14
|
@ctx = OpenSSL::SSL::SSLContext.new
|
15
15
|
ctx_options = TLS_OPTIONS.merge(options.ssl)
|
16
16
|
@ctx.set_params(ctx_options) unless ctx_options.empty?
|
@@ -28,6 +28,12 @@ module HTTPX
|
|
28
28
|
super
|
29
29
|
end
|
30
30
|
|
31
|
+
def verify_hostname(host)
|
32
|
+
return false if @ctx.verify_mode == OpenSSL::SSL::VERIFY_NONE
|
33
|
+
return false if @io.peer_cert.nil?
|
34
|
+
OpenSSL::SSL.verify_certificate_identity(@io.peer_cert, host)
|
35
|
+
end
|
36
|
+
|
31
37
|
def close
|
32
38
|
super
|
33
39
|
# allow reconnections
|
@@ -103,5 +109,17 @@ module HTTPX
|
|
103
109
|
end
|
104
110
|
do_transition(nextstate)
|
105
111
|
end
|
112
|
+
|
113
|
+
def log_transition_state(nextstate)
|
114
|
+
return super unless nextstate == :negotiated
|
115
|
+
server_cert = @io.peer_cert
|
116
|
+
"SSL connection using #{@io.ssl_version} / #{@io.cipher.first}\n" \
|
117
|
+
"ALPN, server accepted to use #{protocol}\n" \
|
118
|
+
"Server certificate:\n" \
|
119
|
+
" subject: #{server_cert.subject}\n" \
|
120
|
+
" start date: #{server_cert.not_before}\n" \
|
121
|
+
" start date: #{server_cert.not_after}\n" \
|
122
|
+
" issuer: #{server_cert.issuer}"
|
123
|
+
end
|
106
124
|
end
|
107
125
|
end
|
data/lib/httpx/io/tcp.rb
CHANGED
@@ -9,18 +9,22 @@ module HTTPX
|
|
9
9
|
|
10
10
|
attr_reader :ip, :port
|
11
11
|
|
12
|
+
attr_reader :addresses
|
13
|
+
|
12
14
|
alias_method :host, :ip
|
13
15
|
|
14
|
-
def initialize(uri, options)
|
16
|
+
def initialize(uri, addresses, options)
|
15
17
|
@state = :idle
|
16
18
|
@hostname = uri.host
|
19
|
+
@addresses = addresses
|
20
|
+
@ip_index = @addresses.size - 1
|
17
21
|
@options = Options.new(options)
|
18
22
|
@fallback_protocol = @options.fallback_protocol
|
19
23
|
@port = uri.port
|
20
24
|
if @options.io
|
21
25
|
@io = case @options.io
|
22
26
|
when Hash
|
23
|
-
@ip =
|
27
|
+
@ip = @addresses[@ip_index]
|
24
28
|
@options.io[@ip] || @options.io["#{@ip}:#{@port}"]
|
25
29
|
else
|
26
30
|
@ip = @hostname
|
@@ -31,7 +35,7 @@ module HTTPX
|
|
31
35
|
@state = :connected
|
32
36
|
end
|
33
37
|
else
|
34
|
-
@ip =
|
38
|
+
@ip = @addresses[@ip_index]
|
35
39
|
end
|
36
40
|
@io ||= build_socket
|
37
41
|
end
|
@@ -55,10 +59,14 @@ module HTTPX
|
|
55
59
|
transition(:idle)
|
56
60
|
@io = build_socket
|
57
61
|
end
|
58
|
-
@io.connect_nonblock(Socket.sockaddr_in(@port, @ip))
|
62
|
+
@io.connect_nonblock(Socket.sockaddr_in(@port, @ip.to_s))
|
59
63
|
rescue Errno::EISCONN
|
60
64
|
end
|
61
65
|
transition(:connected)
|
66
|
+
rescue Errno::EHOSTUNREACH => e
|
67
|
+
raise e if @ip_index <= 0
|
68
|
+
@ip_index -= 1
|
69
|
+
retry
|
62
70
|
rescue Errno::EINPROGRESS,
|
63
71
|
Errno::EALREADY,
|
64
72
|
::IO::WaitReadable
|
@@ -125,8 +133,7 @@ module HTTPX
|
|
125
133
|
private
|
126
134
|
|
127
135
|
def build_socket
|
128
|
-
|
129
|
-
Socket.new(addr.family, :STREAM, 0)
|
136
|
+
Socket.new(@ip.family, :STREAM, 0)
|
130
137
|
end
|
131
138
|
|
132
139
|
def transition(nextstate)
|
@@ -141,8 +148,19 @@ module HTTPX
|
|
141
148
|
end
|
142
149
|
|
143
150
|
def do_transition(nextstate)
|
144
|
-
log(level: 1
|
151
|
+
log(level: 1) do
|
152
|
+
log_transition_state(nextstate)
|
153
|
+
end
|
145
154
|
@state = nextstate
|
146
155
|
end
|
156
|
+
|
157
|
+
def log_transition_state(nextstate)
|
158
|
+
case nextstate
|
159
|
+
when :connected
|
160
|
+
"Connected to #{@hostname} (#{@ip}) port #{@port} (##{@io.fileno})"
|
161
|
+
else
|
162
|
+
"#{@ip}:#{@port} #{@state} -> #{nextstate}"
|
163
|
+
end
|
164
|
+
end
|
147
165
|
end
|
148
166
|
end
|
data/lib/httpx/io/udp.rb
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "socket"
|
4
|
+
require "ipaddr"
|
5
|
+
|
6
|
+
module HTTPX
|
7
|
+
class UDP
|
8
|
+
include Loggable
|
9
|
+
|
10
|
+
def initialize(uri, _, _)
|
11
|
+
ip = IPAddr.new(uri.host)
|
12
|
+
@host = ip.to_s
|
13
|
+
@port = uri.port
|
14
|
+
@io = UDPSocket.new(ip.family)
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_io
|
18
|
+
@io.to_io
|
19
|
+
end
|
20
|
+
|
21
|
+
def connect; end
|
22
|
+
|
23
|
+
def connected?
|
24
|
+
true
|
25
|
+
end
|
26
|
+
|
27
|
+
def close
|
28
|
+
@io.close
|
29
|
+
end
|
30
|
+
|
31
|
+
def write(buffer)
|
32
|
+
siz = @io.send(buffer, 0, @host, @port)
|
33
|
+
buffer.slice!(0, siz)
|
34
|
+
siz
|
35
|
+
end
|
36
|
+
|
37
|
+
if RUBY_VERSION < "2.3"
|
38
|
+
def read(size, buffer)
|
39
|
+
data, _ = @io.recvfrom_nonblock(size)
|
40
|
+
buffer.replace(data)
|
41
|
+
buffer.bytesize
|
42
|
+
rescue ::IO::WaitReadable
|
43
|
+
0
|
44
|
+
rescue IOError
|
45
|
+
end
|
46
|
+
else
|
47
|
+
def read(size, buffer)
|
48
|
+
ret = @io.recvfrom_nonblock(size, 0, buffer, exception: false)
|
49
|
+
return 0 if ret == :wait_readable
|
50
|
+
return if ret.nil?
|
51
|
+
buffer.bytesize
|
52
|
+
rescue IOError
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
data/lib/httpx/io/unix.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "forwardable"
|
2
4
|
|
3
5
|
module HTTPX
|
@@ -6,8 +8,9 @@ module HTTPX
|
|
6
8
|
|
7
9
|
def_delegator :@uri, :port, :scheme
|
8
10
|
|
9
|
-
def initialize(uri, options)
|
11
|
+
def initialize(uri, addresses, options)
|
10
12
|
@uri = uri
|
13
|
+
@addresses = addresses
|
11
14
|
@state = :idle
|
12
15
|
@options = Options.new(options)
|
13
16
|
@path = @options.transport_options[:path]
|
data/lib/httpx/io.rb
CHANGED
@@ -4,12 +4,14 @@ require "socket"
|
|
4
4
|
require "httpx/io/tcp"
|
5
5
|
require "httpx/io/ssl"
|
6
6
|
require "httpx/io/unix"
|
7
|
+
require "httpx/io/udp"
|
7
8
|
|
8
9
|
module HTTPX
|
9
10
|
module IO
|
10
11
|
extend Registry
|
11
12
|
register "tcp", TCP
|
12
13
|
register "ssl", SSL
|
14
|
+
register "udp", UDP
|
13
15
|
register "unix", HTTPX::UNIX
|
14
16
|
end
|
15
17
|
end
|
data/lib/httpx/options.rb
CHANGED
@@ -55,6 +55,7 @@ module HTTPX
|
|
55
55
|
:response_body_class => Class.new(Response::Body),
|
56
56
|
:transport => nil,
|
57
57
|
:transport_options => nil,
|
58
|
+
:resolver_class => (ENV["HTTPX_RESOLVER"] || :native).to_sym,
|
58
59
|
}
|
59
60
|
|
60
61
|
defaults.merge!(options)
|
@@ -86,7 +87,7 @@ module HTTPX
|
|
86
87
|
|
87
88
|
def_option(:transport) do |tr|
|
88
89
|
transport = tr.to_s
|
89
|
-
raise Error, "#{transport} is an unsupported transport type" unless IO.registry.
|
90
|
+
raise Error, "#{transport} is an unsupported transport type" unless IO.registry.key?(transport)
|
90
91
|
self.transport = transport
|
91
92
|
end
|
92
93
|
|
@@ -94,7 +95,7 @@ module HTTPX
|
|
94
95
|
params form json body
|
95
96
|
follow ssl http2_settings
|
96
97
|
request_class response_class headers_class request_body_class response_body_class
|
97
|
-
io fallback_protocol debug debug_level transport_options
|
98
|
+
io fallback_protocol debug debug_level transport_options resolver_class resolver_options
|
98
99
|
].each do |method_name|
|
99
100
|
def_option(method_name)
|
100
101
|
end
|
data/lib/httpx/plugins/proxy.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "resolv"
|
4
|
+
require "ipaddr"
|
3
5
|
require "forwardable"
|
4
6
|
|
5
7
|
module HTTPX
|
@@ -49,21 +51,22 @@ module HTTPX
|
|
49
51
|
uri = URI(request.uri)
|
50
52
|
proxy = proxy_params(uri)
|
51
53
|
return super unless proxy
|
52
|
-
@connection.find_channel(proxy) ||
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
54
|
+
@connection.find_channel(proxy) || build_channel(proxy, options)
|
55
|
+
end
|
56
|
+
|
57
|
+
def build_channel(proxy, options)
|
58
|
+
channel = build_proxy_channel(proxy, **options)
|
59
|
+
set_channel_callbacks(channel, options)
|
60
|
+
channel
|
57
61
|
end
|
58
62
|
|
59
63
|
def build_proxy_channel(proxy, **options)
|
60
64
|
parameters = Parameters.new(**proxy)
|
61
65
|
uri = parameters.uri
|
62
66
|
log { "proxy: #{uri}" }
|
63
|
-
io = TCP.new(uri, @options)
|
64
67
|
proxy_type = Parameters.registry(parameters.uri.scheme)
|
65
|
-
channel = proxy_type.new(
|
66
|
-
@connection.__send__(:
|
68
|
+
channel = proxy_type.new("tcp", uri, parameters, @options.merge(options), &method(:on_response))
|
69
|
+
@connection.__send__(:resolve_channel, channel)
|
67
70
|
channel
|
68
71
|
end
|
69
72
|
|
@@ -102,8 +105,8 @@ module HTTPX
|
|
102
105
|
end
|
103
106
|
|
104
107
|
class ProxyChannel < Channel
|
105
|
-
def initialize(
|
106
|
-
super(
|
108
|
+
def initialize(type, uri, parameters, options, &blk)
|
109
|
+
super(type, uri, options, &blk)
|
107
110
|
@parameters = parameters
|
108
111
|
end
|
109
112
|
|
@@ -144,7 +147,7 @@ module HTTPX
|
|
144
147
|
class ProxySSL < SSL
|
145
148
|
def initialize(tcp, request_uri, options)
|
146
149
|
@io = tcp.to_io
|
147
|
-
super(tcp, options)
|
150
|
+
super(request_uri, tcp.addresses, options)
|
148
151
|
@hostname = request_uri.host
|
149
152
|
@state = :connected
|
150
153
|
end
|
@@ -0,0 +1,181 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
require "cgi"
|
5
|
+
require "forwardable"
|
6
|
+
|
7
|
+
module HTTPX
|
8
|
+
class Resolver::HTTPS
|
9
|
+
extend Forwardable
|
10
|
+
include Resolver::ResolverMixin
|
11
|
+
|
12
|
+
NAMESERVER = "https://1.1.1.1/dns-query"
|
13
|
+
|
14
|
+
RECORD_TYPES = {
|
15
|
+
"A" => Resolv::DNS::Resource::IN::A,
|
16
|
+
"AAAA" => Resolv::DNS::Resource::IN::AAAA,
|
17
|
+
}.freeze
|
18
|
+
|
19
|
+
DEFAULTS = {
|
20
|
+
uri: NAMESERVER,
|
21
|
+
use_get: false,
|
22
|
+
}.freeze
|
23
|
+
|
24
|
+
def_delegator :@channels, :empty?
|
25
|
+
|
26
|
+
def_delegators :@resolver_channel, :to_io, :call, :interests, :close
|
27
|
+
|
28
|
+
def initialize(connection, options)
|
29
|
+
@connection = connection
|
30
|
+
@options = Options.new(options)
|
31
|
+
@resolver_options = Resolver::Options.new(DEFAULTS.merge(@options.resolver_options || {}))
|
32
|
+
@_record_types = Hash.new { |types, host| types[host] = RECORD_TYPES.keys.dup }
|
33
|
+
@queries = {}
|
34
|
+
@requests = {}
|
35
|
+
@channels = []
|
36
|
+
@uri = URI(@resolver_options.uri)
|
37
|
+
@uri_addresses = nil
|
38
|
+
end
|
39
|
+
|
40
|
+
def <<(channel)
|
41
|
+
@uri_addresses ||= Resolv.getaddresses(@uri.host)
|
42
|
+
if @uri_addresses.empty?
|
43
|
+
ex = ResolveError.new("Can't resolve #{channel.uri.host}")
|
44
|
+
ex.set_backtrace(caller)
|
45
|
+
emit(:error, channel, ex)
|
46
|
+
else
|
47
|
+
early_resolve(channel) || resolve(channel)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def timeout
|
52
|
+
timeout = @options.timeout
|
53
|
+
timeout.timeout
|
54
|
+
end
|
55
|
+
|
56
|
+
def closed?
|
57
|
+
return true unless @resolver_channel
|
58
|
+
resolver_channel.closed?
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def resolver_channel
|
64
|
+
@resolver_channel ||= find_channel(@uri, @options)
|
65
|
+
end
|
66
|
+
|
67
|
+
def resolve(channel = @channels.first, hostname = nil)
|
68
|
+
return if @building_channel
|
69
|
+
hostname = hostname || @queries.key(channel) || channel.uri.host
|
70
|
+
type = @_record_types[hostname].shift
|
71
|
+
log(label: "resolver: ") { "query #{type} for #{hostname}" }
|
72
|
+
request = build_request(hostname, type)
|
73
|
+
@requests[request] = channel
|
74
|
+
resolver_channel.send(request)
|
75
|
+
@queries[hostname] = channel
|
76
|
+
@channels << channel
|
77
|
+
end
|
78
|
+
|
79
|
+
def find_channel(_request, **options)
|
80
|
+
@connection.find_channel(@uri) || begin
|
81
|
+
@building_channel = true
|
82
|
+
channel = @connection.build_channel(@uri, **options)
|
83
|
+
emit_addresses(channel, @uri_addresses)
|
84
|
+
set_channel_callbacks(channel)
|
85
|
+
@building_channel = false
|
86
|
+
channel
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def set_channel_callbacks(channel)
|
91
|
+
channel.on(:response, &method(:on_response))
|
92
|
+
channel.on(:promise, &method(:on_response))
|
93
|
+
end
|
94
|
+
|
95
|
+
def on_response(request, response)
|
96
|
+
response.raise_for_status
|
97
|
+
rescue Error => ex
|
98
|
+
channel = @requests[request]
|
99
|
+
hostname = @queries.key(channel)
|
100
|
+
error = ResolveError.new("Can't resolve #{hostname}: #{ex.message}")
|
101
|
+
error.set_backtrace(ex.backtrace)
|
102
|
+
emit(:error, channel, error)
|
103
|
+
else
|
104
|
+
parse(response)
|
105
|
+
ensure
|
106
|
+
@requests.delete(request)
|
107
|
+
end
|
108
|
+
|
109
|
+
def parse(response)
|
110
|
+
answers = decode_response_body(response)
|
111
|
+
if answers.empty?
|
112
|
+
host, channel = @queries.first
|
113
|
+
if @_record_types[host].empty?
|
114
|
+
emit_resolve_error(channel, host)
|
115
|
+
return
|
116
|
+
end
|
117
|
+
else
|
118
|
+
answers = answers.group_by { |answer| answer["name"] }
|
119
|
+
answers.each do |hostname, addresses|
|
120
|
+
addresses = addresses.flat_map do |address|
|
121
|
+
if address.key?("alias")
|
122
|
+
alias_address = answers[address["alias"]]
|
123
|
+
if alias_address.nil?
|
124
|
+
channel = @queries[hostname]
|
125
|
+
@queries.delete(address["name"])
|
126
|
+
resolve(channel, address["alias"])
|
127
|
+
return # rubocop:disable Lint/NonLocalExitFromIterator
|
128
|
+
else
|
129
|
+
alias_address
|
130
|
+
end
|
131
|
+
else
|
132
|
+
address
|
133
|
+
end
|
134
|
+
end.compact
|
135
|
+
next if addresses.empty?
|
136
|
+
hostname = hostname[0..-2] if hostname.end_with?(".")
|
137
|
+
channel = @queries.delete(hostname)
|
138
|
+
next unless channel # probably a retried query for which there's an answer
|
139
|
+
@channels.delete(channel)
|
140
|
+
Resolver.cached_lookup_set(hostname, addresses)
|
141
|
+
emit_addresses(channel, addresses.map { |addr| addr["data"] })
|
142
|
+
end
|
143
|
+
end
|
144
|
+
return if @channels.empty?
|
145
|
+
resolve
|
146
|
+
end
|
147
|
+
|
148
|
+
def build_request(hostname, type)
|
149
|
+
uri = @uri.dup
|
150
|
+
rklass = @options.request_class
|
151
|
+
if @resolver_options.use_get
|
152
|
+
params = URI.decode_www_form(uri.query.to_s)
|
153
|
+
params << ["type", type]
|
154
|
+
params << ["name", CGI.escape(hostname)]
|
155
|
+
uri.query = URI.encode_www_form(params)
|
156
|
+
request = rklass.new("GET", uri, @options)
|
157
|
+
else
|
158
|
+
payload = Resolver.encode_dns_query(hostname, type: RECORD_TYPES[type])
|
159
|
+
request = rklass.new("POST", uri, @options.merge(body: [payload]))
|
160
|
+
request.headers["content-type"] = "application/dns-message"
|
161
|
+
request.headers["accept"] = "application/dns-message"
|
162
|
+
end
|
163
|
+
request
|
164
|
+
end
|
165
|
+
|
166
|
+
def decode_response_body(response)
|
167
|
+
case response.headers["content-type"]
|
168
|
+
when "application/dns-json",
|
169
|
+
"application/json",
|
170
|
+
%r{^application\/x\-javascript} # because google...
|
171
|
+
payload = JSON.parse(response.to_s)
|
172
|
+
payload["Answer"]
|
173
|
+
when "application/dns-udpwireformat",
|
174
|
+
"application/dns-message"
|
175
|
+
Resolver.decode_dns_answer(response.to_s)
|
176
|
+
|
177
|
+
# TODO: what about the rest?
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|