patron 1.0.0 → 1.1.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.
@@ -15,7 +15,7 @@ module Patron
15
15
  ssl_version http_version ssl_cert ssl_cert_type ssl_key ssl_key_password
16
16
  ignore_content_length buffer_size download_byte_limit progress_callback
17
17
  automatic_content_encoding force_ipv4 low_speed_time low_speed_limit
18
- upload_data file_name multipart
18
+ upload_data file_name multipart read_timeout
19
19
  ].freeze
20
20
 
21
21
  # Any HTTP token works as a verb; the ones libcurl does not know natively are sent as custom requests
@@ -27,7 +27,8 @@ module Patron
27
27
  :automatic_content_encoding, :force_ipv4, :download_byte_limit, :low_speed_time,
28
28
  :low_speed_limit, :progress_callback, :ssl_cert, :ssl_cert_type, :ssl_key, :ssl_key_password
29
29
 
30
- attr_reader :action, :headers, :timeout, :connect_timeout, :max_redirects, :auth_type, :buffer_size, :upload_data
30
+ attr_reader :action, :headers, :timeout, :connect_timeout, :max_redirects, :auth_type, :buffer_size, :upload_data,
31
+ :read_timeout
31
32
 
32
33
  # Defaults to a GET with no timeouts and unlimited redirects. Attributes may be given as
33
34
  # keywords; `action` and `multipart` are applied first since `upload_data=` depends on them.
@@ -63,10 +64,18 @@ module Patron
63
64
  end
64
65
 
65
66
  # Sets the request body. A Hash is form-encoded, unless the request is multipart, in which
66
- # case libcurl composes the body from it. Anything else is sent as its `to_s`, or uploaded
67
- # from the file it names when it responds to `to_path`.
67
+ # case libcurl composes the body from it. An object that responds to `to_path` (a File, a
68
+ # Tempfile) is uploaded from the file it names. An object that responds to `readpartial` or
69
+ # `read` (a StringIO, an `IO.pipe` reader, a `Zlib::GzipReader`) is streamed: libcurl calls
70
+ # `readpartial(length, buffer)` until it raises EOFError, or, when the object has no
71
+ # `readpartial`, `read(length, buffer)` until it returns nil. `readpartial` sends whatever a
72
+ # pipe or a socket has so far, where `read` waits for the full length. The body length comes
73
+ # from `size` when it has one, then from a `Content-Length` header, and otherwise the body is
74
+ # sent with chunked transfer-encoding. When the body has to be sent again (a 307/308 redirect,
75
+ # say) the object is rewound with `seek` or `rewind`; one that cannot be rewound fails the
76
+ # request with a Patron::Error. Anything else is sent as its `to_s`.
68
77
  #
69
- # @param data [Hash, #to_s, #to_path, nil]
78
+ # @param data [Hash, #to_path, #read, #to_s, nil]
70
79
  def upload_data=(data)
71
80
  @upload_data = if data.is_a?(Hash) && !multipart
72
81
  Util.build_query_string_from_hash(data, action == :post)
@@ -95,6 +104,18 @@ module Patron
95
104
  @connect_timeout = validate_timeout(seconds)
96
105
  end
97
106
 
107
+ # Sets how long to wait for the next piece of the response, counted from the previous one
108
+ # (or from the start of the request), as opposed to #timeout which bounds the whole transfer.
109
+ # Exceeding it raises Patron::TimeoutError.
110
+ #
111
+ # @param seconds [#to_f, nil] fractional seconds allowed, nil for no limit
112
+ # @raise [ArgumentError] when zero or negative
113
+ def read_timeout=(seconds)
114
+ return @read_timeout = nil if seconds.nil?
115
+ raise ArgumentError, "Read timeout must be nil or a positive number of seconds" unless seconds.respond_to?(:to_f) && seconds.to_f > 0
116
+ @read_timeout = seconds.to_f
117
+ end
118
+
98
119
  # @param count [Integer] -1 for unlimited, 0 to not follow redirects
99
120
  def max_redirects=(count)
100
121
  raise ArgumentError, "Max redirects must be a positive integer, 0 or -1" if count.to_i < -1
@@ -4,32 +4,13 @@ module Patron
4
4
 
5
5
  # Represents the response from the HTTP server.
6
6
  class Response
7
+ include ResponseHead
7
8
  include ResponseDecoding
8
9
 
9
- # @return [String] the original URL used to perform the request (contains the final URL after redirects)
10
- attr_reader :url
11
-
12
- # @return [Integer] the HTTP status code of the final response after all the redirects
13
- attr_reader :status
14
-
15
- # @return [String] the complete status line (code and message)
16
- attr_reader :status_line
17
-
18
- # @return [Integer] how many redirects were followed when fulfilling this request
19
- attr_reader :redirect_count
20
-
21
10
  # @return [String, nil] the response body as a String encoded as `Encoding::BINARY` or
22
11
  # or `nil` if the response was written directly to a file
23
12
  attr_reader :body
24
13
 
25
- # @return [Hash] the response headers. If there were multiple headers received for the same value
26
- # (like "Cookie"), the header values will be within an Array under the key for the header, in order.
27
- attr_reader :headers
28
-
29
- # @return [String] the recognized name of the charset for the response. The name is not checked
30
- # to be a valid charset name, just stored. To check the charset for validity, use #body_decodable?
31
- attr_reader :charset
32
-
33
14
  # @return [Hash{Symbol => Float}, nil] curl's cumulative transfer marks in seconds since the request
34
15
  # started: `:namelookup`, `:connect`, `:appconnect`, `:pretransfer`, `:starttransfer`, `:redirect`
35
16
  # and `:total`. `nil` when the response was not built from a curl transfer (e.g. a stub).
@@ -54,20 +35,9 @@ module Patron
54
35
  @body = body && (body.encoding == Encoding::BINARY ? body : body.b)
55
36
  @timings = timings
56
37
 
57
- parse_headers(decode_header_data(header_data))
58
- @charset = charset_from_content_type || default_charset
38
+ parse_head(header_data, default_charset)
59
39
  end
60
40
 
61
- # Tells whether the HTTP response code is less than 400
62
- #
63
- # @return [Boolean]
64
- def ok? = !error?
65
-
66
- # Tells whether the HTTP response code is larger than 399
67
- #
68
- # @return [Boolean]
69
- def error? = status >= 400
70
-
71
41
  # Tells whether the response body can be decoded losslessly into the curren internal encoding
72
42
  #
73
43
  # @return [Boolean] true if the body is decodable, false if otherwise
@@ -120,27 +90,5 @@ module Patron
120
90
  return decode_body(false) if frozen?
121
91
  @inspectable_body ||= decode_body(false)
122
92
  end
123
-
124
- private
125
-
126
- def parse_headers(header_data_for_multiple_responses)
127
- @headers = {}
128
-
129
- responses = Patron::HeaderParser.parse(header_data_for_multiple_responses)
130
- last_response = responses[-1] # proxies and redirects put their own responses in front of the real one
131
-
132
- @status_line = last_response.status_line
133
- last_response.headers.each do |line|
134
- hdr, val = line.split(":", 2)
135
- val = val&.strip
136
-
137
- if @headers.key?(hdr)
138
- @headers[hdr] = [@headers[hdr]] unless @headers[hdr].kind_of?(Array)
139
- @headers[hdr] << val
140
- else
141
- @headers[hdr] = val
142
- end
143
- end
144
- end
145
93
  end
146
94
  end
@@ -7,8 +7,6 @@ module Patron
7
7
 
8
8
  private
9
9
 
10
- CHARSET_CONTENT_TYPE_RE = /(?:charset|encoding)="?([a-z0-9-]+)"?/i
11
-
12
10
  MISREPORTED_ENCODING_ERROR = <<~EOF
13
11
  The server stated that the response has the charset matching %{declared}, but the actual
14
12
  response body failed to decode as such (not flagged as `valid_encoding?')
@@ -61,16 +59,12 @@ module Patron
61
59
  INTERNAL_CHARSET_MISMATCH_ERROR % { source_encoding: enc, target_encoding: internal }
62
60
  end
63
61
 
64
- def charset_from_content_type
65
- @headers["Content-Type"].to_s[CHARSET_CONTENT_TYPE_RE, 1]
66
- end
67
-
68
62
  def encoding_from_headers_or_binary
69
63
  return Encoding::BINARY unless (charset_name = charset_from_content_type)
70
64
  Encoding.find(charset_name)
71
65
  rescue ArgumentError # invalid charset name
72
66
  raise HeaderCharsetInvalid,
73
- INVALID_CHARSET_NAME_ERROR % { content_type: @headers['Content-Type'].inspect }
67
+ INVALID_CHARSET_NAME_ERROR % { content_type: content_type_header.inspect }
74
68
  end
75
69
 
76
70
  def internal_encoding
@@ -78,17 +72,6 @@ module Patron
78
72
  # so the String is the better oracle for "what does this process speak"
79
73
  Encoding.default_internal || ''.encoding
80
74
  end
81
-
82
- def decode_header_data(str)
83
- # Headers are ISO-8859-1 by the book, but Content-Disposition routinely arrives as raw UTF-8
84
- # and every browser but localized IE treats it as such, so 8859-1 -> UTF-8 -> binary it is
85
- [Encoding::ISO8859_1, Encoding::UTF_8, Encoding::BINARY].each do |possible_enc|
86
- return str.encode(possible_enc)
87
- rescue ::Encoding::UndefinedConversionError
88
- next
89
- end
90
- str
91
- end
92
75
  end
93
76
 
94
77
  private_constant :ResponseDecoding
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Patron
4
+
5
+ # The facts known once the head of a response has arrived: status, headers, the effective
6
+ # URL. Shared by Response, ResponseStream and TransferStats, so that a header value has
7
+ # one shape wherever it is looked at.
8
+ module ResponseHead
9
+ CHARSET_CONTENT_TYPE_RE = /(?:charset|encoding)="?([a-z0-9-]+)"?/i
10
+
11
+ # @return [String] the original URL used to perform the request (contains the final URL after redirects)
12
+ attr_reader :url
13
+
14
+ # @return [Integer] the HTTP status code of the final response after all the redirects
15
+ attr_reader :status
16
+
17
+ # @return [String] the complete status line (code and message)
18
+ attr_reader :status_line
19
+
20
+ # @return [Integer] how many redirects were followed when fulfilling this request
21
+ attr_reader :redirect_count
22
+
23
+ # @return [Hash] the response headers. If there were multiple headers received for the same value
24
+ # (like "Cookie"), the header values will be within an Array under the key for the header, in order.
25
+ attr_reader :headers
26
+
27
+ # @return [String] the recognized name of the charset for the response. The name is not checked
28
+ # to be a valid charset name, just stored.
29
+ attr_reader :charset
30
+
31
+ # Tells whether the HTTP response code is less than 400
32
+ #
33
+ # @return [Boolean]
34
+ def ok? = !error?
35
+
36
+ # Tells whether the HTTP response code is larger than 399
37
+ #
38
+ # @return [Boolean]
39
+ def error? = status >= 400
40
+
41
+ # @return [Integer, nil] the Content-Length header as an Integer, nil when absent or not a number
42
+ def content_length
43
+ value = header_value("Content-Length")
44
+ value = value.first if value.is_a?(Array)
45
+ value.to_s.match?(/\A\d+\z/) ? Integer(value, 10) : nil
46
+ end
47
+
48
+ # @return [String, nil] the media type from the Content-Type header, without its parameters
49
+ # and in lowercase, e.g. "text/event-stream"
50
+ def content_type
51
+ value = content_type_header
52
+ value = value.first if value.is_a?(Array)
53
+ media_type = value.to_s.split(";", 2).first.to_s.strip.downcase
54
+ media_type.empty? ? nil : media_type
55
+ end
56
+
57
+ # Builds a header Hash from "Name: value" lines, with repeated names collected into an Array
58
+ #
59
+ # @api private
60
+ # @param lines [Array<String>]
61
+ # @return [Hash]
62
+ def self.headers_from_lines(lines)
63
+ lines.each_with_object({}) do |line, headers|
64
+ hdr, val = line.split(":", 2)
65
+ val = val&.strip
66
+
67
+ if headers.key?(hdr)
68
+ headers[hdr] = [headers[hdr]] unless headers[hdr].kind_of?(Array)
69
+ headers[hdr] << val
70
+ else
71
+ headers[hdr] = val
72
+ end
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def parse_head(header_data, default_charset)
79
+ responses = Patron::HeaderParser.parse(decode_header_data(header_data))
80
+ last_response = responses[-1] # proxies and redirects put their own responses in front of the real one
81
+
82
+ @status_line = last_response&.status_line
83
+ @headers = ResponseHead.headers_from_lines(last_response ? last_response.headers : [])
84
+ @charset = charset_from_content_type || default_charset
85
+ end
86
+
87
+ def charset_from_content_type
88
+ content_type_header.to_s[CHARSET_CONTENT_TYPE_RE, 1]
89
+ end
90
+
91
+ # HTTP/2 servers and Puma 8+ send header names lowercase, and `@headers` keeps them as received
92
+ def content_type_header = header_value("Content-Type")
93
+
94
+ def header_value(name)
95
+ @headers.find { |hdr, _| hdr.casecmp?(name) }&.last
96
+ end
97
+
98
+ def decode_header_data(str)
99
+ # Headers are ISO-8859-1 by the book, but Content-Disposition routinely arrives as raw UTF-8
100
+ # and every browser but localized IE treats it as such, so 8859-1 -> UTF-8 -> binary it is
101
+ [Encoding::ISO8859_1, Encoding::UTF_8, Encoding::BINARY].each do |possible_enc|
102
+ return str.encode(possible_enc)
103
+ rescue ::Encoding::UndefinedConversionError
104
+ next
105
+ end
106
+ str
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Patron
4
+
5
+ # The response of Session#stream while its block runs: the complete head, and the verbs that
6
+ # receive the body. Every read drives the transfer itself, in the calling thread or fiber, until
7
+ # some bytes have arrived, so waiting for the network yields to a Fiber scheduler like any other
8
+ # IO, and an exception raised between two reads is plain Ruby.
9
+ #
10
+ # The reading methods follow IO: #read and #readpartial fill a String the caller owns, so that
11
+ # `IO.copy_stream(stream, destination)` runs without allocating per chunk. Once the block has
12
+ # ended, every reading method raises IOError.
13
+ #
14
+ # Under a Fiber scheduler use #each and #each_line with a block: an external Enumerator
15
+ # (`each.next`) runs the reads on a different fiber.
16
+ class ResponseStream
17
+ include ResponseHead
18
+
19
+ CHUNK_SIZE = 64 * 1024
20
+ private_constant :CHUNK_SIZE
21
+
22
+ # @api private
23
+ def initialize(session, status:, url:, redirect_count:, header_data:, default_charset: nil)
24
+ @session = session
25
+ @status = status
26
+ @url = url
27
+ @redirect_count = redirect_count
28
+ @header_data = header_data
29
+ @default_charset = default_charset
30
+ @closed = false
31
+ @eof = false
32
+ @peeked = nil
33
+ parse_head(header_data, default_charset)
34
+ end
35
+
36
+ def inspect
37
+ "#<Patron::ResponseStream @status_line='#{@status_line}'#{' (closed)' if @closed}>"
38
+ end
39
+
40
+ # Reads like IO#read. With a length, receives until that many bytes have arrived or the body
41
+ # ends. Without one, receives the rest of the body.
42
+ #
43
+ # @param length [Integer, nil] how many bytes to read, nil for the rest of the body
44
+ # @param outbuf [String, nil] a String to fill in place instead of allocating one
45
+ # @return [String, nil] binary String; with a length, nil at the end of the body; without one, "" there
46
+ # @raise [IOError] when the stream is closed
47
+ def read(length = nil, outbuf = nil)
48
+ ensure_open!
49
+ if length.nil?
50
+ buf = outbuf || String.new(encoding: Encoding::BINARY)
51
+ kept = 0
52
+ while (n = receive(nil, buf, kept))
53
+ kept += n
54
+ end
55
+ return buf
56
+ end
57
+
58
+ raise ArgumentError, "negative length #{length} given" if length.negative?
59
+ buf = outbuf || String.new(capacity: length, encoding: Encoding::BINARY)
60
+ if length.zero?
61
+ buf.clear
62
+ return buf
63
+ end
64
+
65
+ kept = 0
66
+ while kept < length && (n = receive(length - kept, buf, kept))
67
+ kept += n
68
+ end
69
+ kept.zero? ? nil : buf
70
+ end
71
+
72
+ # Reads like IO#readpartial: returns as soon as some bytes have arrived, at most `maxlen`.
73
+ #
74
+ # @param maxlen [Integer] most bytes to return
75
+ # @param outbuf [String, nil] a String to fill in place instead of allocating one
76
+ # @return [String] binary String
77
+ # @raise [EOFError] at the end of the body
78
+ # @raise [IOError] when the stream is closed
79
+ def readpartial(maxlen, outbuf = nil)
80
+ ensure_open!
81
+ raise ArgumentError, "negative length #{maxlen} given" if maxlen.negative?
82
+ buf = outbuf || String.new(capacity: maxlen, encoding: Encoding::BINARY)
83
+ raise EOFError, "end of file reached" unless receive(maxlen, buf, 0)
84
+ buf
85
+ end
86
+
87
+ # Yields the body in chunks of whatever size libcurl delivers them, each a new binary String,
88
+ # so collecting them is safe.
89
+ #
90
+ # @yieldparam chunk [String]
91
+ # @return [self, Enumerator] self, or an Enumerator without a block
92
+ # @raise [IOError] when the stream is closed
93
+ def each
94
+ return enum_for(:each) unless block_given?
95
+ ensure_open!
96
+ loop do
97
+ chunk = String.new(encoding: Encoding::BINARY)
98
+ break unless receive(CHUNK_SIZE, chunk, 0)
99
+ yield chunk
100
+ end
101
+ self
102
+ end
103
+
104
+ # Yields the body line by line, each line with its "\n" and binary. A last line without
105
+ # one is yielded when the body ends.
106
+ #
107
+ # @yieldparam line [String]
108
+ # @return [self, Enumerator] self, or an Enumerator without a block
109
+ # @raise [IOError] when the stream is closed
110
+ def each_line
111
+ return enum_for(:each_line) unless block_given?
112
+ carry = String.new(encoding: Encoding::BINARY)
113
+ each do |chunk|
114
+ carry << chunk
115
+ start = 0
116
+ while (newline = carry.byteindex("\n", start))
117
+ yield carry.byteslice(start, newline + 1 - start)
118
+ start = newline + 1
119
+ end
120
+ carry = carry.byteslice(start, carry.bytesize - start) if start > 0
121
+ end
122
+ yield carry unless carry.empty?
123
+ self
124
+ end
125
+
126
+ # Receives the rest of the body and throws it away, so that the connection can be reused.
127
+ # Bounded by the same read_timeout, download_byte_limit and interrupts as any read.
128
+ #
129
+ # @return [Integer] how many bytes were discarded, 0 at the end of the body
130
+ # @raise [IOError] when the stream is closed
131
+ def drain!
132
+ ensure_open!
133
+ scratch = String.new(capacity: CHUNK_SIZE, encoding: Encoding::BINARY)
134
+ discarded = 0
135
+ while (n = receive(CHUNK_SIZE, scratch, 0))
136
+ discarded += n
137
+ end
138
+ discarded
139
+ end
140
+
141
+ # Tells whether the body has ended. Like IO#eof?, this waits for the next bytes when none
142
+ # have arrived yet.
143
+ #
144
+ # @return [Boolean]
145
+ # @raise [IOError] when the stream is closed
146
+ def eof?
147
+ ensure_open!
148
+ return true if @eof
149
+ return false if @peeked
150
+
151
+ peeked = String.new(encoding: Encoding::BINARY)
152
+ @peeked = peeked if receive(CHUNK_SIZE, peeked, 0)
153
+ @eof
154
+ end
155
+ alias eof eof?
156
+
157
+ # Ends the transfer. When the body has not been received to its end, its connection is
158
+ # closed rather than reused. Called by Session#stream when the block ends; idempotent.
159
+ #
160
+ # @return [nil]
161
+ def close
162
+ finish
163
+ nil
164
+ end
165
+
166
+ # @return [Boolean] whether the stream has been closed
167
+ def closed? = @closed
168
+
169
+ private
170
+
171
+ attr_reader :header_data
172
+
173
+ def ensure_open!
174
+ raise IOError, "closed stream" if @closed
175
+ end
176
+
177
+ # Places up to `maxlen` bytes in `buf` after its first `kept` bytes, returns how many or nil at the end
178
+ def receive(maxlen, buf, kept)
179
+ return receive_peeked(maxlen, buf, kept) if @peeked
180
+
181
+ placed = @session.__send__(:transfer_read, maxlen, buf, kept)
182
+ @eof = true if placed.nil?
183
+ placed
184
+ end
185
+
186
+ # The bytes #eof? had to read ahead
187
+ def receive_peeked(maxlen, buf, kept)
188
+ piece = @peeked
189
+ if maxlen && piece.bytesize > maxlen
190
+ @peeked = piece.byteslice(maxlen, piece.bytesize - maxlen)
191
+ piece = piece.byteslice(0, maxlen)
192
+ else
193
+ @peeked = nil
194
+ end
195
+ buf.force_encoding(Encoding::BINARY)
196
+ buf.replace(buf.byteslice(0, kept)) if buf.bytesize > kept
197
+ buf << piece
198
+ piece.bytesize
199
+ end
200
+
201
+ def finish
202
+ return if @closed
203
+ @closed = true
204
+ @tail = @session.__send__(:transfer_finish)
205
+ end
206
+
207
+ def receipt
208
+ downloaded_bytes, uploaded_bytes, timings, complete, trailer_data = @tail
209
+ TransferStats.new(
210
+ url: @url, status: @status, redirect_count: @redirect_count, header_data: @header_data,
211
+ default_charset: @default_charset, downloaded_bytes: downloaded_bytes.to_i,
212
+ uploaded_bytes: uploaded_bytes.to_i, timings: timings || {}, complete: !!complete,
213
+ trailer_data: trailer_data || ""
214
+ )
215
+ end
216
+ end
217
+ end