diff --git a/CHANGELOG.md b/CHANGELOG.md index bd001dd..14ccc09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +### Unreleased + +- BREAKING: Replace the long-dormant `rest-client` transport with Faraday 2 and `faraday-multipart`. +- Keep HTTP client types out of SDK responses and exceptions while preserving response status, headers, body access, multipart ordering, and rate-limit retries. +- Add a [v3 to v4 migration guide](MIGRATING_TO_V4.md). + ### 3.1.2 / 2025-10-29 - Compress e2e fixture asset to keep gem size close to previous releases (kvz) diff --git a/MIGRATING_TO_V4.md b/MIGRATING_TO_V4.md new file mode 100644 index 0000000..17f73ea --- /dev/null +++ b/MIGRATING_TO_V4.md @@ -0,0 +1,135 @@ +# Migrating from v3 to v4 + +Version 4 replaces the `rest-client` HTTP transport with Faraday 2. Most applications only use the +Transloadit API exposed by this gem and do not need code changes. The migration is a major release +because v3 also delegated undocumented methods to RestClient response objects and exposed +RestClient exception inheritance. + +The minimum supported Ruby version remains 3.1. + +## Update the dependency + +Once v4 is released, update the application Gemfile and bundle: + +```ruby +gem "transloadit", "~> 4.0" +``` + +```shell +bundle update transloadit +``` + +The SDK installs Faraday and its multipart middleware. Applications do not need to add Faraday +directly. Remove an explicit `rest-client` dependency only if the application does not use it for +anything else. Version 4 no longer installs or loads the `RestClient` constant on the application's +behalf. + +## Responses + +Use the SDK-owned response API: + +```ruby +response = transloadit.assembly.get(assembly_id) + +response["ok"] +response[:assembly_id] +response.body +response.headers +response.code +response.status +``` + +`code` continues to return the integer HTTP status, and `status` is its new explicit alias. Header +names remain normalized to lowercase symbols with underscores, for example +`response.headers[:retry_after]`. + +HTTP error statuses still return `Transloadit::Response` objects. They are not converted into +transport exceptions: + +```ruby +response = transloadit.assembly.get(assembly_id) + +if response.code >= 400 + warn response["error"] +end +``` + +### Remove raw RestClient response usage + +In v3, `Transloadit::Response` delegated unknown methods to a `RestClient::Response`. Version 4 no +longer exposes the underlying HTTP client. Replace calls to RestClient-specific response methods +with the SDK-owned response API above. + +For example: + +```ruby +# v3: relied on the delegated RestClient object +response.raw_headers + +# v4 +response.headers +``` + +If an application relies on another delegated method that has no equivalent, open an issue with the +use case before upgrading. + +## Request failures + +Network, TLS, and timeout failures now raise `Transloadit::Exception::RequestFailed` instead of a +RestClient exception: + +```ruby +# v3 +begin + transloadit.assembly.get(assembly_id) +rescue RestClient::Exception => error + warn error.message +end + +# v4 +begin + transloadit.assembly.get(assembly_id) +rescue Transloadit::Exception::RequestFailed => error + warn error.message +end +``` + +The original adapter exception is retained as `error.cause` for diagnostics. Avoid branching on its +class so application behavior does not become coupled to Faraday. + +## Rate-limit failures + +Continue rescuing the SDK exception directly: + +```ruby +begin + assembly.create!(file) +rescue Transloadit::Exception::RateLimitReached => error + retry_after = error.response.wait_time +end +``` + +`RateLimitReached` remains a `StandardError` and retains its Transloadit response through +`error.response`, but it no longer inherits from `RestClient::RequestEntityTooLarge`. Replace code +that rescues the RestClient superclass with the SDK exception. + +## Uploaded file handles + +Version 4 does not close path-backed file objects supplied by the caller. Prefer a block so ownership +is explicit: + +```ruby +File.open("video.mp4", "rb") do |file| + assembly.create!(file) +end +``` + +Multipart field order is unchanged: `params` and `signature` are sent before file bodies. + +## Upgrade checklist + +- Update the gem and run the application's request and upload tests. +- Replace rescues of `RestClient::Exception` and `RestClient::RequestEntityTooLarge`. +- Replace calls to delegated RestClient response methods. +- Confirm the application closes files that it opens. +- Remove the direct `rest-client` dependency if nothing else uses it. diff --git a/README.md b/README.md index 400ffb7..85d8118 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ _If you run Ruby on Rails and are looking to integrate with the browser for file gem install transloadit ``` +Upgrading from v3? See the [v4 migration guide](MIGRATING_TO_V4.md) for response, exception, and +file-handle compatibility notes. + ## Usage To get started, you need to require the 'transloadit' gem: diff --git a/lib/transloadit/assembly.rb b/lib/transloadit/assembly.rb index ac303a5..c6c0899 100644 --- a/lib/transloadit/assembly.rb +++ b/lib/transloadit/assembly.rb @@ -173,8 +173,6 @@ def _handle_rate_limit!(response, ios, is_retrying) if is_retrying warn "Rate limit reached. Waiting for #{response.wait_time} seconds before retrying." sleep response.wait_time - # RestClient closes file streams at the end of a request. - ios.collect! { |file| File.open file.path } else raise Transloadit::Exception::RateLimitReached.new(response) end diff --git a/lib/transloadit/exception.rb b/lib/transloadit/exception.rb index f417ef7..4071199 100644 --- a/lib/transloadit/exception.rb +++ b/lib/transloadit/exception.rb @@ -1,16 +1,24 @@ require "transloadit" -require "rest-client" - module Transloadit::Exception + # + # Exception raised when an HTTP request cannot be completed. + # The original transport error is available through +cause+. + # + class RequestFailed < StandardError + end + # # Exception raised when Rate limit error response is returned from the API. # See {Rate Limiting}[https://transloadit.com/docs/api-docs/#rate-limiting] # - class RateLimitReached < RestClient::RequestEntityTooLarge - def default_message - retry_msg = " Retry in #{@response.wait_time} seconds" if @response - "Transloadit Rate Limit Reached.#{retry_msg}" + class RateLimitReached < StandardError + # @return [Transloadit::Response] the API response that reported the rate limit + attr_reader :response + + def initialize(response) + @response = response + super("Transloadit Rate Limit Reached. Retry in #{response.wait_time} seconds") end end diff --git a/lib/transloadit/request.rb b/lib/transloadit/request.rb index 2e85715..28d3276 100644 --- a/lib/transloadit/request.rb +++ b/lib/transloadit/request.rb @@ -1,6 +1,8 @@ require "transloadit" -require "rest-client" +require "faraday" +require "faraday/multipart" +require "mime/types" require "openssl" # @@ -47,7 +49,7 @@ def initialize(url, secret = nil) # def get(params = {}) request! do - api[url.path + to_query(params)].get(API_HEADERS) + api.get(url.path + to_query(params), nil, API_HEADERS) end end @@ -60,8 +62,10 @@ def get(params = {}) # def delete(payload = {}) request! do - options = {payload: to_payload(payload)} - api(options)[url.path].delete(API_HEADERS) + api.delete(url.path) do |request| + request.headers.update(API_HEADERS) + request.body = to_payload(payload) + end end end @@ -74,7 +78,7 @@ def delete(payload = {}) # def post(payload = {}) request! do - api[url.path].post(to_payload(payload), API_HEADERS) + api.post(url.path, to_payload(payload), API_HEADERS) end end @@ -87,7 +91,7 @@ def post(payload = {}) # def put(payload = {}) request! do - api[url.path].put(to_payload(payload), API_HEADERS) + api.put(url.path, to_payload(payload), API_HEADERS) end end @@ -118,12 +122,20 @@ def self._hmac(key, message) # hostname, then the hostname is used as the base endpoint of the API. # Otherwise uses the class-level API base. # - # @return [RestClient::Resource] the API endpoint for this instance + # @return [Faraday::Connection] the API endpoint for this instance # - def api(options = {}) + def api @api ||= case url.host - when String then RestClient::Resource.new("#{url.scheme}://#{url.host}", options) - else RestClient::Resource.new("#{API_ENDPOINT.scheme}://#{API_ENDPOINT.host}", options) + when String then connection_for("#{url.scheme}://#{url.host}") + else connection_for("#{API_ENDPOINT.scheme}://#{API_ENDPOINT.host}") + end + end + + def connection_for(base_url) + Faraday.new(url: base_url) do |connection| + connection.request :multipart + connection.request :url_encoded + connection.adapter Faraday.default_adapter end end @@ -151,12 +163,33 @@ def to_payload(payload = nil) sig = signature(new_payload[:params]) new_payload[:signature] = sig unless sig.nil? - # Copy all values, excluding :params and :signature keys. - new_payload.update payload.reject { |key, _| key == :params || key == :signature } + payload.each do |key, value| + next if key == :params || key == :signature + + new_payload[key] = upload?(value) ? multipart_file(value) : value + end new_payload end + def upload?(value) + value.respond_to?(:read) + end + + def multipart_file(file) + path = file.path if file.respond_to?(:path) + filename = file.original_filename if file.respond_to?(:original_filename) + filename = File.basename(path) if filename.to_s.empty? && path + filename = "upload" if filename.to_s.empty? + + content_type = file.content_type if file.respond_to?(:content_type) + content_type = MIME::Types.type_for(path).first&.content_type if content_type.to_s.empty? && path + content_type = "application/octet-stream" if content_type.to_s.empty? + + source = path || file + Faraday::Multipart::FilePart.new(source, content_type, filename) + end + # # Updates the GET/DELETE params hash to be compliant with the Transloadit # API by URI escaping and encoding the params hash, and attaching a @@ -181,18 +214,17 @@ def to_query(params = nil) end # - # Wraps a request's results in a Transloadit::Response, even if an exception - # is raised by RestClient. + # Wraps a request's result in a Transloadit::Response. # def request!(&request) - Transloadit::Response.new yield - rescue RestClient::Exception => e - # The response attribute can be nil, for example for RestClient::Exceptions::OpenTimeout exceptions. - # Then, we cannot convert them into a Transloadit::Response, so instead we raise them again for - # the user to be visible. - # See https://github.com/transloadit/ruby-sdk/issues/53 - raise e if e.response.nil? - Transloadit::Response.new e.response + response = yield + Transloadit::Response.new( + body: response.body, + headers: response.headers, + status: response.status + ) + rescue Faraday::Error + raise Transloadit::Exception::RequestFailed, "Transloadit request failed" end # diff --git a/lib/transloadit/response.rb b/lib/transloadit/response.rb index 82279bd..18c2a06 100644 --- a/lib/transloadit/response.rb +++ b/lib/transloadit/response.rb @@ -1,20 +1,30 @@ require "transloadit" -require "rest-client" -require "delegate" - -class Transloadit::Response < Delegator +class Transloadit::Response autoload :Assembly, "transloadit/response/assembly" # - # Creates an enhanced response wrapped around a RestClient response. + # Creates a response without exposing the underlying HTTP client. # - # @param [RestClient::Response] response the JSON response to wrap + # @param [String] body the raw response body + # @param [Hash] headers the response headers + # @param [Integer] status the HTTP response status # - def initialize(response) - __setobj__(response) + def initialize(body:, headers:, status:) + @raw_body = body + @headers = normalize_headers(headers) + @status = status end + # @return [Hash] normalized response headers + attr_reader :headers + + # @return [Integer] the HTTP response status + attr_reader :status + + # RestClient exposed the status through +code+ in previous SDK versions. + alias_method :code, :status + # # Returns the attribute from the JSON response. # @@ -31,7 +41,7 @@ def [](attribute) # @return [Hash] the parsed JSON body hash # def body - MultiJson.load __getobj__.body + MultiJson.load @raw_body end # @@ -56,36 +66,28 @@ def extend!(mod) self end - protected - - # - # The object to delegate method calls to. - # - # @return [RestClient::Response] - # - def __getobj__ - @response - end - # - # Sets the object to delegate method calls to. + # Replaces this response's HTTP data with another response's data. # - # @param [RestClient::Response] response the response to delegate to - # @return [RestClient::Response] the delegated response - # - def __setobj__(response) - @response = response - end - - # - # Replaces the object this instance delegates to with the one the other - # object uses. - # - # @param [Delegator] other the object whose delegate to use + # @param [Transloadit::Response] other the response whose data to use # @return [Transloadit::Response] this response # def replace(other) - __setobj__ other.__getobj__ + @raw_body = other.raw_body + @headers = other.headers + @status = other.status self end + + protected + + attr_reader :raw_body + + private + + def normalize_headers(headers) + headers.to_h.each_with_object({}) do |(name, value), normalized| + normalized[name.to_s.downcase.tr("-", "_").to_sym] = value + end + end end diff --git a/test/unit/transloadit/test_assembly.rb b/test/unit/transloadit/test_assembly.rb index 8df81bf..84f743f 100644 --- a/test/unit/transloadit/test_assembly.rb +++ b/test/unit/transloadit/test_assembly.rb @@ -64,9 +64,13 @@ it "must send the signature before any file" do transloadit = Transloadit.new(key: "", secret: "foo") - Transloadit::Assembly.new( - transloadit - ).create! open("lib/transloadit/version.rb") + File.open("lib/transloadit/version.rb") do |file| + Transloadit::Assembly.new( + transloadit + ).create! file + + _(file.closed?).must_equal false + end assert_requested(:post, "https://api2.transloadit.com/assemblies") do |req| position_params = req.body.index 'name="params"' @@ -76,6 +80,7 @@ _(position_params).wont_be_nil _(position_signature).wont_be_nil _(position_file).wont_be_nil + _(req.body).must_include 'filename="version.rb"' _(position_params < position_signature).must_equal true _(position_signature < position_file).must_equal true @@ -173,9 +178,12 @@ @assembly.options[:tries] = 1 VCR.use_cassette "rate_limit_succeed" do - assert_raises Transloadit::Exception::RateLimitReached do + error = assert_raises Transloadit::Exception::RateLimitReached do @assembly.create! open("lib/transloadit/version.rb") end + + _(error.response["error"]).must_equal "RATE_LIMIT_REACHED" + _(error.message).must_equal "Transloadit Rate Limit Reached. Retry in 0 seconds" end end diff --git a/test/unit/transloadit/test_request.rb b/test/unit/transloadit/test_request.rb index 8435eb3..56a8ca7 100644 --- a/test/unit/transloadit/test_request.rb +++ b/test/unit/transloadit/test_request.rb @@ -83,25 +83,6 @@ lib_path = File.expand_path("../../../lib", __dir__) Dir.mktmpdir do |stub_dir| - File.write(File.join(stub_dir, "rest-client.rb"), <<~RUBY) - module RestClient - class Response; end - - class Resource - def initialize(*); end - def [](*); self; end - def get(*); Response.new; end - def post(*); Response.new; end - def put(*); Response.new; end - def delete(*); Response.new; end - end - - module Exceptions - class OpenTimeout < StandardError; end - end - end - RUBY - File.write(File.join(stub_dir, "multi_json.rb"), <<~RUBY) require "json" @@ -123,6 +104,7 @@ def self.load(json) begin require "transloadit/request" Transloadit::Request.new("/") + raise "RestClient was loaded" if defined?(RestClient) rescue StandardError => e warn e.full_message exit 1 diff --git a/test/unit/transloadit/test_response.rb b/test/unit/transloadit/test_response.rb index 18df6b3..227cafd 100644 --- a/test/unit/transloadit/test_response.rb +++ b/test/unit/transloadit/test_response.rb @@ -3,17 +3,36 @@ describe Transloadit::Response do request_uri = "https://api2.jane.transloadit.com/assemblies/76fe5df1c93a0a530f3e583805cf98b4" - it "must allow delegate initialization" do - response = Transloadit::Response.new("test") + it "must allow initialization" do + response = Transloadit::Response.new(body: "{}", headers: {}, status: 200) _(response.class).must_equal Transloadit::Response end + it "must replace body, headers, and status together" do + response = Transloadit::Response.new( + body: '{"ok":"ASSEMBLY_EXECUTING"}', + headers: {"X-Request-Id" => "old-request"}, + status: 202 + ) + replacement = Transloadit::Response.new( + body: '{"ok":"ASSEMBLY_COMPLETED"}', + headers: {"X-Request-Id" => "new-request"}, + status: 200 + ) + + returned = response.replace(replacement) + + _(returned).must_be_same_as response + _(response["ok"]).must_equal "ASSEMBLY_COMPLETED" + _(response.headers).must_equal x_request_id: "new-request" + _(response.code).must_equal 200 + _(response.status).must_equal 200 + end + describe "when initialized" do before do VCR.use_cassette "fetch_assembly_ok" do - @response = Transloadit::Response.new( - RestClient::Resource.new(request_uri).get - ) + @response = Transloadit::Request.new(request_uri).get end end @@ -41,9 +60,7 @@ describe "when extended as an assembly" do before do VCR.use_cassette "fetch_assembly_ok" do - @response = Transloadit::Response.new( - RestClient::Resource.new(request_uri).get - ).extend!(Transloadit::Response::Assembly) + @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly) end end @@ -56,9 +73,6 @@ # TODO: can this be tested better? it "must allow reloading the assembly" do VCR.use_cassette "fetch_assembly_ok", allow_playback_repeats: true do - _(@response.send(:__getobj__)) - .wont_be_same_as @response.reload!.send(:__getobj__) - _(@response.object_id) .must_equal @response.reload!.object_id end @@ -79,9 +93,7 @@ describe "long-running assembly" do before do VCR.use_cassette "fetch_assembly_executing" do - @response = Transloadit::Response.new( - RestClient::Resource.new(request_uri).get - ).extend!(Transloadit::Response::Assembly) + @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly) end end @@ -109,9 +121,7 @@ describe "statuses" do it "must allow checking for upload" do VCR.use_cassette "fetch_assembly_uploading" do - @response = Transloadit::Response.new( - RestClient::Resource.new(request_uri).get - ).extend!(Transloadit::Response::Assembly) + @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly) end _(@response.finished?).must_equal false @@ -121,9 +131,7 @@ it "must allow to check for executing" do VCR.use_cassette "fetch_assembly_executing" do - @response = Transloadit::Response.new( - RestClient::Resource.new(request_uri).get - ).extend!(Transloadit::Response::Assembly) + @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly) end _(@response.finished?).must_equal false @@ -133,11 +141,9 @@ it "must allow to check for replaying" do VCR.use_cassette "replay_assembly" do - @response = Transloadit::Response.new( - RestClient::Resource.new( - "https://api2.transloadit.com/assemblies/55c965a063a311e6ba2d379ef10b28f7/replay" - ).post({}) - ).extend!(Transloadit::Response::Assembly) + @response = Transloadit::Request.new( + "https://api2.transloadit.com/assemblies/55c965a063a311e6ba2d379ef10b28f7/replay" + ).post.extend!(Transloadit::Response::Assembly) end _(@response.finished?).must_equal false @@ -147,9 +153,7 @@ it "must allow to check for aborted" do VCR.use_cassette "fetch_assembly_aborted" do - @response = Transloadit::Response.new( - RestClient::Resource.new(request_uri).get - ).extend!(Transloadit::Response::Assembly) + @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly) end _(@response.finished?).must_equal true @@ -158,9 +162,7 @@ it "must allow to check for errors" do VCR.use_cassette "fetch_assembly_errors" do - @response = Transloadit::Response.new( - RestClient::Resource.new(request_uri).get - ).extend!(Transloadit::Response::Assembly) + @response = Transloadit::Request.new(request_uri).get.extend!(Transloadit::Response::Assembly) end _(@response.error?).must_equal true diff --git a/test/unit/transloadit/test_transport_compatibility.rb b/test/unit/transloadit/test_transport_compatibility.rb new file mode 100644 index 0000000..57e6dbc --- /dev/null +++ b/test/unit/transloadit/test_transport_compatibility.rb @@ -0,0 +1,222 @@ +require "test_helper" +require "stringio" +require "tempfile" + +describe "Faraday transport compatibility" do + include WebMock::API + + before do + WebMock.reset! + end + + after do + WebMock.reset! + end + + it "sends signed GET parameters and the client header to a selected host" do + endpoint = "https://api2.example.test/assemblies/assembly-id" + stub_request(:get, /\A#{Regexp.escape(endpoint)}/) + .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}') + + response = Transloadit::Request.new(endpoint, "secret").get(wait: true) + + _(response["ok"]).must_equal "ASSEMBLY_COMPLETED" + assert_requested(:get, /\A#{Regexp.escape(endpoint)}/) do |request| + query = Addressable::URI.parse(request.uri.to_s).query_values + _(MultiJson.load(query.fetch("params"))).must_equal "wait" => true + _(query.fetch("signature")).must_match(/\Asha384:[0-9a-f]{96}\z/) + _(request.headers.fetch("Transloadit-Client")).must_equal "ruby-sdk:#{Transloadit::VERSION}" + end + end + + it "form-encodes signed POST payloads" do + endpoint = "https://api2.transloadit.com/assemblies" + stub_request(:post, endpoint) + .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}') + + Transloadit::Request.new("/assemblies", "secret").post(params: {template_id: "template-id"}) + + assert_requested(:post, endpoint) do |request| + form = URI.decode_www_form(request.body).to_h + _(MultiJson.load(form.fetch("params"))).must_equal "template_id" => "template-id" + _(form.fetch("signature")).must_match(/\Asha384:[0-9a-f]{96}\z/) + _(request.headers.fetch("Content-Type")).must_match(/\Aapplication\/x-www-form-urlencoded/) + end + end + + it "form-encodes signed PUT payloads" do + endpoint = "https://api2.transloadit.com/templates/template-id" + stub_request(:put, endpoint) + .to_return(status: 200, body: '{"ok":"TEMPLATE_UPDATED"}') + + Transloadit::Request.new("/templates/template-id", "secret").put(params: {name: "Updated"}) + + assert_requested(:put, endpoint) do |request| + form = URI.decode_www_form(request.body).to_h + _(MultiJson.load(form.fetch("params"))).must_equal "name" => "Updated" + _(form.fetch("signature")).must_match(/\Asha384:[0-9a-f]{96}\z/) + end + end + + it "form-encodes signed DELETE payloads" do + endpoint = "https://api2.transloadit.com/templates/template-id" + stub_request(:delete, endpoint) + .to_return(status: 200, body: '{"ok":"TEMPLATE_DELETED"}') + + Transloadit::Request.new("/templates/template-id", "secret").delete(params: {reason: "cleanup"}) + + assert_requested(:delete, endpoint) do |request| + form = URI.decode_www_form(request.body).to_h + _(MultiJson.load(form.fetch("params"))).must_equal "reason" => "cleanup" + _(form.fetch("signature")).must_match(/\Asha384:[0-9a-f]{96}\z/) + end + end + + it "returns non-success responses with SDK-owned status, headers, and body access" do + endpoint = "https://api2.example.test/assemblies/missing" + stub_request(:get, endpoint).to_return( + status: 422, + headers: {"Retry-After" => "5", "X-Request-Id" => "request-id"}, + body: '{"error":"ASSEMBLY_NOT_FOUND"}' + ) + + response = Transloadit::Request.new(endpoint).get + + _(response).must_be_kind_of Transloadit::Response + _(response).wont_be_kind_of Faraday::Response + _(response.code).must_equal 422 + _(response.status).must_equal 422 + _(response.headers).must_equal retry_after: "5", x_request_id: "request-id" + _(response["error"]).must_equal "ASSEMBLY_NOT_FOUND" + end + + it "returns redirects without following them" do + endpoint = "https://api2.example.test/assemblies" + redirect = "https://uploads.example.test/assemblies" + stub_request(:post, endpoint).to_return( + status: 302, + headers: {"Location" => redirect}, + body: "{}" + ) + + response = Transloadit::Request.new(endpoint).post + + _(response.code).must_equal 302 + _(response.headers[:location]).must_equal redirect + assert_not_requested(:post, redirect) + end + + it "uploads multiple files after params and signature without closing caller-owned files" do + endpoint = "https://api2.transloadit.com/assemblies" + stub_request(:post, endpoint) + .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}') + + Tempfile.create(["first", ".txt"]) do |first| + Tempfile.create(["second", ".json"]) do |second| + first.write("first upload") + first.flush + second.write('{"second":"upload"}') + second.flush + + Transloadit::Request.new("/assemblies", "secret").post( + params: {steps: {}}, + file_0: first, + file_1: second + ) + + _(first.closed?).must_equal false + _(second.closed?).must_equal false + end + end + + assert_requested(:post, endpoint) do |request| + params_position = request.body.index('name="params"') + signature_position = request.body.index('name="signature"') + first_position = request.body.index('name="file_0"') + second_position = request.body.index('name="file_1"') + + _(request.headers.fetch("Content-Type")).must_match(/\Amultipart\/form-data; boundary=/) + _(params_position).wont_be_nil + _(signature_position).wont_be_nil + _(first_position).wont_be_nil + _(second_position).wont_be_nil + _(params_position < signature_position).must_equal true + _(signature_position < first_position).must_equal true + _(first_position < second_position).must_equal true + _(request.body).must_include "Content-Type: text/plain" + _(request.body).must_include "Content-Type: application/json" + _(request.body).must_include "first upload" + _(request.body).must_include '{"second":"upload"}' + end + end + + it "preserves filename and content type from Rails-style uploaded files" do + endpoint = "https://api2.transloadit.com/assemblies" + stub_request(:post, endpoint) + .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}') + + Tempfile.create(["rails-upload", ".bin"]) do |file| + file.write("csv contents") + file.flush + upload = Object.new + upload.define_singleton_method(:read) { |*arguments| file.read(*arguments) } + upload.define_singleton_method(:path) { file.path } + upload.define_singleton_method(:original_filename) { "report.csv" } + upload.define_singleton_method(:content_type) { "text/csv" } + + Transloadit::Request.new("/assemblies").post(params: {steps: {}}, file_0: upload) + end + + assert_requested(:post, endpoint) do |request| + _(request.body).must_include 'filename="report.csv"' + _(request.body).must_include "Content-Type: text/csv" + _(request.body).must_include "csv contents" + end + end + + it "uses safe metadata defaults for in-memory uploads" do + endpoint = "https://api2.transloadit.com/assemblies" + stub_request(:post, endpoint) + .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}') + upload = StringIO.new("memory upload") + + Transloadit::Request.new("/assemblies").post(params: {steps: {}}, file_0: upload) + + _(upload.closed?).must_equal false + assert_requested(:post, endpoint) do |request| + _(request.body).must_include 'filename="upload"' + _(request.body).must_include "Content-Type: application/octet-stream" + _(request.body).must_include "memory upload" + end + end + + it "uses the binary content type when a file extension is unknown" do + endpoint = "https://api2.transloadit.com/assemblies" + stub_request(:post, endpoint) + .to_return(status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}') + + Tempfile.create(["upload", ".transloadit-unknown"]) do |upload| + upload.write("unknown upload") + upload.flush + + Transloadit::Request.new("/assemblies").post(params: {steps: {}}, file_0: upload) + end + + assert_requested(:post, endpoint) do |request| + _(request.body).must_include "Content-Type: application/octet-stream" + _(request.body).must_include "unknown upload" + end + end + + it "wraps adapter failures and retains the original error as the cause" do + endpoint = "https://api2.example.test/assemblies/assembly-id" + stub_request(:get, endpoint).to_timeout + + error = assert_raises Transloadit::Exception::RequestFailed do + Transloadit::Request.new(endpoint).get + end + + _(error.message).must_equal "Transloadit request failed" + _(error.cause).must_be_kind_of Faraday::ConnectionFailed + end +end diff --git a/transloadit.gemspec b/transloadit.gemspec index 691ebe5..12c3d06 100644 --- a/transloadit.gemspec +++ b/transloadit.gemspec @@ -21,7 +21,8 @@ Gem::Specification.new do |gem| gem.files = `git ls-files`.split("\n") gem.require_paths = %w[lib] - gem.add_dependency "rest-client" + gem.add_dependency "faraday", ">= 2.0", "< 3.0" + gem.add_dependency "faraday-multipart", "~> 1.0" gem.add_dependency "multi_json" gem.add_dependency "mime-types"