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.
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 = Resolv.getaddress(@hostname)
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 = Resolv.getaddress(@hostname)
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
- addr = IPAddr.new(@ip)
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, label: "#{inspect}: ") { nextstate.to_s }
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
@@ -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.keys.include?(transport)
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
@@ -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) || begin
53
- channel = build_proxy_channel(proxy, **options)
54
- set_channel_callbacks(channel)
55
- channel
56
- end
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(io, parameters, @options.merge(options), &method(:on_response))
66
- @connection.__send__(:register_channel, channel)
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(io, parameters, options, &blk)
106
- super(io, options, &blk)
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