httpx 0.16.1 → 0.17.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/doc/release_notes/0_17_0.md +49 -0
  3. data/lib/httpx/adapters/webmock.rb +2 -2
  4. data/lib/httpx/chainable.rb +1 -1
  5. data/lib/httpx/connection/http1.rb +15 -9
  6. data/lib/httpx/connection/http2.rb +13 -10
  7. data/lib/httpx/connection.rb +4 -5
  8. data/lib/httpx/headers.rb +1 -1
  9. data/lib/httpx/options.rb +28 -6
  10. data/lib/httpx/parser/http1.rb +10 -6
  11. data/lib/httpx/plugins/digest_authentication.rb +4 -4
  12. data/lib/httpx/plugins/h2c.rb +7 -3
  13. data/lib/httpx/plugins/multipart/decoder.rb +187 -0
  14. data/lib/httpx/plugins/multipart/mime_type_detector.rb +3 -3
  15. data/lib/httpx/plugins/multipart/part.rb +2 -2
  16. data/lib/httpx/plugins/multipart.rb +14 -0
  17. data/lib/httpx/plugins/ntlm_authentication.rb +4 -4
  18. data/lib/httpx/plugins/proxy/ssh.rb +11 -4
  19. data/lib/httpx/plugins/proxy.rb +6 -4
  20. data/lib/httpx/plugins/stream.rb +2 -3
  21. data/lib/httpx/registry.rb +1 -1
  22. data/lib/httpx/request.rb +6 -7
  23. data/lib/httpx/resolver/resolver_mixin.rb +2 -1
  24. data/lib/httpx/response.rb +37 -30
  25. data/lib/httpx/selector.rb +4 -2
  26. data/lib/httpx/session.rb +15 -13
  27. data/lib/httpx/transcoder/form.rb +20 -0
  28. data/lib/httpx/transcoder/json.rb +12 -0
  29. data/lib/httpx/transcoder.rb +62 -1
  30. data/lib/httpx/utils.rb +2 -2
  31. data/lib/httpx/version.rb +1 -1
  32. data/sig/buffer.rbs +2 -2
  33. data/sig/chainable.rbs +6 -1
  34. data/sig/connection/http1.rbs +10 -4
  35. data/sig/connection/http2.rbs +16 -5
  36. data/sig/connection.rbs +4 -4
  37. data/sig/headers.rbs +19 -18
  38. data/sig/options.rbs +13 -5
  39. data/sig/parser/http1.rbs +3 -3
  40. data/sig/plugins/aws_sigv4.rbs +12 -3
  41. data/sig/plugins/basic_authentication.rbs +1 -1
  42. data/sig/plugins/multipart.rbs +64 -8
  43. data/sig/plugins/proxy.rbs +6 -6
  44. data/sig/request.rbs +11 -8
  45. data/sig/resolver/native.rbs +4 -2
  46. data/sig/resolver/resolver_mixin.rbs +1 -1
  47. data/sig/resolver/system.rbs +1 -1
  48. data/sig/response.rbs +8 -2
  49. data/sig/selector.rbs +8 -6
  50. data/sig/session.rbs +8 -14
  51. data/sig/transcoder/form.rbs +1 -0
  52. data/sig/transcoder/json.rbs +1 -0
  53. data/sig/transcoder.rbs +5 -4
  54. metadata +5 -2
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "delegate"
5
+
6
+ module HTTPX::Plugins
7
+ module Multipart
8
+ using HTTPX::RegexpExtensions unless Regexp.method_defined?(:match?)
9
+
10
+ CRLF = "\r\n"
11
+
12
+ class FilePart < SimpleDelegator
13
+ attr_reader :original_filename, :content_type
14
+
15
+ def initialize(filename, content_type)
16
+ @original_filename = filename
17
+ @content_type = content_type
18
+ @file = Tempfile.new("httpx", encoding: Encoding::BINARY, mode: File::RDWR)
19
+ super(@file)
20
+ end
21
+ end
22
+
23
+ TOKEN = %r{[^\s()<>,;:\\"/\[\]?=]+}.freeze
24
+ VALUE = /"(?:\\"|[^"])*"|#{TOKEN}/.freeze
25
+ CONDISP = /Content-Disposition:\s*#{TOKEN}\s*/i.freeze
26
+ BROKEN_QUOTED = /^#{CONDISP}.*;\s*filename="(.*?)"(?:\s*$|\s*;\s*#{TOKEN}=)/i.freeze
27
+ BROKEN_UNQUOTED = /^#{CONDISP}.*;\s*filename=(#{TOKEN})/i.freeze
28
+ MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{CRLF}/ni.freeze
29
+ MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:.*;\s*name=(#{VALUE})/ni.freeze
30
+ MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{CRLF}]*)/ni.freeze
31
+ # Updated definitions from RFC 2231
32
+ ATTRIBUTE_CHAR = %r{[^ \t\v\n\r)(><@,;:\\"/\[\]?='*%]}.freeze
33
+ ATTRIBUTE = /#{ATTRIBUTE_CHAR}+/.freeze
34
+ SECTION = /\*[0-9]+/.freeze
35
+ REGULAR_PARAMETER_NAME = /#{ATTRIBUTE}#{SECTION}?/.freeze
36
+ REGULAR_PARAMETER = /(#{REGULAR_PARAMETER_NAME})=(#{VALUE})/.freeze
37
+ EXTENDED_OTHER_NAME = /#{ATTRIBUTE}\*[1-9][0-9]*\*/.freeze
38
+ EXTENDED_OTHER_VALUE = /%[0-9a-fA-F]{2}|#{ATTRIBUTE_CHAR}/.freeze
39
+ EXTENDED_OTHER_PARAMETER = /(#{EXTENDED_OTHER_NAME})=(#{EXTENDED_OTHER_VALUE}*)/.freeze
40
+ EXTENDED_INITIAL_NAME = /#{ATTRIBUTE}(?:\*0)?\*/.freeze
41
+ EXTENDED_INITIAL_VALUE = /[a-zA-Z0-9\-]*'[a-zA-Z0-9\-]*'#{EXTENDED_OTHER_VALUE}*/.freeze
42
+ EXTENDED_INITIAL_PARAMETER = /(#{EXTENDED_INITIAL_NAME})=(#{EXTENDED_INITIAL_VALUE})/.freeze
43
+ EXTENDED_PARAMETER = /#{EXTENDED_INITIAL_PARAMETER}|#{EXTENDED_OTHER_PARAMETER}/.freeze
44
+ DISPPARM = /;\s*(?:#{REGULAR_PARAMETER}|#{EXTENDED_PARAMETER})\s*/.freeze
45
+ RFC2183 = /^#{CONDISP}(#{DISPPARM})+$/i.freeze
46
+
47
+ class Decoder
48
+ BOUNDARY_RE = /;\s*boundary=([^;]+)/i.freeze
49
+ WINDOW_SIZE = 2 << 14
50
+
51
+ def initialize(response)
52
+ @boundary = begin
53
+ m = response.headers["content-type"].to_s[BOUNDARY_RE, 1]
54
+ raise Error, "no boundary declared in content-type header" unless m
55
+
56
+ m.strip
57
+ end
58
+ @buffer = "".b
59
+ @parts = {}
60
+ @intermediate_boundary = "--#{@boundary}"
61
+ @state = :idle
62
+ end
63
+
64
+ def call(response, _)
65
+ response.body.each do |chunk|
66
+ @buffer << chunk
67
+
68
+ parse
69
+ end
70
+
71
+ raise Error, "invalid or unsupported multipart format" unless @buffer.empty?
72
+
73
+ @parts
74
+ end
75
+
76
+ private
77
+
78
+ def parse
79
+ case @state
80
+ when :idle
81
+ raise Error, "payload does not start with boundary" unless @buffer.start_with?("#{@intermediate_boundary}#{CRLF}")
82
+
83
+ @buffer = @buffer.byteslice(@intermediate_boundary.bytesize + 2..-1)
84
+
85
+ @state = :part_header
86
+ when :part_header
87
+ idx = @buffer.index("#{CRLF}#{CRLF}")
88
+
89
+ # raise Error, "couldn't parse part headers" unless idx
90
+ return unless idx
91
+
92
+ head = @buffer.byteslice(0..idx + 4 - 1)
93
+
94
+ @buffer = @buffer.byteslice(head.bytesize..-1)
95
+
96
+ content_type = head[MULTIPART_CONTENT_TYPE, 1]
97
+ if (name = head[MULTIPART_CONTENT_DISPOSITION, 1])
98
+ name = /\A"(.*)"\Z/ =~ name ? Regexp.last_match(1) : name.dup
99
+ name.gsub!(/\\(.)/, "\\1")
100
+ name
101
+ else
102
+ name = head[MULTIPART_CONTENT_ID, 1]
103
+ end
104
+
105
+ filename = get_filename(head)
106
+
107
+ name = filename || +"#{content_type || "text/plain"}[]" if name.nil? || name.empty?
108
+
109
+ @current = name
110
+
111
+ @parts[name] = if filename
112
+ FilePart.new(filename, content_type)
113
+ else
114
+ "".b
115
+ end
116
+
117
+ @state = :part_body
118
+ when :part_body
119
+ part = @parts[@current]
120
+
121
+ body_separator = if part.is_a?(FilePart)
122
+ "#{CRLF}#{CRLF}"
123
+ else
124
+ CRLF
125
+ end
126
+ idx = @buffer.index(body_separator)
127
+
128
+ if idx
129
+ payload = @buffer.byteslice(0..idx - 1)
130
+ @buffer = @buffer.byteslice(idx + body_separator.bytesize..-1)
131
+ part << payload
132
+ part.rewind if part.respond_to?(:rewind)
133
+ @state = :parse_boundary
134
+ else
135
+ part << @buffer
136
+ @buffer.clear
137
+ end
138
+ when :parse_boundary
139
+ raise Error, "payload does not start with boundary" unless @buffer.start_with?(@intermediate_boundary)
140
+
141
+ @buffer = @buffer.byteslice(@intermediate_boundary.bytesize..-1)
142
+
143
+ if @buffer == "--"
144
+ @buffer.clear
145
+ @state = :done
146
+ return
147
+ elsif @buffer.start_with?(CRLF)
148
+ @buffer = @buffer.byteslice(2..-1)
149
+ @state = :part_header
150
+ else
151
+ return
152
+ end
153
+ when :done
154
+ raise Error, "parsing should have been over by now"
155
+ end until @buffer.empty?
156
+ end
157
+
158
+ def get_filename(head)
159
+ filename = nil
160
+ case head
161
+ when RFC2183
162
+ params = Hash[*head.scan(DISPPARM).flat_map(&:compact)]
163
+
164
+ if (filename = params["filename"])
165
+ filename = Regexp.last_match(1) if filename =~ /^"(.*)"$/
166
+ elsif (filename = params["filename*"])
167
+ encoding, _, filename = filename.split("'", 3)
168
+ end
169
+ when BROKEN_QUOTED, BROKEN_UNQUOTED
170
+ filename = Regexp.last_match(1)
171
+ end
172
+
173
+ return unless filename
174
+
175
+ filename = URI::DEFAULT_PARSER.unescape(filename) if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) }
176
+
177
+ filename.scrub!
178
+
179
+ filename = filename.gsub(/\\(.)/, '\1') unless /\\[^\\"]/.match?(filename)
180
+
181
+ filename.force_encoding ::Encoding.find(encoding) if encoding
182
+
183
+ filename
184
+ end
185
+ end
186
+ end
187
+ end
@@ -17,7 +17,7 @@ module HTTPX
17
17
 
18
18
  elsif defined?(MimeMagic)
19
19
 
20
- def call(file, *)
20
+ def call(file, _)
21
21
  mime = MimeMagic.by_magic(file)
22
22
  mime.type if mime
23
23
  end
@@ -25,7 +25,7 @@ module HTTPX
25
25
  elsif system("which file", out: File::NULL)
26
26
  require "open3"
27
27
 
28
- def call(file, *)
28
+ def call(file, _)
29
29
  return if file.eof? # file command returns "application/x-empty" for empty files
30
30
 
31
31
  Open3.popen3(*%w[file --mime-type --brief -]) do |stdin, stdout, stderr, thread|
@@ -56,7 +56,7 @@ module HTTPX
56
56
 
57
57
  else
58
58
 
59
- def call(*); end
59
+ def call(_, _); end
60
60
 
61
61
  end
62
62
  end
@@ -8,7 +8,7 @@ module HTTPX
8
8
  def call(value)
9
9
  # take out specialized objects of the way
10
10
  if value.respond_to?(:filename) && value.respond_to?(:content_type) && value.respond_to?(:read)
11
- return [value, value.content_type, value.filename]
11
+ return value, value.content_type, value.filename
12
12
  end
13
13
 
14
14
  content_type = filename = nil
@@ -19,7 +19,7 @@ module HTTPX
19
19
  value = value[:body]
20
20
  end
21
21
 
22
- value = value.open(:binmode => true) if Object.const_defined?(:Pathname) && value.is_a?(Pathname)
22
+ value = value.open(File::RDONLY) if Object.const_defined?(:Pathname) && value.is_a?(Pathname)
23
23
 
24
24
  if value.is_a?(File)
25
25
  filename ||= File.basename(value.path)
@@ -36,6 +36,7 @@ module HTTPX
36
36
  end
37
37
  # :nocov:
38
38
  require "httpx/plugins/multipart/encoder"
39
+ require "httpx/plugins/multipart/decoder"
39
40
  require "httpx/plugins/multipart/part"
40
41
  require "httpx/plugins/multipart/mime_type_detector"
41
42
  end
@@ -56,6 +57,19 @@ module HTTPX
56
57
  end
57
58
  end
58
59
 
60
+ def decode(response)
61
+ content_type = response.content_type.mime_type
62
+
63
+ case content_type
64
+ when "application/x-www-form-urlencoded"
65
+ Transcoder::Form.decode(response)
66
+ when "multipart/form-data"
67
+ Decoder.new(response)
68
+ else
69
+ raise Error, "invalid form mime type (#{content_type})"
70
+ end
71
+ end
72
+
59
73
  def multipart?(data)
60
74
  data.any? do |_, v|
61
75
  MULTIPART_VALUE_COND.call(v) ||
@@ -34,13 +34,13 @@ module HTTPX
34
34
 
35
35
  alias_method :ntlm_auth, :ntlm_authentication
36
36
 
37
- def send_requests(*requests, options)
37
+ def send_requests(*requests)
38
38
  requests.flat_map do |request|
39
39
  ntlm = request.options.ntlm
40
40
 
41
41
  if ntlm
42
42
  request.headers["authorization"] = "NTLM #{NTLM.negotiate(domain: ntlm.domain).to_base64}"
43
- probe_response = wrap { super(request, options).first }
43
+ probe_response = wrap { super(request).first }
44
44
 
45
45
  if !probe_response.is_a?(ErrorResponse) && probe_response.status == 401 &&
46
46
  probe_response.headers.key?("www-authenticate") &&
@@ -52,12 +52,12 @@ module HTTPX
52
52
  request.transition(:idle)
53
53
 
54
54
  request.headers["authorization"] = "NTLM #{ntlm_challenge}"
55
- super(request, options)
55
+ super(request)
56
56
  else
57
57
  probe_response
58
58
  end
59
59
  else
60
- super(request, options)
60
+ super(request)
61
61
  end
62
62
  end
63
63
  end
@@ -19,10 +19,14 @@ module HTTPX
19
19
  end
20
20
 
21
21
  module InstanceMethods
22
- private
22
+ def request(*args, **options)
23
+ raise ArgumentError, "must perform at least one request" if args.empty?
24
+
25
+ requests = args.first.is_a?(Request) ? args : build_requests(*args, options)
26
+
27
+ request = requests.first or return super
23
28
 
24
- def send_requests(*requests, options)
25
- request_options = @options.merge(options)
29
+ request_options = request.options
26
30
 
27
31
  return super unless request_options.proxy
28
32
 
@@ -37,18 +41,21 @@ module HTTPX
37
41
  if request_options.debug
38
42
  ssh_options[:verbose] = request_options.debug_level == 2 ? :debug : :info
39
43
  end
44
+
40
45
  request_uri = URI(requests.first.uri)
41
46
  @_gateway = Net::SSH::Gateway.new(ssh_uri.host, ssh_username, ssh_options)
42
47
  begin
43
48
  @_gateway.open(request_uri.host, request_uri.port) do |local_port|
44
49
  io = build_gateway_socket(local_port, request_uri, request_options)
45
- super(*requests, options.merge(io: io))
50
+ super(*args, **options.merge(io: io))
46
51
  end
47
52
  ensure
48
53
  @_gateway.shutdown!
49
54
  end
50
55
  end
51
56
 
57
+ private
58
+
52
59
  def build_gateway_socket(port, request_uri, options)
53
60
  case request_uri.scheme
54
61
  when "https"
@@ -5,7 +5,8 @@ require "ipaddr"
5
5
  require "forwardable"
6
6
 
7
7
  module HTTPX
8
- HTTPProxyError = Class.new(Error)
8
+ class HTTPProxyError < Error; end
9
+
9
10
  module Plugins
10
11
  #
11
12
  # This plugin adds support for proxies. It ships with support for:
@@ -136,10 +137,11 @@ module HTTPX
136
137
  def __proxy_error?(response)
137
138
  error = response.error
138
139
  case error
139
- when ResolveError
140
+ when NativeResolveError
140
141
  # failed resolving proxy domain
141
- proxy_uri = error.connection.options.proxy.uri
142
- proxy_uri.to_s == @_proxy_uris.first
142
+ error.connection.origin.to_s == @_proxy_uris.first
143
+ when ResolveError
144
+ error.message.end_with?(@_proxy_uris.first)
143
145
  when *PROXY_ERRORS
144
146
  # timeout errors connecting to proxy
145
147
  true
@@ -6,7 +6,6 @@ module HTTPX
6
6
  @request = request
7
7
  @session = session
8
8
  @connections = connections
9
- @options = @request.options
10
9
  end
11
10
 
12
11
  def each(&block)
@@ -72,7 +71,7 @@ module HTTPX
72
71
  private
73
72
 
74
73
  def response
75
- @session.__send__(:receive_requests, [@request], @connections, @options) until @request.response
74
+ @session.__send__(:receive_requests, [@request], @connections) until @request.response
76
75
 
77
76
  @request.response
78
77
  end
@@ -106,7 +105,7 @@ module HTTPX
106
105
 
107
106
  request = requests.first
108
107
 
109
- connections = _send_requests(requests, request.options)
108
+ connections = _send_requests(requests)
110
109
 
111
110
  StreamResponse.new(request, self, connections)
112
111
  end
@@ -59,7 +59,7 @@ module HTTPX
59
59
  @registry ||= {}
60
60
  return @registry if tag.nil?
61
61
 
62
- handler = @registry.fetch(tag)
62
+ handler = @registry[tag]
63
63
  raise(Error, "#{tag} is not registered in #{self}") unless handler
64
64
 
65
65
  handler
data/lib/httpx/request.rb CHANGED
@@ -41,16 +41,15 @@ module HTTPX
41
41
 
42
42
  def_delegator :@body, :empty?
43
43
 
44
- def_delegator :@body, :chunk!
45
-
46
44
  def initialize(verb, uri, options = {})
47
45
  @verb = verb.to_s.downcase.to_sym
48
46
  @options = Options.new(options)
49
47
  @uri = Utils.to_uri(uri)
50
48
  if @uri.relative?
51
- raise(Error, "invalid URI: #{@uri}") unless @options.origin
49
+ origin = @options.origin
50
+ raise(Error, "invalid URI: #{@uri}") unless origin
52
51
 
53
- @uri = @options.origin.merge(@uri)
52
+ @uri = origin.merge(@uri)
54
53
  end
55
54
 
56
55
  raise(Error, "unknown method: #{verb}") unless METHODS.include?(@verb)
@@ -98,7 +97,7 @@ module HTTPX
98
97
  def response=(response)
99
98
  return unless response
100
99
 
101
- if response.status == 100
100
+ if response.is_a?(Response) && response.status == 100
102
101
  @informational_status = response.status
103
102
  return
104
103
  end
@@ -158,7 +157,7 @@ module HTTPX
158
157
 
159
158
  class Body < SimpleDelegator
160
159
  class << self
161
- def new(*, options)
160
+ def new(_, options)
162
161
  return options.body if options.body.is_a?(self)
163
162
 
164
163
  super
@@ -223,7 +222,7 @@ module HTTPX
223
222
  def unbounded_body?
224
223
  return @unbounded_body if defined?(@unbounded_body)
225
224
 
226
- @unbounded_body = (chunked? || @body.bytesize == Float::INFINITY)
225
+ @unbounded_body = !@body.nil? && (chunked? || @body.bytesize == Float::INFINITY)
227
226
  end
228
227
 
229
228
  def chunked?
@@ -9,7 +9,7 @@ module HTTPX
9
9
  include Callbacks
10
10
  include Loggable
11
11
 
12
- CHECK_IF_IP = proc do |name|
12
+ CHECK_IF_IP = lambda do |name|
13
13
  begin
14
14
  IPAddr.new(name)
15
15
  true
@@ -55,6 +55,7 @@ module HTTPX
55
55
  return if ips.empty?
56
56
 
57
57
  ips.map { |ip| IPAddr.new(ip) }
58
+ rescue IOError
58
59
  end
59
60
 
60
61
  def emit_resolve_error(connection, hostname = connection.origin.host, ex = nil)