httparty 0.18.1 → 0.24.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +6 -0
  3. data/.github/workflows/ci.yml +24 -0
  4. data/.gitignore +2 -1
  5. data/Changelog.md +402 -308
  6. data/Gemfile +3 -0
  7. data/Guardfile +3 -2
  8. data/README.md +16 -18
  9. data/docs/README.md +89 -2
  10. data/examples/README.md +3 -0
  11. data/examples/aaws.rb +6 -2
  12. data/examples/idn.rb +10 -0
  13. data/examples/party_foul_mode.rb +90 -0
  14. data/httparty.gemspec +4 -2
  15. data/lib/httparty/connection_adapter.rb +8 -25
  16. data/lib/httparty/cookie_hash.rb +3 -1
  17. data/lib/httparty/decompressor.rb +102 -0
  18. data/lib/httparty/exceptions.rb +37 -4
  19. data/lib/httparty/hash_conversions.rb +4 -2
  20. data/lib/httparty/headers_processor.rb +2 -0
  21. data/lib/httparty/logger/apache_formatter.rb +4 -2
  22. data/lib/httparty/logger/curl_formatter.rb +5 -3
  23. data/lib/httparty/logger/logger.rb +2 -0
  24. data/lib/httparty/logger/logstash_formatter.rb +5 -2
  25. data/lib/httparty/module_inheritable_attributes.rb +6 -6
  26. data/lib/httparty/net_digest_auth.rb +9 -10
  27. data/lib/httparty/parser.rb +12 -5
  28. data/lib/httparty/request/body.rb +53 -12
  29. data/lib/httparty/request/multipart_boundary.rb +2 -0
  30. data/lib/httparty/request/streaming_multipart_body.rb +188 -0
  31. data/lib/httparty/request.rb +130 -43
  32. data/lib/httparty/response/headers.rb +2 -0
  33. data/lib/httparty/response.rb +7 -5
  34. data/lib/httparty/response_fragment.rb +2 -0
  35. data/lib/httparty/text_encoder.rb +7 -5
  36. data/lib/httparty/utils.rb +2 -0
  37. data/lib/httparty/version.rb +3 -1
  38. data/lib/httparty.rb +45 -14
  39. data/script/release +4 -4
  40. metadata +33 -14
  41. data/.simplecov +0 -1
  42. data/.travis.yml +0 -11
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module HTTParty
2
4
  module Logger
3
5
  class CurlFormatter #:nodoc:
4
6
  TAG_NAME = HTTParty.name
5
- OUT = '>'.freeze
6
- IN = '<'.freeze
7
+ OUT = '>'
8
+ IN = '<'
7
9
 
8
10
  attr_accessor :level, :logger
9
11
 
@@ -44,7 +46,7 @@ module HTTParty
44
46
  end
45
47
 
46
48
  def log_url
47
- http_method = request.http_method.name.split("::").last.upcase
49
+ http_method = request.http_method.name.split('::').last.upcase
48
50
  uri = if request.options[:base_uri]
49
51
  request.options[:base_uri] + request.path.path
50
52
  else
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'httparty/logger/apache_formatter'
2
4
  require 'httparty/logger/curl_formatter'
3
5
  require 'httparty/logger/logstash_formatter'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module HTTParty
2
4
  module Logger
3
5
  class LogstashFormatter #:nodoc:
@@ -22,6 +24,7 @@ module HTTParty
22
24
  attr_reader :request, :response
23
25
 
24
26
  def logstash_message
27
+ require 'json'
25
28
  {
26
29
  '@timestamp' => current_time,
27
30
  '@version' => 1,
@@ -40,11 +43,11 @@ module HTTParty
40
43
  end
41
44
 
42
45
  def current_time
43
- Time.now.strftime("%Y-%m-%d %H:%M:%S %z")
46
+ Time.now.strftime('%Y-%m-%d %H:%M:%S %z')
44
47
  end
45
48
 
46
49
  def http_method
47
- @http_method ||= request.http_method.name.split("::").last.upcase
50
+ @http_method ||= request.http_method.name.split('::').last.upcase
48
51
  end
49
52
 
50
53
  def path
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module HTTParty
2
4
  module ModuleInheritableAttributes #:nodoc:
3
5
  def self.included(base)
@@ -27,7 +29,7 @@ module HTTParty
27
29
  @mattr_inheritable_attrs += args
28
30
 
29
31
  args.each do |arg|
30
- module_eval %(class << self; attr_accessor :#{arg} end)
32
+ singleton_class.attr_accessor(arg)
31
33
  end
32
34
 
33
35
  @mattr_inheritable_attrs
@@ -36,18 +38,16 @@ module HTTParty
36
38
  def inherited(subclass)
37
39
  super
38
40
  @mattr_inheritable_attrs.each do |inheritable_attribute|
39
- ivar = "@#{inheritable_attribute}"
41
+ ivar = :"@#{inheritable_attribute}"
40
42
  subclass.instance_variable_set(ivar, instance_variable_get(ivar).clone)
41
43
 
42
44
  if instance_variable_get(ivar).respond_to?(:merge)
43
- method = <<-EOM
45
+ subclass.class_eval <<~RUBY, __FILE__, __LINE__ + 1
44
46
  def self.#{inheritable_attribute}
45
47
  duplicate = ModuleInheritableAttributes.hash_deep_dup(#{ivar})
46
48
  #{ivar} = superclass.#{inheritable_attribute}.merge(duplicate)
47
49
  end
48
- EOM
49
-
50
- subclass.class_eval method
50
+ RUBY
51
51
  end
52
52
  end
53
53
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'digest/md5'
2
4
  require 'net/http'
3
5
 
@@ -44,12 +46,9 @@ module Net
44
46
  header << %(algorithm="#{@response['algorithm']}") if algorithm_present?
45
47
 
46
48
  if qop_present?
47
- fields = [
48
- %(cnonce="#{@cnonce}"),
49
- %(qop="#{@response['qop']}"),
50
- "nc=00000001"
51
- ]
52
- fields.each { |field| header << field }
49
+ header << %(cnonce="#{@cnonce}")
50
+ header << %(qop="#{@response['qop']}")
51
+ header << 'nc=00000001'
53
52
  end
54
53
 
55
54
  header << %(opaque="#{@response['opaque']}") if opaque_present?
@@ -98,13 +97,13 @@ module Net
98
97
  end
99
98
 
100
99
  def random
101
- format "%x", (Time.now.to_i + rand(65535))
100
+ format '%x', (Time.now.to_i + rand(65535))
102
101
  end
103
102
 
104
103
  def request_digest
105
104
  a = [md5(a1), @response['nonce'], md5(a2)]
106
- a.insert(2, "00000001", @cnonce, @response['qop']) if qop_present?
107
- md5(a.join(":"))
105
+ a.insert(2, '00000001', @cnonce, @response['qop']) if qop_present?
106
+ md5(a.join(':'))
108
107
  end
109
108
 
110
109
  def md5(str)
@@ -129,7 +128,7 @@ module Net
129
128
  end
130
129
 
131
130
  def a2
132
- [@method, @path].join(":")
131
+ [@method, @path].join(':')
133
132
  end
134
133
  end
135
134
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module HTTParty
2
4
  # The default parser used by HTTParty, supports xml, json, html, csv and
3
5
  # plain text.
@@ -101,7 +103,7 @@ module HTTParty
101
103
  # @return [nil] when the response body is nil, an empty string, spaces only or "null"
102
104
  def parse
103
105
  return nil if body.nil?
104
- return nil if body == "null"
106
+ return nil if body == 'null'
105
107
  return nil if body.valid_encoding? && body.strip.empty?
106
108
  if body.valid_encoding? && body.encoding == Encoding::UTF_8
107
109
  @body = body.gsub(/\A#{UTF8_BOM}/, '')
@@ -116,16 +118,19 @@ module HTTParty
116
118
  protected
117
119
 
118
120
  def xml
121
+ require 'multi_xml'
119
122
  MultiXml.parse(body)
120
123
  end
121
124
 
122
- UTF8_BOM = "\xEF\xBB\xBF".freeze
125
+ UTF8_BOM = "\xEF\xBB\xBF"
123
126
 
124
127
  def json
128
+ require 'json'
125
129
  JSON.parse(body, :quirks_mode => true, :allow_nan => true)
126
130
  end
127
131
 
128
132
  def csv
133
+ require 'csv'
129
134
  CSV.parse(body)
130
135
  end
131
136
 
@@ -142,9 +147,11 @@ module HTTParty
142
147
  end
143
148
 
144
149
  def parse_supported_format
145
- send(format)
146
- rescue NoMethodError => e
147
- raise NotImplementedError, "#{self.class.name} has not implemented a parsing method for the #{format.inspect} format.", e.backtrace
150
+ if respond_to?(format, true)
151
+ send(format)
152
+ else
153
+ raise NotImplementedError, "#{self.class.name} has not implemented a parsing method for the #{format.inspect} format."
154
+ end
148
155
  end
149
156
  end
150
157
  end
@@ -1,8 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'multipart_boundary'
4
+ require_relative 'streaming_multipart_body'
2
5
 
3
6
  module HTTParty
4
7
  class Request
5
8
  class Body
9
+ NEWLINE = "\r\n"
10
+ private_constant :NEWLINE
11
+
6
12
  def initialize(params, query_string_normalizer: nil, force_multipart: false)
7
13
  @params = params
8
14
  @query_string_normalizer = query_string_normalizer
@@ -25,25 +31,48 @@ module HTTParty
25
31
  params.respond_to?(:to_hash) && (force_multipart || has_file?(params))
26
32
  end
27
33
 
34
+ def streaming?
35
+ multipart? && has_file?(params)
36
+ end
37
+
38
+ def to_stream
39
+ return nil unless streaming?
40
+ StreamingMultipartBody.new(prepared_parts, boundary)
41
+ end
42
+
43
+ def prepared_parts
44
+ normalized_params = params.flat_map { |key, value| HashConversions.normalize_keys(key, value) }
45
+ normalized_params.map do |key, value|
46
+ [key, value, file?(value)]
47
+ end
48
+ end
49
+
28
50
  private
29
51
 
52
+ # https://html.spec.whatwg.org/#multipart-form-data
53
+ MULTIPART_FORM_DATA_REPLACEMENT_TABLE = {
54
+ '"' => '%22',
55
+ "\r" => '%0D',
56
+ "\n" => '%0A'
57
+ }.freeze
58
+
30
59
  def generate_multipart
31
60
  normalized_params = params.flat_map { |key, value| HashConversions.normalize_keys(key, value) }
32
61
 
33
- multipart = normalized_params.inject('') do |memo, (key, value)|
34
- memo += "--#{boundary}\r\n"
35
- memo += %(Content-Disposition: form-data; name="#{key}")
62
+ multipart = normalized_params.inject(''.b) do |memo, (key, value)|
63
+ memo << "--#{boundary}#{NEWLINE}".b
64
+ memo << %(Content-Disposition: form-data; name="#{key}").b
36
65
  # value.path is used to support ActionDispatch::Http::UploadedFile
37
66
  # https://github.com/jnunemaker/httparty/pull/585
38
- memo += %(; filename="#{file_name(value)}") if file?(value)
39
- memo += "\r\n"
40
- memo += "Content-Type: #{content_type(value)}\r\n" if file?(value)
41
- memo += "\r\n"
42
- memo += file?(value) ? value.read : value.to_s
43
- memo += "\r\n"
67
+ memo << %(; filename="#{file_name(value).gsub(/["\r\n]/, MULTIPART_FORM_DATA_REPLACEMENT_TABLE)}").b if file?(value)
68
+ memo << NEWLINE.b
69
+ memo << "Content-Type: #{content_type(value)}#{NEWLINE}".b if file?(value)
70
+ memo << NEWLINE.b
71
+ memo << content_body(value)
72
+ memo << NEWLINE.b
44
73
  end
45
74
 
46
- multipart += "--#{boundary}--\r\n"
75
+ multipart << "--#{boundary}--#{NEWLINE}".b
47
76
  end
48
77
 
49
78
  def has_file?(value)
@@ -68,10 +97,22 @@ module HTTParty
68
97
  end
69
98
  end
70
99
 
100
+ def content_body(object)
101
+ if file?(object)
102
+ object = (file = object).read
103
+ object.force_encoding(Encoding::BINARY) if object.respond_to?(:force_encoding)
104
+ file.rewind if file.respond_to?(:rewind)
105
+ object.to_s
106
+ else
107
+ object.to_s.b
108
+ end
109
+ end
110
+
71
111
  def content_type(object)
72
112
  return object.content_type if object.respond_to?(:content_type)
73
- mime = MIME::Types.type_for(object.path)
74
- mime.empty? ? 'application/octet-stream' : mime[0].content_type
113
+ require 'mini_mime'
114
+ mime = MiniMime.lookup_by_filename(object.path)
115
+ mime ? mime.content_type : 'application/octet-stream'
75
116
  end
76
117
 
77
118
  def file_name(object)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'securerandom'
2
4
 
3
5
  module HTTParty
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTParty
4
+ class Request
5
+ class StreamingMultipartBody
6
+ NEWLINE = "\r\n"
7
+ CHUNK_SIZE = 64 * 1024 # 64 KB chunks
8
+
9
+ def initialize(parts, boundary)
10
+ @parts = parts
11
+ @boundary = boundary
12
+ @part_index = 0
13
+ @state = :header
14
+ @current_file = nil
15
+ @header_buffer = nil
16
+ @header_offset = 0
17
+ @footer_sent = false
18
+ end
19
+
20
+ def size
21
+ @size ||= calculate_size
22
+ end
23
+
24
+ def read(length = nil, outbuf = nil)
25
+ outbuf = outbuf ? outbuf.replace(''.b) : ''.b
26
+
27
+ return read_all(outbuf) if length.nil?
28
+
29
+ while outbuf.bytesize < length
30
+ chunk = read_chunk(length - outbuf.bytesize)
31
+ break if chunk.nil?
32
+ outbuf << chunk
33
+ end
34
+
35
+ outbuf.empty? ? nil : outbuf
36
+ end
37
+
38
+ def rewind
39
+ @part_index = 0
40
+ @state = :header
41
+ @current_file = nil
42
+ @header_buffer = nil
43
+ @header_offset = 0
44
+ @footer_sent = false
45
+ @parts.each do |_key, value, _is_file|
46
+ value.rewind if value.respond_to?(:rewind)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def read_all(outbuf)
53
+ while (chunk = read_chunk(CHUNK_SIZE))
54
+ outbuf << chunk
55
+ end
56
+ outbuf.empty? ? nil : outbuf
57
+ end
58
+
59
+ def read_chunk(max_length)
60
+ loop do
61
+ return nil if @part_index >= @parts.size && @footer_sent
62
+
63
+ if @part_index >= @parts.size
64
+ @footer_sent = true
65
+ return "--#{@boundary}--#{NEWLINE}".b
66
+ end
67
+
68
+ key, value, is_file = @parts[@part_index]
69
+
70
+ case @state
71
+ when :header
72
+ chunk = read_header_chunk(key, value, is_file, max_length)
73
+ return chunk if chunk
74
+
75
+ when :body
76
+ chunk = read_body_chunk(value, is_file, max_length)
77
+ return chunk if chunk
78
+
79
+ when :newline
80
+ @state = :header
81
+ @part_index += 1
82
+ return NEWLINE.b
83
+ end
84
+ end
85
+ end
86
+
87
+ def read_header_chunk(key, value, is_file, max_length)
88
+ if @header_buffer.nil?
89
+ @header_buffer = build_part_header(key, value, is_file)
90
+ @header_offset = 0
91
+ end
92
+
93
+ remaining = @header_buffer.bytesize - @header_offset
94
+ if remaining > 0
95
+ chunk_size = [remaining, max_length].min
96
+ chunk = @header_buffer.byteslice(@header_offset, chunk_size)
97
+ @header_offset += chunk_size
98
+ return chunk
99
+ end
100
+
101
+ @header_buffer = nil
102
+ @header_offset = 0
103
+ @state = :body
104
+ nil
105
+ end
106
+
107
+ def read_body_chunk(value, is_file, max_length)
108
+ if is_file
109
+ chunk = read_file_chunk(value, max_length)
110
+ if chunk
111
+ return chunk
112
+ else
113
+ @current_file = nil
114
+ @state = :newline
115
+ return nil
116
+ end
117
+ else
118
+ @state = :newline
119
+ return value.to_s.b
120
+ end
121
+ end
122
+
123
+ def read_file_chunk(file, max_length)
124
+ chunk_size = [max_length, CHUNK_SIZE].min
125
+ chunk = file.read(chunk_size)
126
+ return nil if chunk.nil?
127
+ chunk.force_encoding(Encoding::BINARY) if chunk.respond_to?(:force_encoding)
128
+ chunk
129
+ end
130
+
131
+ def build_part_header(key, value, is_file)
132
+ header = "--#{@boundary}#{NEWLINE}".b
133
+ header << %(Content-Disposition: form-data; name="#{key}").b
134
+ if is_file
135
+ header << %(; filename="#{file_name(value).gsub(/["\r\n]/, replacement_table)}").b
136
+ header << NEWLINE.b
137
+ header << "Content-Type: #{content_type(value)}#{NEWLINE}".b
138
+ end
139
+ header << NEWLINE.b
140
+ header
141
+ end
142
+
143
+ def calculate_size
144
+ total = 0
145
+ @parts.each do |key, value, is_file|
146
+ total += build_part_header(key, value, is_file).bytesize
147
+ total += content_size(value, is_file)
148
+ total += NEWLINE.bytesize
149
+ end
150
+ total += "--#{@boundary}--#{NEWLINE}".bytesize
151
+ total
152
+ end
153
+
154
+ def content_size(value, is_file)
155
+ if is_file
156
+ if value.respond_to?(:size)
157
+ value.size
158
+ elsif value.respond_to?(:stat)
159
+ value.stat.size
160
+ else
161
+ value.read.bytesize.tap { value.rewind }
162
+ end
163
+ else
164
+ value.to_s.b.bytesize
165
+ end
166
+ end
167
+
168
+ def content_type(object)
169
+ return object.content_type if object.respond_to?(:content_type)
170
+ require 'mini_mime'
171
+ mime = MiniMime.lookup_by_filename(object.path)
172
+ mime ? mime.content_type : 'application/octet-stream'
173
+ end
174
+
175
+ def file_name(object)
176
+ object.respond_to?(:original_filename) ? object.original_filename : File.basename(object.path)
177
+ end
178
+
179
+ def replacement_table
180
+ @replacement_table ||= {
181
+ '"' => '%22',
182
+ "\r" => '%0D',
183
+ "\n" => '%0A'
184
+ }.freeze
185
+ end
186
+ end
187
+ end
188
+ end