transloadit 4.0.0 → 4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc246e913322624e733835f51e2924112ec4131af0930694ae322da3a10ea9e7
4
- data.tar.gz: 79bd642651fd5d6b3c0f3153bfb9d44c6f78b16a7b91171d094e227b430f8949
3
+ metadata.gz: 2c1220e478a424b85d5ac449fd7facf3790af3091a69435699a49480853b86fe
4
+ data.tar.gz: 544d23e833e4ee095744d91f948ca7bfc7616bb70e5d576042d44b3032510307
5
5
  SHA512:
6
- metadata.gz: 6ab26e103e6653f9b4126aed543446c6080e6bd30f1348cecefa38bb8406c706ec289e61e9c36c27512535f8590908ca15473cb042bf6ea38ebacac3eb7cd0a8
7
- data.tar.gz: ef78d102680364717e5d4a4193a15cd553a7296478da13134c0cefa65fcc96138acca340990c7173b04e34fa741825788527d5d067e53eab057656acbf20bf1d
6
+ metadata.gz: 69178ed10b8fc21cd0c342e32aeecad79d886721e2c08b7df69e85a3d965faacef50054973a5b6dca7c72b885166a5f371416c641685df6679481d2e310dd0c8
7
+ data.tar.gz: 4a2393daa75a76414b208891d63a2d2d0a88e2c50e22da4b634a33fe7696cb084348fb26b1e823c5b45ca6feda168b12e8450a7dfa290a40d7e7bd29ea082d19
data/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ### Unreleased
2
+
3
+ ### 4.1.0 / 2026-10-18
4
+
5
+ - Add `uploads: {name => io}` to Assembly options and `create!` for named multipart uploads, separate from signed JSON Instructions.
6
+ - Send custom `fields` only in signed JSON, preserving numbers, booleans, arrays, objects, and null without duplicate form fields.
7
+ - Keep IOs in legacy `fields` working as named uploads throughout v4, omit them from JSON, and warn to migrate to `uploads` before the next major release. Positional `file_N` uploads and `create!(fields: metadata)` overrides remain supported.
8
+ - Restore in-memory uploads to their original position on rate-limit retries and raise a clear `IOError` when a non-seekable upload cannot be retried.
9
+
1
10
  ### 4.0.0 / 2026-09-03
2
11
 
3
12
  - BREAKING: Replace the long-dormant `rest-client` transport with Faraday 2 and `faraday-multipart`.
data/Gemfile CHANGED
@@ -5,4 +5,8 @@ gem "minitest"
5
5
  gem "simplecov"
6
6
  gem "simplecov-cobertura"
7
7
 
8
+ # Ruby 3.1 resolves MultiJson 1.19, which cannot parse JSON 3 (ruby-sdk#89).
9
+ # Keep its test bundle compatible without restricting consumers on newer Rubies.
10
+ gem "json", "< 3.0" if RUBY_VERSION.start_with?("3.1.")
11
+
8
12
  gemspec
data/README.md CHANGED
@@ -171,6 +171,38 @@ files = [open('puppies.jpg'), open('kittens.jpg'), open('ferrets.jpg')]
171
171
  response = assembly.create! *files
172
172
  ```
173
173
 
174
+ For names that match a Step's `use.fields` selector, pass `uploads: {name => io}`
175
+ to `assembly` or `create!`. Uploads are multipart parts and are excluded from
176
+ the signed JSON Instructions. Open files in binary mode and close them after
177
+ the request; the SDK leaves caller-owned files open.
178
+
179
+ ```ruby
180
+ File.open('puppies.jpg', 'rb') do |photo|
181
+ response = transloadit.assembly(
182
+ steps: {
183
+ resize: {
184
+ robot: '/image/resize',
185
+ use: {fields: ['photo']},
186
+ width: 320,
187
+ height: 240
188
+ }
189
+ },
190
+ uploads: {photo: photo},
191
+ fields: {count: 2, enabled: false, details: {caption: 'Puppies'}}
192
+ ).create!
193
+ end
194
+ ```
195
+
196
+ Alternatively, use `assembly.create!(uploads: {photo: photo}, fields: metadata)`.
197
+ Per-call `uploads` and `fields` each replace the corresponding Assembly option;
198
+ `uploads: {}` clears only the `uploads` option. Legacy IOs configured in `fields`
199
+ remain uploads for compatibility. Positional files still use `file_0`,
200
+ `file_1`, and so on, and take precedence over named uploads with those names.
201
+ Upload values must respond to `read`; the names `params` and `signature` are reserved.
202
+ For rate-limit retries, in-memory uploads must also support `pos` and `seek`;
203
+ the SDK restores their original position. A non-seekable upload raises `IOError`
204
+ if a retry is needed, instead of sending consumed bytes again.
205
+
174
206
  ### 3. Parallel Assembly
175
207
 
176
208
  Transloadit allows you to perform several processing steps in parallel. You
@@ -226,9 +258,10 @@ has some nice examples for that.
226
258
 
227
259
  ### 5. Using fields
228
260
 
229
- Transloadit allows you to submit form field values that you'll get back in the
230
- notification. This is quite handy if you want to add additional custom metadata
231
- to the upload itself. You can use fields like the following:
261
+ Transloadit allows you to submit custom metadata that you'll get back in the
262
+ notification. `fields` is included once in the signed JSON Instructions and is
263
+ never duplicated as form fields. Numbers, booleans, arrays, objects, and null
264
+ retain their JSON types. You can use fields like the following:
232
265
 
233
266
  ```ruby
234
267
  require 'transloadit'
@@ -246,6 +279,17 @@ transloadit.assembly(
246
279
  ).create! open('/PATH/TO/FILE.mpg')
247
280
  ```
248
281
 
282
+ `create!(fields: metadata)` also sets custom fields and replaces any metadata
283
+ configured on the Assembly. Uploads and metadata may use the same name.
284
+
285
+ For compatibility, IO values passed directly in a `fields` hash still upload
286
+ under their field names throughout v4, but emit a deprecation warning. They are
287
+ excluded from JSON; other values in that hash remain custom metadata. Migrate
288
+ from `assembly(fields: {photo: file, count: 2})` to
289
+ `assembly(uploads: {photo: file}, fields: {count: 2})` before the next major release.
290
+ Explicit `uploads` take precedence over legacy IOs with the same name. Existing
291
+ `assembly(fields: {photo: file}).create!(fields: metadata)` calls continue working.
292
+
249
293
  ### 6. Notify URL
250
294
 
251
295
  If you want to be notified when the processing is finished you can provide
@@ -464,6 +508,11 @@ for the latest [git main](https://rubydoc.info/github/transloadit/ruby-sdk/main/
464
508
  Please see [ci.yml](https://github.com/transloadit/ruby-sdk/tree/main/.github/workflows/ci.yml) for a list of supported ruby versions. It may also work on older Rubies, but support for those is not guaranteed. If it doesn't work on one of the officially supported Rubies, please file a
465
509
  [bug report](https://github.com/transloadit/ruby-sdk/issues). Compatibility patches for other Rubies are welcome.
466
510
 
511
+ On Ruby 3.1, add `gem "json", "< 3.0"` to your application's Gemfile while
512
+ [the older MultiJson/JSON compatibility issue](https://github.com/transloadit/ruby-sdk/issues/89)
513
+ is open. Ruby 3.2 and newer can use JSON 3 with
514
+ [MultiJson 1.21.2 or newer](https://github.com/sferik/multi_json/releases/tag/v1.21.2).
515
+
467
516
  ### Ruby 2.x
468
517
 
469
518
  If you still need support for Ruby 2.x, 2.0.1 is the last version that supports it.
@@ -44,25 +44,45 @@ class Transloadit::Assembly < Transloadit::ApiModel
44
44
  # @option params [String] :template_id the ID of a
45
45
  # {template}[https://transloadit.com/templates] to use instead of
46
46
  # specifying params here directly
47
+ # @option params [Hash] :fields custom metadata, retained as typed JSON
48
+ # @option params [Hash<String, IO>] :uploads named multipart uploads;
49
+ # overrides the Assembly's uploads when supplied (use {} to clear them)
47
50
  #
48
51
  def create!(*ios, **params)
49
52
  params[:steps] = _wrap_steps_in_hash(params[:steps]) unless params[:steps].nil?
50
53
 
54
+ legacy_uploads, = _split_fields(options[:fields])
55
+ if params.key?(:fields)
56
+ uploads, params[:fields] = _split_fields(params[:fields])
57
+ legacy_uploads.merge!(uploads)
58
+ end
59
+ unless legacy_uploads.empty?
60
+ warn "Passing upload IOs in fields is deprecated; use uploads: {name => io} instead."
61
+ end
62
+
51
63
  extra_params = {}
52
- extra_params.merge!(options[:fields]) if options[:fields]
64
+ [legacy_uploads, params.delete(:uploads) { options[:uploads] }].compact.each do |uploads|
65
+ uploads.each do |name, io|
66
+ name = name.to_s
67
+ raise ArgumentError, "Upload name #{name.inspect} is reserved" if %w[params signature].include?(name)
68
+ raise ArgumentError, "Upload #{name.inspect} must be an IO responding to read" unless io.respond_to?(:read)
69
+
70
+ extra_params[name] = io
71
+ end
72
+ end
73
+ # Keep positional file_N uploads and their precedence for existing callers.
74
+ ios.each_with_index { |io, i| extra_params["file_#{i}"] = io }
75
+ upload_positions = _upload_positions(extra_params.values)
53
76
 
54
77
  trials = options[:tries] || DEFAULT_TRIES
55
78
  (1..trials).each do |trial|
56
- # update the payload with file entries
57
- ios.each_with_index { |f, i| extra_params.update "file_#{i}": f }
58
-
59
79
  response = _do_request(
60
80
  "/assemblies", params, "post", extra_params
61
81
  ).extend!(Transloadit::Response::Assembly)
62
82
 
63
83
  return response unless response.rate_limit?
64
84
 
65
- _handle_rate_limit!(response, ios, trial < trials)
85
+ _handle_rate_limit!(response, upload_positions, trial < trials)
66
86
  end
67
87
  end
68
88
 
@@ -123,14 +143,37 @@ class Transloadit::Assembly < Transloadit::ApiModel
123
143
  # @return [Hash] a Transloadit-compatible Hash of the Assembly's contents
124
144
  #
125
145
  def to_hash
126
- options.merge(
146
+ result = options.merge(
127
147
  auth: transloadit.to_hash,
128
148
  steps: steps
129
- ).delete_if { |k, v| v.nil? }
149
+ )
150
+ result.delete(:uploads)
151
+ _, result[:fields] = _split_fields(result[:fields]) if result[:fields]
152
+ result.delete_if { |_, value| value.nil? }
130
153
  end
131
154
 
132
155
  private
133
156
 
157
+ # Legacy fields may contain both named IOs and metadata. Only metadata belongs
158
+ # in the signed JSON; the IOs remain supported as uploads throughout v4.
159
+ def _split_fields(fields)
160
+ return [{}, nil] if fields.nil?
161
+
162
+ fields.partition { |_, value| value.respond_to?(:read) }.map(&:to_h)
163
+ end
164
+
165
+ def _upload_positions(uploads)
166
+ uploads.filter_map do |io|
167
+ # The transport reopens path-backed uploads for each attempt.
168
+ next if io.respond_to?(:path) && io.path
169
+
170
+ position = io.pos if io.respond_to?(:pos) && io.respond_to?(:seek)
171
+ [io, position]
172
+ rescue IOError, SystemCallError
173
+ [io, nil]
174
+ end
175
+ end
176
+
134
177
  #
135
178
  # Returns a Transloadit-compatible Hash wrapping the +steps+ passed to it.
136
179
  # Accepts any supported format the +steps+ could come in.
@@ -163,14 +206,21 @@ class Transloadit::Assembly < Transloadit::ApiModel
163
206
  end
164
207
 
165
208
  #
166
- # Stays idle for certain time and then reopens assembly files for reprocessing.
209
+ # Restores in-memory upload positions and waits before retrying.
167
210
  # Should be called when assembly rate limit is reached.
168
211
  #
169
212
  # @param [Response] response assembly response that comes with a rate limit
170
- # @param [Array<IO>] ios the files sent for the assembly to process.
213
+ # @param [Array<Array>] upload_positions IOs and their positions before upload
171
214
  #
172
- def _handle_rate_limit!(response, ios, is_retrying)
215
+ def _handle_rate_limit!(response, upload_positions, is_retrying)
173
216
  if is_retrying
217
+ upload_positions.each do |io, position|
218
+ raise IOError if position.nil?
219
+
220
+ io.seek(position)
221
+ rescue IOError, SystemCallError
222
+ raise IOError, "Cannot retry a non-seekable upload; use a file or an IO supporting pos and seek"
223
+ end
174
224
  warn "Rate limit reached. Waiting for #{response.wait_time} seconds before retrying."
175
225
  sleep response.wait_time
176
226
  else
@@ -1,3 +1,3 @@
1
1
  class Transloadit
2
- VERSION = "4.0.0"
2
+ VERSION = "4.1.0"
3
3
  end
data/lib/transloadit.rb CHANGED
@@ -79,6 +79,10 @@ class Transloadit
79
79
  # @option options [String] :template_id the ID of a
80
80
  # {template}[https://transloadit.com/templates] to use instead of
81
81
  # specifying options here directly
82
+ # @option options [Hash] :fields custom metadata, retained as typed JSON;
83
+ # passing upload IOs here is deprecated in favor of :uploads
84
+ # @option options [Hash<String, IO>] :uploads named multipart uploads, kept
85
+ # separate from the signed JSON Instructions
82
86
  #
83
87
  def assembly(options = {})
84
88
  Transloadit::Assembly.new(self, options)
@@ -121,7 +121,7 @@ describe Transloadit::Assembly do
121
121
 
122
122
  assert_requested(:post, "https://api2.transloadit.com/assemblies") do |req|
123
123
  values = values_from_post_body(req.body)
124
- _(values["tag"]).must_equal "ninja-cat"
124
+ _(values.keys).must_equal ["params"]
125
125
  _(MultiJson.load(values["params"])["fields"]["tag"]).must_equal "ninja-cat"
126
126
  end
127
127
  end
@@ -0,0 +1,272 @@
1
+ require "test_helper"
2
+ require "stringio"
3
+ require "tempfile"
4
+
5
+ describe "Assembly uploads and custom fields" do
6
+ include WebMock::API
7
+
8
+ before do
9
+ WebMock.reset!
10
+ @transloadit = Transloadit.new(key: "key", secret: "secret")
11
+ @requests = []
12
+ stub_request(:post, "https://api2.transloadit.com/assemblies").to_return do |request|
13
+ @requests << request
14
+ {body: '{"ok":"ASSEMBLY_COMPLETED"}'}
15
+ end
16
+ @fields = {
17
+ "count" => 2,
18
+ "enabled" => false,
19
+ "ratio" => 1.5,
20
+ "empty" => nil,
21
+ "tags" => ["hello & goodbye", 3, true, nil],
22
+ "details" => {"caption" => "café + 猫", "visible" => true}
23
+ }
24
+ end
25
+
26
+ after do
27
+ WebMock.reset!
28
+ end
29
+
30
+ it "sends typed custom fields only in signed JSON without uploads" do
31
+ @transloadit.assembly(fields: @fields).create!
32
+
33
+ _(body_parts.map(&:first)).must_equal %w[params signature]
34
+ _(signed_params.fetch("fields")).must_equal @fields
35
+ end
36
+
37
+ it "sends named File parts separately from typed fields with the same name" do
38
+ Tempfile.create(["assembly-upload", ".bin"]) do |file|
39
+ file.binmode
40
+ file.write("named\x00\xff upload".b)
41
+ file.flush
42
+ fields = @fields.merge("hero" => "metadata for the hero")
43
+ uploads = {hero: file}.freeze
44
+ assembly = @transloadit.assembly(uploads: uploads, fields: fields.freeze)
45
+
46
+ _, warning = capture_io { assembly.create! }
47
+
48
+ _(warning).wont_include "Passing upload IOs in fields"
49
+ _(body_parts.map(&:first)).must_equal %w[params signature hero]
50
+ _(body_parts.to_h.fetch("hero")).must_equal "named\x00\xff upload".b
51
+ _(signed_params.fetch("fields")).must_equal fields
52
+ _(signed_params).wont_include "uploads"
53
+ _(assembly.to_hash).wont_include :uploads
54
+ _(file.closed?).must_equal false
55
+ _(uploads[:hero]).must_equal file
56
+ end
57
+ end
58
+
59
+ it "allows per-call uploads and fields to override constructor defaults" do
60
+ defaults = {unused: StringIO.new("unused upload")}
61
+ assembly = @transloadit.assembly(uploads: defaults, fields: {unused: true})
62
+ upload = StringIO.new("named upload")
63
+ positional = StringIO.new("positional upload")
64
+
65
+ assembly.create!(positional, uploads: {"hero" => upload}, fields: @fields)
66
+
67
+ _(body_parts.map(&:first)).must_equal %w[params signature hero file_0]
68
+ _(body_parts.to_h.fetch("hero")).must_equal "named upload"
69
+ _(body_parts.to_h.fetch("file_0")).must_equal "positional upload"
70
+ _(signed_params.fetch("fields")).must_equal @fields
71
+ _(signed_params).wont_include "uploads"
72
+ _(upload.closed?).must_equal false
73
+ _(positional.closed?).must_equal false
74
+ _(defaults.keys).must_equal [:unused]
75
+ end
76
+
77
+ it "preserves legacy mixed fields while deprecating only their upload entries" do
78
+ upload = StringIO.new("legacy upload")
79
+ fields = @fields.merge("hero" => upload).freeze
80
+ assembly = @transloadit.assembly(fields: fields)
81
+
82
+ _, warning = capture_io { assembly.create! }
83
+
84
+ _(warning).must_match(/fields.*deprecated.*uploads/)
85
+ _(body_parts.map(&:first)).must_equal %w[params signature hero]
86
+ _(body_parts.to_h.fetch("hero")).must_equal "legacy upload"
87
+ _(signed_params.fetch("fields")).must_equal @fields
88
+ _(assembly.to_hash[:fields]).must_equal @fields
89
+ _(JSON.parse(assembly.to_json).fetch("fields")).must_equal @fields
90
+ _(fields.fetch("hero")).must_equal upload
91
+ end
92
+
93
+ it "preserves content's create! fields override for legacy named Files" do
94
+ File.open("lib/transloadit/version.rb", "rb") do |file|
95
+ assembly = @transloadit.assembly(fields: {file: file})
96
+
97
+ _, warning = capture_io { assembly.create!(fields: @fields) }
98
+
99
+ _(warning).must_include "Passing upload IOs in fields is deprecated"
100
+ _(body_parts.map(&:first)).must_equal %w[params signature file]
101
+ _(body_parts.to_h.fetch("file")).must_equal File.binread(file.path)
102
+ _(signed_params.fetch("fields")).must_equal @fields
103
+ _(file.closed?).must_equal false
104
+ end
105
+ end
106
+
107
+ it "separates deprecated uploads in per-call fields from custom fields" do
108
+ fields = @fields.merge("hero" => StringIO.new("per-call legacy upload"))
109
+
110
+ _, warning = capture_io { @transloadit.assembly.create!(fields: fields) }
111
+
112
+ _(warning).must_include "Passing upload IOs in fields is deprecated"
113
+ _(body_parts.map(&:first)).must_equal %w[params signature hero]
114
+ _(body_parts.to_h.fetch("hero")).must_equal "per-call legacy upload"
115
+ _(signed_params.fetch("fields")).must_equal @fields
116
+ end
117
+
118
+ it "keeps positional file_N names and priority over legacy upload names" do
119
+ assembly = @transloadit.assembly(fields: {"file_0" => StringIO.new("legacy")})
120
+
121
+ capture_io { assembly.create!(StringIO.new("first"), StringIO.new("second")) }
122
+
123
+ _(body_parts.map(&:first)).must_equal %w[params signature file_0 file_1]
124
+ _(body_parts.to_h.fetch("file_0")).must_equal "first"
125
+ _(body_parts.to_h.fetch("file_1")).must_equal "second"
126
+ _(signed_params.fetch("fields")).must_equal({})
127
+ end
128
+
129
+ it "lets explicit uploads replace deprecated entries with the same wire name" do
130
+ assembly = @transloadit.assembly(fields: {hero: StringIO.new("legacy")})
131
+
132
+ capture_io { assembly.create!(uploads: {"hero" => StringIO.new("explicit")}) }
133
+
134
+ _(body_parts.map(&:first)).must_equal %w[params signature hero]
135
+ _(body_parts.to_h.fetch("hero")).must_equal "explicit"
136
+ _(signed_params.fetch("fields")).must_equal({})
137
+ end
138
+
139
+ it "supports typed per-call fields and an explicitly empty upload override" do
140
+ assembly = @transloadit.assembly(uploads: {hero: StringIO.new("unused")})
141
+
142
+ assembly.create!(uploads: {}, fields: @fields)
143
+
144
+ _(body_parts.map(&:first)).must_equal %w[params signature]
145
+ _(signed_params.fetch("fields")).must_equal @fields
146
+ _(signed_params).wont_include "uploads"
147
+ end
148
+
149
+ it "preserves deprecated fields uploads when only the uploads option is cleared" do
150
+ assembly = @transloadit.assembly(
151
+ uploads: {unused: StringIO.new("unused")},
152
+ fields: {hero: StringIO.new("legacy upload")}
153
+ )
154
+
155
+ capture_io { assembly.create!(uploads: {}) }
156
+
157
+ _(body_parts.map(&:first)).must_equal %w[params signature hero]
158
+ _(body_parts.to_h.fetch("hero")).must_equal "legacy upload"
159
+ _(signed_params.fetch("fields")).must_equal({})
160
+ end
161
+
162
+ it "rejects non-IO named uploads before making a request" do
163
+ error = assert_raises ArgumentError do
164
+ @transloadit.assembly(uploads: {hero: "path/to/file.jpg"}).create!
165
+ end
166
+
167
+ _(error.message).must_include "IO"
168
+ _(@requests).must_be_empty
169
+ end
170
+
171
+ it "rejects upload names reserved for params and signature" do
172
+ [:params, "signature"].each do |name|
173
+ error = assert_raises ArgumentError do
174
+ @transloadit.assembly.create!(uploads: {name => StringIO.new("upload")})
175
+ end
176
+
177
+ _(error.message).must_include "reserved"
178
+ end
179
+ _(@requests).must_be_empty
180
+ end
181
+
182
+ it "keeps reserved names safe when they are custom fields" do
183
+ fields = {"params" => {"count" => 2}, "signature" => false}
184
+
185
+ @transloadit.assembly(fields: fields).create!
186
+
187
+ _(body_parts.map(&:first)).must_equal %w[params signature]
188
+ _(signed_params.fetch("fields")).must_equal fields
189
+ end
190
+
191
+ it "retries named uploads with the same bytes and signed custom fields" do
192
+ rate_limit_first_request
193
+ upload = StringIO.new("retry upload")
194
+
195
+ capture_io do
196
+ response = @transloadit.assembly(uploads: {hero: upload}, fields: @fields).create!
197
+ _(response.completed?).must_equal true
198
+ end
199
+
200
+ _(@requests.length).must_equal 2
201
+ @requests.each do |request|
202
+ _(body_parts(request).map(&:first)).must_equal %w[params signature hero]
203
+ _(body_parts(request).to_h.fetch("hero")).must_equal "retry upload"
204
+ _(signed_params(request).fetch("fields")).must_equal @fields
205
+ end
206
+ _(upload.closed?).must_equal false
207
+ end
208
+
209
+ it "retries in-memory uploads from their original position" do
210
+ rate_limit_first_request
211
+ upload = StringIO.new("header:retry upload")
212
+ upload.pos = "header:".length
213
+
214
+ capture_io { @transloadit.assembly(uploads: {hero: upload}, fields: @fields).create! }
215
+
216
+ _(@requests.length).must_equal 2
217
+ @requests.each do |request|
218
+ _(body_parts(request).to_h.fetch("hero")).must_equal "retry upload"
219
+ _(signed_params(request).fetch("fields")).must_equal @fields
220
+ end
221
+ end
222
+
223
+ it "fails clearly instead of retrying a consumed non-seekable upload" do
224
+ rate_limit_first_request
225
+ upload = StringIO.new("streamed upload")
226
+ [:pos, :seek, :rewind].each do |method|
227
+ upload.define_singleton_method(method) { |*| raise Errno::ESPIPE }
228
+ end
229
+
230
+ error = assert_raises IOError do
231
+ capture_io { @transloadit.assembly(uploads: {hero: upload}).create! }
232
+ end
233
+
234
+ _(error.message).must_include "Cannot retry"
235
+ _(@requests.length).must_equal 1
236
+ _(body_parts.to_h.fetch("hero")).must_equal "streamed upload"
237
+ _(upload.closed?).must_equal false
238
+ end
239
+
240
+ def rate_limit_first_request
241
+ stub_request(:post, "https://api2.transloadit.com/assemblies").to_return do |request|
242
+ @requests << request
243
+ body = if @requests.length == 1
244
+ '{"error":"RATE_LIMIT_REACHED","info":{"retryIn":0}}'
245
+ else
246
+ '{"ok":"ASSEMBLY_COMPLETED"}'
247
+ end
248
+ {body: body}
249
+ end
250
+ end
251
+
252
+ def body_parts(request = @requests.last)
253
+ content_type = request.headers.fetch("Content-Type")
254
+ return URI.decode_www_form(request.body) unless content_type.start_with?("multipart/form-data")
255
+
256
+ boundary = content_type.split("boundary=", 2).last
257
+ request.body.b.split("--#{boundary}".b).filter_map do |part|
258
+ headers, value = part.split("\r\n\r\n", 2)
259
+ next unless value
260
+
261
+ [headers[/name="([^"]+)"/, 1], value.delete_suffix("\r\n")]
262
+ end
263
+ end
264
+
265
+ def signed_params(request = @requests.last)
266
+ parts = body_parts(request).to_h
267
+ json = parts.fetch("params")
268
+ signature = OpenSSL::HMAC.hexdigest("sha384", "secret", json)
269
+ _(parts.fetch("signature")).must_equal "sha384:#{signature}"
270
+ JSON.parse(json)
271
+ end
272
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: transloadit
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Touset
@@ -260,6 +260,7 @@ files:
260
260
  - test/unit/test_transloadit.rb
261
261
  - test/unit/transloadit/test_api.rb
262
262
  - test/unit/transloadit/test_assembly.rb
263
+ - test/unit/transloadit/test_assembly_uploads.rb
263
264
  - test/unit/transloadit/test_request.rb
264
265
  - test/unit/transloadit/test_response.rb
265
266
  - test/unit/transloadit/test_smart_cdn.rb
@@ -285,7 +286,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
285
286
  - !ruby/object:Gem::Version
286
287
  version: 2.2.0
287
288
  requirements: []
288
- rubygems_version: 4.0.16
289
+ rubygems_version: 4.0.20
289
290
  specification_version: 4
290
291
  summary: Official Ruby gem for Transloadit
291
292
  test_files: []