httpx 0.15.4 → 0.18.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/doc/release_notes/0_16_0.md +93 -0
  3. data/doc/release_notes/0_16_1.md +5 -0
  4. data/doc/release_notes/0_17_0.md +49 -0
  5. data/doc/release_notes/0_18_0.md +69 -0
  6. data/lib/httpx/adapters/datadog.rb +1 -1
  7. data/lib/httpx/adapters/faraday.rb +8 -14
  8. data/lib/httpx/adapters/webmock.rb +9 -3
  9. data/lib/httpx/altsvc.rb +2 -2
  10. data/lib/httpx/buffer.rb +1 -1
  11. data/lib/httpx/callbacks.rb +1 -1
  12. data/lib/httpx/chainable.rb +18 -11
  13. data/lib/httpx/connection/http1.rb +21 -13
  14. data/lib/httpx/connection/http2.rb +20 -25
  15. data/lib/httpx/connection.rb +73 -77
  16. data/lib/httpx/domain_name.rb +1 -1
  17. data/lib/httpx/errors.rb +11 -11
  18. data/lib/httpx/extensions.rb +50 -4
  19. data/lib/httpx/headers.rb +1 -1
  20. data/lib/httpx/io/ssl.rb +3 -3
  21. data/lib/httpx/io/tls.rb +8 -8
  22. data/lib/httpx/loggable.rb +5 -5
  23. data/lib/httpx/options.rb +108 -81
  24. data/lib/httpx/parser/http1.rb +11 -7
  25. data/lib/httpx/plugins/aws_sdk_authentication.rb +42 -18
  26. data/lib/httpx/plugins/aws_sigv4.rb +19 -20
  27. data/lib/httpx/plugins/compression.rb +17 -14
  28. data/lib/httpx/plugins/cookies/cookie.rb +4 -2
  29. data/lib/httpx/plugins/cookies/jar.rb +21 -2
  30. data/lib/httpx/plugins/cookies.rb +20 -7
  31. data/lib/httpx/plugins/digest_authentication.rb +19 -15
  32. data/lib/httpx/plugins/expect.rb +26 -18
  33. data/lib/httpx/plugins/follow_redirects.rb +9 -9
  34. data/lib/httpx/plugins/grpc/call.rb +4 -1
  35. data/lib/httpx/plugins/grpc/message.rb +2 -2
  36. data/lib/httpx/plugins/grpc.rb +72 -46
  37. data/lib/httpx/plugins/h2c.rb +7 -3
  38. data/lib/httpx/plugins/internal_telemetry.rb +8 -8
  39. data/lib/httpx/plugins/multipart/decoder.rb +187 -0
  40. data/lib/httpx/plugins/multipart/mime_type_detector.rb +3 -3
  41. data/lib/httpx/plugins/multipart/part.rb +2 -2
  42. data/lib/httpx/plugins/multipart.rb +16 -2
  43. data/lib/httpx/plugins/ntlm_authentication.rb +12 -10
  44. data/lib/httpx/plugins/proxy/socks4.rb +2 -1
  45. data/lib/httpx/plugins/proxy/socks5.rb +2 -1
  46. data/lib/httpx/plugins/proxy/ssh.rb +20 -13
  47. data/lib/httpx/plugins/proxy.rb +10 -10
  48. data/lib/httpx/plugins/response_cache/store.rb +55 -0
  49. data/lib/httpx/plugins/response_cache.rb +88 -0
  50. data/lib/httpx/plugins/retries.rb +46 -23
  51. data/lib/httpx/plugins/stream.rb +3 -4
  52. data/lib/httpx/plugins/upgrade.rb +7 -6
  53. data/lib/httpx/pool.rb +39 -13
  54. data/lib/httpx/registry.rb +2 -2
  55. data/lib/httpx/request.rb +16 -25
  56. data/lib/httpx/resolver/https.rb +4 -8
  57. data/lib/httpx/resolver/native.rb +19 -5
  58. data/lib/httpx/resolver/resolver_mixin.rb +2 -1
  59. data/lib/httpx/resolver/system.rb +2 -0
  60. data/lib/httpx/resolver.rb +2 -2
  61. data/lib/httpx/response.rb +91 -48
  62. data/lib/httpx/selector.rb +11 -24
  63. data/lib/httpx/session.rb +41 -23
  64. data/lib/httpx/session2.rb +23 -0
  65. data/lib/httpx/timers.rb +84 -0
  66. data/lib/httpx/transcoder/body.rb +3 -2
  67. data/lib/httpx/transcoder/chunker.rb +2 -1
  68. data/lib/httpx/transcoder/form.rb +20 -0
  69. data/lib/httpx/transcoder/json.rb +12 -0
  70. data/lib/httpx/transcoder.rb +62 -1
  71. data/lib/httpx/utils.rb +10 -2
  72. data/lib/httpx/version.rb +1 -1
  73. data/lib/httpx.rb +7 -3
  74. data/sig/buffer.rbs +3 -1
  75. data/sig/chainable.rbs +31 -29
  76. data/sig/connection/http1.rbs +11 -5
  77. data/sig/connection/http2.rbs +16 -5
  78. data/sig/connection.rbs +31 -13
  79. data/sig/errors.rbs +35 -1
  80. data/sig/headers.rbs +20 -19
  81. data/sig/httpx.rbs +4 -1
  82. data/sig/loggable.rbs +3 -1
  83. data/sig/options.rbs +45 -34
  84. data/sig/parser/http1.rbs +3 -3
  85. data/sig/plugins/authentication.rbs +1 -1
  86. data/sig/plugins/aws_sdk_authentication.rbs +25 -3
  87. data/sig/plugins/aws_sigv4.rbs +13 -5
  88. data/sig/plugins/basic_authentication.rbs +1 -1
  89. data/sig/plugins/compression.rbs +4 -6
  90. data/sig/plugins/cookies/cookie.rbs +5 -7
  91. data/sig/plugins/cookies/jar.rbs +9 -10
  92. data/sig/plugins/cookies.rbs +4 -5
  93. data/sig/plugins/digest_authentication.rbs +2 -3
  94. data/sig/plugins/expect.rbs +2 -4
  95. data/sig/plugins/follow_redirects.rbs +3 -5
  96. data/sig/plugins/grpc.rbs +4 -7
  97. data/sig/plugins/h2c.rbs +0 -2
  98. data/sig/plugins/multipart.rbs +64 -10
  99. data/sig/plugins/ntlm_authentication.rbs +2 -3
  100. data/sig/plugins/persistent.rbs +3 -8
  101. data/sig/plugins/proxy/ssh.rbs +4 -4
  102. data/sig/plugins/proxy.rbs +13 -13
  103. data/sig/plugins/push_promise.rbs +0 -2
  104. data/sig/plugins/response_cache.rbs +35 -0
  105. data/sig/plugins/retries.rbs +7 -8
  106. data/sig/plugins/stream.rbs +1 -1
  107. data/sig/plugins/upgrade.rbs +2 -3
  108. data/sig/pool.rbs +7 -2
  109. data/sig/registry.rbs +1 -1
  110. data/sig/request.rbs +11 -8
  111. data/sig/resolver/native.rbs +10 -5
  112. data/sig/resolver/resolver_mixin.rbs +4 -5
  113. data/sig/resolver/system.rbs +4 -0
  114. data/sig/resolver.rbs +7 -0
  115. data/sig/response.rbs +26 -13
  116. data/sig/selector.rbs +11 -9
  117. data/sig/session.rbs +22 -23
  118. data/sig/timers.rbs +32 -0
  119. data/sig/transcoder/body.rbs +6 -1
  120. data/sig/transcoder/chunker.rbs +8 -2
  121. data/sig/transcoder/form.rbs +3 -1
  122. data/sig/transcoder/json.rbs +2 -0
  123. data/sig/transcoder.rbs +13 -5
  124. data/sig/utils.rbs +6 -0
  125. metadata +18 -18
  126. data/lib/httpx/request2.rb +0 -14
@@ -14,19 +14,23 @@ module HTTPX
14
14
 
15
15
  DigestError = Class.new(Error)
16
16
 
17
- def self.extra_options(options)
18
- Class.new(options.class) do
19
- def_option(:digest, <<-OUT)
20
- raise Error, ":digest must be a Digest" unless value.is_a?(#{Digest})
21
-
22
- value
23
- OUT
24
- end.new(options).merge(max_concurrent_requests: 1)
17
+ class << self
18
+ def extra_options(options)
19
+ options.merge(max_concurrent_requests: 1)
20
+ end
21
+
22
+ def load_dependencies(*)
23
+ require "securerandom"
24
+ require "digest"
25
+ end
25
26
  end
26
27
 
27
- def self.load_dependencies(*)
28
- require "securerandom"
29
- require "digest"
28
+ module OptionsMethods
29
+ def option_digest(value)
30
+ raise TypeError, ":digest must be a Digest" unless value.is_a?(Digest)
31
+
32
+ value
33
+ end
30
34
  end
31
35
 
32
36
  module InstanceMethods
@@ -36,12 +40,12 @@ module HTTPX
36
40
 
37
41
  alias_method :digest_auth, :digest_authentication
38
42
 
39
- def send_requests(*requests, options)
43
+ def send_requests(*requests)
40
44
  requests.flat_map do |request|
41
45
  digest = request.options.digest
42
46
 
43
47
  if digest
44
- probe_response = wrap { super(request, options).first }
48
+ probe_response = wrap { super(request).first }
45
49
 
46
50
  if digest && !probe_response.is_a?(ErrorResponse) &&
47
51
  probe_response.status == 401 && probe_response.headers.key?("www-authenticate") &&
@@ -52,12 +56,12 @@ module HTTPX
52
56
  token = digest.generate_header(request, probe_response)
53
57
  request.headers["authorization"] = "Digest #{token}"
54
58
 
55
- super(request, options)
59
+ super(request)
56
60
  else
57
61
  probe_response
58
62
  end
59
63
  else
60
- super(request, options)
64
+ super(request)
61
65
  end
62
66
  end
63
67
  end
@@ -10,26 +10,30 @@ module HTTPX
10
10
  module Expect
11
11
  EXPECT_TIMEOUT = 2
12
12
 
13
- def self.no_expect_store
14
- @no_expect_store ||= []
13
+ class << self
14
+ def no_expect_store
15
+ @no_expect_store ||= []
16
+ end
17
+
18
+ def extra_options(options)
19
+ options.merge(expect_timeout: EXPECT_TIMEOUT)
20
+ end
15
21
  end
16
22
 
17
- def self.extra_options(options)
18
- Class.new(options.class) do
19
- def_option(:expect_timeout, <<-OUT)
20
- seconds = Integer(value)
21
- raise Error, ":expect_timeout must be positive" unless seconds.positive?
23
+ module OptionsMethods
24
+ def option_expect_timeout(value)
25
+ seconds = Integer(value)
26
+ raise TypeError, ":expect_timeout must be positive" unless seconds.positive?
22
27
 
23
- seconds
24
- OUT
28
+ seconds
29
+ end
25
30
 
26
- def_option(:expect_threshold_size, <<-OUT)
27
- bytes = Integer(value)
28
- raise Error, ":expect_threshold_size must be positive" unless bytes.positive?
31
+ def option_expect_threshold_size(value)
32
+ bytes = Integer(value)
33
+ raise TypeError, ":expect_threshold_size must be positive" unless bytes.positive?
29
34
 
30
- bytes
31
- OUT
32
- end.new(options).merge(expect_timeout: EXPECT_TIMEOUT)
35
+ bytes
36
+ end
33
37
  end
34
38
 
35
39
  module RequestMethods
@@ -65,9 +69,14 @@ module HTTPX
65
69
  end
66
70
 
67
71
  module ConnectionMethods
68
- def send(request)
72
+ def send_request_to_parser(request)
73
+ super
74
+
75
+ return unless request.headers["expect"] == "100-continue"
76
+
69
77
  request.once(:expect) do
70
- @timers.after(@options.expect_timeout) do
78
+ @timers.after(request.options.expect_timeout) do
79
+ # expect timeout expired
71
80
  if request.state == :expect && !request.expects?
72
81
  Expect.no_expect_store << request.origin
73
82
  request.headers.delete("expect")
@@ -75,7 +84,6 @@ module HTTPX
75
84
  end
76
85
  end
77
86
  end
78
- super
79
87
  end
80
88
  end
81
89
 
@@ -17,17 +17,17 @@ module HTTPX
17
17
  MAX_REDIRECTS = 3
18
18
  REDIRECT_STATUS = (300..399).freeze
19
19
 
20
- def self.extra_options(options)
21
- Class.new(options.class) do
22
- def_option(:max_redirects, <<-OUT)
23
- num = Integer(value)
24
- raise Error, ":max_redirects must be positive" if num.negative?
20
+ module OptionsMethods
21
+ def option_max_redirects(value)
22
+ num = Integer(value)
23
+ raise TypeError, ":max_redirects must be positive" if num.negative?
25
24
 
26
- num
27
- OUT
25
+ num
26
+ end
28
27
 
29
- def_option(:follow_insecure_redirects)
30
- end.new(options)
28
+ def option_follow_insecure_redirects(value)
29
+ value
30
+ end
31
31
  end
32
32
 
33
33
  module InstanceMethods
@@ -10,6 +10,7 @@ module HTTPX
10
10
  def initialize(response)
11
11
  @response = response
12
12
  @decoder = ->(z) { z }
13
+ @consumed = false
13
14
  end
14
15
 
15
16
  def inspect
@@ -25,7 +26,7 @@ module HTTPX
25
26
  end
26
27
 
27
28
  def trailing_metadata
28
- return unless @response.body.closed?
29
+ return unless @consumed
29
30
 
30
31
  @response.trailing_metadata
31
32
  end
@@ -40,8 +41,10 @@ module HTTPX
40
41
  Message.stream(@response).each do |message|
41
42
  y << @decoder.call(message)
42
43
  end
44
+ @consumed = true
43
45
  end
44
46
  else
47
+ @consumed = true
45
48
  @decoder.call(Message.unary(@response))
46
49
  end
47
50
  end
@@ -17,7 +17,7 @@ module HTTPX
17
17
 
18
18
  # lazy decodes a grpc stream response
19
19
  def stream(response, &block)
20
- return enum_for(__method__, response) unless block_given?
20
+ return enum_for(__method__, response) unless block
21
21
 
22
22
  response.each do |frame|
23
23
  decode(frame, encodings: response.headers.get("grpc-encoding"), encoders: response.encoders, &block)
@@ -57,7 +57,7 @@ module HTTPX
57
57
 
58
58
  yield data
59
59
 
60
- message = message.byteslice(5 + size..-1)
60
+ message = message.byteslice((5 + size)..-1)
61
61
  end
62
62
  end
63
63
 
@@ -49,7 +49,6 @@ module HTTPX
49
49
  class << self
50
50
  def load_dependencies(*)
51
51
  require "stringio"
52
- require "google/protobuf"
53
52
  require "httpx/plugins/grpc/message"
54
53
  require "httpx/plugins/grpc/call"
55
54
  end
@@ -61,36 +60,7 @@ module HTTPX
61
60
  end
62
61
 
63
62
  def extra_options(options)
64
- Class.new(options.class) do
65
- def_option(:grpc_service, <<-OUT)
66
- String(value)
67
- OUT
68
-
69
- def_option(:grpc_compression, <<-OUT)
70
- case value
71
- when true, false
72
- value
73
- else
74
- value.to_s
75
- end
76
- OUT
77
-
78
- def_option(:grpc_rpcs, <<-OUT)
79
- Hash[value]
80
- OUT
81
-
82
- def_option(:grpc_deadline, <<-OUT)
83
- raise Error, ":grpc_deadline must be positive" unless value.positive?
84
-
85
- value
86
- OUT
87
-
88
- def_option(:call_credentials, <<-OUT)
89
- raise Error, ":call_credentials must respond to #call" unless value.respond_to?(:call)
90
-
91
- value
92
- OUT
93
- end.new(options).merge(
63
+ options.merge(
94
64
  fallback_protocol: "h2",
95
65
  http2_settings: { wait_for_handshake: false },
96
66
  grpc_rpcs: {}.freeze,
@@ -100,6 +70,37 @@ module HTTPX
100
70
  end
101
71
  end
102
72
 
73
+ module OptionsMethods
74
+ def option_grpc_service(value)
75
+ String(value)
76
+ end
77
+
78
+ def option_grpc_compression(value)
79
+ case value
80
+ when true, false
81
+ value
82
+ else
83
+ value.to_s
84
+ end
85
+ end
86
+
87
+ def option_grpc_rpcs(value)
88
+ Hash[value]
89
+ end
90
+
91
+ def option_grpc_deadline(value)
92
+ raise TypeError, ":grpc_deadline must be positive" unless value.positive?
93
+
94
+ value
95
+ end
96
+
97
+ def option_call_credentials(value)
98
+ raise TypeError, ":call_credentials must respond to #call" unless value.respond_to?(:call)
99
+
100
+ value
101
+ end
102
+ end
103
+
103
104
  module ResponseMethods
104
105
  attr_reader :trailing_metadata
105
106
 
@@ -140,9 +141,19 @@ module HTTPX
140
141
  deadline: @options.grpc_deadline,
141
142
  }.merge(opts)
142
143
 
143
- with(grpc_rpcs: @options.grpc_rpcs.merge(
144
- rpc_name.underscore => [rpc_name, input, output, rpc_opts]
145
- ).freeze)
144
+ session_class = Class.new(self.class) do
145
+ class_eval(<<-OUT, __FILE__, __LINE__ + 1)
146
+ def #{rpc_name}(input, **opts) # def grpc_action(input, **opts)
147
+ rpc_execute("#{rpc_name}", input, **opts) # rpc_execute("grpc_action", input, **opts)
148
+ end # end
149
+ OUT
150
+ end
151
+
152
+ session_class.new(@options.merge(
153
+ grpc_rpcs: @options.grpc_rpcs.merge(
154
+ rpc_name.underscore => [rpc_name, input, output, rpc_opts]
155
+ ).freeze
156
+ ))
146
157
  end
147
158
 
148
159
  def build_stub(origin, service: nil, compression: false)
@@ -150,7 +161,32 @@ module HTTPX
150
161
 
151
162
  origin = URI.parse("#{scheme}://#{origin}")
152
163
 
153
- with(origin: origin, grpc_service: service, grpc_compression: compression)
164
+ session = self
165
+
166
+ if service && service.respond_to?(:rpc_descs)
167
+ # it's a grpc generic service
168
+ service.rpc_descs.each do |rpc_name, rpc_desc|
169
+ rpc_opts = {
170
+ marshal_method: rpc_desc.marshal_method,
171
+ unmarshal_method: rpc_desc.unmarshal_method,
172
+ }
173
+
174
+ input = rpc_desc.input
175
+ input = input.type if input.respond_to?(:type)
176
+
177
+ output = rpc_desc.output
178
+ if output.respond_to?(:type)
179
+ rpc_opts[:stream] = true
180
+ output = output.type
181
+ end
182
+
183
+ session = session.rpc(rpc_name, input, output, **rpc_opts)
184
+ end
185
+
186
+ service = service.service_name
187
+ end
188
+
189
+ session.with(origin: origin, grpc_service: service, grpc_compression: compression)
154
190
  end
155
191
 
156
192
  def execute(rpc_method, input,
@@ -166,7 +202,7 @@ module HTTPX
166
202
  private
167
203
 
168
204
  def rpc_execute(rpc_name, input, **opts)
169
- rpc_name, input_enc, output_enc, rpc_opts = @options.grpc_rpcs[rpc_name.to_s] || raise(Error, "#{rpc_name}: undefined service")
205
+ rpc_name, input_enc, output_enc, rpc_opts = @options.grpc_rpcs[rpc_name]
170
206
 
171
207
  exec_opts = rpc_opts.merge(opts)
172
208
 
@@ -230,16 +266,6 @@ module HTTPX
230
266
 
231
267
  build_request(:post, uri, headers: headers, body: body)
232
268
  end
233
-
234
- def respond_to_missing?(meth, *, &blk)
235
- @options.grpc_rpcs.key?(meth.to_s) || super
236
- end
237
-
238
- def method_missing(meth, *args, **kwargs, &blk)
239
- return rpc_execute(meth, *args, **kwargs, &blk) if @options.grpc_rpcs.key?(meth.to_s)
240
-
241
- super
242
- end
243
269
  end
244
270
  end
245
271
  register_plugin :grpc, GRPC
@@ -24,15 +24,19 @@ module HTTPX
24
24
  def call(connection, request, response)
25
25
  connection.upgrade_to_h2c(request, response)
26
26
  end
27
+
28
+ def extra_options(options)
29
+ options.merge(max_concurrent_requests: 1)
30
+ end
27
31
  end
28
32
 
29
33
  module InstanceMethods
30
- def send_requests(*requests, options)
34
+ def send_requests(*requests)
31
35
  upgrade_request, *remainder = requests
32
36
 
33
37
  return super unless VALID_H2C_VERBS.include?(upgrade_request.verb) && upgrade_request.scheme == "http"
34
38
 
35
- connection = pool.find_connection(upgrade_request.uri, @options.merge(options))
39
+ connection = pool.find_connection(upgrade_request.uri, upgrade_request.options)
36
40
 
37
41
  return super if connection && connection.upgrade_protocol == :h2c
38
42
 
@@ -42,7 +46,7 @@ module HTTPX
42
46
  upgrade_request.headers["upgrade"] = "h2c"
43
47
  upgrade_request.headers["http2-settings"] = HTTP2Next::Client.settings_header(upgrade_request.options.http2_settings)
44
48
 
45
- super(upgrade_request, *remainder, options.merge(max_concurrent_requests: 1))
49
+ super(upgrade_request, *remainder)
46
50
  end
47
51
  end
48
52
 
@@ -44,6 +44,11 @@ module HTTPX
44
44
  meter_elapsed_time("Session: initialized!!!")
45
45
  end
46
46
 
47
+ def close(*)
48
+ super
49
+ meter_elapsed_time("Session -> close")
50
+ end
51
+
47
52
  private
48
53
 
49
54
  def build_requests(*)
@@ -55,11 +60,6 @@ module HTTPX
55
60
  meter_elapsed_time("Session -> response") if response
56
61
  response
57
62
  end
58
-
59
- def close(*)
60
- super
61
- meter_elapsed_time("Session -> close")
62
- end
63
63
  end
64
64
 
65
65
  module RequestMethods
@@ -69,9 +69,9 @@ module HTTPX
69
69
  end
70
70
 
71
71
  def transition(nextstate)
72
- state = @state
72
+ prev_state = @state
73
73
  super
74
- meter_elapsed_time("Request##{object_id}[#{@verb} #{@uri}: #{state}] -> #{nextstate}") if nextstate == @state
74
+ meter_elapsed_time("Request##{object_id}[#{@verb} #{@uri}: #{prev_state}] -> #{@state}") if prev_state != @state
75
75
  end
76
76
  end
77
77
 
@@ -84,7 +84,7 @@ module HTTPX
84
84
  def transition(nextstate)
85
85
  state = @state
86
86
  super
87
- meter_elapsed_time("Connection[#{@origin}]: #{state} -> #{nextstate}") if nextstate == @state
87
+ meter_elapsed_time("Connection##{object_id}[#{@origin}]: #{state} -> #{nextstate}") if nextstate == @state
88
88
  end
89
89
  end
90
90
  end
@@ -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)