transloadit 3.1.2 → 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.
@@ -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
@@ -83,25 +83,6 @@ describe Transloadit::Request do
83
83
  lib_path = File.expand_path("../../../lib", __dir__)
84
84
 
85
85
  Dir.mktmpdir do |stub_dir|
86
- File.write(File.join(stub_dir, "rest-client.rb"), <<~RUBY)
87
- module RestClient
88
- class Response; end
89
-
90
- class Resource
91
- def initialize(*); end
92
- def [](*); self; end
93
- def get(*); Response.new; end
94
- def post(*); Response.new; end
95
- def put(*); Response.new; end
96
- def delete(*); Response.new; end
97
- end
98
-
99
- module Exceptions
100
- class OpenTimeout < StandardError; end
101
- end
102
- end
103
- RUBY
104
-
105
86
  File.write(File.join(stub_dir, "multi_json.rb"), <<~RUBY)
106
87
  require "json"
107
88
 
@@ -123,6 +104,7 @@ describe Transloadit::Request do
123
104
  begin
124
105
  require "transloadit/request"
125
106
  Transloadit::Request.new("/")
107
+ raise "RestClient was loaded" if defined?(RestClient)
126
108
  rescue StandardError => e
127
109
  warn e.full_message
128
110
  exit 1
@@ -3,17 +3,36 @@ require "test_helper"
3
3
  describe Transloadit::Response do
4
4
  request_uri = "https://api2.jane.transloadit.com/assemblies/76fe5df1c93a0a530f3e583805cf98b4"
5
5
 
6
- it "must allow delegate initialization" do
7
- response = Transloadit::Response.new("test")
6
+ it "must allow initialization" do
7
+ response = Transloadit::Response.new(body: "{}", headers: {}, status: 200)
8
8
  _(response.class).must_equal Transloadit::Response
9
9
  end
10
10
 
11
+ it "must replace body, headers, and status together" do
12
+ response = Transloadit::Response.new(
13
+ body: '{"ok":"ASSEMBLY_EXECUTING"}',
14
+ headers: {"X-Request-Id" => "old-request"},
15
+ status: 202
16
+ )
17
+ replacement = Transloadit::Response.new(
18
+ body: '{"ok":"ASSEMBLY_COMPLETED"}',
19
+ headers: {"X-Request-Id" => "new-request"},
20
+ status: 200
21
+ )
22
+
23
+ returned = response.replace(replacement)
24
+
25
+ _(returned).must_be_same_as response
26
+ _(response["ok"]).must_equal "ASSEMBLY_COMPLETED"
27
+ _(response.headers).must_equal x_request_id: "new-request"
28
+ _(response.code).must_equal 200
29
+ _(response.status).must_equal 200
30
+ end
31
+
11
32
  describe "when initialized" do
12
33
  before do
13
34
  VCR.use_cassette "fetch_assembly_ok" do
14
- @response = Transloadit::Response.new(
15
- RestClient::Resource.new(request_uri).get
16
- )
35
+ @response = Transloadit::Request.new(request_uri).get
17
36
  end
18
37
  end
19
38
 
@@ -41,9 +60,7 @@ describe Transloadit::Response do
41
60
  describe "when extended as an assembly" do
42
61
  before do
43
62
  VCR.use_cassette "fetch_assembly_ok" do
44
- @response = Transloadit::Response.new(
45
- RestClient::Resource.new(request_uri).get
46
- ).extend!(Transloadit::Response::Assembly)
63
+ @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly)
47
64
  end
48
65
  end
49
66
 
@@ -56,9 +73,6 @@ describe Transloadit::Response do
56
73
  # TODO: can this be tested better?
57
74
  it "must allow reloading the assembly" do
58
75
  VCR.use_cassette "fetch_assembly_ok", allow_playback_repeats: true do
59
- _(@response.send(:__getobj__))
60
- .wont_be_same_as @response.reload!.send(:__getobj__)
61
-
62
76
  _(@response.object_id)
63
77
  .must_equal @response.reload!.object_id
64
78
  end
@@ -79,9 +93,7 @@ describe Transloadit::Response do
79
93
  describe "long-running assembly" do
80
94
  before do
81
95
  VCR.use_cassette "fetch_assembly_executing" do
82
- @response = Transloadit::Response.new(
83
- RestClient::Resource.new(request_uri).get
84
- ).extend!(Transloadit::Response::Assembly)
96
+ @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly)
85
97
  end
86
98
  end
87
99
 
@@ -109,9 +121,7 @@ describe Transloadit::Response do
109
121
  describe "statuses" do
110
122
  it "must allow checking for upload" do
111
123
  VCR.use_cassette "fetch_assembly_uploading" do
112
- @response = Transloadit::Response.new(
113
- RestClient::Resource.new(request_uri).get
114
- ).extend!(Transloadit::Response::Assembly)
124
+ @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly)
115
125
  end
116
126
 
117
127
  _(@response.finished?).must_equal false
@@ -121,9 +131,7 @@ describe Transloadit::Response do
121
131
 
122
132
  it "must allow to check for executing" do
123
133
  VCR.use_cassette "fetch_assembly_executing" do
124
- @response = Transloadit::Response.new(
125
- RestClient::Resource.new(request_uri).get
126
- ).extend!(Transloadit::Response::Assembly)
134
+ @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly)
127
135
  end
128
136
 
129
137
  _(@response.finished?).must_equal false
@@ -133,11 +141,9 @@ describe Transloadit::Response do
133
141
 
134
142
  it "must allow to check for replaying" do
135
143
  VCR.use_cassette "replay_assembly" do
136
- @response = Transloadit::Response.new(
137
- RestClient::Resource.new(
138
- "https://api2.transloadit.com/assemblies/55c965a063a311e6ba2d379ef10b28f7/replay"
139
- ).post({})
140
- ).extend!(Transloadit::Response::Assembly)
144
+ @response = Transloadit::Request.new(
145
+ "https://api2.transloadit.com/assemblies/55c965a063a311e6ba2d379ef10b28f7/replay"
146
+ ).post.extend!(Transloadit::Response::Assembly)
141
147
  end
142
148
 
143
149
  _(@response.finished?).must_equal false
@@ -147,9 +153,7 @@ describe Transloadit::Response do
147
153
 
148
154
  it "must allow to check for aborted" do
149
155
  VCR.use_cassette "fetch_assembly_aborted" do
150
- @response = Transloadit::Response.new(
151
- RestClient::Resource.new(request_uri).get
152
- ).extend!(Transloadit::Response::Assembly)
156
+ @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly)
153
157
  end
154
158
 
155
159
  _(@response.finished?).must_equal true
@@ -158,9 +162,7 @@ describe Transloadit::Response do
158
162
 
159
163
  it "must allow to check for errors" do
160
164
  VCR.use_cassette "fetch_assembly_errors" do
161
- @response = Transloadit::Response.new(
162
- RestClient::Resource.new(request_uri).get
163
- ).extend!(Transloadit::Response::Assembly)
165
+ @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly)
164
166
  end
165
167
 
166
168
  _(@response.error?).must_equal true
@@ -0,0 +1,222 @@
1
+ require "test_helper"
2
+ require "stringio"
3
+ require "tempfile"
4
+
5
+ describe "Faraday transport compatibility" do
6
+ include WebMock::API
7
+
8
+ before do
9
+ WebMock.reset!
10
+ end
11
+
12
+ after do
13
+ WebMock.reset!
14
+ end
15
+
16
+ it "sends signed GET parameters and the client header to a selected host" do
17
+ endpoint = "https://api2.example.test/assemblies/assembly-id"
18
+ stub_request(:get, /\A#{Regexp.escape(endpoint)}/)
19
+ .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}')
20
+
21
+ response = Transloadit::Request.new(endpoint, "secret").get(wait: true)
22
+
23
+ _(response["ok"]).must_equal "ASSEMBLY_COMPLETED"
24
+ assert_requested(:get, /\A#{Regexp.escape(endpoint)}/) do |request|
25
+ query = Addressable::URI.parse(request.uri.to_s).query_values
26
+ _(MultiJson.load(query.fetch("params"))).must_equal "wait" => true
27
+ _(query.fetch("signature")).must_match(/\Asha384:[0-9a-f]{96}\z/)
28
+ _(request.headers.fetch("Transloadit-Client")).must_equal "ruby-sdk:#{Transloadit::VERSION}"
29
+ end
30
+ end
31
+
32
+ it "form-encodes signed POST payloads" do
33
+ endpoint = "https://api2.transloadit.com/assemblies"
34
+ stub_request(:post, endpoint)
35
+ .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}')
36
+
37
+ Transloadit::Request.new("/assemblies", "secret").post(params: {template_id: "template-id"})
38
+
39
+ assert_requested(:post, endpoint) do |request|
40
+ form = URI.decode_www_form(request.body).to_h
41
+ _(MultiJson.load(form.fetch("params"))).must_equal "template_id" => "template-id"
42
+ _(form.fetch("signature")).must_match(/\Asha384:[0-9a-f]{96}\z/)
43
+ _(request.headers.fetch("Content-Type")).must_match(/\Aapplication\/x-www-form-urlencoded/)
44
+ end
45
+ end
46
+
47
+ it "form-encodes signed PUT payloads" do
48
+ endpoint = "https://api2.transloadit.com/templates/template-id"
49
+ stub_request(:put, endpoint)
50
+ .to_return(status: 200, body: '{"ok":"TEMPLATE_UPDATED"}')
51
+
52
+ Transloadit::Request.new("/templates/template-id", "secret").put(params: {name: "Updated"})
53
+
54
+ assert_requested(:put, endpoint) do |request|
55
+ form = URI.decode_www_form(request.body).to_h
56
+ _(MultiJson.load(form.fetch("params"))).must_equal "name" => "Updated"
57
+ _(form.fetch("signature")).must_match(/\Asha384:[0-9a-f]{96}\z/)
58
+ end
59
+ end
60
+
61
+ it "form-encodes signed DELETE payloads" do
62
+ endpoint = "https://api2.transloadit.com/templates/template-id"
63
+ stub_request(:delete, endpoint)
64
+ .to_return(status: 200, body: '{"ok":"TEMPLATE_DELETED"}')
65
+
66
+ Transloadit::Request.new("/templates/template-id", "secret").delete(params: {reason: "cleanup"})
67
+
68
+ assert_requested(:delete, endpoint) do |request|
69
+ form = URI.decode_www_form(request.body).to_h
70
+ _(MultiJson.load(form.fetch("params"))).must_equal "reason" => "cleanup"
71
+ _(form.fetch("signature")).must_match(/\Asha384:[0-9a-f]{96}\z/)
72
+ end
73
+ end
74
+
75
+ it "returns non-success responses with SDK-owned status, headers, and body access" do
76
+ endpoint = "https://api2.example.test/assemblies/missing"
77
+ stub_request(:get, endpoint).to_return(
78
+ status: 422,
79
+ headers: {"Retry-After" => "5", "X-Request-Id" => "request-id"},
80
+ body: '{"error":"ASSEMBLY_NOT_FOUND"}'
81
+ )
82
+
83
+ response = Transloadit::Request.new(endpoint).get
84
+
85
+ _(response).must_be_kind_of Transloadit::Response
86
+ _(response).wont_be_kind_of Faraday::Response
87
+ _(response.code).must_equal 422
88
+ _(response.status).must_equal 422
89
+ _(response.headers).must_equal retry_after: "5", x_request_id: "request-id"
90
+ _(response["error"]).must_equal "ASSEMBLY_NOT_FOUND"
91
+ end
92
+
93
+ it "returns redirects without following them" do
94
+ endpoint = "https://api2.example.test/assemblies"
95
+ redirect = "https://uploads.example.test/assemblies"
96
+ stub_request(:post, endpoint).to_return(
97
+ status: 302,
98
+ headers: {"Location" => redirect},
99
+ body: "{}"
100
+ )
101
+
102
+ response = Transloadit::Request.new(endpoint).post
103
+
104
+ _(response.code).must_equal 302
105
+ _(response.headers[:location]).must_equal redirect
106
+ assert_not_requested(:post, redirect)
107
+ end
108
+
109
+ it "uploads multiple files after params and signature without closing caller-owned files" do
110
+ endpoint = "https://api2.transloadit.com/assemblies"
111
+ stub_request(:post, endpoint)
112
+ .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}')
113
+
114
+ Tempfile.create(["first", ".txt"]) do |first|
115
+ Tempfile.create(["second", ".json"]) do |second|
116
+ first.write("first upload")
117
+ first.flush
118
+ second.write('{"second":"upload"}')
119
+ second.flush
120
+
121
+ Transloadit::Request.new("/assemblies", "secret").post(
122
+ params: {steps: {}},
123
+ file_0: first,
124
+ file_1: second
125
+ )
126
+
127
+ _(first.closed?).must_equal false
128
+ _(second.closed?).must_equal false
129
+ end
130
+ end
131
+
132
+ assert_requested(:post, endpoint) do |request|
133
+ params_position = request.body.index('name="params"')
134
+ signature_position = request.body.index('name="signature"')
135
+ first_position = request.body.index('name="file_0"')
136
+ second_position = request.body.index('name="file_1"')
137
+
138
+ _(request.headers.fetch("Content-Type")).must_match(/\Amultipart\/form-data; boundary=/)
139
+ _(params_position).wont_be_nil
140
+ _(signature_position).wont_be_nil
141
+ _(first_position).wont_be_nil
142
+ _(second_position).wont_be_nil
143
+ _(params_position < signature_position).must_equal true
144
+ _(signature_position < first_position).must_equal true
145
+ _(first_position < second_position).must_equal true
146
+ _(request.body).must_include "Content-Type: text/plain"
147
+ _(request.body).must_include "Content-Type: application/json"
148
+ _(request.body).must_include "first upload"
149
+ _(request.body).must_include '{"second":"upload"}'
150
+ end
151
+ end
152
+
153
+ it "preserves filename and content type from Rails-style uploaded files" do
154
+ endpoint = "https://api2.transloadit.com/assemblies"
155
+ stub_request(:post, endpoint)
156
+ .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}')
157
+
158
+ Tempfile.create(["rails-upload", ".bin"]) do |file|
159
+ file.write("csv contents")
160
+ file.flush
161
+ upload = Object.new
162
+ upload.define_singleton_method(:read) { |*arguments| file.read(*arguments) }
163
+ upload.define_singleton_method(:path) { file.path }
164
+ upload.define_singleton_method(:original_filename) { "report.csv" }
165
+ upload.define_singleton_method(:content_type) { "text/csv" }
166
+
167
+ Transloadit::Request.new("/assemblies").post(params: {steps: {}}, file_0: upload)
168
+ end
169
+
170
+ assert_requested(:post, endpoint) do |request|
171
+ _(request.body).must_include 'filename="report.csv"'
172
+ _(request.body).must_include "Content-Type: text/csv"
173
+ _(request.body).must_include "csv contents"
174
+ end
175
+ end
176
+
177
+ it "uses safe metadata defaults for in-memory uploads" do
178
+ endpoint = "https://api2.transloadit.com/assemblies"
179
+ stub_request(:post, endpoint)
180
+ .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}')
181
+ upload = StringIO.new("memory upload")
182
+
183
+ Transloadit::Request.new("/assemblies").post(params: {steps: {}}, file_0: upload)
184
+
185
+ _(upload.closed?).must_equal false
186
+ assert_requested(:post, endpoint) do |request|
187
+ _(request.body).must_include 'filename="upload"'
188
+ _(request.body).must_include "Content-Type: application/octet-stream"
189
+ _(request.body).must_include "memory upload"
190
+ end
191
+ end
192
+
193
+ it "uses the binary content type when a file extension is unknown" do
194
+ endpoint = "https://api2.transloadit.com/assemblies"
195
+ stub_request(:post, endpoint)
196
+ .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}')
197
+
198
+ Tempfile.create(["upload", ".transloadit-unknown"]) do |upload|
199
+ upload.write("unknown upload")
200
+ upload.flush
201
+
202
+ Transloadit::Request.new("/assemblies").post(params: {steps: {}}, file_0: upload)
203
+ end
204
+
205
+ assert_requested(:post, endpoint) do |request|
206
+ _(request.body).must_include "Content-Type: application/octet-stream"
207
+ _(request.body).must_include "unknown upload"
208
+ end
209
+ end
210
+
211
+ it "wraps adapter failures and retains the original error as the cause" do
212
+ endpoint = "https://api2.example.test/assemblies/assembly-id"
213
+ stub_request(:get, endpoint).to_timeout
214
+
215
+ error = assert_raises Transloadit::Exception::RequestFailed do
216
+ Transloadit::Request.new(endpoint).get
217
+ end
218
+
219
+ _(error.message).must_equal "Transloadit request failed"
220
+ _(error.cause).must_be_kind_of Faraday::ConnectionFailed
221
+ end
222
+ end
data/transloadit.gemspec CHANGED
@@ -16,21 +16,22 @@ Gem::Specification.new do |gem|
16
16
  gem.description = "The transloadit gem allows you to automate uploading files through the Transloadit REST API"
17
17
 
18
18
  gem.required_rubygems_version = ">= 2.2.0"
19
- gem.required_ruby_version = ">= 3.0.0"
19
+ gem.required_ruby_version = ">= 3.1.0"
20
20
 
21
21
  gem.files = `git ls-files`.split("\n")
22
22
  gem.require_paths = %w[lib]
23
23
 
24
- gem.add_dependency "rest-client"
24
+ gem.add_dependency "faraday", ">= 2.0", "< 3.0"
25
+ gem.add_dependency "faraday-multipart", "~> 1.0"
25
26
  gem.add_dependency "multi_json"
26
27
  gem.add_dependency "mime-types"
27
28
 
28
29
  gem.add_development_dependency "rake"
29
30
  gem.add_development_dependency "minitest"
30
31
  gem.add_development_dependency "simplecov"
31
- gem.add_development_dependency "standard"
32
+ gem.add_development_dependency "standard", ">= 1.54"
32
33
 
33
- gem.add_development_dependency "vcr"
34
+ gem.add_development_dependency "vcr", ">= 6.4"
34
35
  gem.add_development_dependency "webmock"
35
36
 
36
37
  gem.add_development_dependency "yard"