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 +4 -4
- data/CHANGELOG.md +9 -0
- data/Gemfile +4 -0
- data/README.md +52 -3
- data/lib/transloadit/assembly.rb +60 -10
- data/lib/transloadit/version.rb +1 -1
- data/lib/transloadit.rb +4 -0
- data/test/unit/transloadit/test_assembly.rb +1 -1
- data/test/unit/transloadit/test_assembly_uploads.rb +272 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c1220e478a424b85d5ac449fd7facf3790af3091a69435699a49480853b86fe
|
|
4
|
+
data.tar.gz: 544d23e833e4ee095744d91f948ca7bfc7616bb70e5d576042d44b3032510307
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
230
|
-
notification.
|
|
231
|
-
|
|
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.
|
data/lib/transloadit/assembly.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
)
|
|
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
|
-
#
|
|
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<
|
|
213
|
+
# @param [Array<Array>] upload_positions IOs and their positions before upload
|
|
171
214
|
#
|
|
172
|
-
def _handle_rate_limit!(response,
|
|
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
|
data/lib/transloadit/version.rb
CHANGED
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
|
|
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.
|
|
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.
|
|
289
|
+
rubygems_version: 4.0.20
|
|
289
290
|
specification_version: 4
|
|
290
291
|
summary: Official Ruby gem for Transloadit
|
|
291
292
|
test_files: []
|