httpx 0.16.1 → 0.17.0

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