flashpi 0.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.
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flashpi
4
+ module Internal
5
+ # Requests to the Flashpi API: retries, timeouts, idempotency and typed errors.
6
+ #
7
+ # @api private
8
+ class Engine
9
+ USER_AGENT = "flashpi-ruby/#{VERSION}".freeze
10
+
11
+ # Longest `Retry-After`, in seconds, the SDK waits for by itself; longer waits (daily quotas)
12
+ # are raised.
13
+ MAX_RETRY_AFTER = 60
14
+
15
+ INITIAL_RETRY_DELAY = 0.5
16
+ MAX_RETRY_DELAY = 8.0
17
+ # Waits between checks on an earlier attempt that the API is still running.
18
+ IN_PROGRESS_FIRST_WAIT = 1.0
19
+ IN_PROGRESS_MAX_WAIT = 5.0
20
+ IN_PROGRESS = "idempotency_key_in_progress"
21
+
22
+ OPTION_NAMES = %i[idempotency_key timeout max_retries language correlation_id extra_headers].freeze
23
+
24
+ # What every attempt of one call shares, resolved before the first one is sent. It is a local
25
+ # of the methods that raise the call's errors, and error trackers can record those locals: the
26
+ # body (possibly a .p12 and its password) is kept in a Secret, and `inspect` shows the URL only.
27
+ class Call
28
+ attr_reader :options, :max_retries, :timeout, :url, :target, :idempotency_key
29
+
30
+ def initialize(options:, max_retries:, timeout:, url:, target:, idempotency_key:, content:)
31
+ @options = options
32
+ @max_retries = max_retries
33
+ @timeout = timeout
34
+ @url = url
35
+ @target = target
36
+ @idempotency_key = idempotency_key
37
+ @content = Secret.new(content)
38
+ freeze
39
+ end
40
+
41
+ # @return [String, nil] the body every attempt sends
42
+ def content
43
+ @content.reveal
44
+ end
45
+
46
+ def inspect
47
+ "#<#{self.class.name} #{url}>"
48
+ end
49
+ end
50
+ private_constant :Call
51
+
52
+ # @return [ClientConfig]
53
+ attr_reader :config
54
+ # @return [Transport] how attempts are sent (tests replace it)
55
+ attr_accessor :transport
56
+ # @return [#now, #sleep, #rand] the clock of retries and waits (tests replace it)
57
+ attr_accessor :clock
58
+
59
+ def initialize(config, transport, clock = Clock)
60
+ @config = config
61
+ @transport = transport
62
+ @clock = clock
63
+ @base_path = URI.parse(config.base_url).path.to_s.sub(%r{/+\z}, "")
64
+ end
65
+
66
+ # Sends a request with its retries and recoveries.
67
+ #
68
+ # @param spec [RequestSpec]
69
+ # @return [Flashpi::APIResponse]
70
+ def request(spec)
71
+ call = prepare(spec)
72
+ started = clock.now
73
+ attempt = retries = waits = 0
74
+ # Whether an earlier attempt may have reached the API without its answer reaching us (a
75
+ # network error, a timeout, or a 5xx from a proxy). Only then can a 409
76
+ # idempotency_key_in_progress be this call's own attempt still running.
77
+ unanswered = false
78
+
79
+ loop do
80
+ failure = attempt_once(spec, call)
81
+ return failure if failure.is_a?(APIResponse)
82
+
83
+ # The error of this attempt: an APIError or an APIConnectionError.
84
+ failure.idempotency_key = call.idempotency_key
85
+
86
+ if attempt.positive? && failure.is_a?(APIError)
87
+ document_id = recoverable_document(spec, failure)
88
+ return recover_document(call, document_id) if document_id
89
+ # A DELETE retried after a lost answer finds the resource already gone.
90
+ return deleted(failure) if spec.http_method == :delete && failure.status == 404
91
+
92
+ # This call's earlier attempt is still running on the API (a sync issue whose answer
93
+ # was lost): wait for it with the same key while the call has time left. These checks
94
+ # do not count as retries; once the time is up, the normal retry rules apply.
95
+ if unanswered && failure.status == 409 && failure.code == IN_PROGRESS
96
+ wait = [IN_PROGRESS_FIRST_WAIT * (2**[waits, 16].min), IN_PROGRESS_MAX_WAIT].min
97
+ if clock.now + wait - started <= call.timeout
98
+ waits += 1
99
+ attempt += 1
100
+ clock.sleep(wait)
101
+ next
102
+ end
103
+ end
104
+ end
105
+
106
+ unanswered = true if unanswered?(failure)
107
+ delay = retry_delay(failure, retries)
108
+ raise failure if delay.nil? || retries >= call.max_retries
109
+
110
+ retries += 1
111
+ attempt += 1
112
+ clock.sleep(delay)
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ # One attempt: its APIResponse, or the APIError or APIConnectionError to handle. Any other
119
+ # Flashpi::Error is raised with the call's Idempotency-Key.
120
+ def attempt_once(spec, call)
121
+ raw = transport.perform(attempt_of(spec, call))
122
+ read_response(raw, spec.response_type)
123
+ rescue APIConnectionError => e
124
+ e
125
+ rescue Error => e
126
+ e.idempotency_key = call.idempotency_key
127
+ raise
128
+ end
129
+
130
+ # Whether the attempt may have reached the API without its answer reaching us: a network
131
+ # error, a timeout, or a 5xx from a proxy (no Problem Details).
132
+ def unanswered?(failure)
133
+ failure.is_a?(APIConnectionError) || (failure.is_a?(APIError) && failure.status >= 500 && failure.problem.nil?)
134
+ end
135
+
136
+ # Checks the options and builds what the attempts share. Raises before anything is sent.
137
+ def prepare(spec)
138
+ options = check_options(spec.options)
139
+ max_retries = if options[:max_retries].nil?
140
+ config.max_retries
141
+ else
142
+ Validate.retries!("max_retries",
143
+ options[:max_retries])
144
+ end
145
+ timeout = options[:timeout].nil? ? config.timeout : Validate.timeout!("timeout", options[:timeout])
146
+ target = build_target(spec.path, spec.query)
147
+ longest = [spec.min_timeout, timeout].max
148
+ Call.new(
149
+ options:,
150
+ max_retries:,
151
+ timeout: [longest, Validate::MAX_TIMEOUT].min,
152
+ url: config.base_url.sub(%r{/+\z}, "") + target,
153
+ target: @base_path + target,
154
+ idempotency_key: idempotency_key_of(spec.http_method, options),
155
+ # Serialized once: every attempt sends the same bytes under the same Idempotency-Key,
156
+ # even when the caller changes the body meanwhile.
157
+ content: spec.multipart ? spec.multipart.content : body_content(spec)
158
+ )
159
+ end
160
+
161
+ def body_content(spec)
162
+ spec.body.nil? ? nil : JSONText.encode(spec.body)
163
+ end
164
+
165
+ def attempt_of(spec, call)
166
+ Attempt.new(
167
+ http_method: spec.http_method,
168
+ url: call.url,
169
+ target: call.target,
170
+ headers: build_headers(spec, call),
171
+ content: call.content,
172
+ timeout: call.timeout,
173
+ response_type: spec.response_type
174
+ )
175
+ end
176
+
177
+ # Reads the document an earlier attempt of the call created. A failure to read it keeps the
178
+ # call's key on the error, so trying again later with that key returns the document instead
179
+ # of issuing another.
180
+ def recover_document(call, document_id)
181
+ options = call.options.except(:idempotency_key)
182
+ request(RequestSpec.new(http_method: :get, path: Path.of("/v1/documents/{}", document_id), options:))
183
+ rescue Error => e
184
+ e.idempotency_key = call.idempotency_key
185
+ raise
186
+ end
187
+
188
+ # Seconds to wait before retrying, or nil when the failure must not be retried.
189
+ #
190
+ # Retried: network errors and timeouts, 408, 429 and 5xx except 501 (with a `Retry-After` of
191
+ # {MAX_RETRY_AFTER} or less), and 409 `idempotency_key_in_progress` (an attempt with the
192
+ # same key is still running). POSTs are safe to retry because they keep their
193
+ # Idempotency-Key.
194
+ def retry_delay(failure, retries)
195
+ return backoff(retries) if failure.is_a?(APIConnectionError)
196
+ return nil unless failure.is_a?(APIError)
197
+
198
+ status = failure.status
199
+ return failure.code == IN_PROGRESS ? [IN_PROGRESS_FIRST_WAIT, backoff(retries)].max : nil if status == 409
200
+
201
+ transient = [408, 429].include?(status) || (status >= 500 && status != 501)
202
+ # The shared demo certificate is missing: no retry within seconds will fix that.
203
+ return nil if !transient || failure.code == "demo_issuer_unavailable"
204
+
205
+ retry_after = failure.retry_after
206
+ return backoff(retries) if retry_after.nil?
207
+
208
+ retry_after <= MAX_RETRY_AFTER ? retry_after.to_f : nil
209
+ end
210
+
211
+ # Exponential backoff from 0.5 s up to 8 s, minus up to 25 % of jitter.
212
+ def backoff(retries)
213
+ delay = [INITIAL_RETRY_DELAY * (2**[retries, 16].min), MAX_RETRY_DELAY].min
214
+ (delay * (1 - (clock.rand * 0.25))).round(3).to_f
215
+ end
216
+
217
+ # The id of the document an earlier attempt of this call created, if the answer shows one.
218
+ def recoverable_document(spec, failure)
219
+ return nil unless spec.recover_document && failure.status == 422 && failure.code == "idempotency_key_reused"
220
+
221
+ document_id = (failure.problem || {})[:documentId]
222
+ document_id.is_a?(String) && !document_id.empty? ? document_id : nil
223
+ end
224
+
225
+ # The result of a DELETE whose resource is gone: a 204 with the 404's headers.
226
+ def deleted(failure)
227
+ dropped = %w[content-length content-type content-encoding transfer-encoding]
228
+ headers = failure.headers.except(*dropped)
229
+ APIResponse.new(data: nil, status: 204, headers:, request_id: failure.request_id)
230
+ end
231
+
232
+ # The caller's options with the values that become headers checked and trimmed, so the
233
+ # attempts, the errors and a recovered read all use the same ones.
234
+ def check_options(options)
235
+ unknown = options.keys - OPTION_NAMES
236
+ unless unknown.empty?
237
+ raise ArgumentError, "Unknown request options: #{unknown.map(&:inspect).join(", ")}. The options are " \
238
+ "#{OPTION_NAMES.join(", ")}; bodies and list filters go in a Hash before them"
239
+ end
240
+
241
+ checked = options.dup
242
+ unless options[:extra_headers].nil?
243
+ checked[:extra_headers] =
244
+ Validate.headers!("extra_headers", options[:extra_headers])
245
+ end
246
+ %i[idempotency_key correlation_id].each do |name|
247
+ checked[name] = Validate.header_value!(name.to_s, options[name]) unless options[name].nil?
248
+ end
249
+ checked[:language] = language("language", options[:language]) unless options[:language].nil?
250
+ checked
251
+ end
252
+
253
+ def language(label, value)
254
+ Validate.header_value!(label, value.is_a?(Symbol) ? value.to_s : value)
255
+ end
256
+
257
+ # The caller's Idempotency-Key of a POST (option or header), or a new one.
258
+ def idempotency_key_of(http_method, options)
259
+ return nil unless http_method == :post
260
+ return options[:idempotency_key] unless options[:idempotency_key].nil?
261
+
262
+ # The last spelling wins, as in build_headers.
263
+ header = nil
264
+ (options[:extra_headers] || {}).each { |name, value| header = value if name.downcase == "idempotency-key" }
265
+ header || SecureRandom.uuid
266
+ end
267
+
268
+ # The path and query of a request, after the base URL. Query values that are nil are left
269
+ # out.
270
+ def build_target(path, query)
271
+ pairs = (query || {}).filter_map do |name, value|
272
+ [query_name(name), query_value(value)] unless value.nil?
273
+ end #: Array[[String, String]]
274
+ search = URI.encode_www_form(pairs)
275
+ search.empty? ? path : "#{path}?#{search}"
276
+ end
277
+
278
+ def query_name(name)
279
+ return name.to_s if name.is_a?(Symbol) || name.is_a?(String)
280
+
281
+ raise TypeError, "Filter names must be Strings or Symbols, not #{name.class}"
282
+ end
283
+
284
+ def query_value(value)
285
+ case value
286
+ when true then "true"
287
+ when false then "false"
288
+ when String, Symbol then query_text(value.to_s)
289
+ when Integer then value.to_s
290
+ when Float
291
+ raise ArgumentError, "A filter cannot be NaN or infinite" unless value.finite?
292
+
293
+ value.to_s
294
+ when Date then value.iso8601
295
+ else raise TypeError, "Filter values must be Strings, numbers, booleans or Dates, not #{value.class}"
296
+ end
297
+ end
298
+
299
+ # A filter as UTF-8 text, as the API reads the query.
300
+ def query_text(value)
301
+ text = Validate.utf8(value)
302
+ raise ArgumentError, "A filter must be valid UTF-8 text" if text.nil?
303
+
304
+ text
305
+ end
306
+
307
+ # The headers of an attempt, by lowercase name, from the lowest priority to the highest:
308
+ # client language, client headers, request language, request headers, then the SDK's own.
309
+ def build_headers(spec, call)
310
+ headers = { "accept" => spec.accept, "user-agent" => USER_AGENT }
311
+ language = config.language
312
+ headers["accept-language"] = language if language && !language.empty?
313
+ config.default_headers.each { |name, value| headers[name.downcase] = value }
314
+ language = call.options[:language]
315
+ headers["accept-language"] = language if language && !language.empty?
316
+ (call.options[:extra_headers] || {}).each { |name, value| headers[name.downcase] = value }
317
+ headers["x-correlation-id"] = call.options[:correlation_id] unless call.options[:correlation_id].nil?
318
+ headers["idempotency-key"] = call.idempotency_key unless call.idempotency_key.nil?
319
+ if spec.multipart
320
+ headers["content-type"] = spec.multipart.content_type
321
+ elsif !spec.body.nil?
322
+ headers["content-type"] = "application/json"
323
+ end
324
+ headers["authorization"] = "Bearer #{config.api_key}"
325
+ headers
326
+ end
327
+
328
+ # The data of a successful answer, or the APIError of an error status.
329
+ def read_response(raw, response_type)
330
+ return Problems.error(raw.status, raw.headers, JSONText.decode(raw.body)) unless (200..299).cover?(raw.status)
331
+
332
+ APIResponse.new(
333
+ data: read_data(raw, response_type),
334
+ status: raw.status,
335
+ headers: raw.headers,
336
+ request_id: raw.headers["x-request-id"]
337
+ )
338
+ end
339
+
340
+ def read_data(raw, response_type)
341
+ return raw.body if response_type == :bytes
342
+
343
+ text = JSONText.decode(raw.body)
344
+ return text if response_type == :text
345
+ return nil if response_type == :none || raw.status == 204 || text.empty?
346
+
347
+ begin
348
+ JSONText.parse(text)
349
+ rescue JSONText::Invalid
350
+ raise Error, "The API answered #{raw.status} with a body that is not JSON"
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flashpi
4
+ module Internal
5
+ # JSON in and out: request bodies serialized once, answers parsed with Symbol keys.
6
+ #
7
+ # @api private
8
+ module JSONText
9
+ # A body that is not JSON.
10
+ class Invalid < StandardError; end
11
+
12
+ MAX_NESTING = 512
13
+
14
+ module_function
15
+
16
+ # Compact UTF-8 JSON of a request body, like `JSON.stringify`.
17
+ #
18
+ # The body may hold Hashes (String, Symbol or Integer keys), Arrays, Strings, Symbols,
19
+ # Integers, finite Floats, true, false and nil. Anything else is refused before anything is
20
+ # sent: amounts and dates are text in the Flashpi API.
21
+ #
22
+ # @return [String]
23
+ # @raise [TypeError] for a value JSON has no counterpart for
24
+ # @raise [ArgumentError] for NaN or infinite numbers, text that is not valid UTF-8, a key
25
+ # given twice or a Hash or Array that contains itself
26
+ def encode(body)
27
+ JSON.generate(plain(body, {}), max_nesting: false)
28
+ end
29
+
30
+ # Parses JSON text with Symbol keys.
31
+ #
32
+ # @return [Object]
33
+ # @raise [Invalid] when the text is not JSON
34
+ def parse(text)
35
+ JSON.parse(text, symbolize_names: true, max_nesting: MAX_NESTING)
36
+ rescue JSON::ParserError, EncodingError, ArgumentError => e
37
+ raise Invalid, e.class.name
38
+ end
39
+
40
+ # UTF-8 text from bytes, with invalid sequences replaced (like the Node SDK's TextDecoder).
41
+ #
42
+ # @return [String]
43
+ def decode(bytes)
44
+ bytes.dup.force_encoding(Encoding::UTF_8).scrub("�")
45
+ end
46
+
47
+ # @api private
48
+ def plain(value, seen)
49
+ case value
50
+ when Hash then plain_hash(value, seen)
51
+ when Array then nested(value, seen) { value.map { |item| plain(item, seen) } }
52
+ when String then text(value)
53
+ when Symbol then text(value.to_s)
54
+ when Integer, true, false, nil then value
55
+ when Float
56
+ raise ArgumentError, "The body has a number JSON cannot hold (NaN or infinite)" unless value.finite?
57
+
58
+ value
59
+ else
60
+ raise TypeError, "The body must hold only Hashes, Arrays, Strings, numbers, booleans and nil, " \
61
+ "not #{value.class} (amounts and dates are Strings)"
62
+ end
63
+ end
64
+
65
+ # @api private
66
+ def plain_hash(hash, seen)
67
+ nested(hash, seen) do
68
+ result = {} #: Hash[String, untyped]
69
+ hash.each do |key, item|
70
+ name = key_text(key)
71
+ raise ArgumentError, "The body has the key #{name.inspect} twice" if result.key?(name)
72
+
73
+ result[name] = plain(item, seen)
74
+ end
75
+ result
76
+ end
77
+ end
78
+
79
+ # @api private
80
+ def key_text(key)
81
+ case key
82
+ when String then text(key)
83
+ when Symbol, Integer then text(key.to_s)
84
+ else raise TypeError, "The keys of the body must be Strings or Symbols, not #{key.class}"
85
+ end
86
+ end
87
+
88
+ # @api private
89
+ def nested(container, seen)
90
+ id = container.object_id
91
+ raise ArgumentError, "The body contains itself" if seen.key?(id)
92
+
93
+ seen[id] = true
94
+ result = yield
95
+ seen.delete(id)
96
+ result
97
+ end
98
+
99
+ # @api private
100
+ def text(value)
101
+ utf8 = Validate.utf8(value)
102
+ raise ArgumentError, "The body has text that is not valid UTF-8" if utf8.nil?
103
+
104
+ utf8
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flashpi
4
+ module Internal
5
+ # The multipart/form-data body of a .p12 upload, for `issuers.certificates.upload` and
6
+ # `issuer_onboardings.create`: a `password` field, then a `file` part.
7
+ #
8
+ # It is encoded once, so every retry sends the same bytes (an IO could not be read twice), and
9
+ # kept in a {Secret}: its `inspect` lists only the part names, never the .p12 or its password.
10
+ #
11
+ # @api private
12
+ class Multipart
13
+ # Largest .p12 the API accepts (413 `payload_too_large` above it).
14
+ MAX_CERTIFICATE_BYTES = 1_048_576
15
+ DEFAULT_FILENAME = "certificate.p12"
16
+ CONTENT_TYPE = "application/x-pkcs12"
17
+ PIECE = 65_536
18
+
19
+ # @return [String]
20
+ attr_reader :content_type
21
+
22
+ # The body of a certificate upload: `{ file:, password:, filename: }`.
23
+ #
24
+ # Raises before anything is sent: Flashpi::Error for a password that is not a String, an
25
+ # empty file or one over 1 MiB; TypeError for an upload that is not a Hash, a file that is
26
+ # neither a String of bytes nor an IO, or a filename that is not a String. Error trackers
27
+ # can record the local variables of the method that raises, so the errors are raised by
28
+ # {refuse!}, whose frame holds neither the .p12 nor its password.
29
+ #
30
+ # @param upload [Hash]
31
+ # @return [Multipart]
32
+ def self.certificate(upload)
33
+ refuse!(TypeError, "The certificate upload must be a Hash with file and password") unless upload.is_a?(Hash)
34
+
35
+ password = member(upload, :password)
36
+ refuse!(Error, "The certificate password must be a String") unless password.is_a?(String)
37
+ # Sent as UTF-8 text, as the API reads it (a password in another encoding would not match).
38
+ password = Validate.utf8(password) || refuse!(ArgumentError, "The certificate password must be valid text")
39
+
40
+ filename = member(upload, :filename) || DEFAULT_FILENAME
41
+ refuse!(TypeError, "The certificate filename must be a String") unless filename.is_a?(String)
42
+ filename = Validate.utf8(filename) || refuse!(ArgumentError, "The certificate filename must be valid text")
43
+
44
+ new(password, read(member(upload, :file)), filename)
45
+ end
46
+
47
+ # @api private
48
+ def self.member(upload, name)
49
+ upload.key?(name) ? upload[name] : upload[name.to_s]
50
+ end
51
+
52
+ # The bytes of the file: a String as it is, an IO read to its end in pieces, up to one byte
53
+ # past the largest certificate, so a big or wrong file is refused without loading it whole.
54
+ #
55
+ # @api private
56
+ def self.read(file)
57
+ content = file.is_a?(String) ? file.b : read_io(file)
58
+ refuse!(Error, "The certificate file is empty") if content.empty?
59
+ if content.bytesize > MAX_CERTIFICATE_BYTES
60
+ refuse!(Error, "The certificate file has more than #{MAX_CERTIFICATE_BYTES} bytes, the most the API accepts")
61
+ end
62
+ content
63
+ end
64
+
65
+ # @api private
66
+ def self.read_io(file)
67
+ unless file.respond_to?(:read)
68
+ refuse!(TypeError,
69
+ "The certificate file must be a String of bytes or an IO opened in binary mode, not #{file.class}")
70
+ end
71
+
72
+ content = String.new(encoding: Encoding::BINARY)
73
+ while content.bytesize <= MAX_CERTIFICATE_BYTES
74
+ chunk = read_piece(file, [PIECE, MAX_CERTIFICATE_BYTES + 1 - content.bytesize].min)
75
+ break if chunk.nil?
76
+
77
+ unless chunk.is_a?(String)
78
+ refuse!(TypeError,
79
+ "The read method of the certificate file returned #{chunk.class}, not a String")
80
+ end
81
+ break if chunk.empty?
82
+
83
+ content << chunk.b
84
+ end
85
+ content
86
+ end
87
+
88
+ # One read of the file. A failure of the file's own read (a broken gzip, a reset socket) is then
89
+ # raised with this frame on top, which holds no bytes of the .p12.
90
+ #
91
+ # @api private
92
+ def self.read_piece(file, length)
93
+ file.read(length)
94
+ end
95
+
96
+ # Raises from a frame that holds only the error.
97
+ #
98
+ # @api private
99
+ def self.refuse!(error, message)
100
+ raise error, message
101
+ end
102
+
103
+ def initialize(password, content, filename)
104
+ boundary = "flashpi-#{SecureRandom.hex(16)}"
105
+ boundary = "flashpi-#{SecureRandom.hex(16)}" while content.include?(boundary) || password.b.include?(boundary)
106
+ @content_type = "multipart/form-data; boundary=#{boundary}"
107
+ @body = Secret.new(encode(boundary, password, content, filename).freeze)
108
+ freeze
109
+ end
110
+
111
+ # @return [String] the encoded body
112
+ def content
113
+ @body.reveal
114
+ end
115
+
116
+ # Only the part names: the values are a .p12 and its password.
117
+ def inspect
118
+ "#<#{self.class.name} fields=[\"password\"] files=[\"file\"]>"
119
+ end
120
+
121
+ private
122
+
123
+ def encode(boundary, password, content, filename)
124
+ body = String.new(encoding: Encoding::BINARY)
125
+ body << "--#{boundary}\r\n"
126
+ body << "Content-Disposition: form-data; name=\"password\"\r\n\r\n"
127
+ body << password.b << "\r\n"
128
+ body << "--#{boundary}\r\n"
129
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{escape(filename)}\"\r\n"
130
+ body << "Content-Type: #{CONTENT_TYPE}\r\n\r\n"
131
+ body << content << "\r\n"
132
+ body << "--#{boundary}--\r\n"
133
+ end
134
+
135
+ # As browsers write a file name (WHATWG): quotes and line breaks percent-encoded.
136
+ def escape(filename)
137
+ filename.b.gsub("\"", "%22").gsub("\r", "%0D").gsub("\n", "%0A")
138
+ end
139
+ end
140
+ end
141
+ end